feat(auth): Implement comprehensive authentication and authorization middleware
- Add JWT authentication middleware with token validation - Implement user context extraction methods for user ID, email, and admin status - Create admin middleware to restrict access to admin-only routes - Add convenience method to combine authentication and admin authorization - Update auth middleware to handle token parsing, validation, and context setting - Enhance error handling for various authentication scenarios - Add new JWT service and related dependencies in go.mod
This commit is contained in:
parent
c9d20afda8
commit
8309f38999
33
.env
Normal file
33
.env
Normal file
@ -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
|
||||
3
go.mod
3
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
@ -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 <token>",
|
||||
})
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
133
internal/repositories/booking_repository.go
Normal file
133
internal/repositories/booking_repository.go
Normal file
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
93
internal/repositories/notification_repository.go
Normal file
93
internal/repositories/notification_repository.go
Normal file
@ -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
|
||||
}
|
||||
21
internal/repositories/repository.go
Normal file
21
internal/repositories/repository.go
Normal file
@ -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),
|
||||
}
|
||||
}
|
||||
205
internal/repositories/schedule_repository.go
Normal file
205
internal/repositories/schedule_repository.go
Normal file
@ -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
|
||||
})
|
||||
}
|
||||
112
internal/repositories/user_repository.go
Normal file
112
internal/repositories/user_repository.go
Normal file
@ -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
|
||||
}
|
||||
106
internal/services/jwt_service.go
Normal file
106
internal/services/jwt_service.go
Normal file
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user