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)
This commit is contained in:
ats-tech25 2025-11-05 15:42:59 +00:00
parent d0117e6ac7
commit b8dd31b449
6 changed files with 631 additions and 0 deletions

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}