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}}
+
+
+
+
+
+
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:
+
+ - Browse available appointment slots
+ - Book your first therapy session
+ - Complete your profile for a personalized experience
+
+
If you have any questions, please don't hesitate to contact us at {{.SupportEmail}}.
+
+
+
+`
+
+ // Payment success template
+ paymentSuccessTemplate := `
+
+
+
+
+
+ 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
+
+
+
+
+
+
✗
+
Dear {{.User.FirstName}},
+
Unfortunately, your payment could not be processed and your booking was not confirmed.
+
+
+
Attempted Booking Details:
+
+ - Date & Time: {{.Booking.ScheduledAt.Format "January 2, 2006 at 3:04 PM"}}
+ - Duration: {{.Booking.Duration}} minutes
+ - Amount: ${{printf "%.2f" .Booking.Amount}}
+
+
+
+
Please try booking again or contact us at {{.SupportEmail}} if you continue to experience issues.
+
Common reasons for payment failure:
+
+ - Insufficient funds
+ - Incorrect card details
+ - Card expired or blocked
+ - Bank security restrictions
+
+
+
+
+`
+
+ // Meeting info template
+ meetingInfoTemplate := `
+
+
+
+
+
+ Meeting Information
+
+
+
+
+
+
Dear {{.User.FirstName}},
+
Here are the details for your upcoming therapy session:
+
+
+
Meeting Information:
+
+ - Date & Time: {{.Booking.ScheduledAt.Format "January 2, 2006 at 3:04 PM"}}
+ - Duration: {{.Booking.Duration}} minutes
+ - Meeting Room: {{.Booking.JitsiRoomID}}
+
+
+
+
+
+
Important Notes:
+
+ - ✓ Please join the meeting 5 minutes before the scheduled time
+ - ✓ Ensure you have a stable internet connection
+ - ✓ Test your camera and microphone beforehand
+ - ✓ Find a quiet, private space for the session
+
+
+
+
If you need to reschedule or have any questions, please contact us at {{.SupportEmail}} as soon as possible.
+
+
+
+`
+
+ // Reminder template
+ reminderTemplate := `
+
+
+
+
+
+ Session Reminder
+
+
+
+
+
+
⏰
+
Dear {{.User.FirstName}},
+
This is a friendly reminder that you have a therapy session scheduled {{.ReminderText}}.
+
+
+
Session Details:
+
+ - Date & Time: {{.Booking.ScheduledAt.Format "January 2, 2006 at 3:04 PM"}}
+ - Duration: {{.Booking.Duration}} minutes
+
+
+
+
+
+
Preparation Checklist:
+
+ - ✓ Test your camera and microphone
+ - ✓ Ensure stable internet connection
+ - ✓ Find a quiet, private space
+ - ✓ Have any notes or questions ready
+
+
+
+
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)
+ }
+}