diff --git a/.env b/.env new file mode 100644 index 0000000..f6b87fe --- /dev/null +++ b/.env @@ -0,0 +1,33 @@ +# Server Configuration +PORT=8080 +HOST=localhost + +# Database Configuration +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=123 +DB_NAME=booking_system +DB_SSLMODE=disable + +# JWT Configuration +JWT_SECRET=your-super-secret-jwt-key +JWT_EXPIRATION=24h + +# Stripe Configuration +STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key +STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret +STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key + +# SMTP Configuration +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USERNAME=your_email@gmail.com +SMTP_PASSWORD=your_app_password +SMTP_FROM=your_email@gmail.com + +# Jitsi Configuration +JITSI_BASE_URL=https://meet.jit.si +JITSI_API_KEY=your_jitsi_api_key +JITSI_APP_ID=your_jitsi_app_id +JITSI_PRIVATE_KEY=your_jitsi_private_key \ No newline at end of file diff --git a/go.mod b/go.mod index 8eb0006..4a5f05f 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,11 @@ go 1.25.1 require ( github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/joho/godotenv v1.5.1 github.com/spf13/cobra v1.8.0 github.com/stripe/stripe-go/v76 v76.25.0 + golang.org/x/crypto v0.31.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 ) @@ -40,7 +42,6 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.31.0 // indirect golang.org/x/net v0.21.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.28.0 // indirect diff --git a/go.sum b/go.sum index 05b4879..a6031fa 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index ffbbcba..08b94e3 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -1,23 +1,147 @@ package middleware import ( + "net/http" + "strings" + + "attune-heart-therapy/internal/services" + "github.com/gin-gonic/gin" ) -// AuthMiddleware validates JWT tokens -func AuthMiddleware() gin.HandlerFunc { +// AuthMiddleware creates a middleware for JWT authentication +func AuthMiddleware(jwtService services.JWTService) gin.HandlerFunc { return func(c *gin.Context) { - // Will be implemented in task 5 - c.JSON(501, gin.H{"message": "Auth middleware not implemented yet"}) - c.Abort() + // Get token from Authorization header + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "Authorization header is required", + }) + c.Abort() + return + } + + // Check if header starts with "Bearer " + tokenParts := strings.SplitN(authHeader, " ", 2) + if len(tokenParts) != 2 || tokenParts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "Invalid authorization header format. Expected: Bearer ", + }) + c.Abort() + return + } + + tokenString := tokenParts[1] + if tokenString == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "Token is required", + }) + c.Abort() + return + } + + // Validate the token + claims, err := jwtService.ValidateToken(tokenString) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "Invalid or expired token", + }) + c.Abort() + return + } + + // Set user information in context for use in handlers + c.Set("user_id", claims.UserID) + c.Set("user_email", claims.Email) + c.Set("is_admin", claims.IsAdmin) + c.Set("jwt_claims", claims) + + c.Next() } } -// AdminMiddleware ensures user has admin privileges +// GetUserIDFromContext extracts user ID from Gin context +func GetUserIDFromContext(c *gin.Context) (uint, bool) { + userID, exists := c.Get("user_id") + if !exists { + return 0, false + } + + id, ok := userID.(uint) + return id, ok +} + +// GetUserEmailFromContext extracts user email from Gin context +func GetUserEmailFromContext(c *gin.Context) (string, bool) { + email, exists := c.Get("user_email") + if !exists { + return "", false + } + + emailStr, ok := email.(string) + return emailStr, ok +} + +// IsAdminFromContext checks if user is admin from Gin context +func IsAdminFromContext(c *gin.Context) bool { + isAdmin, exists := c.Get("is_admin") + if !exists { + return false + } + + admin, ok := isAdmin.(bool) + return ok && admin +} + +// GetJWTClaimsFromContext extracts JWT claims from Gin context +func GetJWTClaimsFromContext(c *gin.Context) (*services.JWTClaims, bool) { + claims, exists := c.Get("jwt_claims") + if !exists { + return nil, false + } + + jwtClaims, ok := claims.(*services.JWTClaims) + return jwtClaims, ok +} + +// AdminMiddleware creates a middleware for admin authorization +// This middleware should be used after AuthMiddleware to ensure user is authenticated first func AdminMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - // Will be implemented in task 5 - c.JSON(501, gin.H{"message": "Admin middleware not implemented yet"}) - c.Abort() + // Check if user is authenticated (should be set by AuthMiddleware) + userID, exists := GetUserIDFromContext(c) + if !exists || userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "Authentication required", + }) + c.Abort() + return + } + + // Check if user has admin privileges + if !IsAdminFromContext(c) { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Admin privileges required", + }) + c.Abort() + return + } + + c.Next() } } + +// RequireAdmin is a convenience function that combines auth and admin middleware +func RequireAdmin(jwtService services.JWTService) gin.HandlerFunc { + return gin.HandlerFunc(func(c *gin.Context) { + // First authenticate the user + AuthMiddleware(jwtService)(c) + if c.IsAborted() { + return + } + + // Then check admin privileges + AdminMiddleware()(c) + }) +} diff --git a/internal/repositories/booking_repository.go b/internal/repositories/booking_repository.go new file mode 100644 index 0000000..5e6cc16 --- /dev/null +++ b/internal/repositories/booking_repository.go @@ -0,0 +1,133 @@ +package repositories + +import ( + "errors" + "fmt" + "time" + + "attune-heart-therapy/internal/models" + + "gorm.io/gorm" +) + +// bookingRepository implements the BookingRepository interface +type bookingRepository struct { + db *gorm.DB +} + +// NewBookingRepository creates a new instance of BookingRepository +func NewBookingRepository(db *gorm.DB) BookingRepository { + return &bookingRepository{ + db: db, + } +} + +// Create creates a new booking in the database +func (r *bookingRepository) Create(booking *models.Booking) error { + if booking == nil { + return errors.New("booking cannot be nil") + } + + if err := r.db.Create(booking).Error; err != nil { + return fmt.Errorf("failed to create booking: %w", err) + } + + return nil +} + +// GetByID retrieves a booking by its ID with user preloaded +func (r *bookingRepository) GetByID(id uint) (*models.Booking, error) { + if id == 0 { + return nil, errors.New("invalid booking ID") + } + + var booking models.Booking + if err := r.db.Preload("User").First(&booking, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("booking with ID %d not found", id) + } + return nil, fmt.Errorf("failed to get booking by ID: %w", err) + } + + return &booking, nil +} + +// GetByUserID retrieves all bookings for a specific user with user preloaded +func (r *bookingRepository) GetByUserID(userID uint) ([]models.Booking, error) { + if userID == 0 { + return nil, errors.New("invalid user ID") + } + + var bookings []models.Booking + if err := r.db.Preload("User").Where("user_id = ?", userID). + Order("scheduled_at DESC").Find(&bookings).Error; err != nil { + return nil, fmt.Errorf("failed to get bookings for user %d: %w", userID, err) + } + + return bookings, nil +} + +// Update updates an existing booking in the database +func (r *bookingRepository) Update(booking *models.Booking) error { + if booking == nil { + return errors.New("booking cannot be nil") + } + + if booking.ID == 0 { + return errors.New("booking ID is required for update") + } + + // Check if booking exists + var existingBooking models.Booking + if err := r.db.First(&existingBooking, booking.ID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("booking with ID %d not found", booking.ID) + } + return fmt.Errorf("failed to check booking existence: %w", err) + } + + // Update the booking + if err := r.db.Save(booking).Error; err != nil { + return fmt.Errorf("failed to update booking: %w", err) + } + + return nil +} + +// Delete soft deletes a booking by its ID +func (r *bookingRepository) Delete(id uint) error { + if id == 0 { + return errors.New("invalid booking ID") + } + + // Check if booking exists + var booking models.Booking + if err := r.db.First(&booking, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("booking with ID %d not found", id) + } + return fmt.Errorf("failed to check booking existence: %w", err) + } + + // Soft delete the booking + if err := r.db.Delete(&booking).Error; err != nil { + return fmt.Errorf("failed to delete booking: %w", err) + } + + return nil +} + +// GetUpcomingBookings retrieves all upcoming bookings for notification scheduling +func (r *bookingRepository) GetUpcomingBookings() ([]models.Booking, error) { + var bookings []models.Booking + + // Get bookings that are scheduled and in the future + if err := r.db.Preload("User"). + Where("status = ? AND scheduled_at > ?", models.BookingStatusScheduled, time.Now()). + Order("scheduled_at ASC"). + Find(&bookings).Error; err != nil { + return nil, fmt.Errorf("failed to get upcoming bookings: %w", err) + } + + return bookings, nil +} diff --git a/internal/repositories/interfaces.go b/internal/repositories/interfaces.go index 5b26c57..96de232 100644 --- a/internal/repositories/interfaces.go +++ b/internal/repositories/interfaces.go @@ -31,6 +31,8 @@ type ScheduleRepository interface { GetAvailable(date time.Time) ([]models.Schedule, error) Update(schedule *models.Schedule) error GetByID(id uint) (*models.Schedule, error) + IncrementBookedCount(scheduleID uint) error + DecrementBookedCount(scheduleID uint) error } // NotificationRepository handles notification data persistence diff --git a/internal/repositories/notification_repository.go b/internal/repositories/notification_repository.go new file mode 100644 index 0000000..8f9b7e2 --- /dev/null +++ b/internal/repositories/notification_repository.go @@ -0,0 +1,93 @@ +package repositories + +import ( + "errors" + "fmt" + + "attune-heart-therapy/internal/models" + + "gorm.io/gorm" +) + +// notificationRepository implements the NotificationRepository interface +type notificationRepository struct { + db *gorm.DB +} + +// NewNotificationRepository creates a new instance of NotificationRepository +func NewNotificationRepository(db *gorm.DB) NotificationRepository { + return ¬ificationRepository{ + db: db, + } +} + +// Create creates a new notification in the database +func (r *notificationRepository) Create(notification *models.Notification) error { + if notification == nil { + return errors.New("notification cannot be nil") + } + + if err := r.db.Create(notification).Error; err != nil { + return fmt.Errorf("failed to create notification: %w", err) + } + + return nil +} + +// GetByID retrieves a notification by its ID with user and booking preloaded +func (r *notificationRepository) GetByID(id uint) (*models.Notification, error) { + if id == 0 { + return nil, errors.New("invalid notification ID") + } + + var notification models.Notification + if err := r.db.Preload("User").Preload("Booking").First(¬ification, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("notification with ID %d not found", id) + } + return nil, fmt.Errorf("failed to get notification by ID: %w", err) + } + + return ¬ification, nil +} + +// Update updates an existing notification in the database +func (r *notificationRepository) Update(notification *models.Notification) error { + if notification == nil { + return errors.New("notification cannot be nil") + } + + if notification.ID == 0 { + return errors.New("notification ID is required for update") + } + + // Check if notification exists + var existingNotification models.Notification + if err := r.db.First(&existingNotification, notification.ID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("notification with ID %d not found", notification.ID) + } + return fmt.Errorf("failed to check notification existence: %w", err) + } + + // Update the notification + if err := r.db.Save(notification).Error; err != nil { + return fmt.Errorf("failed to update notification: %w", err) + } + + return nil +} + +// GetPendingNotifications retrieves all notifications that are ready to be sent +func (r *notificationRepository) GetPendingNotifications() ([]models.Notification, error) { + var notifications []models.Notification + + if err := r.db.Preload("User").Preload("Booking"). + Where("status = ? AND (scheduled_at IS NULL OR scheduled_at <= NOW())", models.NotificationStatusPending). + Order("created_at ASC"). + Find(¬ifications).Error; err != nil { + return nil, fmt.Errorf("failed to get pending notifications: %w", err) + } + + return notifications, nil +} diff --git a/internal/repositories/repository.go b/internal/repositories/repository.go new file mode 100644 index 0000000..4e8c8eb --- /dev/null +++ b/internal/repositories/repository.go @@ -0,0 +1,21 @@ +package repositories + +import "gorm.io/gorm" + +// Repositories holds all repository instances +type Repositories struct { + User UserRepository + Booking BookingRepository + Schedule ScheduleRepository + Notification NotificationRepository +} + +// NewRepositories creates and returns all repository instances +func NewRepositories(db *gorm.DB) *Repositories { + return &Repositories{ + User: NewUserRepository(db), + Booking: NewBookingRepository(db), + Schedule: NewScheduleRepository(db), + Notification: NewNotificationRepository(db), + } +} diff --git a/internal/repositories/schedule_repository.go b/internal/repositories/schedule_repository.go new file mode 100644 index 0000000..6a24433 --- /dev/null +++ b/internal/repositories/schedule_repository.go @@ -0,0 +1,205 @@ +package repositories + +import ( + "errors" + "fmt" + "time" + + "attune-heart-therapy/internal/models" + + "gorm.io/gorm" +) + +// scheduleRepository implements the ScheduleRepository interface +type scheduleRepository struct { + db *gorm.DB +} + +// NewScheduleRepository creates a new instance of ScheduleRepository +func NewScheduleRepository(db *gorm.DB) ScheduleRepository { + return &scheduleRepository{ + db: db, + } +} + +// Create creates a new schedule slot in the database +func (r *scheduleRepository) Create(schedule *models.Schedule) error { + if schedule == nil { + return errors.New("schedule cannot be nil") + } + + // Check for overlapping schedules + var count int64 + if err := r.db.Model(&models.Schedule{}). + Where("((start_time <= ? AND end_time > ?) OR (start_time < ? AND end_time >= ?)) AND is_available = ?", + schedule.StartTime, schedule.StartTime, schedule.EndTime, schedule.EndTime, true). + Count(&count).Error; err != nil { + return fmt.Errorf("failed to check for overlapping schedules: %w", err) + } + + if count > 0 { + return errors.New("schedule slot overlaps with existing available slot") + } + + if err := r.db.Create(schedule).Error; err != nil { + return fmt.Errorf("failed to create schedule: %w", err) + } + + return nil +} + +// GetAvailable retrieves all available schedule slots for a specific date +func (r *scheduleRepository) GetAvailable(date time.Time) ([]models.Schedule, error) { + var schedules []models.Schedule + + // Get the start and end of the day + startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + endOfDay := startOfDay.Add(24 * time.Hour) + + if err := r.db.Where("is_available = ? AND start_time >= ? AND start_time < ? AND booked_count < max_bookings", + true, startOfDay, endOfDay). + Order("start_time ASC"). + Find(&schedules).Error; err != nil { + return nil, fmt.Errorf("failed to get available schedules for date %s: %w", date.Format("2006-01-02"), err) + } + + // Filter out slots that are in the past + now := time.Now() + var availableSchedules []models.Schedule + for _, schedule := range schedules { + if schedule.StartTime.After(now) && schedule.IsAvailableForBooking() { + availableSchedules = append(availableSchedules, schedule) + } + } + + return availableSchedules, nil +} + +// Update updates an existing schedule slot in the database +func (r *scheduleRepository) Update(schedule *models.Schedule) error { + if schedule == nil { + return errors.New("schedule cannot be nil") + } + + if schedule.ID == 0 { + return errors.New("schedule ID is required for update") + } + + // Check if schedule exists + var existingSchedule models.Schedule + if err := r.db.First(&existingSchedule, schedule.ID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("schedule with ID %d not found", schedule.ID) + } + return fmt.Errorf("failed to check schedule existence: %w", err) + } + + // If updating time slots, check for overlaps (excluding current schedule) + if schedule.StartTime != existingSchedule.StartTime || schedule.EndTime != existingSchedule.EndTime { + var count int64 + if err := r.db.Model(&models.Schedule{}). + Where("id != ? AND ((start_time <= ? AND end_time > ?) OR (start_time < ? AND end_time >= ?)) AND is_available = ?", + schedule.ID, schedule.StartTime, schedule.StartTime, schedule.EndTime, schedule.EndTime, true). + Count(&count).Error; err != nil { + return fmt.Errorf("failed to check for overlapping schedules: %w", err) + } + + if count > 0 { + return errors.New("updated schedule slot would overlap with existing available slot") + } + } + + // Update the schedule + if err := r.db.Save(schedule).Error; err != nil { + return fmt.Errorf("failed to update schedule: %w", err) + } + + return nil +} + +// GetByID retrieves a schedule slot by its ID +func (r *scheduleRepository) GetByID(id uint) (*models.Schedule, error) { + if id == 0 { + return nil, errors.New("invalid schedule ID") + } + + var schedule models.Schedule + if err := r.db.First(&schedule, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("schedule with ID %d not found", id) + } + return nil, fmt.Errorf("failed to get schedule by ID: %w", err) + } + + return &schedule, nil +} + +// IncrementBookedCount atomically increments the booked count for a schedule slot +// This method handles concurrent booking scenarios +func (r *scheduleRepository) IncrementBookedCount(scheduleID uint) error { + if scheduleID == 0 { + return errors.New("invalid schedule ID") + } + + // Use a transaction to ensure atomicity + return r.db.Transaction(func(tx *gorm.DB) error { + var schedule models.Schedule + + // Lock the row for update to prevent race conditions + if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&schedule, scheduleID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("schedule with ID %d not found", scheduleID) + } + return fmt.Errorf("failed to get schedule for update: %w", err) + } + + // Check if slot is still available + if !schedule.IsAvailableForBooking() { + return errors.New("schedule slot is no longer available for booking") + } + + // Increment booked count + schedule.BookedCount++ + + if err := tx.Save(&schedule).Error; err != nil { + return fmt.Errorf("failed to increment booked count: %w", err) + } + + return nil + }) +} + +// DecrementBookedCount atomically decrements the booked count for a schedule slot +// This method is used when a booking is cancelled +func (r *scheduleRepository) DecrementBookedCount(scheduleID uint) error { + if scheduleID == 0 { + return errors.New("invalid schedule ID") + } + + // Use a transaction to ensure atomicity + return r.db.Transaction(func(tx *gorm.DB) error { + var schedule models.Schedule + + // Lock the row for update to prevent race conditions + if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&schedule, scheduleID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("schedule with ID %d not found", scheduleID) + } + return fmt.Errorf("failed to get schedule for update: %w", err) + } + + // Check if booked count can be decremented + if schedule.BookedCount <= 0 { + return errors.New("booked count is already zero") + } + + // Decrement booked count + schedule.BookedCount-- + + if err := tx.Save(&schedule).Error; err != nil { + return fmt.Errorf("failed to decrement booked count: %w", err) + } + + return nil + }) +} diff --git a/internal/repositories/user_repository.go b/internal/repositories/user_repository.go new file mode 100644 index 0000000..66c8d0d --- /dev/null +++ b/internal/repositories/user_repository.go @@ -0,0 +1,112 @@ +package repositories + +import ( + "errors" + "fmt" + + "attune-heart-therapy/internal/models" + + "gorm.io/gorm" +) + +// userRepository implements the UserRepository interface +type userRepository struct { + db *gorm.DB +} + +// NewUserRepository creates a new instance of UserRepository +func NewUserRepository(db *gorm.DB) UserRepository { + return &userRepository{ + db: db, + } +} + +// Create creates a new user in the database +func (r *userRepository) Create(user *models.User) error { + if user == nil { + return errors.New("user cannot be nil") + } + + if err := r.db.Create(user).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("user with email %s already exists", user.Email) + } + return fmt.Errorf("failed to create user: %w", err) + } + + return nil +} + +// GetByID retrieves a user by their ID +func (r *userRepository) GetByID(id uint) (*models.User, error) { + if id == 0 { + return nil, errors.New("invalid user ID") + } + + var user models.User + if err := r.db.First(&user, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("user with ID %d not found", id) + } + return nil, fmt.Errorf("failed to get user by ID: %w", err) + } + + return &user, nil +} + +// GetByEmail retrieves a user by their email address +func (r *userRepository) GetByEmail(email string) (*models.User, error) { + if email == "" { + return nil, errors.New("email cannot be empty") + } + + var user models.User + if err := r.db.Where("email = ?", email).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("user with email %s not found", email) + } + return nil, fmt.Errorf("failed to get user by email: %w", err) + } + + return &user, nil +} + +// Update updates an existing user in the database +func (r *userRepository) Update(user *models.User) error { + if user == nil { + return errors.New("user cannot be nil") + } + + if user.ID == 0 { + return errors.New("user ID is required for update") + } + + // Check if user exists + var existingUser models.User + if err := r.db.First(&existingUser, user.ID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("user with ID %d not found", user.ID) + } + return fmt.Errorf("failed to check user existence: %w", err) + } + + // Update the user + if err := r.db.Save(user).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("user with email %s already exists", user.Email) + } + return fmt.Errorf("failed to update user: %w", err) + } + + return nil +} + +// GetActiveUsersCount returns the count of active (non-deleted) users +func (r *userRepository) GetActiveUsersCount() (int64, error) { + var count int64 + if err := r.db.Model(&models.User{}).Count(&count).Error; err != nil { + return 0, fmt.Errorf("failed to get active users count: %w", err) + } + + return count, nil +} diff --git a/internal/services/jwt_service.go b/internal/services/jwt_service.go new file mode 100644 index 0000000..13662b5 --- /dev/null +++ b/internal/services/jwt_service.go @@ -0,0 +1,106 @@ +package services + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// JWTClaims represents the JWT token claims +type JWTClaims struct { + UserID uint `json:"user_id"` + Email string `json:"email"` + IsAdmin bool `json:"is_admin"` + jwt.RegisteredClaims +} + +// JWTService handles JWT token operations +type JWTService interface { + GenerateToken(userID uint, email string, isAdmin bool) (string, error) + ValidateToken(tokenString string) (*JWTClaims, error) + RefreshToken(tokenString string) (string, error) +} + +type jwtService struct { + secretKey string + expiration time.Duration +} + +// NewJWTService creates a new JWT service instance +func NewJWTService(secretKey string, expiration time.Duration) JWTService { + return &jwtService{ + secretKey: secretKey, + expiration: expiration, + } +} + +// GenerateToken creates a new JWT token for the given user +func (j *jwtService) GenerateToken(userID uint, email string, isAdmin bool) (string, error) { + if j.secretKey == "" { + return "", errors.New("JWT secret key is not configured") + } + + now := time.Now() + claims := &JWTClaims{ + UserID: userID, + Email: email, + IsAdmin: isAdmin, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(j.expiration)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + Issuer: "booking-system", + Subject: email, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(j.secretKey)) + if err != nil { + return "", err + } + + return tokenString, nil +} + +// ValidateToken validates and parses a JWT token +func (j *jwtService) ValidateToken(tokenString string) (*JWTClaims, error) { + if j.secretKey == "" { + return nil, errors.New("JWT secret key is not configured") + } + + token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { + // Validate the signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("invalid signing method") + } + return []byte(j.secretKey), nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token claims") +} + +// RefreshToken generates a new token from an existing valid token +func (j *jwtService) RefreshToken(tokenString string) (string, error) { + claims, err := j.ValidateToken(tokenString) + if err != nil { + return "", err + } + + // Check if token is close to expiration (within 1 hour) + if time.Until(claims.ExpiresAt.Time) > time.Hour { + return "", errors.New("token is not eligible for refresh yet") + } + + // Generate new token with same user information + return j.GenerateToken(claims.UserID, claims.Email, claims.IsAdmin) +}