From b8dd31b44911a811760e65ee9fe136823510885b Mon Sep 17 00:00:00 2001 From: ats-tech25 Date: Wed, 5 Nov 2025 15:42:59 +0000 Subject: [PATCH] feat(services): Implement comprehensive booking service with Jitsi integration - Add BookingService with core booking management functionality - Implement mock repositories for testing booking service interactions - Create booking integration test with Jitsi room generation - Add methods for creating, retrieving, and managing bookings - Integrate Jitsi service for generating meeting room URLs - Implement schedule management within booking service - Add support for booking creation with user and schedule context - Enhance database layer to support repository retrieval Closes #TICKET_NUMBER (if applicable) --- internal/database/database.go | 6 + internal/server/server.go | 17 ++ internal/services/booking_integration_test.go | 199 ++++++++++++++++ internal/services/booking_service.go | 222 ++++++++++++++++++ internal/services/jitsi_service.go | 92 ++++++++ internal/services/jitsi_service_test.go | 95 ++++++++ 6 files changed, 631 insertions(+) create mode 100644 internal/services/booking_integration_test.go create mode 100644 internal/services/booking_service.go create mode 100644 internal/services/jitsi_service.go create mode 100644 internal/services/jitsi_service_test.go diff --git a/internal/database/database.go b/internal/database/database.go index b98ffb5..450202e 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -6,6 +6,7 @@ import ( "attune-heart-therapy/internal/config" "attune-heart-therapy/internal/models" + "attune-heart-therapy/internal/repositories" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -110,3 +111,8 @@ func (db *DB) Health() error { } return sqlDB.Ping() } + +// GetRepositories returns all repository instances +func (db *DB) GetRepositories() *repositories.Repositories { + return repositories.NewRepositories(db.DB) +} diff --git a/internal/server/server.go b/internal/server/server.go index 8fc18c2..0cc1acf 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -143,9 +143,26 @@ func (s *Server) setupRoutes() { // initializeServices sets up all services and handlers func (s *Server) initializeServices() { + // Initialize repositories + repos := s.db.GetRepositories() + + // Initialize Jitsi service + jitsiService := services.NewJitsiService(&s.config.Jitsi) + // Initialize payment service paymentService := services.NewPaymentService(s.config) + // Initialize booking service with Jitsi integration + bookingService := services.NewBookingService( + repos.Booking, + repos.Schedule, + jitsiService, + paymentService, + ) + + // Store services for later use (if needed) + _ = bookingService // Will be used when booking handlers are implemented + // Initialize payment handler s.paymentHandler = handlers.NewPaymentHandler(paymentService) } diff --git a/internal/services/booking_integration_test.go b/internal/services/booking_integration_test.go new file mode 100644 index 0000000..5c3f66e --- /dev/null +++ b/internal/services/booking_integration_test.go @@ -0,0 +1,199 @@ +package services + +import ( + "testing" + "time" + + "attune-heart-therapy/internal/config" + "attune-heart-therapy/internal/models" +) + +// MockBookingRepository for testing +type MockBookingRepository struct { + bookings map[uint]*models.Booking + nextID uint +} + +func NewMockBookingRepository() *MockBookingRepository { + return &MockBookingRepository{ + bookings: make(map[uint]*models.Booking), + nextID: 1, + } +} + +func (m *MockBookingRepository) Create(booking *models.Booking) error { + booking.ID = m.nextID + m.nextID++ + m.bookings[booking.ID] = booking + return nil +} + +func (m *MockBookingRepository) GetByID(id uint) (*models.Booking, error) { + if booking, exists := m.bookings[id]; exists { + return booking, nil + } + return nil, nil +} + +func (m *MockBookingRepository) GetByUserID(userID uint) ([]models.Booking, error) { + var result []models.Booking + for _, booking := range m.bookings { + if booking.UserID == userID { + result = append(result, *booking) + } + } + return result, nil +} + +func (m *MockBookingRepository) Update(booking *models.Booking) error { + m.bookings[booking.ID] = booking + return nil +} + +func (m *MockBookingRepository) Delete(id uint) error { + delete(m.bookings, id) + return nil +} + +func (m *MockBookingRepository) GetUpcomingBookings() ([]models.Booking, error) { + var result []models.Booking + now := time.Now() + for _, booking := range m.bookings { + if booking.Status == models.BookingStatusScheduled && booking.ScheduledAt.After(now) { + result = append(result, *booking) + } + } + return result, nil +} + +// MockScheduleRepository for testing +type MockScheduleRepository struct { + schedules map[uint]*models.Schedule +} + +func NewMockScheduleRepository() *MockScheduleRepository { + return &MockScheduleRepository{ + schedules: make(map[uint]*models.Schedule), + } +} + +func (m *MockScheduleRepository) Create(schedule *models.Schedule) error { + m.schedules[schedule.ID] = schedule + return nil +} + +func (m *MockScheduleRepository) GetAvailable(date time.Time) ([]models.Schedule, error) { + var result []models.Schedule + for _, schedule := range m.schedules { + if schedule.IsAvailable && schedule.BookedCount < schedule.MaxBookings { + result = append(result, *schedule) + } + } + return result, nil +} + +func (m *MockScheduleRepository) Update(schedule *models.Schedule) error { + m.schedules[schedule.ID] = schedule + return nil +} + +func (m *MockScheduleRepository) GetByID(id uint) (*models.Schedule, error) { + if schedule, exists := m.schedules[id]; exists { + return schedule, nil + } + return nil, nil +} + +func (m *MockScheduleRepository) IncrementBookedCount(scheduleID uint) error { + if schedule, exists := m.schedules[scheduleID]; exists { + schedule.BookedCount++ + } + return nil +} + +func (m *MockScheduleRepository) DecrementBookedCount(scheduleID uint) error { + if schedule, exists := m.schedules[scheduleID]; exists { + schedule.BookedCount-- + } + return nil +} + +func TestBookingService_CreateBookingWithJitsiIntegration(t *testing.T) { + // Setup mock repositories + bookingRepo := NewMockBookingRepository() + scheduleRepo := NewMockScheduleRepository() + + // Setup Jitsi service + jitsiConfig := &config.JitsiConfig{ + BaseURL: "https://meet.jit.si", + } + jitsiService := NewJitsiService(jitsiConfig) + + // Setup mock payment service (nil for this test) + var paymentService PaymentService + + // Create booking service + bookingService := NewBookingService(bookingRepo, scheduleRepo, jitsiService, paymentService) + + // Create a test schedule + schedule := &models.Schedule{ + StartTime: time.Now().Add(24 * time.Hour), + EndTime: time.Now().Add(25 * time.Hour), + IsAvailable: true, + MaxBookings: 1, + BookedCount: 0, + } + schedule.ID = 1 + scheduleRepo.schedules[1] = schedule + + // Create booking request + req := BookingRequest{ + ScheduleID: 1, + Amount: 100.0, + Notes: "Test booking with Jitsi integration", + } + + // Create booking + booking, err := bookingService.CreateBooking(123, req) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if booking == nil { + t.Fatal("Expected booking to be created, got nil") + } + + // Verify booking details + if booking.UserID != 123 { + t.Errorf("Expected user ID 123, got %d", booking.UserID) + } + + if booking.Amount != 100.0 { + t.Errorf("Expected amount 100.0, got %f", booking.Amount) + } + + // Verify Jitsi integration + if booking.JitsiRoomID == "" { + t.Error("Expected Jitsi room ID to be set") + } + + if booking.JitsiRoomURL == "" { + t.Error("Expected Jitsi room URL to be set") + } + + // Verify URL format + expectedPrefix := "https://meet.jit.si/" + if len(booking.JitsiRoomURL) <= len(expectedPrefix) { + t.Error("Expected room URL to contain room ID") + } + + if booking.JitsiRoomURL[:len(expectedPrefix)] != expectedPrefix { + t.Errorf("Expected room URL to start with %s, got %s", expectedPrefix, booking.JitsiRoomURL) + } + + // Verify schedule booking count was incremented + updatedSchedule, _ := scheduleRepo.GetByID(1) + if updatedSchedule.BookedCount != 1 { + t.Errorf("Expected booked count to be 1, got %d", updatedSchedule.BookedCount) + } +} diff --git a/internal/services/booking_service.go b/internal/services/booking_service.go new file mode 100644 index 0000000..2f13659 --- /dev/null +++ b/internal/services/booking_service.go @@ -0,0 +1,222 @@ +package services + +import ( + "fmt" + "log" + "time" + + "attune-heart-therapy/internal/models" + "attune-heart-therapy/internal/repositories" +) + +// bookingService implements the BookingService interface +type bookingService struct { + bookingRepo repositories.BookingRepository + scheduleRepo repositories.ScheduleRepository + jitsiService JitsiService + paymentService PaymentService +} + +// NewBookingService creates a new instance of BookingService +func NewBookingService( + bookingRepo repositories.BookingRepository, + scheduleRepo repositories.ScheduleRepository, + jitsiService JitsiService, + paymentService PaymentService, +) BookingService { + return &bookingService{ + bookingRepo: bookingRepo, + scheduleRepo: scheduleRepo, + jitsiService: jitsiService, + paymentService: paymentService, + } +} + +// GetAvailableSlots retrieves available time slots for a given date +func (s *bookingService) GetAvailableSlots(date time.Time) ([]models.Schedule, error) { + slots, err := s.scheduleRepo.GetAvailable(date) + if err != nil { + log.Printf("Failed to get available slots for date %v: %v", date, err) + return nil, fmt.Errorf("failed to get available slots: %w", err) + } + + return slots, nil +} + +// CreateBooking creates a new booking with Jitsi meeting integration +func (s *bookingService) CreateBooking(userID uint, req BookingRequest) (*models.Booking, error) { + // Get the schedule to validate availability + schedule, err := s.scheduleRepo.GetByID(req.ScheduleID) + if err != nil { + log.Printf("Failed to get schedule %d: %v", req.ScheduleID, err) + return nil, fmt.Errorf("invalid schedule: %w", err) + } + + // Check if the schedule is available + if !schedule.IsAvailable || schedule.BookedCount >= schedule.MaxBookings { + return nil, fmt.Errorf("schedule slot is not available") + } + + // Create the booking record first (without Jitsi details) + booking := &models.Booking{ + UserID: userID, + ScheduledAt: schedule.StartTime, + Duration: 60, // Default duration, can be made configurable + Status: models.BookingStatusScheduled, + Amount: req.Amount, + Notes: req.Notes, + PaymentStatus: models.PaymentStatusPending, + } + + // Save the booking to get an ID + if err := s.bookingRepo.Create(booking); err != nil { + log.Printf("Failed to create booking for user %d: %v", userID, err) + return nil, fmt.Errorf("failed to create booking: %w", err) + } + + // Create Jitsi meeting after successful booking creation + jitsiMeeting, err := s.jitsiService.CreateMeeting(booking.ID, booking.ScheduledAt) + if err != nil { + log.Printf("Failed to create Jitsi meeting for booking %d: %v", booking.ID, err) + // Don't fail the booking creation if Jitsi fails, but log the error + // The meeting can be created later or manually + log.Printf("Booking %d created without Jitsi meeting due to error: %v", booking.ID, err) + } else { + // Update booking with Jitsi meeting details + booking.JitsiRoomID = jitsiMeeting.RoomID + booking.JitsiRoomURL = jitsiMeeting.RoomURL + + if err := s.bookingRepo.Update(booking); err != nil { + log.Printf("Failed to update booking %d with Jitsi details: %v", booking.ID, err) + // Log error but don't fail the booking creation + } else { + log.Printf("Successfully created Jitsi meeting for booking %d: Room ID %s", booking.ID, jitsiMeeting.RoomID) + } + } + + // Increment the booked count for the schedule + if err := s.scheduleRepo.IncrementBookedCount(req.ScheduleID); err != nil { + log.Printf("Failed to increment booked count for schedule %d: %v", req.ScheduleID, err) + // This is not critical, continue with the booking + } + + return booking, nil +} + +// GetUserBookings retrieves all bookings for a specific user +func (s *bookingService) GetUserBookings(userID uint) ([]models.Booking, error) { + bookings, err := s.bookingRepo.GetByUserID(userID) + if err != nil { + log.Printf("Failed to get bookings for user %d: %v", userID, err) + return nil, fmt.Errorf("failed to get user bookings: %w", err) + } + + return bookings, nil +} + +// CancelBooking cancels a booking and cleans up associated resources +func (s *bookingService) CancelBooking(userID, bookingID uint) error { + // Get the booking to validate ownership and status + booking, err := s.bookingRepo.GetByID(bookingID) + if err != nil { + log.Printf("Failed to get booking %d: %v", bookingID, err) + return fmt.Errorf("booking not found: %w", err) + } + + // Validate ownership + if booking.UserID != userID { + return fmt.Errorf("unauthorized: booking does not belong to user") + } + + // Check if booking can be cancelled + if !booking.CanBeCancelled() { + return fmt.Errorf("booking cannot be cancelled") + } + + // Update booking status + booking.Status = models.BookingStatusCancelled + + if err := s.bookingRepo.Update(booking); err != nil { + log.Printf("Failed to update booking %d status to cancelled: %v", bookingID, err) + return fmt.Errorf("failed to cancel booking: %w", err) + } + + // Clean up Jitsi meeting if it exists + if booking.JitsiRoomID != "" { + if err := s.jitsiService.DeleteMeeting(booking.JitsiRoomID); err != nil { + log.Printf("Failed to delete Jitsi meeting %s for cancelled booking %d: %v", + booking.JitsiRoomID, bookingID, err) + // Don't fail the cancellation if Jitsi cleanup fails + } + } + + log.Printf("Successfully cancelled booking %d for user %d", bookingID, userID) + return nil +} + +// RescheduleBooking reschedules a booking to a new time slot +func (s *bookingService) RescheduleBooking(userID, bookingID uint, newScheduleID uint) error { + // Get the existing booking + booking, err := s.bookingRepo.GetByID(bookingID) + if err != nil { + log.Printf("Failed to get booking %d: %v", bookingID, err) + return fmt.Errorf("booking not found: %w", err) + } + + // Validate ownership + if booking.UserID != userID { + return fmt.Errorf("unauthorized: booking does not belong to user") + } + + // Check if booking can be rescheduled + if !booking.CanBeRescheduled() { + return fmt.Errorf("booking cannot be rescheduled") + } + + // Get the new schedule + newSchedule, err := s.scheduleRepo.GetByID(newScheduleID) + if err != nil { + log.Printf("Failed to get new schedule %d: %v", newScheduleID, err) + return fmt.Errorf("invalid new schedule: %w", err) + } + + // Check if the new schedule is available + if !newSchedule.IsAvailable || newSchedule.BookedCount >= newSchedule.MaxBookings { + return fmt.Errorf("new schedule slot is not available") + } + + // Clean up old Jitsi meeting + if booking.JitsiRoomID != "" { + if err := s.jitsiService.DeleteMeeting(booking.JitsiRoomID); err != nil { + log.Printf("Failed to delete old Jitsi meeting %s: %v", booking.JitsiRoomID, err) + } + } + + // Create new Jitsi meeting + jitsiMeeting, err := s.jitsiService.CreateMeeting(booking.ID, newSchedule.StartTime) + if err != nil { + log.Printf("Failed to create new Jitsi meeting for rescheduled booking %d: %v", bookingID, err) + // Continue with rescheduling even if Jitsi fails + booking.JitsiRoomID = "" + booking.JitsiRoomURL = "" + } else { + booking.JitsiRoomID = jitsiMeeting.RoomID + booking.JitsiRoomURL = jitsiMeeting.RoomURL + } + + // Update booking with new schedule details + booking.ScheduledAt = newSchedule.StartTime + + if err := s.bookingRepo.Update(booking); err != nil { + log.Printf("Failed to update rescheduled booking %d: %v", bookingID, err) + return fmt.Errorf("failed to reschedule booking: %w", err) + } + + // Update schedule counts + if err := s.scheduleRepo.IncrementBookedCount(newScheduleID); err != nil { + log.Printf("Failed to increment booked count for new schedule %d: %v", newScheduleID, err) + } + + log.Printf("Successfully rescheduled booking %d for user %d to schedule %d", bookingID, userID, newScheduleID) + return nil +} diff --git a/internal/services/jitsi_service.go b/internal/services/jitsi_service.go new file mode 100644 index 0000000..cac66df --- /dev/null +++ b/internal/services/jitsi_service.go @@ -0,0 +1,92 @@ +package services + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "log" + "time" + + "attune-heart-therapy/internal/config" +) + +// jitsiService implements the JitsiService interface +type jitsiService struct { + config *config.JitsiConfig +} + +// NewJitsiService creates a new instance of JitsiService +func NewJitsiService(cfg *config.JitsiConfig) JitsiService { + return &jitsiService{ + config: cfg, + } +} + +// CreateMeeting creates a new Jitsi meeting room for a booking +func (j *jitsiService) CreateMeeting(bookingID uint, scheduledAt time.Time) (*JitsiMeeting, error) { + // Generate a unique room ID + roomID, err := j.generateRoomID(bookingID) + if err != nil { + log.Printf("Failed to generate room ID for booking %d: %v", bookingID, err) + return nil, fmt.Errorf("failed to generate room ID: %w", err) + } + + // Generate the meeting URL + roomURL := j.GetMeetingURL(roomID) + + meeting := &JitsiMeeting{ + RoomID: roomID, + RoomURL: roomURL, + } + + log.Printf("Created Jitsi meeting for booking %d: Room ID %s", bookingID, roomID) + return meeting, nil +} + +// GetMeetingURL generates the full Jitsi meeting URL for a given room ID +func (j *jitsiService) GetMeetingURL(roomID string) string { + baseURL := j.config.BaseURL + if baseURL == "" { + // Default to meet.jit.si if no base URL is configured + baseURL = "https://meet.jit.si" + } + + // Ensure the base URL doesn't end with a slash + if baseURL[len(baseURL)-1] == '/' { + baseURL = baseURL[:len(baseURL)-1] + } + + return fmt.Sprintf("%s/%s", baseURL, roomID) +} + +// DeleteMeeting handles cleanup of a Jitsi meeting room +// Note: Jitsi Meet doesn't require explicit room deletion as rooms are ephemeral +// This method is implemented for interface compliance and future extensibility +func (j *jitsiService) DeleteMeeting(roomID string) error { + // Jitsi Meet rooms are ephemeral and don't require explicit deletion + // However, we can log the deletion for audit purposes + log.Printf("Meeting room %s marked for cleanup", roomID) + + // In the future, if using Jitsi as a Service (JaaS) or custom deployment, + // this method could make API calls to clean up resources + + return nil +} + +// generateRoomID creates a unique room identifier for the meeting +func (j *jitsiService) generateRoomID(bookingID uint) (string, error) { + // Generate a random component for uniqueness + randomBytes := make([]byte, 8) + _, err := rand.Read(randomBytes) + if err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + randomHex := hex.EncodeToString(randomBytes) + + // Create a room ID that includes the booking ID and timestamp for uniqueness + timestamp := time.Now().Unix() + roomID := fmt.Sprintf("booking-%d-%d-%s", bookingID, timestamp, randomHex) + + return roomID, nil +} diff --git a/internal/services/jitsi_service_test.go b/internal/services/jitsi_service_test.go new file mode 100644 index 0000000..cdd419f --- /dev/null +++ b/internal/services/jitsi_service_test.go @@ -0,0 +1,95 @@ +package services + +import ( + "testing" + "time" + + "attune-heart-therapy/internal/config" +) + +func TestJitsiService_CreateMeeting(t *testing.T) { + // Setup test config + cfg := &config.JitsiConfig{ + BaseURL: "https://meet.jit.si", + } + + service := NewJitsiService(cfg) + + // Test creating a meeting + bookingID := uint(123) + scheduledAt := time.Now().Add(24 * time.Hour) + + meeting, err := service.CreateMeeting(bookingID, scheduledAt) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if meeting == nil { + t.Fatal("Expected meeting to be created, got nil") + } + + if meeting.RoomID == "" { + t.Error("Expected room ID to be generated") + } + + if meeting.RoomURL == "" { + t.Error("Expected room URL to be generated") + } + + // Verify URL format + expectedPrefix := "https://meet.jit.si/" + if len(meeting.RoomURL) <= len(expectedPrefix) { + t.Error("Expected room URL to contain room ID") + } + + if meeting.RoomURL[:len(expectedPrefix)] != expectedPrefix { + t.Errorf("Expected room URL to start with %s, got %s", expectedPrefix, meeting.RoomURL) + } +} + +func TestJitsiService_GetMeetingURL(t *testing.T) { + cfg := &config.JitsiConfig{ + BaseURL: "https://meet.jit.si", + } + + service := NewJitsiService(cfg) + + roomID := "test-room-123" + url := service.GetMeetingURL(roomID) + + expected := "https://meet.jit.si/test-room-123" + if url != expected { + t.Errorf("Expected URL %s, got %s", expected, url) + } +} + +func TestJitsiService_GetMeetingURL_DefaultBaseURL(t *testing.T) { + // Test with empty base URL (should use default) + cfg := &config.JitsiConfig{ + BaseURL: "", + } + + service := NewJitsiService(cfg) + + roomID := "test-room-456" + url := service.GetMeetingURL(roomID) + + expected := "https://meet.jit.si/test-room-456" + if url != expected { + t.Errorf("Expected URL %s, got %s", expected, url) + } +} + +func TestJitsiService_DeleteMeeting(t *testing.T) { + cfg := &config.JitsiConfig{ + BaseURL: "https://meet.jit.si", + } + + service := NewJitsiService(cfg) + + // Test deleting a meeting (should not error) + err := service.DeleteMeeting("test-room-789") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } +}