diff --git a/go.mod b/go.mod index 10e452c..4b9c6b4 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,11 @@ require ( gorm.io/gorm v1.31.1 ) +require ( + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect +) + require ( github.com/bytedance/sonic v1.9.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect diff --git a/go.sum b/go.sum index a6031fa..271b8b6 100644 --- a/go.sum +++ b/go.sum @@ -119,9 +119,13 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/handlers/booking.go b/internal/handlers/booking.go index b8ebbea..962e328 100644 --- a/internal/handlers/booking.go +++ b/internal/handlers/booking.go @@ -1,38 +1,272 @@ package handlers import ( + "net/http" + "strconv" + "time" + + "attune-heart-therapy/internal/middleware" + "attune-heart-therapy/internal/services" + "github.com/gin-gonic/gin" ) type BookingHandler struct { - // Will be implemented in later tasks + bookingService services.BookingService } -func NewBookingHandler() *BookingHandler { - return &BookingHandler{} +func NewBookingHandler(bookingService services.BookingService) *BookingHandler { + return &BookingHandler{ + bookingService: bookingService, + } } +// GetAvailableSlots handles GET /api/schedules for available slots func (h *BookingHandler) GetAvailableSlots(c *gin.Context) { - // Will be implemented in task 9 - c.JSON(501, gin.H{"message": "Not implemented yet"}) + // Get date parameter from query string + dateStr := c.Query("date") + if dateStr == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Date parameter is required (format: YYYY-MM-DD)", + }) + return + } + + // Parse the date + date, err := time.Parse("2006-01-02", dateStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid date format. Expected: YYYY-MM-DD", + "details": err.Error(), + }) + return + } + + // Get available slots + slots, err := h.bookingService.GetAvailableSlots(date) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to get available slots", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "date": dateStr, + "slots": slots, + }) } +// CreateBooking handles POST /api/bookings for booking creation func (h *BookingHandler) CreateBooking(c *gin.Context) { - // Will be implemented in task 9 - c.JSON(501, gin.H{"message": "Not implemented yet"}) + // Get user ID from JWT token (set by auth middleware) + userID, exists := middleware.GetUserIDFromContext(c) + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "User not authenticated", + }) + return + } + + var req services.BookingRequest + + // Bind JSON request to struct + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request format", + "details": err.Error(), + }) + return + } + + // Create the booking + booking, err := h.bookingService.CreateBooking(userID, req) + if err != nil { + // Handle specific error cases + if err.Error() == "schedule slot is not available" { + c.JSON(http.StatusConflict, gin.H{ + "error": "The selected time slot is no longer available", + }) + return + } + + if err.Error() == "invalid schedule" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid schedule ID provided", + }) + return + } + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to create booking", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Booking created successfully", + "booking": booking, + }) } +// GetUserBookings handles GET /api/bookings for user's booking history func (h *BookingHandler) GetUserBookings(c *gin.Context) { - // Will be implemented in task 9 - c.JSON(501, gin.H{"message": "Not implemented yet"}) + // Get user ID from JWT token (set by auth middleware) + userID, exists := middleware.GetUserIDFromContext(c) + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "User not authenticated", + }) + return + } + + // Get user's bookings + bookings, err := h.bookingService.GetUserBookings(userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to get user bookings", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "bookings": bookings, + }) } +// CancelBooking handles PUT /api/bookings/:id/cancel for booking cancellation func (h *BookingHandler) CancelBooking(c *gin.Context) { - // Will be implemented in task 9 - c.JSON(501, gin.H{"message": "Not implemented yet"}) + // Get user ID from JWT token (set by auth middleware) + userID, exists := middleware.GetUserIDFromContext(c) + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "User not authenticated", + }) + return + } + + // Get booking ID from URL parameter + bookingIDStr := c.Param("id") + bookingID, err := strconv.ParseUint(bookingIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid booking ID", + }) + return + } + + // Cancel the booking + if err := h.bookingService.CancelBooking(userID, uint(bookingID)); err != nil { + // Handle specific error cases + if err.Error() == "unauthorized: booking does not belong to user" { + c.JSON(http.StatusForbidden, gin.H{ + "error": "You can only cancel your own bookings", + }) + return + } + + if err.Error() == "booking cannot be cancelled" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "This booking cannot be cancelled (must be at least 24 hours before scheduled time)", + }) + return + } + + if err.Error() == "booking not found" { + c.JSON(http.StatusNotFound, gin.H{ + "error": "Booking not found", + }) + return + } + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to cancel booking", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Booking cancelled successfully", + }) } +// RescheduleBooking handles PUT /api/bookings/:id/reschedule for booking rescheduling func (h *BookingHandler) RescheduleBooking(c *gin.Context) { - // Will be implemented in task 9 - c.JSON(501, gin.H{"message": "Not implemented yet"}) + // Get user ID from JWT token (set by auth middleware) + userID, exists := middleware.GetUserIDFromContext(c) + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "User not authenticated", + }) + return + } + + // Get booking ID from URL parameter + bookingIDStr := c.Param("id") + bookingID, err := strconv.ParseUint(bookingIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid booking ID", + }) + return + } + + var req struct { + NewScheduleID uint `json:"new_schedule_id" binding:"required"` + } + + // Bind JSON request to struct + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request format", + "details": err.Error(), + }) + return + } + + // Reschedule the booking + if err := h.bookingService.RescheduleBooking(userID, uint(bookingID), req.NewScheduleID); err != nil { + // Handle specific error cases + if err.Error() == "unauthorized: booking does not belong to user" { + c.JSON(http.StatusForbidden, gin.H{ + "error": "You can only reschedule your own bookings", + }) + return + } + + if err.Error() == "booking cannot be rescheduled" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "This booking cannot be rescheduled (must be at least 2 hours before scheduled time)", + }) + return + } + + if err.Error() == "new schedule slot is not available" { + c.JSON(http.StatusConflict, gin.H{ + "error": "The new time slot is not available", + }) + return + } + + if err.Error() == "booking not found" || err.Error() == "invalid new schedule" { + c.JSON(http.StatusNotFound, gin.H{ + "error": "Booking or new schedule not found", + }) + return + } + + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to reschedule booking", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Booking rescheduled successfully", + }) } diff --git a/internal/repositories/booking_repository.go b/internal/repositories/booking_repository.go index 5e6cc16..05cd0f9 100644 --- a/internal/repositories/booking_repository.go +++ b/internal/repositories/booking_repository.go @@ -131,3 +131,20 @@ func (r *bookingRepository) GetUpcomingBookings() ([]models.Booking, error) { return bookings, nil } + +// GetByPaymentID retrieves a booking by its payment ID with user preloaded +func (r *bookingRepository) GetByPaymentID(paymentID string) (*models.Booking, error) { + if paymentID == "" { + return nil, errors.New("payment ID cannot be empty") + } + + var booking models.Booking + if err := r.db.Preload("User").Where("payment_id = ?", paymentID).First(&booking).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("booking with payment ID %s not found", paymentID) + } + return nil, fmt.Errorf("failed to get booking by payment ID: %w", err) + } + + return &booking, nil +} diff --git a/internal/repositories/interfaces.go b/internal/repositories/interfaces.go index 96de232..bd58457 100644 --- a/internal/repositories/interfaces.go +++ b/internal/repositories/interfaces.go @@ -20,6 +20,7 @@ type BookingRepository interface { Create(booking *models.Booking) error GetByID(id uint) (*models.Booking, error) GetByUserID(userID uint) ([]models.Booking, error) + GetByPaymentID(paymentID string) (*models.Booking, error) Update(booking *models.Booking) error Delete(id uint) error GetUpcomingBookings() ([]models.Booking, error) diff --git a/internal/server/server.go b/internal/server/server.go index 0cc1acf..f257e84 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,6 +17,7 @@ type Server struct { db *database.DB router *gin.Engine paymentHandler *handlers.PaymentHandler + bookingHandler *handlers.BookingHandler } func New(cfg *config.Config) *Server { @@ -91,7 +92,7 @@ func (s *Server) setupRoutes() { s.router.GET("/health", s.healthCheck) // API v1 routes group - v1 := s.router.Group("/api/v1") + v1 := s.router.Group("/api") { // Auth routes (will be implemented in later tasks) auth := v1.Group("/auth") @@ -104,23 +105,17 @@ func (s *Server) setupRoutes() { }) } - // Booking routes (will be implemented in later tasks) - bookings := v1.Group("/bookings") - { - bookings.GET("/", func(c *gin.Context) { - c.JSON(501, gin.H{"message": "Not implemented yet"}) - }) - bookings.POST("/", func(c *gin.Context) { - c.JSON(501, gin.H{"message": "Not implemented yet"}) - }) - } + // Schedule routes - public endpoint for getting available slots + v1.GET("/schedules", s.bookingHandler.GetAvailableSlots) - // Schedule routes (will be implemented in later tasks) - schedules := v1.Group("/schedules") + // Booking routes - require authentication + bookings := v1.Group("/bookings") + // Note: Authentication middleware will be added in task 13 { - schedules.GET("/", func(c *gin.Context) { - c.JSON(501, gin.H{"message": "Not implemented yet"}) - }) + bookings.GET("/", s.bookingHandler.GetUserBookings) + bookings.POST("/", s.bookingHandler.CreateBooking) + bookings.PUT("/:id/cancel", s.bookingHandler.CancelBooking) + bookings.PUT("/:id/reschedule", s.bookingHandler.RescheduleBooking) } // Payment routes @@ -149,22 +144,31 @@ func (s *Server) initializeServices() { // Initialize Jitsi service jitsiService := services.NewJitsiService(&s.config.Jitsi) - // Initialize payment service - paymentService := services.NewPaymentService(s.config) + // Initialize notification service + notificationService := services.NewNotificationService(repos.Notification, s.config) - // Initialize booking service with Jitsi integration + // Initialize JWT service (needed for user service) + jwtService := services.NewJWTService(s.config.JWT.Secret, s.config.JWT.Expiration) + + // Initialize user service with notification integration + _ = services.NewUserService(repos.User, jwtService, notificationService) // Ready for auth handlers + + // Initialize payment service with notification integration + paymentService := services.NewPaymentService(s.config, repos.Booking, repos.User, notificationService) + + // Initialize booking service with notification integration bookingService := services.NewBookingService( repos.Booking, repos.Schedule, + repos.User, jitsiService, paymentService, + notificationService, ) - // Store services for later use (if needed) - _ = bookingService // Will be used when booking handlers are implemented - - // Initialize payment handler + // Initialize handlers s.paymentHandler = handlers.NewPaymentHandler(paymentService) + s.bookingHandler = handlers.NewBookingHandler(bookingService) } // healthCheck handles the health check endpoint diff --git a/internal/services/booking_integration_test.go b/internal/services/booking_integration_test.go index 5c3f66e..23bf258 100644 --- a/internal/services/booking_integration_test.go +++ b/internal/services/booking_integration_test.go @@ -118,10 +118,93 @@ func (m *MockScheduleRepository) DecrementBookedCount(scheduleID uint) error { return nil } +func (m *MockBookingRepository) GetByPaymentID(paymentID string) (*models.Booking, error) { + for _, booking := range m.bookings { + if booking.PaymentID == paymentID { + return booking, nil + } + } + return nil, nil +} + +// MockUserRepository for testing +type MockUserRepository struct { + users map[uint]*models.User +} + +func NewMockUserRepository() *MockUserRepository { + return &MockUserRepository{ + users: make(map[uint]*models.User), + } +} + +func (m *MockUserRepository) Create(user *models.User) error { + m.users[user.ID] = user + return nil +} + +func (m *MockUserRepository) GetByID(id uint) (*models.User, error) { + if user, exists := m.users[id]; exists { + return user, nil + } + user := &models.User{ + Email: "test@example.com", + FirstName: "Test", + } + user.ID = id + return user, nil +} + +func (m *MockUserRepository) GetByEmail(email string) (*models.User, error) { + for _, user := range m.users { + if user.Email == email { + return user, nil + } + } + return nil, nil +} + +func (m *MockUserRepository) Update(user *models.User) error { + m.users[user.ID] = user + return nil +} + +func (m *MockUserRepository) GetActiveUsersCount() (int64, error) { + return int64(len(m.users)), nil +} + +// MockNotificationService for testing +type MockNotificationService struct{} + +func (m *MockNotificationService) SendWelcomeEmail(user *models.User) error { + return nil +} + +func (m *MockNotificationService) SendPaymentNotification(user *models.User, booking *models.Booking, success bool) error { + return nil +} + +func (m *MockNotificationService) SendMeetingInfo(user *models.User, booking *models.Booking) error { + return nil +} + +func (m *MockNotificationService) SendReminder(user *models.User, booking *models.Booking) error { + return nil +} + +func (m *MockNotificationService) ScheduleReminder(bookingID uint, reminderTime time.Time) error { + return nil +} + +func (m *MockNotificationService) ProcessPendingNotifications() error { + return nil +} + func TestBookingService_CreateBookingWithJitsiIntegration(t *testing.T) { // Setup mock repositories bookingRepo := NewMockBookingRepository() scheduleRepo := NewMockScheduleRepository() + userRepo := NewMockUserRepository() // Setup Jitsi service jitsiConfig := &config.JitsiConfig{ @@ -129,11 +212,12 @@ func TestBookingService_CreateBookingWithJitsiIntegration(t *testing.T) { } jitsiService := NewJitsiService(jitsiConfig) - // Setup mock payment service (nil for this test) + // Setup mock services var paymentService PaymentService + notificationService := &MockNotificationService{} // Create booking service - bookingService := NewBookingService(bookingRepo, scheduleRepo, jitsiService, paymentService) + bookingService := NewBookingService(bookingRepo, scheduleRepo, userRepo, jitsiService, paymentService, notificationService) // Create a test schedule schedule := &models.Schedule{ diff --git a/internal/services/booking_service.go b/internal/services/booking_service.go index 2f13659..79e04ae 100644 --- a/internal/services/booking_service.go +++ b/internal/services/booking_service.go @@ -11,24 +11,30 @@ import ( // bookingService implements the BookingService interface type bookingService struct { - bookingRepo repositories.BookingRepository - scheduleRepo repositories.ScheduleRepository - jitsiService JitsiService - paymentService PaymentService + bookingRepo repositories.BookingRepository + scheduleRepo repositories.ScheduleRepository + userRepo repositories.UserRepository + jitsiService JitsiService + paymentService PaymentService + notificationService NotificationService } // NewBookingService creates a new instance of BookingService func NewBookingService( bookingRepo repositories.BookingRepository, scheduleRepo repositories.ScheduleRepository, + userRepo repositories.UserRepository, jitsiService JitsiService, paymentService PaymentService, + notificationService NotificationService, ) BookingService { return &bookingService{ - bookingRepo: bookingRepo, - scheduleRepo: scheduleRepo, - jitsiService: jitsiService, - paymentService: paymentService, + bookingRepo: bookingRepo, + scheduleRepo: scheduleRepo, + userRepo: userRepo, + jitsiService: jitsiService, + paymentService: paymentService, + notificationService: notificationService, } } @@ -100,6 +106,18 @@ func (s *bookingService) CreateBooking(userID uint, req BookingRequest) (*models // This is not critical, continue with the booking } + // Send meeting information notification if Jitsi meeting was created successfully + if booking.JitsiRoomID != "" && booking.JitsiRoomURL != "" { + user, err := s.userRepo.GetByID(userID) + if err != nil { + log.Printf("Failed to get user %d for notification: %v", userID, err) + } else { + if err := s.notificationService.SendMeetingInfo(user, booking); err != nil { + log.Printf("Failed to send meeting info notification to user %d: %v", userID, err) + } + } + } + return booking, nil } diff --git a/internal/services/interfaces.go b/internal/services/interfaces.go index d4dbf24..c5b8846 100644 --- a/internal/services/interfaces.go +++ b/internal/services/interfaces.go @@ -39,6 +39,7 @@ type NotificationService interface { SendMeetingInfo(user *models.User, booking *models.Booking) error SendReminder(user *models.User, booking *models.Booking) error ScheduleReminder(bookingID uint, reminderTime time.Time) error + ProcessPendingNotifications() error } // JitsiService handles video conference integration diff --git a/internal/services/notification_service.go b/internal/services/notification_service.go new file mode 100644 index 0000000..bc2c099 --- /dev/null +++ b/internal/services/notification_service.go @@ -0,0 +1,236 @@ +package services + +import ( + "fmt" + "log" + "time" + + "attune-heart-therapy/internal/config" + "attune-heart-therapy/internal/models" + "attune-heart-therapy/internal/repositories" + "attune-heart-therapy/internal/templates" + + "gopkg.in/gomail.v2" +) + +// notificationService implements the NotificationService interface +type notificationService struct { + notificationRepo repositories.NotificationRepository + templateService *templates.EmailTemplateService + config *config.Config + dialer *gomail.Dialer +} + +// NewNotificationService creates a new instance of NotificationService +func NewNotificationService(notificationRepo repositories.NotificationRepository, cfg *config.Config) NotificationService { + dialer := gomail.NewDialer( + cfg.SMTP.Host, + cfg.SMTP.Port, + cfg.SMTP.Username, + cfg.SMTP.Password, + ) + + return ¬ificationService{ + notificationRepo: notificationRepo, + templateService: templates.NewEmailTemplateService(), + config: cfg, + dialer: dialer, + } +} + +// SendWelcomeEmail sends a welcome email to a newly registered user +func (s *notificationService) SendWelcomeEmail(user *models.User) error { + templateData := templates.TemplateData{ + User: user, + } + + emailTemplate, err := s.templateService.RenderTemplate(models.NotificationTypeWelcome, templateData) + if err != nil { + return fmt.Errorf("failed to render welcome email template: %w", err) + } + + notification := &models.Notification{ + UserID: user.ID, + Type: models.NotificationTypeWelcome, + Subject: emailTemplate.Subject, + Body: emailTemplate.Body, + Status: models.NotificationStatusPending, + } + + if err := s.notificationRepo.Create(notification); err != nil { + return fmt.Errorf("failed to create welcome notification: %w", err) + } + + return s.sendEmail(user.Email, emailTemplate.Subject, emailTemplate.Body) +} + +// SendPaymentNotification sends payment status notification to user +func (s *notificationService) SendPaymentNotification(user *models.User, booking *models.Booking, success bool) error { + var notificationType models.NotificationType + if success { + notificationType = models.NotificationTypePaymentSuccess + } else { + notificationType = models.NotificationTypePaymentFailed + } + + templateData := templates.TemplateData{ + User: user, + Booking: booking, + } + + emailTemplate, err := s.templateService.RenderTemplate(notificationType, templateData) + if err != nil { + return fmt.Errorf("failed to render payment notification template: %w", err) + } + + notification := &models.Notification{ + UserID: user.ID, + BookingID: &booking.ID, + Type: notificationType, + Subject: emailTemplate.Subject, + Body: emailTemplate.Body, + Status: models.NotificationStatusPending, + } + + if err := s.notificationRepo.Create(notification); err != nil { + return fmt.Errorf("failed to create payment notification: %w", err) + } + + return s.sendEmail(user.Email, emailTemplate.Subject, emailTemplate.Body) +} + +// SendMeetingInfo sends meeting information to user after successful booking +func (s *notificationService) SendMeetingInfo(user *models.User, booking *models.Booking) error { + templateData := templates.TemplateData{ + User: user, + Booking: booking, + } + + emailTemplate, err := s.templateService.RenderTemplate(models.NotificationTypeMeetingInfo, templateData) + if err != nil { + return fmt.Errorf("failed to render meeting info template: %w", err) + } + + notification := &models.Notification{ + UserID: user.ID, + BookingID: &booking.ID, + Type: models.NotificationTypeMeetingInfo, + Subject: emailTemplate.Subject, + Body: emailTemplate.Body, + Status: models.NotificationStatusPending, + } + + if err := s.notificationRepo.Create(notification); err != nil { + return fmt.Errorf("failed to create meeting info notification: %w", err) + } + + return s.sendEmail(user.Email, emailTemplate.Subject, emailTemplate.Body) +} + +// SendReminder sends a reminder notification to user before their meeting +func (s *notificationService) SendReminder(user *models.User, booking *models.Booking) error { + templateData := templates.TemplateData{ + User: user, + Booking: booking, + ReminderText: templates.GetReminderText(booking.ScheduledAt), + } + + emailTemplate, err := s.templateService.RenderTemplate(models.NotificationTypeReminder, templateData) + if err != nil { + return fmt.Errorf("failed to render reminder template: %w", err) + } + + notification := &models.Notification{ + UserID: user.ID, + BookingID: &booking.ID, + Type: models.NotificationTypeReminder, + Subject: emailTemplate.Subject, + Body: emailTemplate.Body, + Status: models.NotificationStatusPending, + } + + if err := s.notificationRepo.Create(notification); err != nil { + return fmt.Errorf("failed to create reminder notification: %w", err) + } + + return s.sendEmail(user.Email, emailTemplate.Subject, emailTemplate.Body) +} + +// ScheduleReminder schedules a reminder notification for a specific time +func (s *notificationService) ScheduleReminder(bookingID uint, reminderTime time.Time) error { + // Create a scheduled notification that will be processed later + notification := &models.Notification{ + BookingID: &bookingID, + Type: models.NotificationTypeReminder, + Subject: "Scheduled Reminder", + Body: "This is a scheduled reminder notification", + Status: models.NotificationStatusPending, + ScheduledAt: &reminderTime, + } + + if err := s.notificationRepo.Create(notification); err != nil { + return fmt.Errorf("failed to schedule reminder: %w", err) + } + + log.Printf("Reminder scheduled for booking %d at %s", bookingID, reminderTime.Format(time.RFC3339)) + return nil +} + +// ProcessPendingNotifications processes all pending notifications that are ready to be sent +func (s *notificationService) ProcessPendingNotifications() error { + notifications, err := s.notificationRepo.GetPendingNotifications() + if err != nil { + return fmt.Errorf("failed to get pending notifications: %w", err) + } + + for _, notification := range notifications { + if !notification.IsReadyToSend() { + continue + } + + // For reminder notifications, we need to fetch the booking and user data + if notification.Type == models.NotificationTypeReminder && notification.BookingID != nil { + // This would require additional repository methods to fetch booking with user + // For now, we'll skip processing reminders in batch processing + log.Printf("Skipping reminder notification %d - requires specific booking context", notification.ID) + continue + } + + // Send the notification + if err := s.sendEmail(notification.User.Email, notification.Subject, notification.Body); err != nil { + notification.MarkAsFailed(err.Error()) + log.Printf("Failed to send notification %d: %v", notification.ID, err) + } else { + notification.MarkAsSent() + log.Printf("Successfully sent notification %d to %s", notification.ID, notification.User.Email) + } + + // Update the notification status + if err := s.notificationRepo.Update(¬ification); err != nil { + log.Printf("Failed to update notification %d status: %v", notification.ID, err) + } + } + + return nil +} + +// sendEmail sends an email using the configured SMTP settings +func (s *notificationService) sendEmail(to, subject, body string) error { + if s.config.SMTP.Host == "" || s.config.SMTP.From == "" { + log.Printf("SMTP not configured, skipping email to %s", to) + return nil // Don't fail if SMTP is not configured + } + + message := gomail.NewMessage() + message.SetHeader("From", s.config.SMTP.From) + message.SetHeader("To", to) + message.SetHeader("Subject", subject) + message.SetBody("text/html", body) + + if err := s.dialer.DialAndSend(message); err != nil { + return fmt.Errorf("failed to send email to %s: %w", to, err) + } + + log.Printf("Email sent successfully to %s", to) + return nil +} diff --git a/internal/services/payment_service.go b/internal/services/payment_service.go index 33140ea..5b661da 100644 --- a/internal/services/payment_service.go +++ b/internal/services/payment_service.go @@ -6,6 +6,8 @@ import ( "log" "attune-heart-therapy/internal/config" + "attune-heart-therapy/internal/models" + "attune-heart-therapy/internal/repositories" "github.com/stripe/stripe-go/v76" "github.com/stripe/stripe-go/v76/paymentintent" @@ -14,16 +16,22 @@ import ( // paymentService implements the PaymentService interface type paymentService struct { - config *config.Config + config *config.Config + bookingRepo repositories.BookingRepository + userRepo repositories.UserRepository + notificationService NotificationService } // NewPaymentService creates a new instance of PaymentService -func NewPaymentService(cfg *config.Config) PaymentService { +func NewPaymentService(cfg *config.Config, bookingRepo repositories.BookingRepository, userRepo repositories.UserRepository, notificationService NotificationService) PaymentService { // Set Stripe API key stripe.Key = cfg.Stripe.SecretKey return &paymentService{ - config: cfg, + config: cfg, + bookingRepo: bookingRepo, + userRepo: userRepo, + notificationService: notificationService, } } @@ -106,8 +114,11 @@ func (s *paymentService) HandleWebhook(payload []byte, signature string) error { } log.Printf("Payment succeeded for payment intent: %s", paymentIntent.ID) - // TODO: Update booking status to confirmed - // This will be handled when booking service is integrated + + // Find booking by payment ID and update status + if err := s.handlePaymentSuccess(paymentIntent.ID); err != nil { + log.Printf("Failed to handle payment success for %s: %v", paymentIntent.ID, err) + } case "payment_intent.payment_failed": var paymentIntent stripe.PaymentIntent @@ -123,8 +134,11 @@ func (s *paymentService) HandleWebhook(payload []byte, signature string) error { } log.Printf("Payment failed for payment intent: %s", paymentIntent.ID) - // TODO: Update booking status to failed - // This will be handled when booking service is integrated + + // Find booking by payment ID and update status + if err := s.handlePaymentFailure(paymentIntent.ID); err != nil { + log.Printf("Failed to handle payment failure for %s: %v", paymentIntent.ID, err) + } case "payment_intent.canceled": var paymentIntent stripe.PaymentIntent @@ -149,3 +163,61 @@ func (s *paymentService) HandleWebhook(payload []byte, signature string) error { return nil } + +// handlePaymentSuccess processes successful payment and sends notifications +func (s *paymentService) handlePaymentSuccess(paymentIntentID string) error { + // Find booking by payment ID + booking, err := s.bookingRepo.GetByPaymentID(paymentIntentID) + if err != nil { + return fmt.Errorf("failed to find booking for payment %s: %w", paymentIntentID, err) + } + + // Update booking payment status + booking.PaymentStatus = models.PaymentStatusSucceeded + if err := s.bookingRepo.Update(booking); err != nil { + return fmt.Errorf("failed to update booking payment status: %w", err) + } + + // Get user for notification + user, err := s.userRepo.GetByID(booking.UserID) + if err != nil { + return fmt.Errorf("failed to get user for notification: %w", err) + } + + // Send payment success notification + if err := s.notificationService.SendPaymentNotification(user, booking, true); err != nil { + log.Printf("Failed to send payment success notification: %v", err) + // Don't return error as payment processing was successful + } + + return nil +} + +// handlePaymentFailure processes failed payment and sends notifications +func (s *paymentService) handlePaymentFailure(paymentIntentID string) error { + // Find booking by payment ID + booking, err := s.bookingRepo.GetByPaymentID(paymentIntentID) + if err != nil { + return fmt.Errorf("failed to find booking for payment %s: %w", paymentIntentID, err) + } + + // Update booking payment status + booking.PaymentStatus = models.PaymentStatusFailed + if err := s.bookingRepo.Update(booking); err != nil { + return fmt.Errorf("failed to update booking payment status: %w", err) + } + + // Get user for notification + user, err := s.userRepo.GetByID(booking.UserID) + if err != nil { + return fmt.Errorf("failed to get user for notification: %w", err) + } + + // Send payment failure notification + if err := s.notificationService.SendPaymentNotification(user, booking, false); err != nil { + log.Printf("Failed to send payment failure notification: %v", err) + // Don't return error as the main payment processing was handled + } + + return nil +} diff --git a/internal/services/user_service.go b/internal/services/user_service.go index 1431901..56becd0 100644 --- a/internal/services/user_service.go +++ b/internal/services/user_service.go @@ -13,17 +13,19 @@ import ( // userService implements the UserService interface type userService struct { - userRepo repositories.UserRepository - jwtService JWTService - validator *validator.Validate + userRepo repositories.UserRepository + jwtService JWTService + notificationService NotificationService + validator *validator.Validate } // NewUserService creates a new instance of UserService -func NewUserService(userRepo repositories.UserRepository, jwtService JWTService) UserService { +func NewUserService(userRepo repositories.UserRepository, jwtService JWTService, notificationService NotificationService) UserService { return &userService{ - userRepo: userRepo, - jwtService: jwtService, - validator: validator.New(), + userRepo: userRepo, + jwtService: jwtService, + notificationService: notificationService, + validator: validator.New(), } } @@ -69,6 +71,12 @@ func (s *userService) Register(req RegisterRequest) (*models.User, error) { return nil, fmt.Errorf("failed to create user: %w", err) } + // Send welcome email notification + if err := s.notificationService.SendWelcomeEmail(user); err != nil { + // Log the error but don't fail the registration + fmt.Printf("Failed to send welcome email to %s: %v\n", user.Email, err) + } + // Clear password hash from response for security user.PasswordHash = "" diff --git a/internal/templates/email_templates.go b/internal/templates/email_templates.go new file mode 100644 index 0000000..1f4b6be --- /dev/null +++ b/internal/templates/email_templates.go @@ -0,0 +1,403 @@ +package templates + +import ( + "bytes" + "fmt" + "html/template" + "time" + + "attune-heart-therapy/internal/models" +) + +// EmailTemplate represents an email template with subject and body +type EmailTemplate struct { + Subject string + Body string +} + +// TemplateData contains data for email template rendering +type TemplateData struct { + User *models.User + Booking *models.Booking + Amount float64 + PaymentID string + JoinURL string + ReminderText string + CompanyName string + SupportEmail string +} + +// EmailTemplateService handles email template rendering +type EmailTemplateService struct { + templates map[models.NotificationType]*template.Template + baseData TemplateData +} + +// NewEmailTemplateService creates a new email template service +func NewEmailTemplateService() *EmailTemplateService { + service := &EmailTemplateService{ + templates: make(map[models.NotificationType]*template.Template), + baseData: TemplateData{ + CompanyName: "Attune Heart Therapy", + SupportEmail: "support@attuneheart.com", + }, + } + + service.initializeTemplates() + return service +} + +// initializeTemplates initializes all email templates +func (s *EmailTemplateService) initializeTemplates() { + // Welcome email template + welcomeTemplate := ` + + + + + + Welcome to {{.CompanyName}} + + + +
+

Welcome to {{.CompanyName}}!

+
+
+

Hello {{.User.FirstName}}!

+

Thank you for registering with us. We're excited to help you on your wellness journey.

+

You can now book video conference sessions with our therapists through our platform.

+

Here's what you can do next:

+ +

If you have any questions, please don't hesitate to contact us at {{.SupportEmail}}.

+
+ + +` + + // Payment success template + paymentSuccessTemplate := ` + + + + + + Payment Successful + + + +
+

Payment Successful!

+
+
+
+

Dear {{.User.FirstName}},

+

Your payment has been successfully processed and your booking is confirmed.

+ +
+

Booking Details:

+
+ Date & Time: + {{.Booking.ScheduledAt.Format "January 2, 2006 at 3:04 PM"}} +
+
+ Duration: + {{.Booking.Duration}} minutes +
+
+ Amount Paid: + ${{printf "%.2f" .Booking.Amount}} +
+
+ Payment ID: + {{.Booking.PaymentID}} +
+
+ +

You will receive meeting details closer to your appointment time.

+

If you need to reschedule or have any questions, please contact us at {{.SupportEmail}}.

+
+ + +` + + // Payment failed template + paymentFailedTemplate := ` + + + + + + Payment Failed + + + +
+

Payment Failed

+
+
+
+

Dear {{.User.FirstName}},

+

Unfortunately, your payment could not be processed and your booking was not confirmed.

+ +
+

Attempted Booking Details:

+ +
+ +

Please try booking again or contact us at {{.SupportEmail}} if you continue to experience issues.

+

Common reasons for payment failure:

+ +
+ + +` + + // Meeting info template + meetingInfoTemplate := ` + + + + + + Meeting Information + + + +
+

Your Therapy Session Details

+
+
+

Dear {{.User.FirstName}},

+

Here are the details for your upcoming therapy session:

+ +
+

Meeting Information:

+ +
+ Join Meeting +
+
+ +
+

Important Notes:

+ +
+ +

If you need to reschedule or have any questions, please contact us at {{.SupportEmail}} as soon as possible.

+
+ + +` + + // Reminder template + reminderTemplate := ` + + + + + + Session Reminder + + + +
+

Session Reminder

+
+
+
+

Dear {{.User.FirstName}},

+

This is a friendly reminder that you have a therapy session scheduled {{.ReminderText}}.

+ +
+

Session Details:

+ +
+ Join Meeting +
+
+ +
+

Preparation Checklist:

+ +
+ +

We look forward to seeing you soon!

+
+ + +` + + // Parse and store templates + s.templates[models.NotificationTypeWelcome] = template.Must(template.New("welcome").Parse(welcomeTemplate)) + s.templates[models.NotificationTypePaymentSuccess] = template.Must(template.New("payment_success").Parse(paymentSuccessTemplate)) + s.templates[models.NotificationTypePaymentFailed] = template.Must(template.New("payment_failed").Parse(paymentFailedTemplate)) + s.templates[models.NotificationTypeMeetingInfo] = template.Must(template.New("meeting_info").Parse(meetingInfoTemplate)) + s.templates[models.NotificationTypeReminder] = template.Must(template.New("reminder").Parse(reminderTemplate)) +} + +// RenderTemplate renders an email template with the provided data +func (s *EmailTemplateService) RenderTemplate(notificationType models.NotificationType, data TemplateData) (*EmailTemplate, error) { + tmpl, exists := s.templates[notificationType] + if !exists { + return nil, fmt.Errorf("template not found for notification type: %s", notificationType) + } + + // Merge base data with provided data + mergedData := s.baseData + if data.User != nil { + mergedData.User = data.User + } + if data.Booking != nil { + mergedData.Booking = data.Booking + } + mergedData.Amount = data.Amount + mergedData.PaymentID = data.PaymentID + mergedData.JoinURL = data.JoinURL + mergedData.ReminderText = data.ReminderText + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, mergedData); err != nil { + return nil, fmt.Errorf("failed to render template: %w", err) + } + + // Generate subject based on notification type + subject := s.getSubjectForType(notificationType, mergedData) + + return &EmailTemplate{ + Subject: subject, + Body: buf.String(), + }, nil +} + +// getSubjectForType returns the appropriate subject line for each notification type +func (s *EmailTemplateService) getSubjectForType(notificationType models.NotificationType, data TemplateData) string { + switch notificationType { + case models.NotificationTypeWelcome: + return fmt.Sprintf("Welcome to %s!", data.CompanyName) + case models.NotificationTypePaymentSuccess: + return "Payment Successful - Booking Confirmed" + case models.NotificationTypePaymentFailed: + return "Payment Failed - Booking Not Confirmed" + case models.NotificationTypeMeetingInfo: + return "Meeting Information - Your Therapy Session" + case models.NotificationTypeReminder: + if data.Booking != nil { + timeUntil := time.Until(data.Booking.ScheduledAt) + if timeUntil > 24*time.Hour { + return "Reminder: Your Therapy Session is Tomorrow" + } else if timeUntil > time.Hour { + return "Reminder: Your Therapy Session is Today" + } else { + return "Reminder: Your Therapy Session Starts Soon" + } + } + return "Reminder: Your Therapy Session is Coming Up" + case models.NotificationTypeCancellation: + return "Booking Cancelled - Confirmation" + case models.NotificationTypeReschedule: + return "Booking Rescheduled - New Time Confirmed" + default: + return "Notification from " + data.CompanyName + } +} + +// GetReminderText generates appropriate reminder text based on time until meeting +func GetReminderText(scheduledAt time.Time) string { + timeUntil := time.Until(scheduledAt) + + if timeUntil > 24*time.Hour { + return "tomorrow" + } else if timeUntil > time.Hour { + hours := int(timeUntil.Hours()) + if hours == 1 { + return "in 1 hour" + } + return fmt.Sprintf("in %d hours", hours) + } else { + minutes := int(timeUntil.Minutes()) + if minutes <= 1 { + return "now" + } + return fmt.Sprintf("in %d minutes", minutes) + } +}