diff --git a/back-end/.gitignore b/back-end/.gitignore new file mode 100644 index 000000000..b9ee84105 --- /dev/null +++ b/back-end/.gitignore @@ -0,0 +1,4 @@ +# Block Environment Secrets +.env +*.env +back-end/.env \ No newline at end of file diff --git a/back-end/cmd/api/main.go b/back-end/cmd/api/main.go new file mode 100644 index 000000000..698dbd516 --- /dev/null +++ b/back-end/cmd/api/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "log" + + "bookmyvenue/config" + "bookmyvenue/internal/domain" + "bookmyvenue/internal/handler" + "bookmyvenue/internal/repository" + "bookmyvenue/internal/router" + "bookmyvenue/internal/service" + "bookmyvenue/pkg/s3" +) + +func main() { + cfg := config.LoadConfig() + + db := config.ConnectDB(cfg) + rdb := config.ConnectRedis(cfg) + + err := db.AutoMigrate(&domain.User{}, + &domain.Admin{}, + &domain.Venue{}, + &domain.Space{}, + &domain.Slot{}, + &domain.Booking{}, + &domain.Payment{}, + &domain.PaymentAuditLog{}, + &domain.Refund{}, + &domain.CancellationPolicy{}, + &domain.VenueEditDraft{}, + &domain.OutboxEvent{},) + + if err != nil { + log.Fatalf("Auto-migration failed: %v", err) + } + + // S3 Client + s3Client, err := s3.NewS3Client(cfg) + if err != nil { + log.Printf("⚠️ S3 Client not initialized: %v (presigned URLs won't work)", err) + } + + // User & Owner + userRepo := repository.NewUserRepository(db) + authService := service.NewAuthService(userRepo, cfg) + authHandler := handler.NewAuthHandler(authService) + + // Admin + adminRepo := repository.NewAdminRepository(db) + adminAuthService := service.NewAdminAuthService(adminRepo, cfg) + adminAuthHandler := handler.NewAdminAuthHandler(adminAuthService) + + // Venue + venueRepo := repository.NewVenueRepository(db) + spaceRepo := repository.NewSpaceRepository(db) + venueService := service.NewVenueService(venueRepo, spaceRepo, s3Client) + venueHandler := handler.NewVenueHandler(venueService) + + adminVenueService := service.NewAdminVenueService(venueRepo) + adminVenueHandler := handler.NewAdminVenueHandler(adminVenueService) + + r := router.SetupRouter(cfg,rdb, authHandler,adminAuthHandler,venueHandler,adminVenueHandler) + + port := fmt.Sprintf(":%s", cfg.ServerPort) + log.Printf("🚀 BookMyVenue server starting on port %s", cfg.ServerPort) + if err := r.Run(port); err != nil { + log.Fatalf("Server failed to start: %v", err) + } +} \ No newline at end of file diff --git a/back-end/config/config.go b/back-end/config/config.go new file mode 100644 index 000000000..cb442ef52 --- /dev/null +++ b/back-end/config/config.go @@ -0,0 +1,135 @@ +package config + +import ( + "log" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + // Database + DBHost string + DBPort string + DBUser string + DBPassword string + DBName string + DBSSLMode string + // Auth + JWTSecret string + AccessTokenExpiryMins int + RefreshTokenExpiryDays int + // Redis + RedisHost string + RedisPort string + RedisPassword string + RedisDB int + // AWS S3 + AWSRegion string + AWSAccessKeyID string + AWSSecretAccessKey string + AWSS3Bucket string + // RabbitMQ + RabbitMQURL string + // Server + ServerPort string +} + +func LoadConfig() *Config { + + if err := godotenv.Load(); err != nil { + log.Println("No .env file found") + } + + dbHost := os.Getenv("DB_HOST") + dbPort := os.Getenv("DB_PORT") + dbUser := os.Getenv("DB_USER") + dbPassword := os.Getenv("DB_PASSWORD") + dbName := os.Getenv("DB_NAME") + dbSSLMode := os.Getenv("DB_SSLMODE") + redisHost := getEnvOrDefault("REDIS_HOST", "localhost") + redisPort := getEnvOrDefault("REDIS_PORT", "6379") + redisPassword := os.Getenv("REDIS_PASSWORD") + redisDB := getEnvAsInt("REDIS_DB", 0) + awsRegion := getEnvOrDefault("AWS_REGION", "ap-south-1") + awsAccessKey := os.Getenv("AWS_ACCESS_KEY_ID") + awsSecretKey := os.Getenv("AWS_SECRET_ACCESS_KEY") + awsBucket := os.Getenv("AWS_S3_BUCKET") + rabbitURL := getEnvOrDefault("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/") + + if dbHost == "" { + dbHost = "localhost" + } + if dbPort == "" { + dbPort = "5432" + } + if dbUser == "" { + dbUser = "postgres" + } + if dbName == "" { + dbName = "bookmyvenue" + } + if dbSSLMode == "" { + dbSSLMode = "disable" + } + + if dbPassword == "" { + log.Fatal("CRITICAL CONFIGURATION ERROR: DB_PASSWORD is not set in the environment variables!") + } + + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + log.Fatal("CRITICAL: JWT_SECRET is not set!") + } + + accessExpiry := getEnvAsInt("ACCESS_TOKEN_EXPIRY_MINS", 15) + refreshExpiry := getEnvAsInt("REFRESH_TOKEN_EXPIRY_DAYS", 30) + + serverPort := getEnvOrDefault("SERVER_PORT", "8080") + + + return &Config{ + DBHost: dbHost, + DBPort: dbPort, + DBUser: dbUser, + DBPassword: dbPassword, + DBName: dbName, + DBSSLMode: dbSSLMode, + JWTSecret: jwtSecret, + AccessTokenExpiryMins: accessExpiry, + RefreshTokenExpiryDays: refreshExpiry, + ServerPort: serverPort, + RedisHost: redisHost, + RedisPort: redisPort, + RedisPassword: redisPassword, + RedisDB: redisDB, + AWSRegion: awsRegion, + AWSAccessKeyID: awsAccessKey, + AWSSecretAccessKey: awsSecretKey, + AWSS3Bucket: awsBucket, + RabbitMQURL: rabbitURL, + } +} + + +func getEnvOrDefault(key, defaultVal string) string { + val := os.Getenv(key) + if val == "" { + return defaultVal + } + return val +} + +func getEnvAsInt(key string, defaultVal int) int { + valStr := os.Getenv(key) + if valStr == "" { + return defaultVal + } + val, err := strconv.Atoi(valStr) + if err != nil { + log.Printf("WARNING: %s is not a valid integer, using default %d", key, defaultVal) + return defaultVal + } + return val +} \ No newline at end of file diff --git a/back-end/config/db.go b/back-end/config/db.go new file mode 100644 index 000000000..9a7ddbb8a --- /dev/null +++ b/back-end/config/db.go @@ -0,0 +1,22 @@ +package config + +import ( + "fmt" + "log" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func ConnectDB(cfg *Config) *gorm.DB { + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s", + cfg.DBHost, cfg.DBUser, cfg.DBPassword, cfg.DBName, cfg.DBPort, cfg.DBSSLMode) + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatalf("CRITICAL DATABASE ERROR: Failed to connect to database: %v", err) + } + + log.Println("Database connection successfully established!") + return db +} \ No newline at end of file diff --git a/back-end/config/redis.go b/back-end/config/redis.go new file mode 100644 index 000000000..c21a77468 --- /dev/null +++ b/back-end/config/redis.go @@ -0,0 +1,26 @@ +package config + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/redis/go-redis/v9" +) + +func ConnectRedis(cfg *Config) *redis.Client { + rdb := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%s", cfg.RedisHost, cfg.RedisPort), + Password: cfg.RedisPassword, + DB: cfg.RedisDB, + }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := rdb.Ping(ctx).Result() + if err != nil { + log.Fatalf("Failed to connect to Redis: %v", err) + } + log.Println("🔌 Connected to Redis successfully!") + return rdb +} \ No newline at end of file diff --git a/back-end/go.mod b/back-end/go.mod new file mode 100644 index 000000000..636d29f1e --- /dev/null +++ b/back-end/go.mod @@ -0,0 +1,72 @@ +module bookmyvenue + +go 1.25.5 + +require ( + github.com/aws/aws-sdk-go-v2 v1.42.0 + github.com/aws/aws-sdk-go-v2/config v1.32.25 + github.com/aws/aws-sdk-go-v2/credentials v1.19.24 + github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 + github.com/gin-gonic/gin v1.12.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.21.0 + golang.org/x/crypto v0.48.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect + github.com/aws/smithy-go v1.27.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/back-end/go.sum b/back-end/go.sum new file mode 100644 index 000000000..13c999794 --- /dev/null +++ b/back-end/go.sum @@ -0,0 +1,162 @@ +github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= +github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls= +github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM= +github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= +github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= +github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/back-end/internal/domain/admin.go b/back-end/internal/domain/admin.go new file mode 100644 index 000000000..835944b9a --- /dev/null +++ b/back-end/internal/domain/admin.go @@ -0,0 +1,16 @@ +package domain + +import ( + "time" + "github.com/google/uuid" +) + +type Admin struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + Name string `gorm:"type:varchar(100);not null"` + Email string `gorm:"type:varchar(255);uniqueIndex;not null"` + PasswordHash string `gorm:"type:varchar(255);not null"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} \ No newline at end of file diff --git a/back-end/internal/domain/booking.go b/back-end/internal/domain/booking.go new file mode 100644 index 000000000..3b9c4c23e --- /dev/null +++ b/back-end/internal/domain/booking.go @@ -0,0 +1,32 @@ +package domain + +import ( + "time" + "github.com/google/uuid" +) + +type Booking struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + + // User Relation + UserID uuid.UUID `gorm:"type:uuid;not null"` + User User `gorm:"foreignKey:UserID"` + + // Space Relation + SpaceID uuid.UUID `gorm:"type:uuid;not null"` + Space Space `gorm:"foreignKey:SpaceID"` + + // Slot Relation + SlotID uuid.UUID `gorm:"type:uuid;not null"` + Slot Slot `gorm:"foreignKey:SlotID"` + + TotalAmount float64 `gorm:"type:decimal(10,2);not null"` + AmountPaid float64 `gorm:"type:decimal(10,2);default:0.00;not null"` + Status string `gorm:"type:varchar(20);default:'pending';not null"` + CancellationReason *string `gorm:"type:text"` + CancelledBy *string `gorm:"type:varchar(20)"` + CancelledAt *time.Time `gorm:"type:timestamp"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} \ No newline at end of file diff --git a/back-end/internal/domain/cancellation_policy.go b/back-end/internal/domain/cancellation_policy.go new file mode 100644 index 000000000..db9a8528d --- /dev/null +++ b/back-end/internal/domain/cancellation_policy.go @@ -0,0 +1,22 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type CancellationPolicy struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + VenueID uuid.UUID `gorm:"type:uuid;uniqueIndex;not null"` + FullRefundDays int `gorm:"default:15;not null"` + FullRefundPercent float64 `gorm:"type:decimal(5,2);default:95.00;not null"` + + PartialRefundDays int `gorm:"default:7;not null"` + PartialRefundPercent float64 `gorm:"type:decimal(5,2);default:50.00;not null"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} \ No newline at end of file diff --git a/back-end/internal/domain/outbox_event.go b/back-end/internal/domain/outbox_event.go new file mode 100644 index 000000000..4565ba7d7 --- /dev/null +++ b/back-end/internal/domain/outbox_event.go @@ -0,0 +1,18 @@ +package domain + +import ( + "time" + "github.com/google/uuid" +) + +type OutboxEvent struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + Topic string `gorm:"type:varchar(100);not null;index"` + Payload string `gorm:"type:text;not null"` + Status string `gorm:"type:varchar(20);default:'pending';not null;index"` + Retries int `gorm:"type:integer;default:0;not null"` + ErrorLog *string `gorm:"type:text"` + + CreatedAt time.Time `gorm:"autoCreateTime;index"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} \ No newline at end of file diff --git a/back-end/internal/domain/payment.go b/back-end/internal/domain/payment.go new file mode 100644 index 000000000..c8a5be465 --- /dev/null +++ b/back-end/internal/domain/payment.go @@ -0,0 +1,25 @@ +package domain + +import ( + "time" + "github.com/google/uuid" +) + +type Payment struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + BookingID uuid.UUID `gorm:"type:uuid;not null"` + Booking Booking `gorm:"foreignKey:BookingID"` + + RazorpayOrderID string `gorm:"type:varchar(100);uniqueIndex;not null"` + RazorpayPaymentID *string `gorm:"type:varchar(100)"` + RazorpaySignature *string `gorm:"type:varchar(255)"` + + Amount float64 `gorm:"type:decimal(10,2);not null"` + Status string `gorm:"type:varchar(20);default:'pending';not null"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + // Relationships + AuditLogs []PaymentAuditLog `gorm:"foreignKey:PaymentID;constraint:OnDelete:CASCADE"` +} \ No newline at end of file diff --git a/back-end/internal/domain/payment_audit_log.go b/back-end/internal/domain/payment_audit_log.go new file mode 100644 index 000000000..5bc011774 --- /dev/null +++ b/back-end/internal/domain/payment_audit_log.go @@ -0,0 +1,17 @@ +package domain + +import ( + "time" + "github.com/google/uuid" +) + +type PaymentAuditLog struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + PaymentID uuid.UUID `gorm:"type:uuid;not null;index"` + Payment Payment `gorm:"foreignKey:PaymentID"` + + FromStatus string `gorm:"type:varchar(20);not null"` + ToStatus string `gorm:"type:varchar(20);not null"` + Metadata string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` +} \ No newline at end of file diff --git a/back-end/internal/domain/refund.go b/back-end/internal/domain/refund.go new file mode 100644 index 000000000..cd19b04df --- /dev/null +++ b/back-end/internal/domain/refund.go @@ -0,0 +1,20 @@ +package domain + +import ( + "time" + "github.com/google/uuid" +) + +type Refund struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + PaymentID uuid.UUID `gorm:"type:uuid;not null"` + Payment Payment `gorm:"foreignKey:PaymentID"` + + RazorpayRefundID string `gorm:"type:varchar(100);uniqueIndex;not null"` + Amount float64 `gorm:"type:decimal(10,2);not null"` + Reason string `gorm:"type:text"` + Status string `gorm:"type:varchar(20);default:'pending';not null"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} \ No newline at end of file diff --git a/back-end/internal/domain/slot.go b/back-end/internal/domain/slot.go new file mode 100644 index 000000000..f36260f5b --- /dev/null +++ b/back-end/internal/domain/slot.go @@ -0,0 +1,22 @@ +package domain + +import ( + "time" + "github.com/google/uuid" +) + +type Slot struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + SpaceID uuid.UUID `gorm:"type:uuid;not null;index"` + Space Space `gorm:"foreignKey:SpaceID"` + + Date time.Time `gorm:"type:date;not null;index"` + IsBooked bool `gorm:"default:false;not null"` + BookingID *uuid.UUID `gorm:"type:uuid;constraint:OnDelete:SET NULL"` + + StartTime *string `gorm:"type:varchar(5)"` + EndTime *string `gorm:"type:varchar(5)"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} \ No newline at end of file diff --git a/back-end/internal/domain/space.go b/back-end/internal/domain/space.go new file mode 100644 index 000000000..0b7544d80 --- /dev/null +++ b/back-end/internal/domain/space.go @@ -0,0 +1,28 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Space struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + + // Venue Relation + VenueID uuid.UUID `gorm:"type:uuid;not null"` + Venue Venue `gorm:"foreignKey:VenueID"` + + Name string `gorm:"type:varchar(100);not null"` + Capacity int `gorm:"not null"` + Price float64 `gorm:"type:decimal(10,2);not null"` + BookingType string `gorm:"type:varchar(20);default:'daily';not null"` + Images []string `gorm:"serializer:json"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` + + Slots []Slot `gorm:"foreignKey:SpaceID;constraint:OnDelete:CASCADE"` +} \ No newline at end of file diff --git a/back-end/internal/domain/user.go b/back-end/internal/domain/user.go new file mode 100644 index 000000000..536a6d24c --- /dev/null +++ b/back-end/internal/domain/user.go @@ -0,0 +1,23 @@ + package domain + + import ( + "time" + "github.com/google/uuid" + ) + + type User struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + Name string `gorm:"type:varchar(100);not null"` + Email string `gorm:"type:varchar(255);uniqueIndex;not null"` + PasswordHash string `gorm:"type:varchar(255);not null"` + Status string `gorm:"type:varchar(20);default:'active';not null"` + Role string `gorm:"type:varchar(20);default:'user';not null"` + + // Owner Specific Fields + Phone *string `gorm:"type:varchar(15)"` + BusinessName *string `gorm:"type:varchar(150)"` + GSTNumber *string `gorm:"type:varchar(15)"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + } \ No newline at end of file diff --git a/back-end/internal/domain/venue.go b/back-end/internal/domain/venue.go new file mode 100644 index 000000000..44a190461 --- /dev/null +++ b/back-end/internal/domain/venue.go @@ -0,0 +1,32 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Venue struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + OwnerID uuid.UUID `gorm:"type:uuid;not null"` + Owner User `gorm:"foreignKey:OwnerID"` + Name string `gorm:"type:varchar(150);not null"` + Description string `gorm:"type:text;not null"` + Type string `gorm:"type:varchar(50);index;not null"` + Address string `gorm:"type:text;not null"` + City string `gorm:"type:varchar(100);index;not null"` + Rules string `gorm:"type:text"` + Timings string `gorm:"type:varchar(100)"` + Status string `gorm:"type:varchar(20);default:'pending';not null"` + RejectionReason *string `gorm:"type:text"` + // S3 image + Images []string `gorm:"serializer:json"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` + // Relationships + Spaces []Space `gorm:"foreignKey:VenueID;constraint:OnDelete:CASCADE"` + CancellationPolicy *CancellationPolicy `gorm:"foreignKey:VenueID;constraint:OnDelete:CASCADE"` + VenueEditDrafts []VenueEditDraft `gorm:"foreignKey:VenueID;constraint:OnDelete:CASCADE"` +} \ No newline at end of file diff --git a/back-end/internal/domain/venue_edit_draft.go b/back-end/internal/domain/venue_edit_draft.go new file mode 100644 index 000000000..ea101be2b --- /dev/null +++ b/back-end/internal/domain/venue_edit_draft.go @@ -0,0 +1,30 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type VenueEditDraft struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + VenueID uuid.UUID `gorm:"type:uuid;not null;index"` + Venue Venue `gorm:"foreignKey:VenueID;constraint:OnDelete:CASCADE"` + + Name string `gorm:"type:varchar(150);not null"` + Description string `gorm:"type:text;not null"` + Type string `gorm:"type:varchar(50);not null"` + Address string `gorm:"type:text;not null"` + City string `gorm:"type:varchar(100);not null"` + Rules string `gorm:"type:text"` + Timings string `gorm:"type:varchar(100)"` + Images []string `gorm:"serializer:json"` + + Status string `gorm:"type:varchar(20);default:'pending_review';not null;index"` + AdminNote *string `gorm:"type:text"` + + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} \ No newline at end of file diff --git a/back-end/internal/handler/admin_auth_handler.go b/back-end/internal/handler/admin_auth_handler.go new file mode 100644 index 000000000..bf0d04cc1 --- /dev/null +++ b/back-end/internal/handler/admin_auth_handler.go @@ -0,0 +1,46 @@ +package handler + +import ( + "bookmyvenue/internal/service" + "net/http" + + "github.com/gin-gonic/gin" +) + +type AdminAuthHandler struct { + adminAuthService service.AdminAuthService +} + +func NewAdminAuthHandler(adminAuthService service.AdminAuthService) *AdminAuthHandler { + return &AdminAuthHandler{adminAuthService: adminAuthService} +} + +func (h *AdminAuthHandler) Login(c *gin.Context) { + var req service.LoginRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + if req.Email == "" || req.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"}) + return + } + + response, err := h.adminAuthService.Login(req) + + if err != nil { + if err.Error() == "invalid email or password" { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "admin login successful", + "data": response, + }) +} \ No newline at end of file diff --git a/back-end/internal/handler/admin_venue_handler.go b/back-end/internal/handler/admin_venue_handler.go new file mode 100644 index 000000000..e0dab2614 --- /dev/null +++ b/back-end/internal/handler/admin_venue_handler.go @@ -0,0 +1,133 @@ +package handler + +import ( + "bookmyvenue/internal/service" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type AdminVenueHandler struct { + adminService service.AdminVenueService +} + +func NewAdminVenueHandler(adminService service.AdminVenueService) *AdminVenueHandler { + return &AdminVenueHandler{adminService: adminService} +} + +func (h *AdminVenueHandler) GetPendingVenues(c *gin.Context) { + venues, err := h.adminService.GetPendingVenues() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "pending venues fetched successfully", + "count": len(venues), + "data": venues, + }) +} + +func (h *AdminVenueHandler) ApproveVenue(c *gin.Context) { + venueID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid venue ID"}) + return + } + err = h.adminService.ApproveVenue(venueID) + if err != nil { + if err.Error() == "venue not found" { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "venue approved successfully: it is now active", + }) +} + +func (h *AdminVenueHandler) RejectVenue(c *gin.Context) { + venueID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid venue ID"}) + return + } + var req service.RejectVenueRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + err = h.adminService.RejectVenue(venueID, req.Reason) + if err != nil { + if err.Error() == "venue not found" { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "venue rejected successfully", + }) +} + +func (h *AdminVenueHandler) GetPendingDrafts(c *gin.Context) { + drafts, err := h.adminService.GetPendingDrafts() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "pending edit drafts fetched successfully", + "count": len(drafts), + "data": drafts, + }) +} + +func (h *AdminVenueHandler) ApproveEditDraft(c *gin.Context) { + draftID, err := uuid.Parse(c.Param("draft_id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft ID"}) + return + } + err = h.adminService.ApproveEditDraft(draftID) + if err != nil { + if err.Error() == "edit draft not found" { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "venue draft approved successfully: changes merged to live table", + }) +} + +func (h *AdminVenueHandler) RejectEditDraft(c *gin.Context) { + draftID, err := uuid.Parse(c.Param("draft_id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft ID"}) + return + } + var req service.RejectDraftRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + err = h.adminService.RejectEditDraft(draftID, req.AdminNote) + if err != nil { + if err.Error() == "edit draft not found" { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "venue draft rejected successfully", + }) +} diff --git a/back-end/internal/handler/auth_handler.go b/back-end/internal/handler/auth_handler.go new file mode 100644 index 000000000..d043e6c74 --- /dev/null +++ b/back-end/internal/handler/auth_handler.go @@ -0,0 +1,111 @@ +package handler + +import ( + "bookmyvenue/internal/service" + "net/http" + + "github.com/gin-gonic/gin" +) + +type AuthHandler struct { + authService service.AuthService +} + +func NewAuthHandler(authService service.AuthService) *AuthHandler { + return &AuthHandler{authService: authService} +} + +func (h *AuthHandler) RegisterUser(c *gin.Context) { + var req service.RegisterRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + if req.Name == "" || req.Email == "" || req.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name, email, and password are required"}) + return + } + + response, err := h.authService.RegisterUser(req) + + if err != nil { + if err.Error() == "email already registered" { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "user registered successfully", + "data": response, + }) +} + +func (h *AuthHandler) RegisterOwner(c *gin.Context) { + var req service.OwnerRegisterRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + if req.Name == "" || req.Email == "" || req.Password == "" || req.Phone == "" || req.BusinessName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name, email, password, phone, and business_name are required"}) + return + } + + response, err := h.authService.RegisterOwner(req) + + if err != nil { + if err.Error() == "email already registered" { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "owner registered successfully", + "data": response, + }) +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req service.LoginRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + if req.Email == "" || req.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"}) + return + } + + response, err := h.authService.Login(req) + + if err != nil { + if err.Error() == "invalid email or password" { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + if err.Error() == "account is suspended" || err.Error() == "account is banned" { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "login successful", + "data": response, + }) +} + diff --git a/back-end/internal/handler/auth_middleware.go b/back-end/internal/handler/auth_middleware.go new file mode 100644 index 000000000..c142a9cf6 --- /dev/null +++ b/back-end/internal/handler/auth_middleware.go @@ -0,0 +1,68 @@ +package handler + +import ( + "bookmyvenue/config" + "bookmyvenue/pkg/utils" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +func AuthMiddleware(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + c.Abort() + return + } + + parts := strings.Split(authHeader, " ") + + if len(parts) != 2 || parts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization format. Use: Bearer "}) + c.Abort() + return + } + + tokenStr := parts[1] + + claims, err := utils.ValidateToken(tokenStr, cfg.JWTSecret) + + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) + c.Abort() + return + } + + c.Set("user_id", claims.UserID) + c.Set("user_role", claims.Role) + + c.Next() + } +} + +func RoleMiddleware(allowedRoles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + userRole, exists := c.Get("user_role") + + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + c.Abort() + return + } + + roleStr := userRole.(string) + for _, role := range allowedRoles { + if roleStr == role { + c.Next() + return + } + } + + c.JSON(http.StatusForbidden, gin.H{"error": "you don't have permission to access this resource"}) + c.Abort() + } +} \ No newline at end of file diff --git a/back-end/internal/handler/rate_limiter_middleware.go b/back-end/internal/handler/rate_limiter_middleware.go new file mode 100644 index 000000000..634274139 --- /dev/null +++ b/back-end/internal/handler/rate_limiter_middleware.go @@ -0,0 +1,76 @@ +package handler + +import ( + "context" + "fmt" + "log" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +// Lua script for run Token Bucket algorithm in Redis +const tokenBucketScript = ` +local key = KEYS[1] +local capacity = tonumber(ARGV[1]) +local refill_rate = tonumber(ARGV[2]) -- tokens per millisecond +local now = tonumber(ARGV[3]) -- current time in ms +local cost = tonumber(ARGV[4] or 1) +-- Fetch current bucket state from Hash +local bucket = redis.call('HMGET', key, 'tokens', 'last_updated') +local tokens = tonumber(bucket[1]) +local last_updated = tonumber(bucket[2]) +if not tokens then + -- First request, fill the bucket + tokens = capacity + last_updated = now +else + -- Calculate how many tokens should be refilled + local elapsed = now - last_updated + if elapsed > 0 then + local refill = elapsed * refill_rate + tokens = math.min(capacity, tokens + refill) + last_updated = now + end +end +-- Allow request if there are enough tokens +if tokens >= cost then + tokens = tokens - cost + redis.call('HMSET', key, 'tokens', tokens, 'last_updated', last_updated) + redis.call('EXPIRE', key, 3600) -- expire key after 1 hour of inactivity + return 1 -- Allowed +else + redis.call('HMSET', key, 'tokens', tokens, 'last_updated', last_updated) + redis.call('EXPIRE', key, 3600) + return 0 -- Blocked +end +` + +func RateLimiter(rdb *redis.Client, keyPrefix string, capacity int, refillPeriod time.Duration) gin.HandlerFunc { + refillRate := float64(capacity) / float64(refillPeriod.Milliseconds()) + return func(c *gin.Context) { + ip := c.ClientIP() + key := fmt.Sprintf("rate_limit:%s:%s", keyPrefix, ip) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + nowMs := time.Now().UnixNano() / int64(time.Millisecond) + res, err := rdb.Eval(ctx, tokenBucketScript, []string{key}, capacity, refillRate, nowMs, 1).Result() + if err != nil { + log.Printf("Rate limiter error (fail-open): %v", err) + c.Next() + return + } + allowed, ok := res.(int64) + if !ok || allowed != 1 { + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": "too many requests. Please try again later.", + }) + c.Abort() + return + } + c.Next() + } +} + diff --git a/back-end/internal/handler/venue_handler.go b/back-end/internal/handler/venue_handler.go new file mode 100644 index 000000000..2e3331c75 --- /dev/null +++ b/back-end/internal/handler/venue_handler.go @@ -0,0 +1,339 @@ +package handler + +import ( + "bookmyvenue/internal/service" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type VenueHandler struct { + venueService service.VenueService +} + +func NewVenueHandler(venueService service.VenueService) *VenueHandler { + return &VenueHandler{venueService: venueService} +} + +func getOwnerID(c *gin.Context) (uuid.UUID, error) { + userID, exists := c.Get("user_id") + if !exists { + return uuid.Nil, errors.New("user ID not found in token") + } + return userID.(uuid.UUID), nil +} + +// Venue Endpoints + +func (h *VenueHandler) CreateVenue(c *gin.Context) { + ownerID, err := getOwnerID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"}) + return + } + var req service.CreateVenueRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + venue, err := h.venueService.CreateVenue(ownerID, req) + if err != nil { + if err.Error() == "a venue with this name and address already exists" { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + if err.Error() == "venue must have between 4 and 10 images" || + err.Error() == "invalid timings format: must be HH:MM-HH:MM (e.g., 08:00-22:00)" { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{ + "message": "venue submitted for approval", + "data": venue, + }) +} + +func (h *VenueHandler) GetOwnerVenues(c *gin.Context) { + ownerID, err := getOwnerID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"}) + return + } + venues, err := h.venueService.GetOwnerVenues(ownerID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "venues fetched successfully", + "count": len(venues), + "data": venues, + }) +} + +func (h *VenueHandler) GetVenueByID(c *gin.Context) { + ownerID, err := getOwnerID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"}) + return + } + venueID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid venue ID"}) + return + } + venue, err := h.venueService.GetVenueByID(ownerID, venueID) + if err != nil { + if err.Error() == "venue not found" { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if err.Error() == "unauthorized: you don't own this venue" { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "venue fetched successfully", + "data": venue, + }) +} + +func (h *VenueHandler) UpdateVenue(c *gin.Context) { + ownerID, err := getOwnerID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"}) + return + } + + venueID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid venue ID"}) + return + } + + var req service.UpdateVenueRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + venue, isDraft, err := h.venueService.UpdateVenue(ownerID, venueID, req) + if err != nil { + if err.Error() == "venue not found" { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if err.Error() == "unauthorized: you don't own this venue" { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if isDraft { + c.JSON(http.StatusOK, gin.H{ + "message": "changes submitted for admin approval", + "data": venue, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "venue updated successfully", + "data": venue, + }) +} + +func (h *VenueHandler) DeleteVenue(c *gin.Context) { + ownerID, err := getOwnerID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"}) + return + } + venueID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid venue ID"}) + return + } + err = h.venueService.DeleteVenue(ownerID, venueID) + if err != nil { + if err.Error() == "venue not found" { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if err.Error() == "unauthorized: you don't own this venue" { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "venue deleted successfully", + }) +} + +func (h *VenueHandler) ToggleVenueStatus(c *gin.Context) { + ownerID, err := getOwnerID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"}) + return + } + venueID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid venue ID"}) + return + } + venue, err := h.venueService.ToggleVenueStatus(ownerID, venueID) + if err != nil { + if err.Error() == "venue not found" { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if err.Error() == "unauthorized: you don't own this venue" { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "venue status toggled successfully", + "data": venue, + }) +} + +// Space Endpoints + +func (h *VenueHandler) AddSpace(c *gin.Context) { + ownerID, err := getOwnerID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"}) + return + } + venueID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid venue ID"}) + return + } + var req service.CreateSpaceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + space, err := h.venueService.AddSpace(ownerID, venueID, req) + if err != nil { + if err.Error() == "venue not found" { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if err.Error() == "unauthorized: you don't own this venue" { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{ + "message": "space added successfully", + "data": space, + }) +} + +func (h *VenueHandler) UpdateSpace(c *gin.Context) { + ownerID, err := getOwnerID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"}) + return + } + spaceID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid space ID"}) + return + } + var req service.CreateSpaceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + space, err := h.venueService.UpdateSpace(ownerID, spaceID, req) + if err != nil { + if err.Error() == "space not found" { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if err.Error() == "unauthorized: you don't own this venue" { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "space updated successfully", + "data": space, + }) +} + +func (h *VenueHandler) DeleteSpace(c *gin.Context) { + ownerID, err := getOwnerID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user ID"}) + return + } + spaceID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid space ID"}) + return + } + err = h.venueService.DeleteSpace(ownerID, spaceID) + if err != nil { + if err.Error() == "space not found" { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if err.Error() == "unauthorized: you don't own this venue" { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "space deleted successfully", + }) +} + +// S3 + +func (h *VenueHandler) GetPresignedURL(c *gin.Context) { + fileName := c.Query("filename") + contentType := c.Query("content_type") + if fileName == "" || contentType == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "filename and content_type query params are required"}) + return + } + if contentType != "image/jpeg" && contentType != "image/png" { + c.JSON(http.StatusBadRequest, gin.H{"error": "only image/jpeg and image/png are allowed"}) + return + } + result, err := h.venueService.GeneratePresignedURL(c.Request.Context(), fileName, contentType) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "presigned URL generated successfully", + "data": result, + }) +} + + diff --git a/back-end/internal/repository/admin_repository.go b/back-end/internal/repository/admin_repository.go new file mode 100644 index 000000000..b99f81568 --- /dev/null +++ b/back-end/internal/repository/admin_repository.go @@ -0,0 +1,38 @@ +package repository + +import ( + "bookmyvenue/internal/domain" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type AdminRepository interface { + FindByEmail(email string) (*domain.Admin, error) + FindByID(id uuid.UUID) (*domain.Admin, error) +} +type adminRepository struct { + db *gorm.DB +} + +func NewAdminRepository(db *gorm.DB) AdminRepository { + return &adminRepository{db: db} +} + +func (r *adminRepository) FindByEmail(email string) (*domain.Admin, error) { + var admin domain.Admin + err := r.db.Where("email = ?", email).First(&admin).Error + if err != nil { + return nil, err + } + return &admin, nil +} + +func (r *adminRepository) FindByID(id uuid.UUID) (*domain.Admin, error) { + var admin domain.Admin + err := r.db.Where("id = ?", id).First(&admin).Error + if err != nil { + return nil, err + } + return &admin, nil +} \ No newline at end of file diff --git a/back-end/internal/repository/space_repository.go b/back-end/internal/repository/space_repository.go new file mode 100644 index 000000000..c2946b829 --- /dev/null +++ b/back-end/internal/repository/space_repository.go @@ -0,0 +1,55 @@ +package repository + +import ( + "bookmyvenue/internal/domain" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type SpaceRepository interface { + Create(space *domain.Space) error + FindByID(id uuid.UUID) (*domain.Space, error) + FindByVenueID(venueID uuid.UUID) ([]domain.Space, error) + Update(space *domain.Space) error + Delete(id uuid.UUID) error +} +type spaceRepository struct { + db *gorm.DB +} + +func NewSpaceRepository(db *gorm.DB) SpaceRepository { + return &spaceRepository{db: db} +} +func (r *spaceRepository) Create(space *domain.Space) error { + return r.db.Create(space).Error +} +func (r *spaceRepository) FindByID(id uuid.UUID) (*domain.Space, error) { + var space domain.Space + err := r.db.First(&space, "id = ?", id).Error + if err != nil { + return nil, err + } + return &space, nil +} +func (r *spaceRepository) FindByVenueID(venueID uuid.UUID) ([]domain.Space, error) { + var spaces []domain.Space + err := r.db.Where("venue_id = ?", venueID). + Order("created_at ASC"). + Find(&spaces).Error + if err != nil { + return nil, err + } + return spaces, nil +} +func (r *spaceRepository) Update(space *domain.Space) error { + return r.db.Save(space).Error +} +func (r *spaceRepository) Delete(id uuid.UUID) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("space_id = ?", id).Delete(&domain.Slot{}).Error; err != nil { + return err + } + return tx.Delete(&domain.Space{}, "id = ?", id).Error + }) +} \ No newline at end of file diff --git a/back-end/internal/repository/user_repository.go b/back-end/internal/repository/user_repository.go new file mode 100644 index 000000000..73bbd251c --- /dev/null +++ b/back-end/internal/repository/user_repository.go @@ -0,0 +1,44 @@ +package repository + +import ( + "bookmyvenue/internal/domain" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type UserRepository interface { + Create(user *domain.User) error + FindByEmail(email string) (*domain.User, error) + FindByID(id uuid.UUID) (*domain.User, error) +} + +type userRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) UserRepository { + return &userRepository{db: db} +} + +func (r *userRepository) Create(user *domain.User) error { + return r.db.Create(user).Error +} + +func (r *userRepository) FindByEmail(email string) (*domain.User, error) { + var user domain.User + err := r.db.Where("email = ?", email).First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *userRepository) FindByID(id uuid.UUID) (*domain.User, error) { + var user domain.User + err := r.db.Where("id = ?", id).First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} \ No newline at end of file diff --git a/back-end/internal/repository/venue_repository.go b/back-end/internal/repository/venue_repository.go new file mode 100644 index 000000000..12aea9230 --- /dev/null +++ b/back-end/internal/repository/venue_repository.go @@ -0,0 +1,193 @@ +package repository + +import ( + "bookmyvenue/internal/domain" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type VenueRepository interface { + Create(venue *domain.Venue) error + FindByID(id uuid.UUID) (*domain.Venue, error) + FindByOwnerID(ownerID uuid.UUID) ([]domain.Venue, error) + ExistsByNameAndAddress(ownerID uuid.UUID, name string, address string) (bool, error) + Update(venue *domain.Venue) error + Delete(id uuid.UUID) error + + CreateCancellationPolicy(policy *domain.CancellationPolicy) error + FindCancellationPolicyByVenueID(venueID uuid.UUID) (*domain.CancellationPolicy, error) + UpdateCancellationPolicy(policy *domain.CancellationPolicy) error + + CreateEditDraft(draft *domain.VenueEditDraft) error + FindPendingDraftByVenueID(venueID uuid.UUID) (*domain.VenueEditDraft, error) + UpdateEditDraft(draft *domain.VenueEditDraft) error + + FindPendingVenues() ([]domain.Venue, error) + FindEditDraftByID(id uuid.UUID) (*domain.VenueEditDraft, error) + FindPendingEditDrafts() ([]domain.VenueEditDraft, error) + ApproveDraftAndMerge(venue *domain.Venue, draft *domain.VenueEditDraft) error +} +type venueRepository struct { + db *gorm.DB +} + +func NewVenueRepository(db *gorm.DB) VenueRepository { + return &venueRepository{db: db} +} + +// venue + +func (r *venueRepository) Create(venue *domain.Venue) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(venue).Error; err != nil { + return err + } + policy := &domain.CancellationPolicy{ + VenueID: venue.ID, + FullRefundDays: 15, + FullRefundPercent: 95.00, + PartialRefundDays: 7, + PartialRefundPercent: 50.00, + } + return tx.Create(policy).Error + }) +} + +func (r *venueRepository) FindByID(id uuid.UUID) (*domain.Venue, error) { + var venue domain.Venue + err := r.db.Preload("Spaces").Preload("CancellationPolicy"). + First(&venue, "id = ?", id).Error + if err != nil { + return nil, err + } + return &venue, nil +} + +func (r *venueRepository) FindByOwnerID(ownerID uuid.UUID) ([]domain.Venue, error) { + var venues []domain.Venue + err := r.db.Preload("Spaces").Preload("CancellationPolicy"). + Where("owner_id = ?", ownerID). + Order("created_at DESC"). + Find(&venues).Error + if err != nil { + return nil, err + } + return venues, nil +} + +func (r *venueRepository) ExistsByNameAndAddress(ownerID uuid.UUID, name string, address string) (bool, error) { + var count int64 + err := r.db.Model(&domain.Venue{}). + Where("owner_id = ? AND name = ? AND address = ?", ownerID, name, address). + Count(&count).Error + return count > 0, err +} + +func (r *venueRepository) Update(venue *domain.Venue) error { + return r.db.Save(venue).Error +} +func (r *venueRepository) Delete(id uuid.UUID) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var spaceIDs []uuid.UUID + if err := tx.Model(&domain.Space{}).Where("venue_id = ?", id).Pluck("id", &spaceIDs).Error; err != nil { + return err + } + if len(spaceIDs) > 0 { + if err := tx.Where("space_id IN ?", spaceIDs).Delete(&domain.Slot{}).Error; err != nil { + return err + } + } + if err := tx.Where("venue_id = ?", id).Delete(&domain.Space{}).Error; err != nil { + return err + } + if err := tx.Where("venue_id = ?", id).Delete(&domain.CancellationPolicy{}).Error; err != nil { + return err + } + if err := tx.Where("venue_id = ?", id).Delete(&domain.VenueEditDraft{}).Error; err != nil { + return err + } + if err := tx.Delete(&domain.Venue{}, "id = ?", id).Error; err != nil { + return err + } + return nil + }) +} + +// cancellation policy + +func (r *venueRepository) CreateCancellationPolicy(policy *domain.CancellationPolicy) error { + return r.db.Create(policy).Error +} + +func (r *venueRepository) FindCancellationPolicyByVenueID(venueID uuid.UUID) (*domain.CancellationPolicy, error) { + var policy domain.CancellationPolicy + err := r.db.Where("venue_id = ?", venueID).First(&policy).Error + if err != nil { + return nil, err + } + return &policy, nil +} + +func (r *venueRepository) UpdateCancellationPolicy(policy *domain.CancellationPolicy) error { + return r.db.Save(policy).Error +} + +// edit draft + +func (r *venueRepository) CreateEditDraft(draft *domain.VenueEditDraft) error { + return r.db.Create(draft).Error +} + +func (r *venueRepository) FindPendingDraftByVenueID(venueID uuid.UUID) (*domain.VenueEditDraft, error) { + var draft domain.VenueEditDraft + err := r.db.Where("venue_id = ? AND status = ?", venueID, "pending_review"). + First(&draft).Error + if err != nil { + return nil, err + } + return &draft, nil +} + +func (r *venueRepository) UpdateEditDraft(draft *domain.VenueEditDraft) error { + return r.db.Save(draft).Error +} + +func (r *venueRepository) FindPendingVenues() ([]domain.Venue, error) { + var venues []domain.Venue + err := r.db.Preload("Spaces").Preload("CancellationPolicy"). + Where("status = ?", "pending"). + Order("created_at ASC"). + Find(&venues).Error + return venues, err +} + +func (r *venueRepository) FindEditDraftByID(id uuid.UUID) (*domain.VenueEditDraft, error) { + var draft domain.VenueEditDraft + err := r.db.First(&draft, "id = ?", id).Error + if err != nil { + return nil, err + } + return &draft, nil +} + +func (r *venueRepository) FindPendingEditDrafts() ([]domain.VenueEditDraft, error) { + var drafts []domain.VenueEditDraft + err := r.db.Where("status = ?", "pending_review"). + Order("created_at ASC"). + Find(&drafts).Error + return drafts, err +} + +func (r *venueRepository) ApproveDraftAndMerge(venue *domain.Venue, draft *domain.VenueEditDraft) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Save(venue).Error; err != nil { + return err + } + if err := tx.Save(draft).Error; err != nil { + return err + } + return nil + }) +} + diff --git a/back-end/internal/router/router.go b/back-end/internal/router/router.go new file mode 100644 index 000000000..cd0ccbcbe --- /dev/null +++ b/back-end/internal/router/router.go @@ -0,0 +1,92 @@ +package router + +import ( + "bookmyvenue/config" + "bookmyvenue/internal/handler" + "time" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +func SetupRouter(cfg *config.Config,rdb *redis.Client, authHandler *handler.AuthHandler,adminAuthHandler *handler.AdminAuthHandler,venueHandler *handler.VenueHandler,adminVenueHandler *handler.AdminVenueHandler) *gin.Engine { + r := gin.Default() + + globalLimiter := handler.RateLimiter(rdb, "global", 100, 1*time.Minute) + loginLimiter := handler.RateLimiter(rdb, "login", 5, 15*time.Minute) + registerLimiter := handler.RateLimiter(rdb, "register", 3, 1*time.Hour) + venueSubmitLimiter := handler.RateLimiter(rdb, "venue_submit", 10, 24*time.Hour) + venueUpdateLimiter := handler.RateLimiter(rdb, "venue_update", 30, 1*time.Hour) + + r.Use(globalLimiter) + + // Public Auth Routes (User/Owner) + auth := r.Group("/api/auth") + { + auth.POST("/register/user", registerLimiter, authHandler.RegisterUser) + auth.POST("/register/owner", registerLimiter, authHandler.RegisterOwner) + auth.POST("/login", loginLimiter, authHandler.Login) + } + + // Public Auth Routes (Admin) + adminAuth := r.Group("/api/admin/auth") + { + adminAuth.POST("/login", loginLimiter, adminAuthHandler.Login) + } + + // Protected User/Owner Routes + userRoutes := r.Group("/api/user") + userRoutes.Use(handler.AuthMiddleware(cfg)) + userRoutes.Use(handler.RoleMiddleware("user", "owner")) + { + userRoutes.GET("/profile", func(c *gin.Context) { + userID, _ := c.Get("user_id") + role, _ := c.Get("user_role") + c.JSON(200, gin.H{ + "message": "protected route working!", + "user_id": userID, + "role": role, + }) + }) + } + + // Protected Owner Routes + ownerRoutes := r.Group("/api/owner") + ownerRoutes.Use(handler.AuthMiddleware(cfg)) + ownerRoutes.Use(handler.RoleMiddleware("owner")) + { + // S3 Presigned URL + ownerRoutes.GET("/venues/presigned-url", venueHandler.GetPresignedURL) + // Venue CRUD + ownerRoutes.POST("/venues",venueSubmitLimiter, venueHandler.CreateVenue) + ownerRoutes.GET("/venues",venueHandler.GetOwnerVenues) + ownerRoutes.GET("/venues/:id", venueHandler.GetVenueByID) + ownerRoutes.PUT("/venues/:id",venueUpdateLimiter, venueHandler.UpdateVenue) + ownerRoutes.DELETE("/venues/:id", venueHandler.DeleteVenue) + ownerRoutes.PATCH("/venues/:id/toggle", venueHandler.ToggleVenueStatus) + // Space CRUD (nested under venue) + ownerRoutes.POST("/venues/:id/spaces", venueHandler.AddSpace) + ownerRoutes.PUT("/spaces/:id", venueHandler.UpdateSpace) + ownerRoutes.DELETE("/spaces/:id", venueHandler.DeleteSpace) + } + + // Protected Admin Routes + adminRoutes := r.Group("/api/admin") + adminRoutes.Use(handler.AuthMiddleware(cfg)) + adminRoutes.Use(handler.RoleMiddleware("admin")) + { + adminRoutes.GET("/dashboard", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "admin dashboard — only admins can see this"}) + }) + + adminRoutes.GET("/venues/pending", adminVenueHandler.GetPendingVenues) + adminRoutes.POST("/venues/:id/approve", adminVenueHandler.ApproveVenue) + adminRoutes.POST("/venues/:id/reject", adminVenueHandler.RejectVenue) + + adminRoutes.GET("/venues/drafts/pending", adminVenueHandler.GetPendingDrafts) + adminRoutes.POST("/venues/drafts/:draft_id/approve", adminVenueHandler.ApproveEditDraft) + adminRoutes.POST("/venues/drafts/:draft_id/reject", adminVenueHandler.RejectEditDraft) + } + + return r +} \ No newline at end of file diff --git a/back-end/internal/service/admin_auth_service.go b/back-end/internal/service/admin_auth_service.go new file mode 100644 index 000000000..a7de12e98 --- /dev/null +++ b/back-end/internal/service/admin_auth_service.go @@ -0,0 +1,58 @@ +package service + +import ( + "bookmyvenue/config" + "bookmyvenue/internal/repository" + "bookmyvenue/pkg/utils" + "errors" + + "gorm.io/gorm" +) + +type AdminAuthService interface { + Login(req LoginRequest) (*AuthResponse, error) +} + +type adminAuthService struct { + adminRepo repository.AdminRepository + cfg *config.Config +} + +func NewAdminAuthService(adminRepo repository.AdminRepository, cfg *config.Config) AdminAuthService { + return &adminAuthService{ + adminRepo: adminRepo, + cfg: cfg, + } +} + +func (s *adminAuthService) Login(req LoginRequest) (*AuthResponse, error) { + admin, err := s.adminRepo.FindByEmail(req.Email) + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("invalid email or password") + } + return nil, errors.New("internal server error") + } + + if !utils.CheckPasswordHash(req.Password, admin.PasswordHash) { + return nil, errors.New("invalid email or password") + } + + tokenPair, err := utils.GenerateTokenPair( + admin.ID, + "admin", + s.cfg.JWTSecret, + s.cfg.AccessTokenExpiryMins, + s.cfg.RefreshTokenExpiryDays, + ) + if err != nil { + return nil, errors.New("failed to generate tokens") + } + + return &AuthResponse{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + Role: "admin", + }, nil +} \ No newline at end of file diff --git a/back-end/internal/service/admin_venue_service.go b/back-end/internal/service/admin_venue_service.go new file mode 100644 index 000000000..15fb31b1a --- /dev/null +++ b/back-end/internal/service/admin_venue_service.go @@ -0,0 +1,181 @@ +package service + +import ( + "bookmyvenue/internal/domain" + "bookmyvenue/internal/repository" + "errors" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type RejectVenueRequest struct { + Reason string `json:"reason" binding:"required,min=5"` +} +type RejectDraftRequest struct { + AdminNote string `json:"admin_note" binding:"required,min=5"` +} + +type VenueEditDraftResponse struct { + ID uuid.UUID `json:"id"` + VenueID uuid.UUID `json:"venue_id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Address string `json:"address"` + City string `json:"city"` + Rules string `json:"rules"` + Timings string `json:"timings"` + Images []string `json:"images"` + Status string `json:"status"` + AdminNote *string `json:"admin_note"` +} + +type AdminVenueService interface { + GetPendingVenues() ([]VenueResponse, error) + ApproveVenue(venueID uuid.UUID) error + RejectVenue(venueID uuid.UUID, reason string) error + + GetPendingDrafts() ([]VenueEditDraftResponse, error) + ApproveEditDraft(draftID uuid.UUID) error + RejectEditDraft(draftID uuid.UUID, adminNote string) error +} + +type adminVenueService struct { + venueRepo repository.VenueRepository +} +func NewAdminVenueService(venueRepo repository.VenueRepository) AdminVenueService { + return &adminVenueService{venueRepo: venueRepo} +} + +func mapToDraftResponse(d domain.VenueEditDraft) VenueEditDraftResponse { + return VenueEditDraftResponse{ + ID: d.ID, + VenueID: d.VenueID, + Name: d.Name, + Description: d.Description, + Type: d.Type, + Address: d.Address, + City: d.City, + Rules: d.Rules, + Timings: d.Timings, + Images: d.Images, + Status: d.Status, + AdminNote: d.AdminNote, + } +} +func mapToDraftResponses(drafts []domain.VenueEditDraft) []VenueEditDraftResponse { + responses := make([]VenueEditDraftResponse, len(drafts)) + for i, d := range drafts { + responses[i] = mapToDraftResponse(d) + } + return responses +} + +func (s *adminVenueService) GetPendingVenues() ([]VenueResponse, error) { + venues, err := s.venueRepo.FindPendingVenues() + if err != nil { + return nil, errors.New("failed to fetch pending venues") + } + return mapToVenueResponses(venues), nil +} + +func (s *adminVenueService) ApproveVenue(venueID uuid.UUID) error { + venue, err := s.venueRepo.FindByID(venueID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("venue not found") + } + return errors.New("failed to fetch venue") + } + if venue.Status != "pending" { + return errors.New("venue is not in pending status") + } + venue.Status = "approved" + if err := s.venueRepo.Update(venue); err != nil { + return errors.New("failed to approve venue") + } + return nil +} + +func (s *adminVenueService) RejectVenue(venueID uuid.UUID, reason string) error { + venue, err := s.venueRepo.FindByID(venueID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("venue not found") + } + return errors.New("failed to fetch venue") + } + if venue.Status != "pending" { + return errors.New("venue is not in pending status") + } + venue.Status = "rejected" + venue.RejectionReason = &reason + if err := s.venueRepo.Update(venue); err != nil { + return errors.New("failed to reject venue") + } + return nil +} + +func (s *adminVenueService) GetPendingDrafts() ([]VenueEditDraftResponse, error) { + drafts, err := s.venueRepo.FindPendingEditDrafts() + if err != nil { + return nil, errors.New("failed to fetch pending drafts") + } + return mapToDraftResponses(drafts), nil +} + +func (s *adminVenueService) ApproveEditDraft(draftID uuid.UUID) error { + draft, err := s.venueRepo.FindEditDraftByID(draftID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("edit draft not found") + } + return errors.New("failed to fetch draft") + } + if draft.Status != "pending_review" { + return errors.New("draft is not in pending status") + } + venue, err := s.venueRepo.FindByID(draft.VenueID) + if err != nil { + return errors.New("live venue associated with draft not found") + } + + venue.Name = draft.Name + venue.Description = draft.Description + venue.Type = draft.Type + venue.Address = draft.Address + venue.City = draft.City + venue.Rules = draft.Rules + venue.Timings = draft.Timings + venue.Images = draft.Images + draft.Status = "approved" + + if err := s.venueRepo.ApproveDraftAndMerge(venue, draft); err != nil { + return errors.New("failed to merge and approve draft updates") + } + return nil +} + +func (s *adminVenueService) RejectEditDraft(draftID uuid.UUID, adminNote string) error { + draft, err := s.venueRepo.FindEditDraftByID(draftID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("edit draft not found") + } + return errors.New("failed to fetch draft") + } + if draft.Status != "pending_review" { + return errors.New("draft is not in pending status") + } + draft.Status = "rejected" + draft.AdminNote = &adminNote + if err := s.venueRepo.UpdateEditDraft(draft); err != nil { + return errors.New("failed to reject edit draft") + } + return nil +} + + + + diff --git a/back-end/internal/service/auth_service.go b/back-end/internal/service/auth_service.go new file mode 100644 index 000000000..70f2f35a6 --- /dev/null +++ b/back-end/internal/service/auth_service.go @@ -0,0 +1,180 @@ +package service + +import ( + "bookmyvenue/config" + "bookmyvenue/internal/domain" + "bookmyvenue/internal/repository" + "bookmyvenue/pkg/utils" + "errors" + + "gorm.io/gorm" +) + +type AuthService interface { + RegisterUser(req RegisterRequest) (*AuthResponse, error) + RegisterOwner(req OwnerRegisterRequest) (*AuthResponse, error) + Login(req LoginRequest) (*AuthResponse, error) +} + +type RegisterRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` +} +type OwnerRegisterRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + Phone string `json:"phone"` + BusinessName string `json:"business_name"` + GSTNumber string `json:"gst_number"` +} +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} +type AuthResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Role string `json:"role"` +} + +type authService struct { + userRepo repository.UserRepository + cfg *config.Config +} + +func NewAuthService(userRepo repository.UserRepository, cfg *config.Config) AuthService { + return &authService{ + userRepo: userRepo, + cfg: cfg, + } +} + +func (s *authService) RegisterUser(req RegisterRequest) (*AuthResponse, error) { + existingUser, err := s.userRepo.FindByEmail(req.Email) + + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("internal server error") + } + if existingUser != nil { + return nil, errors.New("email already registered") + } + + hashedPassword, err := utils.HashPassword(req.Password) + if err != nil { + return nil, errors.New("failed to hash password") + } + + user := &domain.User{ + Name: req.Name, + Email: req.Email, + PasswordHash: hashedPassword, + Role: "user", + Status: "active", + } + + if err := s.userRepo.Create(user); err != nil { + return nil, errors.New("failed to create user") + } + + tokenPair, err := utils.GenerateTokenPair( + user.ID, + user.Role, + s.cfg.JWTSecret, + s.cfg.AccessTokenExpiryMins, + s.cfg.RefreshTokenExpiryDays, + ) + if err != nil { + return nil, errors.New("failed to generate tokens") + } + + return &AuthResponse{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + Role: user.Role, + }, nil +} + +func (s *authService) RegisterOwner(req OwnerRegisterRequest) (*AuthResponse, error) { + existingUser, err := s.userRepo.FindByEmail(req.Email) + + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("internal server error") + } + if existingUser != nil { + return nil, errors.New("email already registered") + } + + hashedPassword, err := utils.HashPassword(req.Password) + if err != nil { + return nil, errors.New("failed to hash password") + } + + user := &domain.User{ + Name: req.Name, + Email: req.Email, + PasswordHash: hashedPassword, + Role: "owner", + Status: "active", + Phone: &req.Phone, + BusinessName: &req.BusinessName, + GSTNumber: &req.GSTNumber, + } + + if err := s.userRepo.Create(user); err != nil { + return nil, errors.New("failed to create owner") + } + + tokenPair, err := utils.GenerateTokenPair( + user.ID, + user.Role, + s.cfg.JWTSecret, + s.cfg.AccessTokenExpiryMins, + s.cfg.RefreshTokenExpiryDays, + ) + if err != nil { + return nil, errors.New("failed to generate tokens") + } + + return &AuthResponse{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + Role: user.Role, + }, nil +} + +func (s *authService) Login(req LoginRequest) (*AuthResponse, error) { + user, err := s.userRepo.FindByEmail(req.Email) + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("invalid email or password") + } + return nil, errors.New("internal server error") + } + + if user.Status != "active" { + return nil, errors.New("account is " + user.Status) + } + + if !utils.CheckPasswordHash(req.Password, user.PasswordHash) { + return nil, errors.New("invalid email or password") + } + + tokenPair, err := utils.GenerateTokenPair( + user.ID, + user.Role, + s.cfg.JWTSecret, + s.cfg.AccessTokenExpiryMins, + s.cfg.RefreshTokenExpiryDays, + ) + if err != nil { + return nil, errors.New("failed to generate tokens") + } + return &AuthResponse{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + Role: user.Role, + }, nil +} \ No newline at end of file diff --git a/back-end/internal/service/venue_service.go b/back-end/internal/service/venue_service.go new file mode 100644 index 000000000..5255d950c --- /dev/null +++ b/back-end/internal/service/venue_service.go @@ -0,0 +1,502 @@ +package service + +import ( + "bookmyvenue/internal/domain" + "bookmyvenue/internal/repository" + "bookmyvenue/pkg/s3" + "context" + "errors" + "regexp" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type CreateVenueRequest struct { + Name string `json:"name" binding:"required,min=3,max=150"` + Description string `json:"description" binding:"required,min=10"` + Type string `json:"type" binding:"required,oneof=banquet_hall sports_turf conference_room party_hall coworking_space"` + Address string `json:"address" binding:"required"` + City string `json:"city" binding:"required"` + Rules string `json:"rules"` + Timings string `json:"timings" binding:"required"` + Images []string `json:"images" binding:"required"` +} +type CreateSpaceRequest struct { + Name string `json:"name" binding:"required,min=3,max=100"` + Capacity int `json:"capacity" binding:"required,gt=0"` + Price float64 `json:"price" binding:"required,gt=0"` + BookingType string `json:"booking_type" binding:"required,oneof=hourly daily"` + Images []string `json:"images"` +} +type UpdateVenueRequest struct { + Name *string `json:"name" binding:"omitempty,min=3,max=150"` + Description *string `json:"description" binding:"omitempty,min=10"` + Type *string `json:"type" binding:"omitempty,oneof=banquet_hall sports_turf conference_room party_hall coworking_space"` + Address *string `json:"address"` + City *string `json:"city"` + Rules *string `json:"rules"` + Timings *string `json:"timings"` + Images []string `json:"images"` +} +type CancellationPolicyResponse struct { + ID uuid.UUID `json:"id"` + VenueID uuid.UUID `json:"venue_id"` + FullRefundDays int `json:"full_refund_days"` + FullRefundPercent float64 `json:"full_refund_percent"` + PartialRefundDays int `json:"partial_refund_days"` + PartialRefundPercent float64 `json:"partial_refund_percent"` +} +type SpaceResponse struct { + ID uuid.UUID `json:"id"` + VenueID uuid.UUID `json:"venue_id"` + Name string `json:"name"` + Capacity int `json:"capacity"` + Price float64 `json:"price"` + BookingType string `json:"booking_type"` + Images []string `json:"images"` +} +type VenueResponse struct { + ID uuid.UUID `json:"id"` + OwnerID uuid.UUID `json:"owner_id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Address string `json:"address"` + City string `json:"city"` + Rules string `json:"rules"` + Timings string `json:"timings"` + Status string `json:"status"` + Images []string `json:"images"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Spaces []SpaceResponse `json:"spaces,omitempty"` + CancellationPolicy *CancellationPolicyResponse `json:"cancellation_policy,omitempty"` +} +type PresignedURLResponse struct { + UploadURL string `json:"upload_url"` + DownloadURL string `json:"download_url"` +} + +type VenueService interface { + // Venue + CreateVenue(ownerID uuid.UUID, req CreateVenueRequest) (*VenueResponse, error) + GetVenueByID(ownerID uuid.UUID, venueID uuid.UUID) (*VenueResponse, error) + GetOwnerVenues(ownerID uuid.UUID) ([]VenueResponse, error) + UpdateVenue(ownerID uuid.UUID, venueID uuid.UUID, req UpdateVenueRequest) (venue *VenueResponse, isDraft bool, err error) + DeleteVenue(ownerID uuid.UUID, venueID uuid.UUID) error + ToggleVenueStatus(ownerID uuid.UUID, venueID uuid.UUID) (*VenueResponse, error) + + // Space + AddSpace(ownerID uuid.UUID, venueID uuid.UUID, req CreateSpaceRequest) (*SpaceResponse, error) + UpdateSpace(ownerID uuid.UUID, spaceID uuid.UUID, req CreateSpaceRequest) (*SpaceResponse, error) + + DeleteSpace(ownerID uuid.UUID, spaceID uuid.UUID) error + + // S3 + GeneratePresignedURL(ctx context.Context, fileName string, contentType string) (*PresignedURLResponse, error) +} + +type venueService struct { + venueRepo repository.VenueRepository + spaceRepo repository.SpaceRepository + s3Client s3.S3Client +} + +func NewVenueService(venueRepo repository.VenueRepository, spaceRepo repository.SpaceRepository, s3Client s3.S3Client) VenueService { + return &venueService{ + venueRepo: venueRepo, + spaceRepo: spaceRepo, + s3Client: s3Client, + } +} + +func mapToSpaceResponse(s domain.Space) SpaceResponse { + return SpaceResponse{ + ID: s.ID, + VenueID: s.VenueID, + Name: s.Name, + Capacity: s.Capacity, + Price: s.Price, + BookingType: s.BookingType, + Images: s.Images, + } +} +func mapToSpaceResponses(spaces []domain.Space) []SpaceResponse { + responses := make([]SpaceResponse, len(spaces)) + for i, s := range spaces { + responses[i] = mapToSpaceResponse(s) + } + return responses +} +func mapToCancellationPolicyResponse(p *domain.CancellationPolicy) *CancellationPolicyResponse { + if p == nil { + return nil + } + return &CancellationPolicyResponse{ + ID: p.ID, + VenueID: p.VenueID, + FullRefundDays: p.FullRefundDays, + FullRefundPercent: p.FullRefundPercent, + PartialRefundDays: p.PartialRefundDays, + PartialRefundPercent: p.PartialRefundPercent, + } +} +func mapToVenueResponse(v *domain.Venue) *VenueResponse { + var spaces []SpaceResponse + if len(v.Spaces) > 0 { + spaces = mapToSpaceResponses(v.Spaces) + } + return &VenueResponse{ + ID: v.ID, + OwnerID: v.OwnerID, + Name: v.Name, + Description: v.Description, + Type: v.Type, + Address: v.Address, + City: v.City, + Rules: v.Rules, + Timings: v.Timings, + Status: v.Status, + Images: v.Images, + CreatedAt: v.CreatedAt, + UpdatedAt: v.UpdatedAt, + Spaces: spaces, + CancellationPolicy: mapToCancellationPolicyResponse(v.CancellationPolicy), + } +} +func mapToVenueResponses(venues []domain.Venue) []VenueResponse { + responses := make([]VenueResponse, len(venues)) + for i, v := range venues { + responses[i] = *mapToVenueResponse(&v) + } + return responses +} + +// Regex pattern to match HH:MM-HH:MM (e.g., 09:00-22:00) +var timingsRegex = regexp.MustCompile(`^([0-1][0-9]|2[0-3]):[0-5][0-9]-([0-1][0-9]|2[0-3]):[0-5][0-9]$`) + +// Venue + +func (s *venueService) CreateVenue(ownerID uuid.UUID, req CreateVenueRequest) (*VenueResponse, error) { + exists, err := s.venueRepo.ExistsByNameAndAddress(ownerID, req.Name, req.Address) + if err != nil { + return nil, errors.New("failed to verify duplicate venue status") + } + if exists { + return nil, errors.New("a venue with this name and address already exists") + } + imgCount := len(req.Images) + if imgCount < 4 || imgCount > 10 { + return nil, errors.New("venue must have between 4 and 10 images") + } + if !timingsRegex.MatchString(req.Timings) { + return nil, errors.New("invalid timings format: must be HH:MM-HH:MM (e.g., 08:00-22:00)") + } + venue := &domain.Venue{ + OwnerID: ownerID, + Name: req.Name, + Description: req.Description, + Type: req.Type, + Address: req.Address, + City: req.City, + Rules: req.Rules, + Timings: req.Timings, + Images: req.Images, + Status: "pending", + } + if err := s.venueRepo.Create(venue); err != nil { + return nil, errors.New("failed to create venue") + } + return mapToVenueResponse(venue), nil +} + +func (s *venueService) GetVenueByID(ownerID uuid.UUID, venueID uuid.UUID) (*VenueResponse, error) { + venue, err := s.venueRepo.FindByID(venueID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("venue not found") + } + return nil, errors.New("failed to fetch venue") + } + if venue.OwnerID != ownerID { + return nil, errors.New("unauthorized: you don't own this venue") + } + return mapToVenueResponse(venue), nil +} + +func (s *venueService) GetOwnerVenues(ownerID uuid.UUID) ([]VenueResponse, error) { + venues, err := s.venueRepo.FindByOwnerID(ownerID) + if err != nil { + return nil, errors.New("failed to fetch venues") + } + return mapToVenueResponses(venues), nil +} + +func (s *venueService) UpdateVenue(ownerID uuid.UUID, venueID uuid.UUID, req UpdateVenueRequest) (*VenueResponse, bool, error) { + venue, err := s.venueRepo.FindByID(venueID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, errors.New("venue not found") + } + return nil, false, errors.New("failed to fetch venue") + } + if venue.OwnerID != ownerID { + return nil, false, errors.New("unauthorized: you don't own this venue") + } + if venue.Status == "suspended" { + return nil, false, errors.New("cannot edit a suspended venue: please contact admin support") + } + if req.Timings != nil { + if !timingsRegex.MatchString(*req.Timings) { + return nil, false, errors.New("invalid timings format: must be HH:MM-HH:MM") + } + } + if req.Images != nil { + imgCount := len(req.Images) + if imgCount < 4 || imgCount > 10 { + return nil, false, errors.New("venue must have between 4 and 10 images") + } + } + if venue.Status == "approved" { + hasMajorChanges := req.Name != nil || req.Address != nil || req.City != nil || req.Type != nil || req.Images != nil + if hasMajorChanges { + existingDraft, err := s.venueRepo.FindPendingDraftByVenueID(venueID) + if err == nil && existingDraft != nil { + if req.Name != nil { existingDraft.Name = *req.Name } + if req.Description != nil { existingDraft.Description = *req.Description } + if req.Type != nil { existingDraft.Type = *req.Type } + if req.Address != nil { existingDraft.Address = *req.Address } + if req.City != nil { existingDraft.City = *req.City } + if req.Rules != nil { existingDraft.Rules = *req.Rules } + if req.Timings != nil { existingDraft.Timings = *req.Timings } + if req.Images != nil { existingDraft.Images = req.Images } + + if err := s.venueRepo.UpdateEditDraft(existingDraft); err != nil { + return nil, false, errors.New("failed to update pending edit request") + } + return mapToVenueResponse(venue), true, nil + } + draft := &domain.VenueEditDraft{ + VenueID: venueID, + Name: venue.Name, + Description: venue.Description, + Type: venue.Type, + Address: venue.Address, + City: venue.City, + Rules: venue.Rules, + Timings: venue.Timings, + Images: venue.Images, + Status: "pending_review", + } + if req.Name != nil { draft.Name = *req.Name } + if req.Description != nil { draft.Description = *req.Description } + if req.Type != nil { draft.Type = *req.Type } + if req.Address != nil { draft.Address = *req.Address } + if req.City != nil { draft.City = *req.City } + if req.Rules != nil { draft.Rules = *req.Rules } + if req.Timings != nil { draft.Timings = *req.Timings } + if req.Images != nil { draft.Images = req.Images } + if err := s.venueRepo.CreateEditDraft(draft); err != nil { + return nil, false, errors.New("failed to submit edit request") + } + return mapToVenueResponse(venue), true, nil + } + } + if req.Name != nil { venue.Name = *req.Name } + if req.Description != nil { venue.Description = *req.Description } + if req.Type != nil { venue.Type = *req.Type } + if req.Address != nil { venue.Address = *req.Address } + if req.City != nil { venue.City = *req.City } + if req.Rules != nil { venue.Rules = *req.Rules } + if req.Timings != nil { venue.Timings = *req.Timings } + if req.Images != nil { venue.Images = req.Images } + if venue.Status == "rejected" { + venue.Status = "pending" + } + if err := s.venueRepo.Update(venue); err != nil { + return nil, false, errors.New("failed to update venue") + } + return mapToVenueResponse(venue), false, nil +} + +func (s *venueService) DeleteVenue(ownerID uuid.UUID, venueID uuid.UUID) error { + venue, err := s.venueRepo.FindByID(venueID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("venue not found") + } + return errors.New("failed to fetch venue") + } + if venue.OwnerID != ownerID { + return errors.New("unauthorized: you don't own this venue") + } + if err := s.venueRepo.Delete(venueID); err != nil { + return errors.New("failed to delete venue") + } + return nil +} + +func (s *venueService) ToggleVenueStatus(ownerID uuid.UUID, venueID uuid.UUID) (*VenueResponse, error) { + venue, err := s.venueRepo.FindByID(venueID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("venue not found") + } + return nil, errors.New("failed to fetch venue") + } + + if venue.OwnerID != ownerID { + return nil, errors.New("unauthorized: you don't own this venue") + } + + if venue.Status == "approved" || venue.Status == "active" { + venue.Status = "inactive" + } else if venue.Status == "inactive" { + venue.Status = "active" + } else { + return nil, errors.New("cannot toggle status: venue is pending or suspended") + } + + if err := s.venueRepo.Update(venue); err != nil { + return nil, errors.New("failed to toggle venue status") + } + + return mapToVenueResponse(venue), nil +} + +// Space + +func (s *venueService) AddSpace(ownerID uuid.UUID, venueID uuid.UUID, req CreateSpaceRequest) (*SpaceResponse, error) { + venue, err := s.venueRepo.FindByID(venueID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("venue not found") + } + return nil, errors.New("failed to fetch venue") + } + if venue.OwnerID != ownerID { + return nil, errors.New("unauthorized: you don't own this venue") + } + if venue.Status == "suspended" { + return nil, errors.New("cannot add spaces to a suspended venue: please contact admin support") + } + if len(venue.Spaces) >= 10 { + return nil, errors.New("maximum limit of 10 spaces per venue has been reached") + } + imgCount := len(req.Images) + if imgCount > 0 { + if imgCount > 5 { + return nil, errors.New("a space cannot have more than 5 images") + } + } + + space := &domain.Space{ + VenueID: venueID, + Name: req.Name, + Capacity: req.Capacity, + Price: req.Price, + BookingType: req.BookingType, + Images: req.Images, + } + + if err := s.spaceRepo.Create(space); err != nil { + return nil, errors.New("failed to add space") + } + + response := mapToSpaceResponse(*space) + return &response, nil +} + +func (s *venueService) UpdateSpace(ownerID uuid.UUID, spaceID uuid.UUID, req CreateSpaceRequest) (*SpaceResponse, error) { + space, err := s.spaceRepo.FindByID(spaceID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("space not found") + } + return nil, errors.New("failed to fetch space") + } + + venue, err := s.venueRepo.FindByID(space.VenueID) + if err != nil { + return nil, errors.New("failed to fetch venue") + } + + if venue.OwnerID != ownerID { + return nil, errors.New("unauthorized: you don't own this venue") + } + + if venue.Status == "suspended" { + return nil, errors.New("cannot update spaces of a suspended venue") + } + + if req.Images != nil { + if len(req.Images) > 5 { + return nil, errors.New("a space cannot have more than 5 images") + } + } + + space.Name = req.Name + space.Capacity = req.Capacity + space.Price = req.Price + if req.BookingType != "" { + space.BookingType = req.BookingType + } + if req.Images != nil { + space.Images = req.Images + } + + if err := s.spaceRepo.Update(space); err != nil { + return nil, errors.New("failed to update space") + } + + response := mapToSpaceResponse(*space) + return &response, nil +} + +func (s *venueService) DeleteSpace(ownerID uuid.UUID, spaceID uuid.UUID) error { + space, err := s.spaceRepo.FindByID(spaceID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("space not found") + } + return errors.New("failed to fetch space") + } + + venue, err := s.venueRepo.FindByID(space.VenueID) + if err != nil { + return errors.New("failed to fetch venue") + } + + if venue.OwnerID != ownerID { + return errors.New("unauthorized: you don't own this venue") + } + + if venue.Status == "suspended" { + return errors.New("cannot delete spaces of a suspended venue") + } + + if err := s.spaceRepo.Delete(spaceID); err != nil { + return errors.New("failed to delete space") + } + + return nil +} + +// S3 + +func (s *venueService) GeneratePresignedURL(ctx context.Context, fileName string, contentType string) (*PresignedURLResponse, error) { + uploadURL, downloadURL, err := s.s3Client.GeneratePresignedURL(ctx, fileName, contentType) + if err != nil { + return nil, errors.New("failed to generate presigned URL") + } + return &PresignedURLResponse{ + UploadURL: uploadURL, + DownloadURL: downloadURL, + }, nil +} + + + + diff --git a/back-end/pkg/rabbitmq/rabbitmq.go b/back-end/pkg/rabbitmq/rabbitmq.go new file mode 100644 index 000000000..5dab979fb --- /dev/null +++ b/back-end/pkg/rabbitmq/rabbitmq.go @@ -0,0 +1 @@ +package rabbitmq \ No newline at end of file diff --git a/back-end/pkg/s3/s3.go b/back-end/pkg/s3/s3.go new file mode 100644 index 000000000..d0d627182 --- /dev/null +++ b/back-end/pkg/s3/s3.go @@ -0,0 +1,69 @@ +package s3 + +import ( + "context" + "errors" + "fmt" + "time" + + "bookmyvenue/config" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/google/uuid" +) + +type S3Client interface { + GeneratePresignedURL(ctx context.Context, fileName string, contentType string) (uploadURL string, downloadURL string, err error) +} + +type s3Client struct { + client *s3.Client + presignClient *s3.PresignClient + bucketName string + region string +} + +func NewS3Client(cfg *config.Config) (S3Client, error) { + if cfg.AWSAccessKeyID == "" || cfg.AWSSecretAccessKey == "" || cfg.AWSS3Bucket == "" { + return nil, errors.New("missing AWS S3 credentials in configuration (.env)") + } + + creds := credentials.NewStaticCredentialsProvider(cfg.AWSAccessKeyID, cfg.AWSSecretAccessKey, "") + awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion(cfg.AWSRegion), + awsconfig.WithCredentialsProvider(creds), + ) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + client := s3.NewFromConfig(awsCfg) + presignClient := s3.NewPresignClient(client) + + return &s3Client{ + client: client, + presignClient: presignClient, + bucketName: cfg.AWSS3Bucket, + region: cfg.AWSRegion, + }, nil +} + +func (s *s3Client) GeneratePresignedURL(ctx context.Context, fileName string, contentType string) (string, string, error) { + uniqueKey := "temp/" + uuid.New().String() + "_" + fileName + + req, err := s.presignClient.PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucketName), + Key: aws.String(uniqueKey), + ContentType: aws.String(contentType), + }, s3.WithPresignExpires(15*time.Minute)) + + if err != nil { + return "", "", fmt.Errorf("failed to generate presigned PUT url: %w", err) + } + + downloadURL := "https://" + s.bucketName + ".s3." + s.region + ".amazonaws.com/" + uniqueKey + return req.URL, downloadURL, nil +} \ No newline at end of file diff --git a/back-end/pkg/utils/hash.go b/back-end/pkg/utils/hash.go new file mode 100644 index 000000000..105143dfa --- /dev/null +++ b/back-end/pkg/utils/hash.go @@ -0,0 +1,13 @@ +package utils + +import "golang.org/x/crypto/bcrypt" + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} \ No newline at end of file diff --git a/back-end/pkg/utils/jwt.go b/back-end/pkg/utils/jwt.go new file mode 100644 index 000000000..920712a34 --- /dev/null +++ b/back-end/pkg/utils/jwt.go @@ -0,0 +1,70 @@ +package utils + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type JWTClaims struct { + UserID uuid.UUID `json:"user_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +func GenerateTokenPair(userID uuid.UUID, role string, secret string, accessExpiryMins int, refreshExpiryDays int) (*TokenPair, error) { + accessClaims := JWTClaims{ + UserID: userID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(accessExpiryMins) * time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + accessTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + accessToken, err := accessTokenObj.SignedString([]byte(secret)) + if err != nil { + return nil, err + } + + refreshClaims := JWTClaims{ + UserID: userID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().AddDate(0, 0, refreshExpiryDays)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + refreshTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) + refreshToken, err := refreshTokenObj.SignedString([]byte(secret)) + if err != nil { + return nil, err + } + + return &TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshToken, + }, nil +} + +func ValidateToken(tokenStr string, secret string) (*JWTClaims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { + return claims, nil + } + + return nil, jwt.ErrSignatureInvalid +} \ No newline at end of file