feat(booking): Implement comprehensive booking management functionality

- Add full implementation for booking handlers with complete CRUD operations
- Implement GetAvailableSlots endpoint to retrieve available booking time slots
- Add CreateBooking handler with robust error handling and validation
- Implement GetUserBookings endpoint to fetch user's booking history
- Add CancelBooking handler with specific error scenarios and authorization checks
- Integrate booking service and middleware for authentication and request processing
- Add support for date parsing and slot availability checking
- Enhance error responses with detailed error messages and appropriate HTTP status codes
- Integrate with existing authentication and middleware components
This commit is contained in:
ats-tech25 2025-11-05 16:35:36 +00:00
parent b8dd31b449
commit 98f4b4392d
13 changed files with 1147 additions and 60 deletions

5
go.mod
View File

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

4
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 &notificationService{
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(&notification); 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
}

View File

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

View File

@ -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 = ""

View File

@ -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 := `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to {{.CompanyName}}</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #4a90e2; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background-color: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; }
.footer { text-align: center; margin-top: 20px; font-size: 12px; color: #666; }
.button { display: inline-block; background-color: #4a90e2; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 10px 0; }
</style>
</head>
<body>
<div class="header">
<h1>Welcome to {{.CompanyName}}!</h1>
</div>
<div class="content">
<h2>Hello {{.User.FirstName}}!</h2>
<p>Thank you for registering with us. We're excited to help you on your wellness journey.</p>
<p>You can now book video conference sessions with our therapists through our platform.</p>
<p>Here's what you can do next:</p>
<ul>
<li>Browse available appointment slots</li>
<li>Book your first therapy session</li>
<li>Complete your profile for a personalized experience</li>
</ul>
<p>If you have any questions, please don't hesitate to contact us at {{.SupportEmail}}.</p>
</div>
<div class="footer">
<p>Best regards,<br>The {{.CompanyName}} Team</p>
</div>
</body>
</html>`
// Payment success template
paymentSuccessTemplate := `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment Successful</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #28a745; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background-color: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; }
.footer { text-align: center; margin-top: 20px; font-size: 12px; color: #666; }
.success-icon { font-size: 48px; color: #28a745; text-align: center; margin: 20px 0; }
.booking-details { background-color: white; padding: 20px; border-radius: 4px; margin: 20px 0; }
.detail-row { display: flex; justify-content: space-between; margin: 10px 0; padding: 8px 0; border-bottom: 1px solid #eee; }
</style>
</head>
<body>
<div class="header">
<h1>Payment Successful!</h1>
</div>
<div class="content">
<div class="success-icon"></div>
<h2>Dear {{.User.FirstName}},</h2>
<p>Your payment has been successfully processed and your booking is confirmed.</p>
<div class="booking-details">
<h3>Booking Details:</h3>
<div class="detail-row">
<strong>Date & Time:</strong>
<span>{{.Booking.ScheduledAt.Format "January 2, 2006 at 3:04 PM"}}</span>
</div>
<div class="detail-row">
<strong>Duration:</strong>
<span>{{.Booking.Duration}} minutes</span>
</div>
<div class="detail-row">
<strong>Amount Paid:</strong>
<span>${{printf "%.2f" .Booking.Amount}}</span>
</div>
<div class="detail-row">
<strong>Payment ID:</strong>
<span>{{.Booking.PaymentID}}</span>
</div>
</div>
<p>You will receive meeting details closer to your appointment time.</p>
<p>If you need to reschedule or have any questions, please contact us at {{.SupportEmail}}.</p>
</div>
<div class="footer">
<p>Best regards,<br>The {{.CompanyName}} Team</p>
</div>
</body>
</html>`
// Payment failed template
paymentFailedTemplate := `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment Failed</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #dc3545; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background-color: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; }
.footer { text-align: center; margin-top: 20px; font-size: 12px; color: #666; }
.error-icon { font-size: 48px; color: #dc3545; text-align: center; margin: 20px 0; }
.booking-details { background-color: white; padding: 20px; border-radius: 4px; margin: 20px 0; }
.button { display: inline-block; background-color: #4a90e2; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 10px 0; }
</style>
</head>
<body>
<div class="header">
<h1>Payment Failed</h1>
</div>
<div class="content">
<div class="error-icon"></div>
<h2>Dear {{.User.FirstName}},</h2>
<p>Unfortunately, your payment could not be processed and your booking was not confirmed.</p>
<div class="booking-details">
<h3>Attempted Booking Details:</h3>
<ul>
<li><strong>Date & Time:</strong> {{.Booking.ScheduledAt.Format "January 2, 2006 at 3:04 PM"}}</li>
<li><strong>Duration:</strong> {{.Booking.Duration}} minutes</li>
<li><strong>Amount:</strong> ${{printf "%.2f" .Booking.Amount}}</li>
</ul>
</div>
<p>Please try booking again or contact us at {{.SupportEmail}} if you continue to experience issues.</p>
<p>Common reasons for payment failure:</p>
<ul>
<li>Insufficient funds</li>
<li>Incorrect card details</li>
<li>Card expired or blocked</li>
<li>Bank security restrictions</li>
</ul>
</div>
<div class="footer">
<p>Best regards,<br>The {{.CompanyName}} Team</p>
</div>
</body>
</html>`
// Meeting info template
meetingInfoTemplate := `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meeting Information</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #17a2b8; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background-color: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; }
.footer { text-align: center; margin-top: 20px; font-size: 12px; color: #666; }
.meeting-details { background-color: white; padding: 20px; border-radius: 4px; margin: 20px 0; }
.join-button { display: inline-block; background-color: #28a745; color: white; padding: 15px 30px; text-decoration: none; border-radius: 4px; margin: 15px 0; font-weight: bold; }
.checklist { background-color: #e9f7ef; padding: 15px; border-radius: 4px; margin: 15px 0; }
</style>
</head>
<body>
<div class="header">
<h1>Your Therapy Session Details</h1>
</div>
<div class="content">
<h2>Dear {{.User.FirstName}},</h2>
<p>Here are the details for your upcoming therapy session:</p>
<div class="meeting-details">
<h3>Meeting Information:</h3>
<ul>
<li><strong>Date & Time:</strong> {{.Booking.ScheduledAt.Format "January 2, 2006 at 3:04 PM"}}</li>
<li><strong>Duration:</strong> {{.Booking.Duration}} minutes</li>
<li><strong>Meeting Room:</strong> {{.Booking.JitsiRoomID}}</li>
</ul>
<div style="text-align: center;">
<a href="{{.Booking.JitsiRoomURL}}" class="join-button">Join Meeting</a>
</div>
</div>
<div class="checklist">
<h3>Important Notes:</h3>
<ul>
<li> Please join the meeting 5 minutes before the scheduled time</li>
<li> Ensure you have a stable internet connection</li>
<li> Test your camera and microphone beforehand</li>
<li> Find a quiet, private space for the session</li>
</ul>
</div>
<p>If you need to reschedule or have any questions, please contact us at {{.SupportEmail}} as soon as possible.</p>
</div>
<div class="footer">
<p>Best regards,<br>The {{.CompanyName}} Team</p>
</div>
</body>
</html>`
// Reminder template
reminderTemplate := `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Session Reminder</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #ffc107; color: #333; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background-color: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; }
.footer { text-align: center; margin-top: 20px; font-size: 12px; color: #666; }
.reminder-icon { font-size: 48px; text-align: center; margin: 20px 0; }
.session-details { background-color: white; padding: 20px; border-radius: 4px; margin: 20px 0; }
.join-button { display: inline-block; background-color: #28a745; color: white; padding: 15px 30px; text-decoration: none; border-radius: 4px; margin: 15px 0; font-weight: bold; }
.checklist { background-color: #fff3cd; padding: 15px; border-radius: 4px; margin: 15px 0; }
</style>
</head>
<body>
<div class="header">
<h1>Session Reminder</h1>
</div>
<div class="content">
<div class="reminder-icon"></div>
<h2>Dear {{.User.FirstName}},</h2>
<p>This is a friendly reminder that you have a therapy session scheduled {{.ReminderText}}.</p>
<div class="session-details">
<h3>Session Details:</h3>
<ul>
<li><strong>Date & Time:</strong> {{.Booking.ScheduledAt.Format "January 2, 2006 at 3:04 PM"}}</li>
<li><strong>Duration:</strong> {{.Booking.Duration}} minutes</li>
</ul>
<div style="text-align: center;">
<a href="{{.Booking.JitsiRoomURL}}" class="join-button">Join Meeting</a>
</div>
</div>
<div class="checklist">
<h3>Preparation Checklist:</h3>
<ul>
<li> Test your camera and microphone</li>
<li> Ensure stable internet connection</li>
<li> Find a quiet, private space</li>
<li> Have any notes or questions ready</li>
</ul>
</div>
<p>We look forward to seeing you soon!</p>
</div>
<div class="footer">
<p>Best regards,<br>The {{.CompanyName}} Team</p>
</div>
</body>
</html>`
// 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)
}
}