From 3091fb40204ede5b49a3c5647f963d61a39ae640 Mon Sep 17 00:00:00 2001 From: CFOU Date: Mon, 14 Sep 2026 20:50:19 +0200 Subject: [PATCH] first --- .env.example | 48 + .gitignore | 19 + backend/cmd/api/main.go | 161 ++ backend/cmd/seed/main.go | 50 + backend/go.mod | 73 + backend/go.sum | 168 ++ .../internal/modules/auth/admin_handler.go | 130 ++ backend/internal/modules/auth/admin_routes.go | 14 + .../internal/modules/auth/customer_handler.go | 112 ++ .../internal/modules/auth/customer_routes.go | 15 + backend/internal/modules/auth/model.go | 21 + backend/internal/modules/auth/repository.go | 118 ++ backend/internal/modules/auth/service.go | 123 ++ .../internal/modules/categories/handler.go | 167 ++ backend/internal/modules/categories/model.go | 22 + .../internal/modules/categories/repository.go | 98 ++ backend/internal/modules/categories/routes.go | 18 + .../internal/modules/categories/service.go | 72 + backend/internal/modules/media/handler.go | 126 ++ backend/internal/modules/media/model.go | 24 + backend/internal/modules/media/repository.go | 66 + backend/internal/modules/media/routes.go | 11 + backend/internal/modules/media/service.go | 99 ++ backend/internal/modules/media/storage.go | 19 + .../internal/modules/media/storage_local.go | 51 + backend/internal/modules/media/storage_s3.go | 89 ++ backend/internal/modules/orders/handler.go | 185 +++ backend/internal/modules/orders/model.go | 63 + backend/internal/modules/orders/repository.go | 85 ++ backend/internal/modules/orders/routes.go | 17 + backend/internal/modules/orders/service.go | 171 +++ backend/internal/modules/pricing/handler.go | 128 ++ backend/internal/modules/pricing/model.go | 26 + .../internal/modules/pricing/repository.go | 86 ++ backend/internal/modules/pricing/routes.go | 24 + backend/internal/modules/pricing/service.go | 72 + backend/internal/modules/products/handler.go | 303 ++++ backend/internal/modules/products/model.go | 41 + .../internal/modules/products/repository.go | 132 ++ backend/internal/modules/products/routes.go | 24 + backend/internal/modules/products/service.go | 113 ++ backend/internal/modules/site/handler.go | 62 + backend/internal/modules/site/model.go | 19 + backend/internal/modules/site/repository.go | 37 + backend/internal/modules/site/routes.go | 9 + backend/internal/modules/site/service.go | 23 + backend/internal/modules/telegram/handler.go | 74 + backend/internal/modules/telegram/model.go | 22 + .../internal/modules/telegram/repository.go | 44 + backend/internal/modules/telegram/routes.go | 10 + backend/internal/modules/telegram/sender.go | 55 + backend/internal/modules/telegram/service.go | 75 + backend/internal/modules/units/handler.go | 109 ++ backend/internal/modules/units/model.go | 19 + backend/internal/modules/units/repository.go | 94 ++ backend/internal/modules/units/routes.go | 11 + backend/internal/modules/units/service.go | 48 + backend/internal/modules/users/handler.go | 134 ++ backend/internal/modules/users/model.go | 27 + backend/internal/modules/users/repository.go | 98 ++ backend/internal/modules/users/routes.go | 14 + backend/internal/modules/users/service.go | 83 + backend/internal/platform/config/config.go | 166 ++ backend/internal/platform/db/db.go | 34 + backend/internal/platform/logger/logger.go | 16 + backend/internal/platform/middleware/auth.go | 100 ++ backend/internal/platform/middleware/cors.go | 30 + .../internal/platform/middleware/ratelimit.go | 66 + backend/internal/platform/redis/redis.go | 28 + backend/internal/platform/security/jwt.go | 104 ++ .../internal/platform/security/password.go | 82 + .../migrations/000001_create_users.down.sql | 1 + backend/migrations/000001_create_users.up.sql | 11 + .../000002_create_site_settings.down.sql | 1 + .../000002_create_site_settings.up.sql | 10 + .../000003_create_categories.down.sql | 1 + .../000003_create_categories.up.sql | 10 + .../migrations/000004_create_units.down.sql | 1 + backend/migrations/000004_create_units.up.sql | 7 + .../migrations/000005_create_media.down.sql | 1 + backend/migrations/000005_create_media.up.sql | 11 + .../000006_create_products.down.sql | 1 + .../migrations/000006_create_products.up.sql | 15 + .../000007_create_product_media.down.sql | 1 + .../000007_create_product_media.up.sql | 10 + .../000008_create_price_tiers.down.sql | 1 + .../000008_create_price_tiers.up.sql | 12 + .../000009_create_telegram_settings.down.sql | 1 + .../000009_create_telegram_settings.up.sql | 12 + .../migrations/000010_create_orders.down.sql | 2 + .../migrations/000010_create_orders.up.sql | 27 + .../000011_add_position_to_products.down.sql | 1 + .../000011_add_position_to_products.up.sql | 3 + backend/test/auth/service_test.go | 217 +++ backend/test/orders/service_test.go | 244 +++ backend/test/pricing/service_test.go | 142 ++ backend/test/security/jwt_test.go | 150 ++ backend/test/security/password_test.go | 71 + backend/test/site/service_test.go | 62 + backend/test/telegram/service_test.go | 157 ++ backend/test/users/service_test.go | 183 +++ docker-compose.yml | 37 + frontend/.gitignore | 24 + frontend/.oxlintrc.json | 8 + frontend/README.md | 32 + frontend/index.html | 13 + frontend/package-lock.json | 1359 +++++++++++++++++ frontend/package.json | 26 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.tsx | 50 + frontend/src/api/client.ts | 86 ++ frontend/src/api/reorder.ts | 20 + frontend/src/api/types.ts | 100 ++ frontend/src/features/admin/AdminLayout.tsx | 81 + .../src/features/admin/CategoriesPage.tsx | 204 +++ frontend/src/features/admin/DashboardPage.tsx | 16 + frontend/src/features/admin/MediaPage.tsx | 102 ++ .../src/features/admin/OrderDetailPage.tsx | 102 ++ frontend/src/features/admin/OrdersPage.tsx | 71 + .../src/features/admin/ProductEditPage.tsx | 280 ++++ frontend/src/features/admin/ProductsPage.tsx | 187 +++ .../src/features/admin/SiteSettingsPage.tsx | 82 + .../features/admin/TelegramSettingsPage.tsx | 134 ++ frontend/src/features/admin/UnitsPage.tsx | 130 ++ frontend/src/features/admin/UsersPage.tsx | 128 ++ frontend/src/features/auth/AuthContext.tsx | 63 + frontend/src/features/auth/LoginPage.tsx | 71 + frontend/src/index.css | 290 ++++ frontend/src/main.tsx | 10 + frontend/src/router/ProtectedRoute.tsx | 15 + frontend/tsconfig.app.json | 26 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 17 + 135 files changed, 10262 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/cmd/api/main.go create mode 100644 backend/cmd/seed/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/modules/auth/admin_handler.go create mode 100644 backend/internal/modules/auth/admin_routes.go create mode 100644 backend/internal/modules/auth/customer_handler.go create mode 100644 backend/internal/modules/auth/customer_routes.go create mode 100644 backend/internal/modules/auth/model.go create mode 100644 backend/internal/modules/auth/repository.go create mode 100644 backend/internal/modules/auth/service.go create mode 100644 backend/internal/modules/categories/handler.go create mode 100644 backend/internal/modules/categories/model.go create mode 100644 backend/internal/modules/categories/repository.go create mode 100644 backend/internal/modules/categories/routes.go create mode 100644 backend/internal/modules/categories/service.go create mode 100644 backend/internal/modules/media/handler.go create mode 100644 backend/internal/modules/media/model.go create mode 100644 backend/internal/modules/media/repository.go create mode 100644 backend/internal/modules/media/routes.go create mode 100644 backend/internal/modules/media/service.go create mode 100644 backend/internal/modules/media/storage.go create mode 100644 backend/internal/modules/media/storage_local.go create mode 100644 backend/internal/modules/media/storage_s3.go create mode 100644 backend/internal/modules/orders/handler.go create mode 100644 backend/internal/modules/orders/model.go create mode 100644 backend/internal/modules/orders/repository.go create mode 100644 backend/internal/modules/orders/routes.go create mode 100644 backend/internal/modules/orders/service.go create mode 100644 backend/internal/modules/pricing/handler.go create mode 100644 backend/internal/modules/pricing/model.go create mode 100644 backend/internal/modules/pricing/repository.go create mode 100644 backend/internal/modules/pricing/routes.go create mode 100644 backend/internal/modules/pricing/service.go create mode 100644 backend/internal/modules/products/handler.go create mode 100644 backend/internal/modules/products/model.go create mode 100644 backend/internal/modules/products/repository.go create mode 100644 backend/internal/modules/products/routes.go create mode 100644 backend/internal/modules/products/service.go create mode 100644 backend/internal/modules/site/handler.go create mode 100644 backend/internal/modules/site/model.go create mode 100644 backend/internal/modules/site/repository.go create mode 100644 backend/internal/modules/site/routes.go create mode 100644 backend/internal/modules/site/service.go create mode 100644 backend/internal/modules/telegram/handler.go create mode 100644 backend/internal/modules/telegram/model.go create mode 100644 backend/internal/modules/telegram/repository.go create mode 100644 backend/internal/modules/telegram/routes.go create mode 100644 backend/internal/modules/telegram/sender.go create mode 100644 backend/internal/modules/telegram/service.go create mode 100644 backend/internal/modules/units/handler.go create mode 100644 backend/internal/modules/units/model.go create mode 100644 backend/internal/modules/units/repository.go create mode 100644 backend/internal/modules/units/routes.go create mode 100644 backend/internal/modules/units/service.go create mode 100644 backend/internal/modules/users/handler.go create mode 100644 backend/internal/modules/users/model.go create mode 100644 backend/internal/modules/users/repository.go create mode 100644 backend/internal/modules/users/routes.go create mode 100644 backend/internal/modules/users/service.go create mode 100644 backend/internal/platform/config/config.go create mode 100644 backend/internal/platform/db/db.go create mode 100644 backend/internal/platform/logger/logger.go create mode 100644 backend/internal/platform/middleware/auth.go create mode 100644 backend/internal/platform/middleware/cors.go create mode 100644 backend/internal/platform/middleware/ratelimit.go create mode 100644 backend/internal/platform/redis/redis.go create mode 100644 backend/internal/platform/security/jwt.go create mode 100644 backend/internal/platform/security/password.go create mode 100644 backend/migrations/000001_create_users.down.sql create mode 100644 backend/migrations/000001_create_users.up.sql create mode 100644 backend/migrations/000002_create_site_settings.down.sql create mode 100644 backend/migrations/000002_create_site_settings.up.sql create mode 100644 backend/migrations/000003_create_categories.down.sql create mode 100644 backend/migrations/000003_create_categories.up.sql create mode 100644 backend/migrations/000004_create_units.down.sql create mode 100644 backend/migrations/000004_create_units.up.sql create mode 100644 backend/migrations/000005_create_media.down.sql create mode 100644 backend/migrations/000005_create_media.up.sql create mode 100644 backend/migrations/000006_create_products.down.sql create mode 100644 backend/migrations/000006_create_products.up.sql create mode 100644 backend/migrations/000007_create_product_media.down.sql create mode 100644 backend/migrations/000007_create_product_media.up.sql create mode 100644 backend/migrations/000008_create_price_tiers.down.sql create mode 100644 backend/migrations/000008_create_price_tiers.up.sql create mode 100644 backend/migrations/000009_create_telegram_settings.down.sql create mode 100644 backend/migrations/000009_create_telegram_settings.up.sql create mode 100644 backend/migrations/000010_create_orders.down.sql create mode 100644 backend/migrations/000010_create_orders.up.sql create mode 100644 backend/migrations/000011_add_position_to_products.down.sql create mode 100644 backend/migrations/000011_add_position_to_products.up.sql create mode 100644 backend/test/auth/service_test.go create mode 100644 backend/test/orders/service_test.go create mode 100644 backend/test/pricing/service_test.go create mode 100644 backend/test/security/jwt_test.go create mode 100644 backend/test/security/password_test.go create mode 100644 backend/test/site/service_test.go create mode 100644 backend/test/telegram/service_test.go create mode 100644 backend/test/users/service_test.go create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/reorder.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/features/admin/AdminLayout.tsx create mode 100644 frontend/src/features/admin/CategoriesPage.tsx create mode 100644 frontend/src/features/admin/DashboardPage.tsx create mode 100644 frontend/src/features/admin/MediaPage.tsx create mode 100644 frontend/src/features/admin/OrderDetailPage.tsx create mode 100644 frontend/src/features/admin/OrdersPage.tsx create mode 100644 frontend/src/features/admin/ProductEditPage.tsx create mode 100644 frontend/src/features/admin/ProductsPage.tsx create mode 100644 frontend/src/features/admin/SiteSettingsPage.tsx create mode 100644 frontend/src/features/admin/TelegramSettingsPage.tsx create mode 100644 frontend/src/features/admin/UnitsPage.tsx create mode 100644 frontend/src/features/admin/UsersPage.tsx create mode 100644 frontend/src/features/auth/AuthContext.tsx create mode 100644 frontend/src/features/auth/LoginPage.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/router/ProtectedRoute.tsx create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..471ea9d --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# --- PostgreSQL (dockerized) --- +DB_HOST=localhost +DB_PORT=5432 +DB_USER=platform +DB_PASSWORD=change-me-strong-password +DB_NAME=platform +DATABASE_URL=postgres://platform:change-me-strong-password@localhost:5432/platform?sslmode=disable + +# --- Redis (dockerized, session / refresh-token state) --- +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=change-me-strong-redis-password +REDIS_URL=redis://:change-me-strong-redis-password@localhost:6379/0 + +# --- JWT / Auth --- +# Generate with: openssl rand -base64 48 +JWT_SECRET=change-me-to-a-long-random-secret +ACCESS_TOKEN_TTL_MINUTES=15 +REFRESH_TOKEN_TTL_HOURS=168 + +# --- Server --- +PORT=8080 +GIN_MODE=debug +CORS_ORIGIN=http://localhost:5173 +# Cookie Secure flag for the refresh token cookie. Defaults to false only +# when GIN_MODE=debug (plain http:// on localhost); set to true in any +# environment served over HTTPS. +COOKIE_SECURE=false + +# --- Seed (initial admin account, cmd/seed) --- +SEED_ADMIN_EMAIL=admin@example.com +SEED_ADMIN_PASSWORD=change-me-strong-password + +# --- Media storage (module: media) --- +# "local" saves files under MEDIA_LOCAL_DIR and serves them from /uploads. +# "s3" uploads to any S3-compatible bucket (AWS S3, MinIO, Cloudflare R2, ...). +MEDIA_STORAGE_DRIVER=local +MEDIA_MAX_UPLOAD_MB=10 +MEDIA_LOCAL_DIR=./uploads +MEDIA_LOCAL_BASE_URL=http://localhost:8080/uploads +# Only required when MEDIA_STORAGE_DRIVER=s3: +S3_BUCKET= +S3_REGION= +S3_ENDPOINT= +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_USE_PATH_STYLE=false +S3_PUBLIC_BASE_URL= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..009149d --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Env +.env +.env.local + +# Backend +backend/bin/ +backend/tmp/ +*.exe + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# OS / IDE +.DS_Store +*.log +.vscode/ +.idea/ diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go new file mode 100644 index 0000000..ef6d918 --- /dev/null +++ b/backend/cmd/api/main.go @@ -0,0 +1,161 @@ +package main + +import ( + "context" + "log" + "log/slog" + + "github.com/gin-gonic/gin" + + "backend/internal/modules/auth" + "backend/internal/modules/categories" + "backend/internal/modules/media" + "backend/internal/modules/orders" + "backend/internal/modules/pricing" + "backend/internal/modules/products" + "backend/internal/modules/site" + "backend/internal/modules/telegram" + "backend/internal/modules/units" + "backend/internal/modules/users" + "backend/internal/platform/config" + "backend/internal/platform/db" + "backend/internal/platform/logger" + appmiddleware "backend/internal/platform/middleware" + "backend/internal/platform/redis" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("load config: %v", err) + } + + appLogger := logger.New(cfg.Server.GinMode == "debug") + slog.SetDefault(appLogger) + + database, err := db.Connect(cfg.Database.URL, cfg.Server.GinMode == "debug") + if err != nil { + log.Fatalf("connect database: %v", err) + } + + redisClient, err := redis.Connect(cfg.Redis.URL) + if err != nil { + log.Fatalf("connect redis: %v", err) + } + + // --- Users module --- + usersRepo := users.NewRepository(database) + usersService := users.NewService(usersRepo) + usersHandler := users.NewHandler(usersService) + + // --- Auth module (admin space mounted; customer space wired but not mounted) --- + refreshStore := auth.NewRefreshStore(redisClient) + authService := auth.NewService(usersService, refreshStore, cfg.JWT.Secret, cfg.JWT.AccessTTL, cfg.JWT.RefreshTTL) + adminAuthHandler := auth.NewAdminHandler(authService, cfg.Server.CookieSecure) + + // --- Site module --- + siteRepo := site.NewRepository(database) + siteService := site.NewService(siteRepo) + siteHandler := site.NewHandler(siteService) + + // --- Media module (pluggable local/S3 storage) --- + mediaStorage, err := buildMediaStorage(cfg.Media) + if err != nil { + log.Fatalf("init media storage: %v", err) + } + mediaRepo := media.NewRepository(database) + mediaService := media.NewService(mediaRepo, mediaStorage, cfg.Media.MaxUploadMB*1024*1024) + mediaHandler := media.NewHandler(mediaService) + + // --- Categories module --- + categoriesRepo := categories.NewRepository(database) + categoriesService := categories.NewService(categoriesRepo) + categoriesHandler := categories.NewHandler(categoriesService) + + // --- Units module --- + unitsRepo := units.NewRepository(database) + unitsService := units.NewService(unitsRepo) + unitsHandler := units.NewHandler(unitsService) + + // --- Products module --- + productsRepo := products.NewRepository(database) + productsService := products.NewService(productsRepo) + productsHandler := products.NewHandler(productsService) + + // --- Pricing module (quantity-based tiers, spec section 16) --- + pricingRepo := pricing.NewRepository(database) + pricingService := pricing.NewService(pricingRepo) + pricingHandler := pricing.NewHandler(pricingService) + + // --- Telegram module (notifications, spec section 20) --- + telegramRepo := telegram.NewRepository(database) + telegramService := telegram.NewService(telegramRepo, telegram.NewHTTPSender(), appLogger) + telegramHandler := telegram.NewHandler(telegramService) + + // --- Orders module (depends on products/units/pricing for authoritative + // pricing, and on telegram as its pluggable OrderNotifier) --- + ordersRepo := orders.NewRepository(database) + ordersService := orders.NewService(ordersRepo, productsService, unitsService, pricingService, telegramService) + ordersHandler := orders.NewHandler(ordersService) + + requireAdmin := appmiddleware.RequireAdmin(cfg.JWT.Secret) + loginRateLimit := appmiddleware.NewPerIPRateLimiter(5, 5).Middleware() + orderRateLimit := appmiddleware.NewPerIPRateLimiter(10, 10).Middleware() + + gin.SetMode(cfg.Server.GinMode) + router := gin.New() + router.Use(gin.Recovery()) + router.Use(gin.Logger()) + router.Use(appmiddleware.CORS(cfg.Server.CORSOrigin)) + + if cfg.Media.Driver == "local" { + router.Static("/uploads", cfg.Media.LocalDir) + } + + router.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + api := router.Group("/api") + + auth.RegisterAdminRoutes(api, adminAuthHandler, requireAdmin, loginRateLimit) + users.RegisterAdminRoutes(api, usersHandler, requireAdmin) + site.RegisterRoutes(api, siteHandler, requireAdmin) + media.RegisterAdminRoutes(api, mediaHandler, requireAdmin) + + categories.RegisterAdminRoutes(api, categoriesHandler, requireAdmin) + categories.RegisterPublicRoutes(api, categoriesHandler) + + units.RegisterAdminRoutes(api, unitsHandler, requireAdmin) + + products.RegisterAdminRoutes(api, productsHandler, requireAdmin) + products.RegisterPublicRoutes(api, productsHandler) + + pricing.RegisterAdminRoutes(api, pricingHandler, requireAdmin) + pricing.RegisterPublicRoutes(api, pricingHandler) + + telegram.RegisterAdminRoutes(api, telegramHandler, requireAdmin) + + orders.RegisterPublicRoutes(api, ordersHandler, orderRateLimit) + orders.RegisterAdminRoutes(api, ordersHandler, requireAdmin) + + appLogger.Info("starting server", "port", cfg.Server.Port) + if err := router.Run(":" + cfg.Server.Port); err != nil { + log.Fatalf("server error: %v", err) + } +} + +func buildMediaStorage(cfg config.MediaConfig) (media.Storage, error) { + if cfg.Driver == "s3" { + return media.NewS3Storage(context.Background(), media.S3Config{ + Bucket: cfg.S3Bucket, + Region: cfg.S3Region, + Endpoint: cfg.S3Endpoint, + AccessKeyID: cfg.S3AccessKeyID, + SecretKey: cfg.S3SecretKey, + UsePathStyle: cfg.S3UsePathStyle, + PublicBaseURL: cfg.S3PublicBaseURL, + }) + } + return media.NewLocalStorage(cfg.LocalDir, cfg.LocalBaseURL) +} diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go new file mode 100644 index 0000000..6c058a2 --- /dev/null +++ b/backend/cmd/seed/main.go @@ -0,0 +1,50 @@ +// cmd/seed creates the initial admin account so the developer can hand the +// client a username/password without ever touching the database by hand. +package main + +import ( + "context" + "errors" + "log" + + "backend/internal/modules/users" + "backend/internal/platform/config" + "backend/internal/platform/db" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("load config: %v", err) + } + + if cfg.Seed.AdminEmail == "" || cfg.Seed.AdminPassword == "" { + log.Fatal("SEED_ADMIN_EMAIL and SEED_ADMIN_PASSWORD must be set") + } + if len(cfg.Seed.AdminPassword) < 12 { + log.Fatal("SEED_ADMIN_PASSWORD must be at least 12 characters") + } + + database, err := db.Connect(cfg.Database.URL, false) + if err != nil { + log.Fatalf("connect database: %v", err) + } + + repo := users.NewRepository(database) + service := users.NewService(repo) + + ctx := context.Background() + + if existing, err := repo.FindByEmail(ctx, cfg.Seed.AdminEmail); err == nil && existing != nil { + log.Fatalf("an account with email %q already exists (id=%s)", cfg.Seed.AdminEmail, existing.ID) + } else if err != nil && !errors.Is(err, users.ErrNotFound) { + log.Fatalf("check existing admin: %v", err) + } + + admin, err := service.Create(ctx, cfg.Seed.AdminEmail, cfg.Seed.AdminPassword, users.RoleAdmin) + if err != nil { + log.Fatalf("create admin: %v", err) + } + + log.Printf("admin account created: email=%s id=%s", admin.Email, admin.ID) +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..10e3841 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,73 @@ +module backend + +go 1.26.0 + +require ( + github.com/aws/aws-sdk-go-v2/config v1.33.4 + github.com/aws/aws-sdk-go-v2/credentials v1.20.4 + github.com/aws/aws-sdk-go-v2/service/s3 v1.113.1 + github.com/aws/smithy-go v1.28.1 + 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/jackc/pgx/v5 v5.10.0 + github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.22.0 + golang.org/x/crypto v0.57.0 + golang.org/x/time v0.16.0 + gorm.io/driver/postgres v1.6.2 + gorm.io/gorm v1.31.2 +) + +require ( + github.com/aws/aws-sdk-go-v2 v1.47.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 // 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/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.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..bb1ab37 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,168 @@ +github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ= +github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8= +github.com/aws/aws-sdk-go-v2/config v1.33.4 h1:FzvkXKSzwqHni4U7nDigHg4jjtqMpVUuHgmZfSoJVQ0= +github.com/aws/aws-sdk-go-v2/config v1.33.4/go.mod h1:VZqGZnZsCWVfK/iGPptJIyNIX3XEX6iQU2Rel4sLrr8= +github.com/aws/aws-sdk-go-v2/credentials v1.20.4 h1:hTvrJJseKbvw32kmiE0G+u/9ZqpqscjDrTigHIXP2qs= +github.com/aws/aws-sdk-go-v2/credentials v1.20.4/go.mod h1:gWp9O1ZBWwpcIrgV+mVHk4gZUurAEDkgypu/OXOlIaw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 h1:AM4hHjww+PSFtt6E+UrBrPlZkWsePCLEt9AjkfQX+yM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0/go.mod h1:3x/yXezeQjpOvBb4jEMxrS8SXvpdvJ5abv6l5c1gWM8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 h1:Hp/VgjP0BysR3OgLlR057Vz2LcbbVnoWeJ+3qWiS/fY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3/go.mod h1:nwGV5qw7F1IZPgxCvA/ph8N2TAuz+BkRG/bXn808qMA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 h1:MUaM4f+kj1ZIBPZfUS8cxP1GKXXZtHJjAthy93AN7SM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3/go.mod h1:6YmVmEVRI5ZZzRjCSsb9SryKH0hAlMRdgA7kG9aDvBU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6Wee+WOLwrHHUMy9c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 h1:BHKCSX4QXERe8So8rbWqaM7owqOmDJxATXgJwGng22A= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3/go.mod h1:GqWeeKfYfezihA2KfFL9l7ohEdZWe1tuFWh3GfyNSnE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 h1:L8vIOxylma91TcR96NFTEC07G3JDwSl+CvK2b+IODms= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3/go.mod h1:fmPIZQzTExYuBNWFyi1P7IoDjvskgphXqK1yObMzusM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.113.1 h1:cFHmwLxZPvtoAlo79MboURL2+7b0TMHycnrcf5VZNXk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.113.1/go.mod h1:/uA+2Qj4jd5qBWagVC1AyzzDFXVK997E7U04w7Kw0wI= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 h1:ZD5qFpWcaOKdTuhBi431pIDkCgrMkMlMT6jlpSPoIRI= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.0/go.mod h1:8Nuuf+tR346PjJ3MvZPh9pekbLiLQFWJhzMXfwy7alA= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 h1:JGeeBcMlhg1xtOXYpeCaTQBZObtXMPQCUqBcmr65NRA= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.0/go.mod h1:XwteswG9EOMRFm73UT0t+MbTwyLxMrEXkU6e+v92Lzo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 h1:obhahQXDEdVEv8y5bTKXR30LVaxYe1kyYM0L7l2Iq+k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0/go.mod h1:6twZZ/aXHNy1vXUO8koUbp++MYzMASkOgEBdkbJYmO0= +github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 h1:khXV3+K5D3f4e8xtplaRdSFn1bEg3gj5EBHQvbCOZbQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.50.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.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.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= +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.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +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.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4= +gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/backend/internal/modules/auth/admin_handler.go b/backend/internal/modules/auth/admin_handler.go new file mode 100644 index 0000000..ecaf1f0 --- /dev/null +++ b/backend/internal/modules/auth/admin_handler.go @@ -0,0 +1,130 @@ +package auth + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "backend/internal/modules/users" + "backend/internal/platform/middleware" + "backend/internal/platform/security" +) + +const ( + adminRefreshCookieName = "admin_refresh_token" + adminRefreshCookiePath = "/api/auth/admin" +) + +// AdminHandler exposes the admin-space auth endpoints. It is kept entirely +// separate from CustomerHandler: different cookie name, different cookie +// path, different JWT audience, so an admin session can never be replayed +// against a future customer-facing endpoint. +type AdminHandler struct { + service *Service + cookieSecure bool +} + +func NewAdminHandler(service *Service, cookieSecure bool) *AdminHandler { + return &AdminHandler{service: service, cookieSecure: cookieSecure} +} + +type loginRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +type authUserResponse struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Role string `json:"role"` +} + +type accessTokenResponse struct { + AccessToken string `json:"access_token"` + User *authUserResponse `json:"user,omitempty"` +} + +func (h *AdminHandler) Login(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + + pair, user, err := h.service.Login(c.Request.Context(), security.AudienceAdmin, users.RoleAdmin, req.Email, req.Password) + if err != nil { + h.respondLoginError(c, err) + return + } + + h.setRefreshCookie(c, pair.RefreshToken) + c.JSON(http.StatusOK, accessTokenResponse{ + AccessToken: pair.AccessToken, + User: &authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role}, + }) +} + +func (h *AdminHandler) Refresh(c *gin.Context) { + token, err := c.Cookie(adminRefreshCookieName) + if err != nil || token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing refresh token"}) + return + } + + pair, err := h.service.Refresh(c.Request.Context(), security.AudienceAdmin, token) + if err != nil { + h.clearRefreshCookie(c) + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"}) + return + } + + h.setRefreshCookie(c, pair.RefreshToken) + c.JSON(http.StatusOK, accessTokenResponse{AccessToken: pair.AccessToken}) +} + +func (h *AdminHandler) Logout(c *gin.Context) { + if token, err := c.Cookie(adminRefreshCookieName); err == nil && token != "" { + _ = h.service.Logout(c.Request.Context(), security.AudienceAdmin, token) + } + h.clearRefreshCookie(c) + c.JSON(http.StatusOK, gin.H{"message": "logged out"}) +} + +func (h *AdminHandler) Me(c *gin.Context) { + userID, ok := middleware.GetUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + user, err := h.service.Me(c.Request.Context(), userID) + if err != nil { + if errors.Is(err, users.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + c.JSON(http.StatusOK, authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role}) +} + +func (h *AdminHandler) respondLoginError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrInvalidCredentials), errors.Is(err, ErrAccountDisabled): + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"}) + } +} + +func (h *AdminHandler) setRefreshCookie(c *gin.Context, token string) { + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(adminRefreshCookieName, token, int(h.service.RefreshTTL().Seconds()), adminRefreshCookiePath, "", h.cookieSecure, true) +} + +func (h *AdminHandler) clearRefreshCookie(c *gin.Context) { + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(adminRefreshCookieName, "", -1, adminRefreshCookiePath, "", h.cookieSecure, true) +} diff --git a/backend/internal/modules/auth/admin_routes.go b/backend/internal/modules/auth/admin_routes.go new file mode 100644 index 0000000..4aa9ea3 --- /dev/null +++ b/backend/internal/modules/auth/admin_routes.go @@ -0,0 +1,14 @@ +package auth + +import "github.com/gin-gonic/gin" + +// RegisterAdminRoutes mounts the admin-space auth endpoints. loginRateLimit +// is applied only to /login to blunt credential-stuffing/brute-force +// attempts without throttling normal authenticated traffic. +func RegisterAdminRoutes(rg *gin.RouterGroup, h *AdminHandler, requireAdmin gin.HandlerFunc, loginRateLimit gin.HandlerFunc) { + group := rg.Group("/auth/admin") + group.POST("/login", loginRateLimit, h.Login) + group.POST("/refresh", h.Refresh) + group.POST("/logout", h.Logout) + group.GET("/me", requireAdmin, h.Me) +} diff --git a/backend/internal/modules/auth/customer_handler.go b/backend/internal/modules/auth/customer_handler.go new file mode 100644 index 0000000..df217e8 --- /dev/null +++ b/backend/internal/modules/auth/customer_handler.go @@ -0,0 +1,112 @@ +package auth + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "backend/internal/modules/users" + "backend/internal/platform/middleware" + "backend/internal/platform/security" +) + +// customer cookie name/path are intentionally distinct from the admin ones +// (see admin_handler.go) so the two spaces never share a session cookie. +const ( + customerRefreshCookieName = "customer_refresh_token" + customerRefreshCookiePath = "/api/auth/customer" +) + +// CustomerHandler mirrors AdminHandler but issues/accepts only +// customer-audience tokens. Not mounted by main.go in this phase (no +// customer-facing module needs it yet) — it exists so the future +// cart/orders module can activate customer auth without touching the admin +// code path at all. +type CustomerHandler struct { + service *Service + cookieSecure bool +} + +func NewCustomerHandler(service *Service, cookieSecure bool) *CustomerHandler { + return &CustomerHandler{service: service, cookieSecure: cookieSecure} +} + +func (h *CustomerHandler) Login(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + + pair, user, err := h.service.Login(c.Request.Context(), security.AudienceCustomer, users.RoleCustomer, req.Email, req.Password) + if err != nil { + switch { + case errors.Is(err, ErrInvalidCredentials), errors.Is(err, ErrAccountDisabled): + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"}) + } + return + } + + h.setRefreshCookie(c, pair.RefreshToken) + c.JSON(http.StatusOK, accessTokenResponse{ + AccessToken: pair.AccessToken, + User: &authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role}, + }) +} + +func (h *CustomerHandler) Refresh(c *gin.Context) { + token, err := c.Cookie(customerRefreshCookieName) + if err != nil || token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing refresh token"}) + return + } + + pair, err := h.service.Refresh(c.Request.Context(), security.AudienceCustomer, token) + if err != nil { + h.clearRefreshCookie(c) + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"}) + return + } + + h.setRefreshCookie(c, pair.RefreshToken) + c.JSON(http.StatusOK, accessTokenResponse{AccessToken: pair.AccessToken}) +} + +func (h *CustomerHandler) Logout(c *gin.Context) { + if token, err := c.Cookie(customerRefreshCookieName); err == nil && token != "" { + _ = h.service.Logout(c.Request.Context(), security.AudienceCustomer, token) + } + h.clearRefreshCookie(c) + c.JSON(http.StatusOK, gin.H{"message": "logged out"}) +} + +func (h *CustomerHandler) Me(c *gin.Context) { + userID, ok := middleware.GetUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + user, err := h.service.Me(c.Request.Context(), userID) + if err != nil { + if errors.Is(err, users.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + c.JSON(http.StatusOK, authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role}) +} + +func (h *CustomerHandler) setRefreshCookie(c *gin.Context, token string) { + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(customerRefreshCookieName, token, int(h.service.RefreshTTL().Seconds()), customerRefreshCookiePath, "", h.cookieSecure, true) +} + +func (h *CustomerHandler) clearRefreshCookie(c *gin.Context) { + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(customerRefreshCookieName, "", -1, customerRefreshCookiePath, "", h.cookieSecure, true) +} diff --git a/backend/internal/modules/auth/customer_routes.go b/backend/internal/modules/auth/customer_routes.go new file mode 100644 index 0000000..166303f --- /dev/null +++ b/backend/internal/modules/auth/customer_routes.go @@ -0,0 +1,15 @@ +package auth + +import "github.com/gin-gonic/gin" + +// RegisterCustomerRoutes mounts the customer-space auth endpoints. Deliberately +// NOT called from cmd/api/main.go in this phase: there is no customer-facing +// module (cart/orders) yet, so exposing these routes today would just be a +// dead surface. Wiring is ready for when that module is activated. +func RegisterCustomerRoutes(rg *gin.RouterGroup, h *CustomerHandler, requireCustomer gin.HandlerFunc, loginRateLimit gin.HandlerFunc) { + group := rg.Group("/auth/customer") + group.POST("/login", loginRateLimit, h.Login) + group.POST("/refresh", h.Refresh) + group.POST("/logout", h.Logout) + group.GET("/me", requireCustomer, h.Me) +} diff --git a/backend/internal/modules/auth/model.go b/backend/internal/modules/auth/model.go new file mode 100644 index 0000000..9bf2997 --- /dev/null +++ b/backend/internal/modules/auth/model.go @@ -0,0 +1,21 @@ +// Package auth issues and verifies sessions (access + refresh tokens) for +// both the admin space and the (not-yet-mounted) customer space. It has no +// database table of its own: user identity/credentials live in the users +// module, and refresh-token/session state lives entirely in Redis. +package auth + +import "errors" + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrInvalidRefreshToken = errors.New("invalid refresh token") + ErrAccountDisabled = errors.New("account disabled") +) + +// TokenPair is returned to the client on login/refresh: the access token is +// meant to be kept in memory by the frontend, the refresh token is meant to +// be set as an httpOnly cookie by the handler (never returned to JS). +type TokenPair struct { + AccessToken string + RefreshToken string +} diff --git a/backend/internal/modules/auth/repository.go b/backend/internal/modules/auth/repository.go new file mode 100644 index 0000000..f28ba70 --- /dev/null +++ b/backend/internal/modules/auth/repository.go @@ -0,0 +1,118 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + + "backend/internal/platform/security" +) + +// RefreshStore tracks live refresh tokens in Redis, scoped per audience +// (admin/customer) so a session in one space is never visible to the other. +type RefreshStore interface { + Issue(ctx context.Context, aud security.Audience, userID uuid.UUID, ttl time.Duration) (string, error) + Rotate(ctx context.Context, aud security.Audience, oldToken string, ttl time.Duration) (newToken string, userID uuid.UUID, err error) + Revoke(ctx context.Context, aud security.Audience, token string) error + RevokeAllForUser(ctx context.Context, aud security.Audience, userID uuid.UUID) error +} + +type redisRefreshStore struct { + client *redis.Client +} + +func NewRefreshStore(client *redis.Client) RefreshStore { + return &redisRefreshStore{client: client} +} + +func sessionKey(aud security.Audience, token string) string { + return fmt.Sprintf("session:%s:%s", aud, token) +} + +func userSetKey(aud security.Audience, userID uuid.UUID) string { + return fmt.Sprintf("session:%s:user:%s", aud, userID) +} + +func randomToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func (s *redisRefreshStore) Issue(ctx context.Context, aud security.Audience, userID uuid.UUID, ttl time.Duration) (string, error) { + token, err := randomToken() + if err != nil { + return "", fmt.Errorf("generate refresh token: %w", err) + } + if err := s.client.Set(ctx, sessionKey(aud, token), userID.String(), ttl).Err(); err != nil { + return "", fmt.Errorf("store refresh token: %w", err) + } + // Best-effort secondary index for RevokeAllForUser; a token still present + // here after its primary key has naturally expired is harmless (RevokeAllForUser + // simply issues a DEL for an already-gone key). + s.client.SAdd(ctx, userSetKey(aud, userID), token) + return token, nil +} + +func (s *redisRefreshStore) Rotate(ctx context.Context, aud security.Audience, oldToken string, ttl time.Duration) (string, uuid.UUID, error) { + val, err := s.client.GetDel(ctx, sessionKey(aud, oldToken)).Result() + if errors.Is(err, redis.Nil) { + return "", uuid.UUID{}, ErrInvalidRefreshToken + } + if err != nil { + return "", uuid.UUID{}, fmt.Errorf("lookup refresh token: %w", err) + } + + userID, err := uuid.Parse(val) + if err != nil { + return "", uuid.UUID{}, fmt.Errorf("corrupt session value: %w", err) + } + s.client.SRem(ctx, userSetKey(aud, userID), oldToken) + + newToken, err := s.Issue(ctx, aud, userID, ttl) + if err != nil { + return "", uuid.UUID{}, err + } + return newToken, userID, nil +} + +func (s *redisRefreshStore) Revoke(ctx context.Context, aud security.Audience, token string) error { + val, err := s.client.GetDel(ctx, sessionKey(aud, token)).Result() + if errors.Is(err, redis.Nil) { + return nil // already revoked/expired: logout is idempotent + } + if err != nil { + return fmt.Errorf("revoke refresh token: %w", err) + } + if userID, parseErr := uuid.Parse(val); parseErr == nil { + s.client.SRem(ctx, userSetKey(aud, userID), token) + } + return nil +} + +func (s *redisRefreshStore) RevokeAllForUser(ctx context.Context, aud security.Audience, userID uuid.UUID) error { + setKey := userSetKey(aud, userID) + tokens, err := s.client.SMembers(ctx, setKey).Result() + if err != nil { + return fmt.Errorf("list sessions: %w", err) + } + if len(tokens) > 0 { + keys := make([]string, len(tokens)) + for i, t := range tokens { + keys[i] = sessionKey(aud, t) + } + if err := s.client.Del(ctx, keys...).Err(); err != nil { + return fmt.Errorf("revoke sessions: %w", err) + } + } + s.client.Del(ctx, setKey) + return nil +} diff --git a/backend/internal/modules/auth/service.go b/backend/internal/modules/auth/service.go new file mode 100644 index 0000000..8585e18 --- /dev/null +++ b/backend/internal/modules/auth/service.go @@ -0,0 +1,123 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "backend/internal/modules/users" + "backend/internal/platform/security" +) + +// UserFinder is the minimal slice of the users module this service needs. +// Defined here (consumer side) rather than depending on the full +// users.Repository, so auth only ever reads user records, never writes them. +type UserFinder interface { + FindByEmail(ctx context.Context, email string) (*users.User, error) + FindByID(ctx context.Context, id uuid.UUID) (*users.User, error) +} + +type Service struct { + users UserFinder + refresh RefreshStore + jwtSecret string + accessTTL time.Duration + refreshTTL time.Duration +} + +func NewService(userFinder UserFinder, refresh RefreshStore, jwtSecret string, accessTTL, refreshTTL time.Duration) *Service { + return &Service{ + users: userFinder, + refresh: refresh, + jwtSecret: jwtSecret, + accessTTL: accessTTL, + refreshTTL: refreshTTL, + } +} + +func (s *Service) RefreshTTL() time.Duration { return s.refreshTTL } + +// Login authenticates a user for a specific space (aud) and enforces that +// the account's role matches that space (an admin account cannot log into +// the customer space and vice versa, even before the customer module ships). +func (s *Service) Login(ctx context.Context, aud security.Audience, expectedRole, email, password string) (*TokenPair, *users.User, error) { + user, err := s.users.FindByEmail(ctx, email) + if err != nil { + if errors.Is(err, users.ErrNotFound) { + return nil, nil, ErrInvalidCredentials + } + return nil, nil, err + } + + if !user.IsActive { + return nil, nil, ErrAccountDisabled + } + if user.Role != expectedRole { + return nil, nil, ErrInvalidCredentials + } + + ok, err := security.VerifyPassword(user.PasswordHash, password) + if err != nil || !ok { + return nil, nil, ErrInvalidCredentials + } + + pair, err := s.issuePair(ctx, aud, user) + if err != nil { + return nil, nil, err + } + return pair, user, nil +} + +// Refresh rotates a refresh token (single use) and issues a fresh pair. If +// the presented token is unknown to the store (already used, revoked, or +// expired) it is treated as invalid; genuine reuse of a stolen token simply +// fails from that point on since the old token was deleted at issuance time. +func (s *Service) Refresh(ctx context.Context, aud security.Audience, refreshToken string) (*TokenPair, error) { + newRefreshToken, userID, err := s.refresh.Rotate(ctx, aud, refreshToken, s.refreshTTL) + if err != nil { + return nil, err + } + + user, err := s.users.FindByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("lookup user for refresh: %w", err) + } + if !user.IsActive { + _ = s.refresh.RevokeAllForUser(ctx, aud, userID) + return nil, ErrAccountDisabled + } + + access, err := security.IssueAccessToken(s.jwtSecret, s.accessTTL, user.ID, user.Role, aud) + if err != nil { + return nil, fmt.Errorf("issue access token: %w", err) + } + + return &TokenPair{AccessToken: access, RefreshToken: newRefreshToken}, nil +} + +func (s *Service) Logout(ctx context.Context, aud security.Audience, refreshToken string) error { + return s.refresh.Revoke(ctx, aud, refreshToken) +} + +func (s *Service) LogoutAll(ctx context.Context, aud security.Audience, userID uuid.UUID) error { + return s.refresh.RevokeAllForUser(ctx, aud, userID) +} + +func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*users.User, error) { + return s.users.FindByID(ctx, userID) +} + +func (s *Service) issuePair(ctx context.Context, aud security.Audience, user *users.User) (*TokenPair, error) { + access, err := security.IssueAccessToken(s.jwtSecret, s.accessTTL, user.ID, user.Role, aud) + if err != nil { + return nil, fmt.Errorf("issue access token: %w", err) + } + refreshToken, err := s.refresh.Issue(ctx, aud, user.ID, s.refreshTTL) + if err != nil { + return nil, fmt.Errorf("issue refresh token: %w", err) + } + return &TokenPair{AccessToken: access, RefreshToken: refreshToken}, nil +} diff --git a/backend/internal/modules/categories/handler.go b/backend/internal/modules/categories/handler.go new file mode 100644 index 0000000..3a51377 --- /dev/null +++ b/backend/internal/modules/categories/handler.go @@ -0,0 +1,167 @@ +package categories + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +type categoryResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Position int `json:"position"` + IsActive bool `json:"is_active"` +} + +func toResponse(cat *Category) categoryResponse { + return categoryResponse{ + ID: cat.ID, + Name: cat.Name, + Slug: cat.Slug, + Description: cat.Description, + Position: cat.Position, + IsActive: cat.IsActive, + } +} + +// ListAdmin returns every category (active or not). +func (h *Handler) ListAdmin(c *gin.Context) { + list, err := h.service.List(c.Request.Context(), false) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list categories"}) + return + } + c.JSON(http.StatusOK, gin.H{"categories": toResponseList(list)}) +} + +// ListPublic returns only active categories, for storefront navigation. +func (h *Handler) ListPublic(c *gin.Context) { + list, err := h.service.List(c.Request.Context(), true) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list categories"}) + return + } + c.JSON(http.StatusOK, gin.H{"categories": toResponseList(list)}) +} + +func toResponseList(list []*Category) []categoryResponse { + resp := make([]categoryResponse, 0, len(list)) + for _, cat := range list { + resp = append(resp, toResponse(cat)) + } + return resp +} + +type upsertRequest struct { + Name string `json:"name" binding:"required"` + Slug string `json:"slug" binding:"required"` + Description string `json:"description"` + Position int `json:"position"` + IsActive bool `json:"is_active"` +} + +func (h *Handler) Create(c *gin.Context) { + var req upsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + cat, err := h.service.Create(c.Request.Context(), req.Name, req.Slug, req.Description, req.Position, req.IsActive) + if err != nil { + if errors.Is(err, ErrSlugTaken) { + c.JSON(http.StatusConflict, gin.H{"error": "slug already in use"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create category"}) + return + } + c.JSON(http.StatusCreated, toResponse(cat)) +} + +func (h *Handler) Update(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var req upsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + cat, err := h.service.Update(c.Request.Context(), id, req.Name, req.Slug, req.Description, req.Position, req.IsActive) + if err != nil { + switch { + case errors.Is(err, ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) + case errors.Is(err, ErrSlugTaken): + c.JSON(http.StatusConflict, gin.H{"error": "slug already in use"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update category"}) + } + return + } + c.JSON(http.StatusOK, toResponse(cat)) +} + +type updatePositionRequest struct { + Position int `json:"position"` +} + +// UpdatePosition lets the admin reorder the category display order (e.g. +// move up/move down in the admin list) without resending the whole +// category payload. +func (h *Handler) UpdatePosition(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var req updatePositionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + cat, err := h.service.UpdatePosition(c.Request.Context(), id, req.Position) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update position"}) + return + } + c.JSON(http.StatusOK, toResponse(cat)) +} + +func (h *Handler) Delete(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + if err := h.service.Delete(c.Request.Context(), id); err != nil { + switch { + case errors.Is(err, ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) + case errors.Is(err, ErrInUse): + c.JSON(http.StatusConflict, gin.H{"error": "category is used by existing products"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete category"}) + } + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/modules/categories/model.go b/backend/internal/modules/categories/model.go new file mode 100644 index 0000000..dbbfdba --- /dev/null +++ b/backend/internal/modules/categories/model.go @@ -0,0 +1,22 @@ +// Package categories lets the admin organize products into their own +// categories (name, slug, description, ordering) without touching code. +package categories + +import ( + "time" + + "github.com/google/uuid" +) + +type Category struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + Name string `gorm:"not null"` + Slug string `gorm:"uniqueIndex;not null"` + Description string `gorm:"not null;default:''"` + Position int `gorm:"not null;default:0"` + IsActive bool `gorm:"not null;default:true"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (Category) TableName() string { return "categories" } diff --git a/backend/internal/modules/categories/repository.go b/backend/internal/modules/categories/repository.go new file mode 100644 index 0000000..d626267 --- /dev/null +++ b/backend/internal/modules/categories/repository.go @@ -0,0 +1,98 @@ +package categories + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" +) + +var ( + ErrNotFound = errors.New("category not found") + ErrSlugTaken = errors.New("slug already in use") + ErrInUse = errors.New("category is referenced by existing products") +) + +type Repository interface { + Create(ctx context.Context, cat *Category) error + FindByID(ctx context.Context, id uuid.UUID) (*Category, error) + List(ctx context.Context, activeOnly bool) ([]*Category, error) + Update(ctx context.Context, cat *Category) error + Delete(ctx context.Context, id uuid.UUID) error +} + +type gormRepository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) Repository { + return &gormRepository{db: db} +} + +func (r *gormRepository) Create(ctx context.Context, cat *Category) error { + if err := r.db.WithContext(ctx).Create(cat).Error; err != nil { + if isUniqueViolation(err) { + return ErrSlugTaken + } + return err + } + return nil +} + +func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Category, error) { + var cat Category + err := r.db.WithContext(ctx).Where("id = ?", id).First(&cat).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &cat, nil +} + +func (r *gormRepository) List(ctx context.Context, activeOnly bool) ([]*Category, error) { + q := r.db.WithContext(ctx).Order("position asc, name asc") + if activeOnly { + q = q.Where("is_active = ?", true) + } + var list []*Category + if err := q.Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func (r *gormRepository) Update(ctx context.Context, cat *Category) error { + err := r.db.WithContext(ctx).Save(cat).Error + if isUniqueViolation(err) { + return ErrSlugTaken + } + return err +} + +func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error { + res := r.db.WithContext(ctx).Delete(&Category{}, "id = ?", id) + if res.Error != nil { + if isForeignKeyViolation(res.Error) { + return ErrInUse + } + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + +func isForeignKeyViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23503" +} diff --git a/backend/internal/modules/categories/routes.go b/backend/internal/modules/categories/routes.go new file mode 100644 index 0000000..89d83c5 --- /dev/null +++ b/backend/internal/modules/categories/routes.go @@ -0,0 +1,18 @@ +package categories + +import "github.com/gin-gonic/gin" + +func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) { + group := rg.Group("/admin/categories", requireAdmin) + group.GET("", h.ListAdmin) + group.POST("", h.Create) + group.PUT("/:id", h.Update) + group.PATCH("/:id/position", h.UpdatePosition) + group.DELETE("/:id", h.Delete) +} + +// RegisterPublicRoutes exposes the read-only, active-only category list +// used by the storefront navigation/filtering. +func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) { + rg.GET("/categories", h.ListPublic) +} diff --git a/backend/internal/modules/categories/service.go b/backend/internal/modules/categories/service.go new file mode 100644 index 0000000..eddeb73 --- /dev/null +++ b/backend/internal/modules/categories/service.go @@ -0,0 +1,72 @@ +package categories + +import ( + "context" + + "github.com/google/uuid" +) + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) List(ctx context.Context, activeOnly bool) ([]*Category, error) { + return s.repo.List(ctx, activeOnly) +} + +func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Category, error) { + return s.repo.FindByID(ctx, id) +} + +func (s *Service) Create(ctx context.Context, name, slug, description string, position int, isActive bool) (*Category, error) { + cat := &Category{ + ID: uuid.New(), + Name: name, + Slug: slug, + Description: description, + Position: position, + IsActive: isActive, + } + if err := s.repo.Create(ctx, cat); err != nil { + return nil, err + } + return cat, nil +} + +func (s *Service) Update(ctx context.Context, id uuid.UUID, name, slug, description string, position int, isActive bool) (*Category, error) { + cat, err := s.repo.FindByID(ctx, id) + if err != nil { + return nil, err + } + cat.Name = name + cat.Slug = slug + cat.Description = description + cat.Position = position + cat.IsActive = isActive + if err := s.repo.Update(ctx, cat); err != nil { + return nil, err + } + return cat, nil +} + +func (s *Service) Delete(ctx context.Context, id uuid.UUID) error { + return s.repo.Delete(ctx, id) +} + +// UpdatePosition lets the admin reorder the catalog display order without +// resending the full category payload. +func (s *Service) UpdatePosition(ctx context.Context, id uuid.UUID, position int) (*Category, error) { + cat, err := s.repo.FindByID(ctx, id) + if err != nil { + return nil, err + } + cat.Position = position + if err := s.repo.Update(ctx, cat); err != nil { + return nil, err + } + return cat, nil +} diff --git a/backend/internal/modules/media/handler.go b/backend/internal/modules/media/handler.go new file mode 100644 index 0000000..f4b2324 --- /dev/null +++ b/backend/internal/modules/media/handler.go @@ -0,0 +1,126 @@ +package media + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +type mediaResponse struct { + ID uuid.UUID `json:"id"` + Filename string `json:"filename"` + URL string `json:"url"` + MimeType string `json:"mime_type"` + SizeBytes int64 `json:"size_bytes"` + AltText string `json:"alt_text"` +} + +func toResponse(m *Media) mediaResponse { + return mediaResponse{ + ID: m.ID, + Filename: m.Filename, + URL: m.URL, + MimeType: m.MimeType, + SizeBytes: m.SizeBytes, + AltText: m.AltText, + } +} + +func (h *Handler) List(c *gin.Context) { + list, err := h.service.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list media"}) + return + } + resp := make([]mediaResponse, 0, len(list)) + for _, m := range list { + resp = append(resp, toResponse(m)) + } + c.JSON(http.StatusOK, gin.H{"media": resp}) +} + +func (h *Handler) Upload(c *gin.Context) { + fileHeader, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"}) + return + } + + file, err := fileHeader.Open() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to open uploaded file"}) + return + } + defer file.Close() + + contentType := fileHeader.Header.Get("Content-Type") + altText := c.PostForm("alt_text") + + m, err := h.service.Upload(c.Request.Context(), fileHeader.Filename, file, fileHeader.Size, contentType, altText) + if err != nil { + switch { + case errors.Is(err, ErrFileTooLarge): + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds the maximum allowed size"}) + case errors.Is(err, ErrUnsupportedType): + c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "unsupported file type"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upload file"}) + } + return + } + c.JSON(http.StatusCreated, toResponse(m)) +} + +type updateRequest struct { + AltText string `json:"alt_text"` +} + +func (h *Handler) Update(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var req updateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + m, err := h.service.UpdateAltText(c.Request.Context(), id, req.AltText) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update media"}) + return + } + c.JSON(http.StatusOK, toResponse(m)) +} + +func (h *Handler) Delete(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + if err := h.service.Delete(c.Request.Context(), id); err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete media"}) + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/modules/media/model.go b/backend/internal/modules/media/model.go new file mode 100644 index 0000000..d55e764 --- /dev/null +++ b/backend/internal/modules/media/model.go @@ -0,0 +1,24 @@ +// Package media manages uploaded files (images/videos) and abstracts the +// storage backend behind a Storage interface so the admin can switch +// between local disk and any S3-compatible bucket via configuration only. +package media + +import ( + "time" + + "github.com/google/uuid" +) + +type Media struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + Filename string `gorm:"not null"` + StorageKey string `gorm:"not null"` + URL string `gorm:"not null"` + MimeType string `gorm:"not null"` + SizeBytes int64 `gorm:"not null"` + AltText string `gorm:"not null;default:''"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (Media) TableName() string { return "media" } diff --git a/backend/internal/modules/media/repository.go b/backend/internal/modules/media/repository.go new file mode 100644 index 0000000..22b3360 --- /dev/null +++ b/backend/internal/modules/media/repository.go @@ -0,0 +1,66 @@ +package media + +import ( + "context" + "errors" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +var ErrNotFound = errors.New("media not found") + +type Repository interface { + Create(ctx context.Context, m *Media) error + FindByID(ctx context.Context, id uuid.UUID) (*Media, error) + List(ctx context.Context) ([]*Media, error) + Update(ctx context.Context, m *Media) error + Delete(ctx context.Context, id uuid.UUID) error +} + +type gormRepository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) Repository { + return &gormRepository{db: db} +} + +func (r *gormRepository) Create(ctx context.Context, m *Media) error { + return r.db.WithContext(ctx).Create(m).Error +} + +func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Media, error) { + var m Media + err := r.db.WithContext(ctx).Where("id = ?", id).First(&m).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (r *gormRepository) List(ctx context.Context) ([]*Media, error) { + var list []*Media + if err := r.db.WithContext(ctx).Order("created_at desc").Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func (r *gormRepository) Update(ctx context.Context, m *Media) error { + return r.db.WithContext(ctx).Save(m).Error +} + +func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error { + res := r.db.WithContext(ctx).Delete(&Media{}, "id = ?", id) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil +} diff --git a/backend/internal/modules/media/routes.go b/backend/internal/modules/media/routes.go new file mode 100644 index 0000000..426d298 --- /dev/null +++ b/backend/internal/modules/media/routes.go @@ -0,0 +1,11 @@ +package media + +import "github.com/gin-gonic/gin" + +func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) { + group := rg.Group("/admin/media", requireAdmin) + group.GET("", h.List) + group.POST("", h.Upload) + group.PUT("/:id", h.Update) + group.DELETE("/:id", h.Delete) +} diff --git a/backend/internal/modules/media/service.go b/backend/internal/modules/media/service.go new file mode 100644 index 0000000..fe38bb6 --- /dev/null +++ b/backend/internal/modules/media/service.go @@ -0,0 +1,99 @@ +package media + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/google/uuid" +) + +var ( + ErrFileTooLarge = errors.New("file exceeds the maximum allowed size") + ErrUnsupportedType = errors.New("unsupported file type") +) + +// allowedTypes maps accepted MIME types to a safe file extension. Anything +// not in this list is rejected -- uploads are never trusted to declare +// their own extension. +var allowedTypes = map[string]string{ + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", + "video/mp4": ".mp4", + "video/webm": ".webm", +} + +type Service struct { + repo Repository + storage Storage + maxSizeByte int64 +} + +func NewService(repo Repository, storage Storage, maxSizeBytes int64) *Service { + return &Service{repo: repo, storage: storage, maxSizeByte: maxSizeBytes} +} + +func (s *Service) List(ctx context.Context) ([]*Media, error) { + return s.repo.List(ctx) +} + +func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Media, error) { + return s.repo.FindByID(ctx, id) +} + +func (s *Service) Upload(ctx context.Context, filename string, reader io.Reader, size int64, contentType, altText string) (*Media, error) { + if size > s.maxSizeByte { + return nil, ErrFileTooLarge + } + ext, ok := allowedTypes[contentType] + if !ok { + return nil, ErrUnsupportedType + } + + key := uuid.NewString() + ext + url, err := s.storage.Save(ctx, key, reader, size, contentType) + if err != nil { + return nil, fmt.Errorf("save file: %w", err) + } + + m := &Media{ + ID: uuid.New(), + Filename: filename, + StorageKey: key, + URL: url, + MimeType: contentType, + SizeBytes: size, + AltText: altText, + } + if err := s.repo.Create(ctx, m); err != nil { + _ = s.storage.Delete(ctx, key) + return nil, fmt.Errorf("save metadata: %w", err) + } + return m, nil +} + +func (s *Service) UpdateAltText(ctx context.Context, id uuid.UUID, altText string) (*Media, error) { + m, err := s.repo.FindByID(ctx, id) + if err != nil { + return nil, err + } + m.AltText = altText + if err := s.repo.Update(ctx, m); err != nil { + return nil, err + } + return m, nil +} + +func (s *Service) Delete(ctx context.Context, id uuid.UUID) error { + m, err := s.repo.FindByID(ctx, id) + if err != nil { + return err + } + if err := s.storage.Delete(ctx, m.StorageKey); err != nil { + return fmt.Errorf("delete stored file: %w", err) + } + return s.repo.Delete(ctx, id) +} diff --git a/backend/internal/modules/media/storage.go b/backend/internal/modules/media/storage.go new file mode 100644 index 0000000..9f78485 --- /dev/null +++ b/backend/internal/modules/media/storage.go @@ -0,0 +1,19 @@ +package media + +import ( + "context" + "io" +) + +// Storage is the pluggable backend that actually persists uploaded bytes. +// The service/handler layers never know whether files end up on local disk +// or in an S3 bucket -- only main.go decides which implementation to wire +// in, based on MEDIA_STORAGE_DRIVER. +type Storage interface { + // Save persists the content under the given key and returns the + // publicly reachable URL for it. + Save(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (url string, err error) + // Delete removes the object identified by key. Deleting a + // already-removed key must not be treated as an error. + Delete(ctx context.Context, key string) error +} diff --git a/backend/internal/modules/media/storage_local.go b/backend/internal/modules/media/storage_local.go new file mode 100644 index 0000000..dbad748 --- /dev/null +++ b/backend/internal/modules/media/storage_local.go @@ -0,0 +1,51 @@ +package media + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// LocalStorage saves uploads under a directory on disk and serves them via +// a static file route (mounted by main.go at /uploads when this driver is +// active). +type LocalStorage struct { + dir string + baseURL string +} + +func NewLocalStorage(dir, baseURL string) (*LocalStorage, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create upload dir: %w", err) + } + return &LocalStorage{dir: dir, baseURL: strings.TrimRight(baseURL, "/")}, nil +} + +func (s *LocalStorage) Save(_ context.Context, key string, reader io.Reader, _ int64, _ string) (string, error) { + // key is always a freshly generated UUID-based name (see service.go), + // never derived from user input, so path traversal is not reachable here. + dest := filepath.Join(s.dir, filepath.Base(key)) + + f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return "", fmt.Errorf("create file: %w", err) + } + defer f.Close() + + if _, err := io.Copy(f, reader); err != nil { + return "", fmt.Errorf("write file: %w", err) + } + + return s.baseURL + "/" + filepath.Base(key), nil +} + +func (s *LocalStorage) Delete(_ context.Context, key string) error { + err := os.Remove(filepath.Join(s.dir, filepath.Base(key))) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("delete file: %w", err) + } + return nil +} diff --git a/backend/internal/modules/media/storage_s3.go b/backend/internal/modules/media/storage_s3.go new file mode 100644 index 0000000..5d06f26 --- /dev/null +++ b/backend/internal/modules/media/storage_s3.go @@ -0,0 +1,89 @@ +package media + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + 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/aws/smithy-go" +) + +// S3Config holds the settings needed to talk to any S3-compatible bucket +// (AWS S3 itself, MinIO, Cloudflare R2, ...). +type S3Config struct { + Bucket string + Region string + Endpoint string // leave empty for real AWS S3 + AccessKeyID string + SecretKey string + UsePathStyle bool + PublicBaseURL string // e.g. https://cdn.example.com or https://bucket.s3.region.amazonaws.com +} + +type S3Storage struct { + client *s3.Client + bucket string + baseURL string +} + +func NewS3Storage(ctx context.Context, cfg S3Config) (*S3Storage, error) { + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion(cfg.Region), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretKey, "")), + ) + if err != nil { + return nil, fmt.Errorf("load aws config: %w", err) + } + + client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + if cfg.Endpoint != "" { + o.BaseEndpoint = &cfg.Endpoint + } + o.UsePathStyle = cfg.UsePathStyle + }) + + baseURL := cfg.PublicBaseURL + if baseURL == "" { + if cfg.Endpoint != "" { + baseURL = strings.TrimRight(cfg.Endpoint, "/") + "/" + cfg.Bucket + } else { + baseURL = fmt.Sprintf("https://%s.s3.%s.amazonaws.com", cfg.Bucket, cfg.Region) + } + } + + return &S3Storage{client: client, bucket: cfg.Bucket, baseURL: strings.TrimRight(baseURL, "/")}, nil +} + +func (s *S3Storage) Save(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (string, error) { + _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: &s.bucket, + Key: &key, + Body: reader, + ContentType: &contentType, + ContentLength: &size, + }) + if err != nil { + return "", fmt.Errorf("s3 put object: %w", err) + } + return s.baseURL + "/" + key, nil +} + +func (s *S3Storage) Delete(ctx context.Context, key string) error { + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: &s.bucket, + Key: &key, + }) + if err != nil { + var apiErr smithy.APIError + if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NoSuchKey" { + return nil + } + return fmt.Errorf("s3 delete object: %w", err) + } + return nil +} diff --git a/backend/internal/modules/orders/handler.go b/backend/internal/modules/orders/handler.go new file mode 100644 index 0000000..049b330 --- /dev/null +++ b/backend/internal/modules/orders/handler.go @@ -0,0 +1,185 @@ +package orders + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "backend/internal/modules/pricing" + "backend/internal/modules/products" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +type itemRequest struct { + ProductID uuid.UUID `json:"product_id" binding:"required"` + PriceTierID uuid.UUID `json:"price_tier_id" binding:"required"` + Multiplier int64 `json:"multiplier" binding:"required,gte=1"` +} + +type createRequest struct { + CustomerName string `json:"customer_name" binding:"required"` + CustomerEmail string `json:"customer_email" binding:"required,email"` + CustomerPhone string `json:"customer_phone"` + Notes string `json:"notes"` + Items []itemRequest `json:"items" binding:"required,min=1,dive"` +} + +type orderItemResponse struct { + ProductID *uuid.UUID `json:"product_id,omitempty"` + ProductName string `json:"product_name"` + UnitSymbol string `json:"unit_symbol"` + TierQuantity float64 `json:"tier_quantity"` + Multiplier int64 `json:"multiplier"` + UnitPriceCents int64 `json:"unit_price_cents"` + TotalCents int64 `json:"total_cents"` +} + +type orderResponse struct { + ID uuid.UUID `json:"id"` + CustomerName string `json:"customer_name"` + CustomerEmail string `json:"customer_email"` + CustomerPhone string `json:"customer_phone"` + Status string `json:"status"` + TotalCents int64 `json:"total_cents"` + Notes string `json:"notes"` + Items []orderItemResponse `json:"items,omitempty"` +} + +func toResponse(order *Order, items []*OrderItem) orderResponse { + resp := orderResponse{ + ID: order.ID, + CustomerName: order.CustomerName, + CustomerEmail: order.CustomerEmail, + CustomerPhone: order.CustomerPhone, + Status: order.Status, + TotalCents: order.TotalCents, + Notes: order.Notes, + } + for _, item := range items { + resp.Items = append(resp.Items, orderItemResponse{ + ProductID: item.ProductID, + ProductName: item.ProductName, + UnitSymbol: item.UnitSymbol, + TierQuantity: item.TierQuantity, + Multiplier: item.Multiplier, + UnitPriceCents: item.UnitPriceCents, + TotalCents: item.TotalCents, + }) + } + return resp +} + +// Create is public: a visitor (connected or not, per spec section 17) can +// place an order without an account. +func (h *Handler) Create(c *gin.Context) { + var req createRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + + items := make([]ItemInput, 0, len(req.Items)) + for _, item := range req.Items { + items = append(items, ItemInput{ + ProductID: item.ProductID, + PriceTierID: item.PriceTierID, + Multiplier: item.Multiplier, + }) + } + + order, orderItems, err := h.service.Create(c.Request.Context(), CreateInput{ + CustomerName: req.CustomerName, + CustomerEmail: req.CustomerEmail, + CustomerPhone: req.CustomerPhone, + Notes: req.Notes, + Items: items, + }) + if err != nil { + h.respondCreateError(c, err) + return + } + c.JSON(http.StatusCreated, toResponse(order, orderItems)) +} + +func (h *Handler) respondCreateError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrEmptyOrder), errors.Is(err, ErrProductMismatch): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case errors.Is(err, pricing.ErrNotFound): + c.JSON(http.StatusBadRequest, gin.H{"error": "unknown price tier"}) + case errors.Is(err, products.ErrNotFound): + c.JSON(http.StatusBadRequest, gin.H{"error": "unknown product"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create order"}) + } +} + +func (h *Handler) ListAdmin(c *gin.Context) { + list, err := h.service.List(c.Request.Context(), c.Query("status")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orders"}) + return + } + resp := make([]orderResponse, 0, len(list)) + for _, order := range list { + resp = append(resp, toResponse(order, nil)) + } + c.JSON(http.StatusOK, gin.H{"orders": resp}) +} + +func (h *Handler) GetAdmin(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + order, items, err := h.service.Get(c.Request.Context(), id) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "order not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load order"}) + return + } + c.JSON(http.StatusOK, toResponse(order, items)) +} + +type updateStatusRequest struct { + Status string `json:"status" binding:"required"` +} + +func (h *Handler) UpdateStatus(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var req updateStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + order, err := h.service.UpdateStatus(c.Request.Context(), id, req.Status) + if err != nil { + switch { + case errors.Is(err, ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "order not found"}) + case errors.Is(err, ErrInvalidStatus): + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update order status"}) + } + return + } + c.JSON(http.StatusOK, toResponse(order, nil)) +} diff --git a/backend/internal/modules/orders/model.go b/backend/internal/modules/orders/model.go new file mode 100644 index 0000000..bdb474d --- /dev/null +++ b/backend/internal/modules/orders/model.go @@ -0,0 +1,63 @@ +// Package orders lets a customer (connected or not, per spec section 17-19) +// place an order directly from the storefront, and lets the admin track +// and update it. Pricing is never trusted from the client: each line is +// resolved against the pricing module's authoritative price tier at +// creation time. +package orders + +import ( + "time" + + "github.com/google/uuid" +) + +const ( + StatusPending = "pending" + StatusConfirmed = "confirmed" + StatusPreparing = "preparing" + StatusShipped = "shipped" + StatusCompleted = "completed" + StatusCancelled = "cancelled" +) + +var ValidStatuses = map[string]bool{ + StatusPending: true, + StatusConfirmed: true, + StatusPreparing: true, + StatusShipped: true, + StatusCompleted: true, + StatusCancelled: true, +} + +type Order struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + CustomerName string `gorm:"not null"` + CustomerEmail string `gorm:"not null"` + CustomerPhone string `gorm:"not null;default:''"` + Status string `gorm:"not null;default:pending"` + TotalCents int64 `gorm:"not null;default:0"` + Notes string `gorm:"not null;default:''"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (Order) TableName() string { return "orders" } + +// OrderItem snapshots the product name, unit symbol and unit price at order +// time, so the order stays accurate even if the product/unit is later +// renamed or deleted. +type OrderItem struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + OrderID uuid.UUID `gorm:"type:uuid;not null;index"` + ProductID *uuid.UUID `gorm:"type:uuid"` + ProductName string `gorm:"not null"` + PriceTierID *uuid.UUID `gorm:"type:uuid"` + UnitSymbol string `gorm:"not null"` + TierQuantity float64 `gorm:"not null"` + Multiplier int64 `gorm:"not null;default:1"` + UnitPriceCents int64 `gorm:"not null"` + TotalCents int64 `gorm:"not null"` + CreatedAt time.Time +} + +func (OrderItem) TableName() string { return "order_items" } diff --git a/backend/internal/modules/orders/repository.go b/backend/internal/modules/orders/repository.go new file mode 100644 index 0000000..bee810a --- /dev/null +++ b/backend/internal/modules/orders/repository.go @@ -0,0 +1,85 @@ +package orders + +import ( + "context" + "errors" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +var ErrNotFound = errors.New("order not found") + +type Repository interface { + Create(ctx context.Context, order *Order, items []*OrderItem) error + FindByID(ctx context.Context, id uuid.UUID) (*Order, []*OrderItem, error) + List(ctx context.Context, status string) ([]*Order, error) + UpdateStatus(ctx context.Context, id uuid.UUID, status string) (*Order, error) +} + +type gormRepository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) Repository { + return &gormRepository{db: db} +} + +func (r *gormRepository) Create(ctx context.Context, order *Order, items []*OrderItem) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(order).Error; err != nil { + return err + } + for _, item := range items { + item.OrderID = order.ID + } + if len(items) > 0 { + if err := tx.Create(&items).Error; err != nil { + return err + } + } + return nil + }) +} + +func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Order, []*OrderItem, error) { + var order Order + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&order).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, ErrNotFound + } + return nil, nil, err + } + var items []*OrderItem + if err := r.db.WithContext(ctx).Where("order_id = ?", id).Order("created_at asc").Find(&items).Error; err != nil { + return nil, nil, err + } + return &order, items, nil +} + +func (r *gormRepository) List(ctx context.Context, status string) ([]*Order, error) { + q := r.db.WithContext(ctx).Order("created_at desc") + if status != "" { + q = q.Where("status = ?", status) + } + var list []*Order + if err := q.Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func (r *gormRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status string) (*Order, error) { + res := r.db.WithContext(ctx).Model(&Order{}).Where("id = ?", id).Update("status", status) + if res.Error != nil { + return nil, res.Error + } + if res.RowsAffected == 0 { + return nil, ErrNotFound + } + var order Order + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&order).Error; err != nil { + return nil, err + } + return &order, nil +} diff --git a/backend/internal/modules/orders/routes.go b/backend/internal/modules/orders/routes.go new file mode 100644 index 0000000..d9750cc --- /dev/null +++ b/backend/internal/modules/orders/routes.go @@ -0,0 +1,17 @@ +package orders + +import "github.com/gin-gonic/gin" + +// RegisterPublicRoutes exposes order creation to any visitor, connected or +// not (spec section 17-18). rateLimit throttles it to blunt spam/abuse +// since no authentication gates this endpoint. +func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler, rateLimit gin.HandlerFunc) { + rg.POST("/orders", rateLimit, h.Create) +} + +func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) { + group := rg.Group("/admin/orders", requireAdmin) + group.GET("", h.ListAdmin) + group.GET("/:id", h.GetAdmin) + group.PATCH("/:id/status", h.UpdateStatus) +} diff --git a/backend/internal/modules/orders/service.go b/backend/internal/modules/orders/service.go new file mode 100644 index 0000000..c051e49 --- /dev/null +++ b/backend/internal/modules/orders/service.go @@ -0,0 +1,171 @@ +package orders + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + + "backend/internal/modules/pricing" + "backend/internal/modules/products" + "backend/internal/modules/units" +) + +var ( + ErrEmptyOrder = errors.New("order must contain at least one item") + ErrInvalidStatus = errors.New("invalid order status") + ErrProductMismatch = errors.New("price tier does not belong to the requested product") +) + +// The dependencies below are the minimal slices of other modules' services +// this module needs, defined on the consumer side (Go idiom) so orders +// never has to import their concrete handler/repository types. +type ProductFinder interface { + Get(ctx context.Context, id uuid.UUID) (*products.Product, error) +} + +type UnitFinder interface { + Get(ctx context.Context, id uuid.UUID) (*units.Unit, error) +} + +type PriceResolver interface { + PriceForQuantity(ctx context.Context, tierID uuid.UUID, multiplier int64) (int64, *pricing.PriceTier, error) +} + +// OrderNotifier is satisfied by the telegram module's Service (and, later, +// any other notification channel) without orders ever depending on it +// directly. +type OrderNotifier interface { + NotifyNewOrder(ctx context.Context, summary string) + NotifyOrderStatusChange(ctx context.Context, summary string) +} + +type Service struct { + repo Repository + products ProductFinder + units UnitFinder + prices PriceResolver + notifier OrderNotifier +} + +func NewService(repo Repository, products ProductFinder, units UnitFinder, prices PriceResolver, notifier OrderNotifier) *Service { + return &Service{repo: repo, products: products, units: units, prices: prices, notifier: notifier} +} + +type ItemInput struct { + ProductID uuid.UUID + PriceTierID uuid.UUID + Multiplier int64 +} + +type CreateInput struct { + CustomerName string + CustomerEmail string + CustomerPhone string + Notes string + Items []ItemInput +} + +func (s *Service) Create(ctx context.Context, in CreateInput) (*Order, []*OrderItem, error) { + if len(in.Items) == 0 { + return nil, nil, ErrEmptyOrder + } + + order := &Order{ + ID: uuid.New(), + CustomerName: in.CustomerName, + CustomerEmail: in.CustomerEmail, + CustomerPhone: in.CustomerPhone, + Status: StatusPending, + Notes: in.Notes, + } + + items := make([]*OrderItem, 0, len(in.Items)) + var total int64 + + for _, in := range in.Items { + multiplier := in.Multiplier + if multiplier < 1 { + multiplier = 1 + } + + lineTotal, tier, err := s.prices.PriceForQuantity(ctx, in.PriceTierID, multiplier) + if err != nil { + return nil, nil, fmt.Errorf("resolve price tier %s: %w", in.PriceTierID, err) + } + if tier.ProductID != in.ProductID { + return nil, nil, ErrProductMismatch + } + + product, err := s.products.Get(ctx, in.ProductID) + if err != nil { + return nil, nil, fmt.Errorf("resolve product %s: %w", in.ProductID, err) + } + unit, err := s.units.Get(ctx, tier.UnitID) + if err != nil { + return nil, nil, fmt.Errorf("resolve unit %s: %w", tier.UnitID, err) + } + + items = append(items, &OrderItem{ + ID: uuid.New(), + ProductID: &product.ID, + ProductName: product.Name, + PriceTierID: &tier.ID, + UnitSymbol: unit.Symbol, + TierQuantity: tier.Quantity, + Multiplier: multiplier, + UnitPriceCents: tier.PriceCents, + TotalCents: lineTotal, + }) + total += lineTotal + } + + order.TotalCents = total + + if err := s.repo.Create(ctx, order, items); err != nil { + return nil, nil, err + } + + s.notifier.NotifyNewOrder(context.Background(), summarizeNewOrder(order, items)) + + return order, items, nil +} + +func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Order, []*OrderItem, error) { + return s.repo.FindByID(ctx, id) +} + +func (s *Service) List(ctx context.Context, status string) ([]*Order, error) { + return s.repo.List(ctx, status) +} + +func (s *Service) UpdateStatus(ctx context.Context, id uuid.UUID, status string) (*Order, error) { + if !ValidStatuses[status] { + return nil, ErrInvalidStatus + } + order, err := s.repo.UpdateStatus(ctx, id, status) + if err != nil { + return nil, err + } + s.notifier.NotifyOrderStatusChange(context.Background(), fmt.Sprintf( + "Order %s status changed to %q (customer: %s)", order.ID, order.Status, order.CustomerName, + )) + return order, nil +} + +func summarizeNewOrder(order *Order, items []*OrderItem) string { + var b strings.Builder + fmt.Fprintf(&b, "New order from %s (%s)\n", order.CustomerName, order.CustomerEmail) + for _, item := range items { + fmt.Fprintf(&b, "- %dx %s (%.2f %s) = %.2f\n", + item.Multiplier, item.ProductName, item.TierQuantity, item.UnitSymbol, centsToUnits(item.TotalCents)) + } + fmt.Fprintf(&b, "Total: %.2f", centsToUnits(order.TotalCents)) + return b.String() +} + +func centsToUnits(cents int64) float64 { + return float64(cents) / 100 +} diff --git a/backend/internal/modules/pricing/handler.go b/backend/internal/modules/pricing/handler.go new file mode 100644 index 0000000..5bc1098 --- /dev/null +++ b/backend/internal/modules/pricing/handler.go @@ -0,0 +1,128 @@ +package pricing + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +type tierResponse struct { + ID uuid.UUID `json:"id"` + ProductID uuid.UUID `json:"product_id"` + UnitID uuid.UUID `json:"unit_id"` + Quantity float64 `json:"quantity"` + PriceCents int64 `json:"price_cents"` + Position int `json:"position"` +} + +func toResponse(t *PriceTier) tierResponse { + return tierResponse{ + ID: t.ID, + ProductID: t.ProductID, + UnitID: t.UnitID, + Quantity: t.Quantity, + PriceCents: t.PriceCents, + Position: t.Position, + } +} + +func (h *Handler) ListByProduct(c *gin.Context) { + productID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product id"}) + return + } + list, err := h.service.ListByProduct(c.Request.Context(), productID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list price tiers"}) + return + } + resp := make([]tierResponse, 0, len(list)) + for _, t := range list { + resp = append(resp, toResponse(t)) + } + c.JSON(http.StatusOK, gin.H{"price_tiers": resp}) +} + +type upsertRequest struct { + UnitID uuid.UUID `json:"unit_id" binding:"required"` + Quantity float64 `json:"quantity" binding:"required,gt=0"` + PriceCents int64 `json:"price_cents" binding:"gte=0"` + Position int `json:"position"` +} + +func (h *Handler) Create(c *gin.Context) { + productID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product id"}) + return + } + var req upsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + t, err := h.service.Create(c.Request.Context(), productID, req.UnitID, req.Quantity, req.PriceCents, req.Position) + if err != nil { + if errors.Is(err, ErrInvalidReference) { + c.JSON(http.StatusBadRequest, gin.H{"error": "product or unit does not exist"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create price tier"}) + return + } + c.JSON(http.StatusCreated, toResponse(t)) +} + +func (h *Handler) Update(c *gin.Context) { + id, err := uuid.Parse(c.Param("tierId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var req upsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + t, err := h.service.Update(c.Request.Context(), id, req.UnitID, req.Quantity, req.PriceCents, req.Position) + if err != nil { + switch { + case errors.Is(err, ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "price tier not found"}) + case errors.Is(err, ErrInvalidReference): + c.JSON(http.StatusBadRequest, gin.H{"error": "product or unit does not exist"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update price tier"}) + } + return + } + c.JSON(http.StatusOK, toResponse(t)) +} + +func (h *Handler) Delete(c *gin.Context) { + id, err := uuid.Parse(c.Param("tierId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + if err := h.service.Delete(c.Request.Context(), id); err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "price tier not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete price tier"}) + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/modules/pricing/model.go b/backend/internal/modules/pricing/model.go new file mode 100644 index 0000000..85266d5 --- /dev/null +++ b/backend/internal/modules/pricing/model.go @@ -0,0 +1,26 @@ +// Package pricing implements quantity-based pricing for products (spec +// section 16): a product can have several price tiers, each pairing a +// quantity with a unit (from the units module) and a price. Pricing logic +// (computing an order line's total from a chosen tier) is centralized here +// in the backend -- the frontend only ever displays tiers and sends back a +// tier ID + multiplier; it never sends a price the backend has to trust. +package pricing + +import ( + "time" + + "github.com/google/uuid" +) + +type PriceTier struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + ProductID uuid.UUID `gorm:"type:uuid;not null;index"` + UnitID uuid.UUID `gorm:"type:uuid;not null"` + Quantity float64 `gorm:"not null"` + PriceCents int64 `gorm:"not null"` + Position int `gorm:"not null;default:0"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (PriceTier) TableName() string { return "price_tiers" } diff --git a/backend/internal/modules/pricing/repository.go b/backend/internal/modules/pricing/repository.go new file mode 100644 index 0000000..120177d --- /dev/null +++ b/backend/internal/modules/pricing/repository.go @@ -0,0 +1,86 @@ +package pricing + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" +) + +var ( + ErrNotFound = errors.New("price tier not found") + ErrInvalidReference = errors.New("product or unit does not exist") +) + +type Repository interface { + Create(ctx context.Context, t *PriceTier) error + FindByID(ctx context.Context, id uuid.UUID) (*PriceTier, error) + ListByProduct(ctx context.Context, productID uuid.UUID) ([]*PriceTier, error) + Update(ctx context.Context, t *PriceTier) error + Delete(ctx context.Context, id uuid.UUID) error +} + +type gormRepository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) Repository { + return &gormRepository{db: db} +} + +func (r *gormRepository) Create(ctx context.Context, t *PriceTier) error { + if err := r.db.WithContext(ctx).Create(t).Error; err != nil { + if isForeignKeyViolation(err) { + return ErrInvalidReference + } + return err + } + return nil +} + +func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*PriceTier, error) { + var t PriceTier + err := r.db.WithContext(ctx).Where("id = ?", id).First(&t).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &t, nil +} + +func (r *gormRepository) ListByProduct(ctx context.Context, productID uuid.UUID) ([]*PriceTier, error) { + var list []*PriceTier + err := r.db.WithContext(ctx).Where("product_id = ?", productID).Order("position asc, quantity asc").Find(&list).Error + if err != nil { + return nil, err + } + return list, nil +} + +func (r *gormRepository) Update(ctx context.Context, t *PriceTier) error { + err := r.db.WithContext(ctx).Save(t).Error + if isForeignKeyViolation(err) { + return ErrInvalidReference + } + return err +} + +func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error { + res := r.db.WithContext(ctx).Delete(&PriceTier{}, "id = ?", id) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + +func isForeignKeyViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23503" +} diff --git a/backend/internal/modules/pricing/routes.go b/backend/internal/modules/pricing/routes.go new file mode 100644 index 0000000..31399eb --- /dev/null +++ b/backend/internal/modules/pricing/routes.go @@ -0,0 +1,24 @@ +package pricing + +import "github.com/gin-gonic/gin" + +// RegisterAdminRoutes nests price-tier management under a product, e.g. +// PUT /api/admin/products/:id/price-tiers/:tierId, so tiers are always +// managed in the context of the product they belong to. +func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) { + group := rg.Group("/admin/products/:id/price-tiers", requireAdmin) + group.GET("", h.ListByProduct) + group.POST("", h.Create) + group.PUT("/:tierId", h.Update) + group.DELETE("/:tierId", h.Delete) +} + +// RegisterPublicRoutes exposes read-only tiers so the storefront can render +// the available quantity/price options for a product. Kept as its own +// top-level path (rather than nested under /products/:slug) because the +// products module's public detail route uses a :slug wildcard at that same +// position -- gin does not allow two different wildcard names at the same +// path segment. +func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) { + rg.GET("/price-tiers/by-product/:id", h.ListByProduct) +} diff --git a/backend/internal/modules/pricing/service.go b/backend/internal/modules/pricing/service.go new file mode 100644 index 0000000..91a5a3a --- /dev/null +++ b/backend/internal/modules/pricing/service.go @@ -0,0 +1,72 @@ +package pricing + +import ( + "context" + + "github.com/google/uuid" +) + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) ListByProduct(ctx context.Context, productID uuid.UUID) ([]*PriceTier, error) { + return s.repo.ListByProduct(ctx, productID) +} + +func (s *Service) Get(ctx context.Context, id uuid.UUID) (*PriceTier, error) { + return s.repo.FindByID(ctx, id) +} + +func (s *Service) Create(ctx context.Context, productID, unitID uuid.UUID, quantity float64, priceCents int64, position int) (*PriceTier, error) { + t := &PriceTier{ + ID: uuid.New(), + ProductID: productID, + UnitID: unitID, + Quantity: quantity, + PriceCents: priceCents, + Position: position, + } + if err := s.repo.Create(ctx, t); err != nil { + return nil, err + } + return t, nil +} + +func (s *Service) Update(ctx context.Context, id uuid.UUID, unitID uuid.UUID, quantity float64, priceCents int64, position int) (*PriceTier, error) { + t, err := s.repo.FindByID(ctx, id) + if err != nil { + return nil, err + } + t.UnitID = unitID + t.Quantity = quantity + t.PriceCents = priceCents + t.Position = position + if err := s.repo.Update(ctx, t); err != nil { + return nil, err + } + return t, nil +} + +func (s *Service) Delete(ctx context.Context, id uuid.UUID) error { + return s.repo.Delete(ctx, id) +} + +// PriceForQuantity is the centralized pricing calculation used by the +// orders module: given a chosen tier and how many of that tier package the +// customer wants, it returns the authoritative total in cents. The +// frontend never gets to supply a price directly. +func (s *Service) PriceForQuantity(ctx context.Context, tierID uuid.UUID, multiplier int64) (totalCents int64, tier *PriceTier, err error) { + t, err := s.repo.FindByID(ctx, tierID) + if err != nil { + return 0, nil, err + } + if multiplier < 1 { + multiplier = 1 + } + return t.PriceCents * multiplier, t, nil +} diff --git a/backend/internal/modules/products/handler.go b/backend/internal/modules/products/handler.go new file mode 100644 index 0000000..2a40aab --- /dev/null +++ b/backend/internal/modules/products/handler.go @@ -0,0 +1,303 @@ +package products + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +type galleryItemResponse struct { + MediaID uuid.UUID `json:"media_id"` + Position int `json:"position"` +} + +type productResponse struct { + ID uuid.UUID `json:"id"` + CategoryID *uuid.UUID `json:"category_id,omitempty"` + Name string `json:"name"` + Slug string `json:"slug"` + ShortDescription string `json:"short_description"` + Description string `json:"description"` + IsActive bool `json:"is_active"` + IsFeatured bool `json:"is_featured"` + PrimaryMediaID *uuid.UUID `json:"primary_media_id,omitempty"` + Position int `json:"position"` +} + +func toResponse(p *Product) productResponse { + return productResponse{ + ID: p.ID, + CategoryID: p.CategoryID, + Name: p.Name, + Slug: p.Slug, + ShortDescription: p.ShortDescription, + Description: p.Description, + IsActive: p.IsActive, + IsFeatured: p.IsFeatured, + PrimaryMediaID: p.PrimaryMediaID, + Position: p.Position, + } +} + +func parseCategoryFilter(c *gin.Context) *uuid.UUID { + raw := c.Query("category_id") + if raw == "" { + return nil + } + id, err := uuid.Parse(raw) + if err != nil { + return nil + } + return &id +} + +func (h *Handler) ListAdmin(c *gin.Context) { + list, err := h.service.List(c.Request.Context(), false, parseCategoryFilter(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list products"}) + return + } + c.JSON(http.StatusOK, gin.H{"products": toResponseList(list)}) +} + +func (h *Handler) ListPublic(c *gin.Context) { + list, err := h.service.List(c.Request.Context(), true, parseCategoryFilter(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list products"}) + return + } + c.JSON(http.StatusOK, gin.H{"products": toResponseList(list)}) +} + +func toResponseList(list []*Product) []productResponse { + resp := make([]productResponse, 0, len(list)) + for _, p := range list { + resp = append(resp, toResponse(p)) + } + return resp +} + +func (h *Handler) GetAdmin(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + p, err := h.service.Get(c.Request.Context(), id) + if err != nil { + h.respondGetError(c, err) + return + } + c.JSON(http.StatusOK, toResponse(p)) +} + +func (h *Handler) GetPublicBySlug(c *gin.Context) { + p, err := h.service.GetBySlug(c.Request.Context(), c.Param("slug")) + if err != nil { + h.respondGetError(c, err) + return + } + if !p.IsActive { + c.JSON(http.StatusNotFound, gin.H{"error": "product not found"}) + return + } + c.JSON(http.StatusOK, toResponse(p)) +} + +func (h *Handler) respondGetError(c *gin.Context, err error) { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "product not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load product"}) +} + +type upsertRequest struct { + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name" binding:"required"` + Slug string `json:"slug" binding:"required"` + ShortDescription string `json:"short_description"` + Description string `json:"description"` + IsActive bool `json:"is_active"` + IsFeatured bool `json:"is_featured"` + PrimaryMediaID *uuid.UUID `json:"primary_media_id"` + Position int `json:"position"` +} + +func (req upsertRequest) toInput() UpsertInput { + return UpsertInput{ + CategoryID: req.CategoryID, + Name: req.Name, + Slug: req.Slug, + ShortDescription: req.ShortDescription, + Description: req.Description, + IsActive: req.IsActive, + IsFeatured: req.IsFeatured, + PrimaryMediaID: req.PrimaryMediaID, + Position: req.Position, + } +} + +func (h *Handler) Create(c *gin.Context) { + var req upsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + p, err := h.service.Create(c.Request.Context(), req.toInput()) + if err != nil { + if errors.Is(err, ErrSlugTaken) { + c.JSON(http.StatusConflict, gin.H{"error": "slug already in use"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create product"}) + return + } + c.JSON(http.StatusCreated, toResponse(p)) +} + +func (h *Handler) Update(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var req upsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + p, err := h.service.Update(c.Request.Context(), id, req.toInput()) + if err != nil { + switch { + case errors.Is(err, ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "product not found"}) + case errors.Is(err, ErrSlugTaken): + c.JSON(http.StatusConflict, gin.H{"error": "slug already in use"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update product"}) + } + return + } + c.JSON(http.StatusOK, toResponse(p)) +} + +type updatePositionRequest struct { + Position int `json:"position"` +} + +// UpdatePosition lets the admin reorder the catalog display order (e.g. +// move up/move down in the admin list) without resending the whole +// product payload. +func (h *Handler) UpdatePosition(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var req updatePositionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + p, err := h.service.UpdatePosition(c.Request.Context(), id, req.Position) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "product not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update position"}) + return + } + c.JSON(http.StatusOK, toResponse(p)) +} + +func (h *Handler) Delete(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + if err := h.service.Delete(c.Request.Context(), id); err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "product not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete product"}) + return + } + c.Status(http.StatusNoContent) +} + +type addGalleryRequest struct { + MediaID uuid.UUID `json:"media_id" binding:"required"` + Position int `json:"position"` +} + +func (h *Handler) AddGalleryItem(c *gin.Context) { + productID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var req addGalleryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + if err := h.service.AddGalleryItem(c.Request.Context(), productID, req.MediaID, req.Position); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to attach media"}) + return + } + c.Status(http.StatusCreated) +} + +func (h *Handler) RemoveGalleryItem(c *gin.Context) { + productID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + mediaID, err := uuid.Parse(c.Param("mediaId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid media id"}) + return + } + if err := h.service.RemoveGalleryItem(c.Request.Context(), productID, mediaID); err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "gallery item not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to detach media"}) + return + } + c.Status(http.StatusNoContent) +} + +func (h *Handler) ListGallery(c *gin.Context) { + productID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + items, err := h.service.ListGallery(c.Request.Context(), productID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list gallery"}) + return + } + resp := make([]galleryItemResponse, 0, len(items)) + for _, item := range items { + resp = append(resp, galleryItemResponse{MediaID: item.MediaID, Position: item.Position}) + } + c.JSON(http.StatusOK, gin.H{"gallery": resp}) +} diff --git a/backend/internal/modules/products/model.go b/backend/internal/modules/products/model.go new file mode 100644 index 0000000..8263020 --- /dev/null +++ b/backend/internal/modules/products/model.go @@ -0,0 +1,41 @@ +// Package products owns the product catalog entity itself (identity, +// description, category, media). Quantity-based pricing lives in the +// pricing module, which references products by ID -- keeping "what a +// product is" separate from "how it's priced", per spec section 13/16. +package products + +import ( + "time" + + "github.com/google/uuid" +) + +type Product struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + CategoryID *uuid.UUID `gorm:"type:uuid"` + Name string `gorm:"not null"` + Slug string `gorm:"uniqueIndex;not null"` + ShortDescription string `gorm:"not null;default:''"` + Description string `gorm:"not null;default:''"` + IsActive bool `gorm:"not null;default:true"` + IsFeatured bool `gorm:"not null;default:false"` + PrimaryMediaID *uuid.UUID `gorm:"type:uuid"` + Position int `gorm:"not null;default:0"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (Product) TableName() string { return "products" } + +// GalleryItem attaches a media item to a product's gallery, ordered by +// Position. A product's primary image (PrimaryMediaID) is separate from +// the gallery so it can be picked from unrelated media too. +type GalleryItem struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + ProductID uuid.UUID `gorm:"type:uuid;not null;index"` + MediaID uuid.UUID `gorm:"type:uuid;not null"` + Position int `gorm:"not null;default:0"` + CreatedAt time.Time +} + +func (GalleryItem) TableName() string { return "product_media" } diff --git a/backend/internal/modules/products/repository.go b/backend/internal/modules/products/repository.go new file mode 100644 index 0000000..84cce66 --- /dev/null +++ b/backend/internal/modules/products/repository.go @@ -0,0 +1,132 @@ +package products + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" +) + +var ( + ErrNotFound = errors.New("product not found") + ErrSlugTaken = errors.New("slug already in use") +) + +type Repository interface { + Create(ctx context.Context, p *Product) error + FindByID(ctx context.Context, id uuid.UUID) (*Product, error) + FindBySlug(ctx context.Context, slug string) (*Product, error) + List(ctx context.Context, activeOnly bool, categoryID *uuid.UUID) ([]*Product, error) + Update(ctx context.Context, p *Product) error + Delete(ctx context.Context, id uuid.UUID) error + + AddGalleryItem(ctx context.Context, item *GalleryItem) error + RemoveGalleryItem(ctx context.Context, productID, mediaID uuid.UUID) error + ListGallery(ctx context.Context, productID uuid.UUID) ([]*GalleryItem, error) +} + +type gormRepository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) Repository { + return &gormRepository{db: db} +} + +func (r *gormRepository) Create(ctx context.Context, p *Product) error { + if err := r.db.WithContext(ctx).Create(p).Error; err != nil { + if isUniqueViolation(err) { + return ErrSlugTaken + } + return err + } + return nil +} + +func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Product, error) { + var p Product + err := r.db.WithContext(ctx).Where("id = ?", id).First(&p).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &p, nil +} + +func (r *gormRepository) FindBySlug(ctx context.Context, slug string) (*Product, error) { + var p Product + err := r.db.WithContext(ctx).Where("slug = ?", slug).First(&p).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &p, nil +} + +func (r *gormRepository) List(ctx context.Context, activeOnly bool, categoryID *uuid.UUID) ([]*Product, error) { + q := r.db.WithContext(ctx).Order("position asc, created_at desc") + if activeOnly { + q = q.Where("is_active = ?", true) + } + if categoryID != nil { + q = q.Where("category_id = ?", *categoryID) + } + var list []*Product + if err := q.Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func (r *gormRepository) Update(ctx context.Context, p *Product) error { + err := r.db.WithContext(ctx).Save(p).Error + if isUniqueViolation(err) { + return ErrSlugTaken + } + return err +} + +func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error { + res := r.db.WithContext(ctx).Delete(&Product{}, "id = ?", id) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + +func (r *gormRepository) AddGalleryItem(ctx context.Context, item *GalleryItem) error { + return r.db.WithContext(ctx).Create(item).Error +} + +func (r *gormRepository) RemoveGalleryItem(ctx context.Context, productID, mediaID uuid.UUID) error { + res := r.db.WithContext(ctx).Delete(&GalleryItem{}, "product_id = ? AND media_id = ?", productID, mediaID) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + +func (r *gormRepository) ListGallery(ctx context.Context, productID uuid.UUID) ([]*GalleryItem, error) { + var list []*GalleryItem + if err := r.db.WithContext(ctx).Where("product_id = ?", productID).Order("position asc").Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} diff --git a/backend/internal/modules/products/routes.go b/backend/internal/modules/products/routes.go new file mode 100644 index 0000000..3a21d0b --- /dev/null +++ b/backend/internal/modules/products/routes.go @@ -0,0 +1,24 @@ +package products + +import "github.com/gin-gonic/gin" + +func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) { + group := rg.Group("/admin/products", requireAdmin) + group.GET("", h.ListAdmin) + group.POST("", h.Create) + group.GET("/:id", h.GetAdmin) + group.PUT("/:id", h.Update) + group.PATCH("/:id/position", h.UpdatePosition) + group.DELETE("/:id", h.Delete) + + group.GET("/:id/gallery", h.ListGallery) + group.POST("/:id/gallery", h.AddGalleryItem) + group.DELETE("/:id/gallery/:mediaId", h.RemoveGalleryItem) +} + +// RegisterPublicRoutes exposes the read-only, active-only product catalog +// used by the storefront (site vitrine / boutique). +func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) { + rg.GET("/products", h.ListPublic) + rg.GET("/products/:slug", h.GetPublicBySlug) +} diff --git a/backend/internal/modules/products/service.go b/backend/internal/modules/products/service.go new file mode 100644 index 0000000..b420fff --- /dev/null +++ b/backend/internal/modules/products/service.go @@ -0,0 +1,113 @@ +package products + +import ( + "context" + + "github.com/google/uuid" +) + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) List(ctx context.Context, activeOnly bool, categoryID *uuid.UUID) ([]*Product, error) { + return s.repo.List(ctx, activeOnly, categoryID) +} + +func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Product, error) { + return s.repo.FindByID(ctx, id) +} + +func (s *Service) GetBySlug(ctx context.Context, slug string) (*Product, error) { + return s.repo.FindBySlug(ctx, slug) +} + +type UpsertInput struct { + CategoryID *uuid.UUID + Name string + Slug string + ShortDescription string + Description string + IsActive bool + IsFeatured bool + PrimaryMediaID *uuid.UUID + Position int +} + +func (s *Service) Create(ctx context.Context, in UpsertInput) (*Product, error) { + p := &Product{ + ID: uuid.New(), + CategoryID: in.CategoryID, + Name: in.Name, + Slug: in.Slug, + ShortDescription: in.ShortDescription, + Description: in.Description, + IsActive: in.IsActive, + IsFeatured: in.IsFeatured, + PrimaryMediaID: in.PrimaryMediaID, + Position: in.Position, + } + if err := s.repo.Create(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +func (s *Service) Update(ctx context.Context, id uuid.UUID, in UpsertInput) (*Product, error) { + p, err := s.repo.FindByID(ctx, id) + if err != nil { + return nil, err + } + p.CategoryID = in.CategoryID + p.Name = in.Name + p.Slug = in.Slug + p.ShortDescription = in.ShortDescription + p.Description = in.Description + p.IsActive = in.IsActive + p.IsFeatured = in.IsFeatured + p.PrimaryMediaID = in.PrimaryMediaID + p.Position = in.Position + if err := s.repo.Update(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +func (s *Service) Delete(ctx context.Context, id uuid.UUID) error { + return s.repo.Delete(ctx, id) +} + +// UpdatePosition lets the admin reorder the catalog display order without +// resending the full product payload. +func (s *Service) UpdatePosition(ctx context.Context, id uuid.UUID, position int) (*Product, error) { + p, err := s.repo.FindByID(ctx, id) + if err != nil { + return nil, err + } + p.Position = position + if err := s.repo.Update(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +func (s *Service) AddGalleryItem(ctx context.Context, productID, mediaID uuid.UUID, position int) error { + return s.repo.AddGalleryItem(ctx, &GalleryItem{ + ID: uuid.New(), + ProductID: productID, + MediaID: mediaID, + Position: position, + }) +} + +func (s *Service) RemoveGalleryItem(ctx context.Context, productID, mediaID uuid.UUID) error { + return s.repo.RemoveGalleryItem(ctx, productID, mediaID) +} + +func (s *Service) ListGallery(ctx context.Context, productID uuid.UUID) ([]*GalleryItem, error) { + return s.repo.ListGallery(ctx, productID) +} diff --git a/backend/internal/modules/site/handler.go b/backend/internal/modules/site/handler.go new file mode 100644 index 0000000..3b6b8b8 --- /dev/null +++ b/backend/internal/modules/site/handler.go @@ -0,0 +1,62 @@ +package site + +import ( + "net/http" + "regexp" + + "github.com/gin-gonic/gin" +) + +var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +type settingsResponse struct { + Name string `json:"name"` + Description string `json:"description"` + Slug string `json:"slug"` +} + +func toResponse(s *Settings) settingsResponse { + return settingsResponse{Name: s.Name, Description: s.Description, Slug: s.Slug} +} + +func (h *Handler) Get(c *gin.Context) { + settings, err := h.service.Get(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load site settings"}) + return + } + c.JSON(http.StatusOK, toResponse(settings)) +} + +type updateRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Slug string `json:"slug" binding:"required"` +} + +func (h *Handler) Update(c *gin.Context) { + var req updateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + if !slugPattern.MatchString(req.Slug) { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug must be lowercase letters, digits and hyphens only"}) + return + } + + settings, err := h.service.Update(c.Request.Context(), req.Name, req.Description, req.Slug) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update site settings"}) + return + } + c.JSON(http.StatusOK, toResponse(settings)) +} diff --git a/backend/internal/modules/site/model.go b/backend/internal/modules/site/model.go new file mode 100644 index 0000000..7bdf7d7 --- /dev/null +++ b/backend/internal/modules/site/model.go @@ -0,0 +1,19 @@ +// Package site owns the minimal, config-driven site identity (name, +// description, slug). It is the proof of the "code defines capabilities, +// admin defines content" pattern: future modules (appearance, menu, pages, +// ...) will follow this same shape (single-row or keyed settings table + +// admin-only read/write endpoints). +package site + +import "time" + +type Settings struct { + ID int16 `gorm:"primaryKey"` + Name string + Description string + Slug string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (Settings) TableName() string { return "site_settings" } diff --git a/backend/internal/modules/site/repository.go b/backend/internal/modules/site/repository.go new file mode 100644 index 0000000..1588efe --- /dev/null +++ b/backend/internal/modules/site/repository.go @@ -0,0 +1,37 @@ +package site + +import ( + "context" + + "gorm.io/gorm" +) + +type Repository interface { + Get(ctx context.Context) (*Settings, error) + Update(ctx context.Context, s *Settings) error +} + +type gormRepository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) Repository { + return &gormRepository{db: db} +} + +func (r *gormRepository) Get(ctx context.Context) (*Settings, error) { + var s Settings + if err := r.db.WithContext(ctx).First(&s, "id = 1").Error; err != nil { + return nil, err + } + return &s, nil +} + +func (r *gormRepository) Update(ctx context.Context, s *Settings) error { + s.ID = 1 + return r.db.WithContext(ctx).Model(&Settings{}).Where("id = 1").Updates(map[string]any{ + "name": s.Name, + "description": s.Description, + "slug": s.Slug, + }).Error +} diff --git a/backend/internal/modules/site/routes.go b/backend/internal/modules/site/routes.go new file mode 100644 index 0000000..91482f5 --- /dev/null +++ b/backend/internal/modules/site/routes.go @@ -0,0 +1,9 @@ +package site + +import "github.com/gin-gonic/gin" + +func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) { + group := rg.Group("/admin/site-settings") + group.GET("", h.Get) + group.PUT("", requireAdmin, h.Update) +} diff --git a/backend/internal/modules/site/service.go b/backend/internal/modules/site/service.go new file mode 100644 index 0000000..dc5ccf0 --- /dev/null +++ b/backend/internal/modules/site/service.go @@ -0,0 +1,23 @@ +package site + +import "context" + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Get(ctx context.Context) (*Settings, error) { + return s.repo.Get(ctx) +} + +func (s *Service) Update(ctx context.Context, name, description, slug string) (*Settings, error) { + settings := &Settings{Name: name, Description: description, Slug: slug} + if err := s.repo.Update(ctx, settings); err != nil { + return nil, err + } + return s.repo.Get(ctx) +} diff --git a/backend/internal/modules/telegram/handler.go b/backend/internal/modules/telegram/handler.go new file mode 100644 index 0000000..dc9145e --- /dev/null +++ b/backend/internal/modules/telegram/handler.go @@ -0,0 +1,74 @@ +package telegram + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +// settingsResponse never includes the raw bot token (spec section 32: secrets +// must never be exposed to the frontend) -- only whether one is configured. +type settingsResponse struct { + Enabled bool `json:"enabled"` + BotTokenConfigured bool `json:"bot_token_configured"` + ChatID string `json:"chat_id"` + NotifyNewOrder bool `json:"notify_new_order"` + NotifyStatusChange bool `json:"notify_status_change"` +} + +func toResponse(s *Settings) settingsResponse { + return settingsResponse{ + Enabled: s.Enabled, + BotTokenConfigured: s.BotToken != "", + ChatID: s.ChatID, + NotifyNewOrder: s.NotifyNewOrder, + NotifyStatusChange: s.NotifyStatusChange, + } +} + +func (h *Handler) Get(c *gin.Context) { + settings, err := h.service.Get(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load telegram settings"}) + return + } + c.JSON(http.StatusOK, toResponse(settings)) +} + +type updateRequest struct { + Enabled bool `json:"enabled"` + BotToken string `json:"bot_token"` + ChatID string `json:"chat_id" binding:"required_if=Enabled true"` + NotifyNewOrder bool `json:"notify_new_order"` + NotifyStatusChange bool `json:"notify_status_change"` +} + +func (h *Handler) Update(c *gin.Context) { + var req updateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + settings, err := h.service.Update(c.Request.Context(), req.Enabled, req.BotToken, req.ChatID, req.NotifyNewOrder, req.NotifyStatusChange) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update telegram settings"}) + return + } + c.JSON(http.StatusOK, toResponse(settings)) +} + +func (h *Handler) Test(c *gin.Context) { + if err := h.service.SendTestMessage(c.Request.Context()); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "test notification sent"}) +} diff --git a/backend/internal/modules/telegram/model.go b/backend/internal/modules/telegram/model.go new file mode 100644 index 0000000..32151e0 --- /dev/null +++ b/backend/internal/modules/telegram/model.go @@ -0,0 +1,22 @@ +// Package telegram is the notifications module (spec section 20): the +// admin configures a bot token + chat id and toggles which events trigger a +// message. It is a pluggable notifier -- other modules (orders) depend on +// the small OrderNotifier interface it satisfies, never on Telegram +// directly, so a future module (email, WhatsApp, ...) can be swapped in the +// same way. +package telegram + +import "time" + +type Settings struct { + ID int16 `gorm:"primaryKey"` + Enabled bool + BotToken string + ChatID string + NotifyNewOrder bool + NotifyStatusChange bool + CreatedAt time.Time + UpdatedAt time.Time +} + +func (Settings) TableName() string { return "telegram_settings" } diff --git a/backend/internal/modules/telegram/repository.go b/backend/internal/modules/telegram/repository.go new file mode 100644 index 0000000..c5789f9 --- /dev/null +++ b/backend/internal/modules/telegram/repository.go @@ -0,0 +1,44 @@ +package telegram + +import ( + "context" + + "gorm.io/gorm" +) + +type Repository interface { + Get(ctx context.Context) (*Settings, error) + Update(ctx context.Context, s *Settings) error +} + +type gormRepository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) Repository { + return &gormRepository{db: db} +} + +func (r *gormRepository) Get(ctx context.Context) (*Settings, error) { + var s Settings + if err := r.db.WithContext(ctx).First(&s, "id = 1").Error; err != nil { + return nil, err + } + return &s, nil +} + +func (r *gormRepository) Update(ctx context.Context, s *Settings) error { + s.ID = 1 + updates := map[string]any{ + "enabled": s.Enabled, + "chat_id": s.ChatID, + "notify_new_order": s.NotifyNewOrder, + "notify_status_change": s.NotifyStatusChange, + } + // bot_token is only overwritten when explicitly provided (see service.go): + // a blank value in the update request means "keep the existing secret". + if s.BotToken != "" { + updates["bot_token"] = s.BotToken + } + return r.db.WithContext(ctx).Model(&Settings{}).Where("id = 1").Updates(updates).Error +} diff --git a/backend/internal/modules/telegram/routes.go b/backend/internal/modules/telegram/routes.go new file mode 100644 index 0000000..d664f19 --- /dev/null +++ b/backend/internal/modules/telegram/routes.go @@ -0,0 +1,10 @@ +package telegram + +import "github.com/gin-gonic/gin" + +func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) { + group := rg.Group("/admin/notifications/telegram", requireAdmin) + group.GET("", h.Get) + group.PUT("", h.Update) + group.POST("/test", h.Test) +} diff --git a/backend/internal/modules/telegram/sender.go b/backend/internal/modules/telegram/sender.go new file mode 100644 index 0000000..95f7aba --- /dev/null +++ b/backend/internal/modules/telegram/sender.go @@ -0,0 +1,55 @@ +package telegram + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Sender abstracts the actual Telegram Bot API call so the service layer +// can be unit-tested without making real network requests. +type Sender interface { + Send(ctx context.Context, botToken, chatID, text string) error +} + +type httpSender struct { + client *http.Client +} + +func NewHTTPSender() Sender { + return &httpSender{client: &http.Client{Timeout: 10 * time.Second}} +} + +func (s *httpSender) Send(ctx context.Context, botToken, chatID, text string) error { + if botToken == "" || chatID == "" { + return fmt.Errorf("telegram bot token and chat id must be configured") + } + + endpoint := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken) + form := url.Values{ + "chat_id": {chatID}, + "text": {text}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return fmt.Errorf("build telegram request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("call telegram api: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("telegram api returned status %d: %s", resp.StatusCode, string(body)) + } + return nil +} diff --git a/backend/internal/modules/telegram/service.go b/backend/internal/modules/telegram/service.go new file mode 100644 index 0000000..62822a0 --- /dev/null +++ b/backend/internal/modules/telegram/service.go @@ -0,0 +1,75 @@ +package telegram + +import ( + "context" + "fmt" + "log/slog" +) + +type Service struct { + repo Repository + sender Sender + logger *slog.Logger +} + +func NewService(repo Repository, sender Sender, logger *slog.Logger) *Service { + return &Service{repo: repo, sender: sender, logger: logger} +} + +func (s *Service) Get(ctx context.Context) (*Settings, error) { + return s.repo.Get(ctx) +} + +// Update persists the settings. botToken == "" means "leave the currently +// stored token untouched" -- the admin never has to re-type the secret just +// to flip a checkbox, and the API never has to echo it back. +func (s *Service) Update(ctx context.Context, enabled bool, botToken, chatID string, notifyNewOrder, notifyStatusChange bool) (*Settings, error) { + settings := &Settings{ + Enabled: enabled, + BotToken: botToken, + ChatID: chatID, + NotifyNewOrder: notifyNewOrder, + NotifyStatusChange: notifyStatusChange, + } + if err := s.repo.Update(ctx, settings); err != nil { + return nil, err + } + return s.repo.Get(ctx) +} + +func (s *Service) SendTestMessage(ctx context.Context) error { + settings, err := s.repo.Get(ctx) + if err != nil { + return err + } + if settings.BotToken == "" || settings.ChatID == "" { + return fmt.Errorf("bot token and chat id must be configured before testing") + } + return s.sender.Send(ctx, settings.BotToken, settings.ChatID, "Test notification: your Telegram integration is working.") +} + +// NotifyNewOrder implements orders.OrderNotifier. It never returns an error +// to the caller: a broken/misconfigured Telegram integration must not +// prevent a customer's order from being created, so failures are only +// logged. +func (s *Service) NotifyNewOrder(ctx context.Context, summary string) { + s.notify(ctx, func(st *Settings) bool { return st.NotifyNewOrder }, summary) +} + +func (s *Service) NotifyOrderStatusChange(ctx context.Context, summary string) { + s.notify(ctx, func(st *Settings) bool { return st.NotifyStatusChange }, summary) +} + +func (s *Service) notify(ctx context.Context, shouldSend func(*Settings) bool, text string) { + settings, err := s.repo.Get(ctx) + if err != nil { + s.logger.Error("telegram: failed to load settings", "error", err) + return + } + if !settings.Enabled || !shouldSend(settings) { + return + } + if err := s.sender.Send(ctx, settings.BotToken, settings.ChatID, text); err != nil { + s.logger.Error("telegram: failed to send notification", "error", err) + } +} diff --git a/backend/internal/modules/units/handler.go b/backend/internal/modules/units/handler.go new file mode 100644 index 0000000..410a381 --- /dev/null +++ b/backend/internal/modules/units/handler.go @@ -0,0 +1,109 @@ +package units + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +type unitResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Symbol string `json:"symbol"` +} + +func toResponse(u *Unit) unitResponse { + return unitResponse{ID: u.ID, Name: u.Name, Symbol: u.Symbol} +} + +func (h *Handler) List(c *gin.Context) { + list, err := h.service.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list units"}) + return + } + resp := make([]unitResponse, 0, len(list)) + for _, u := range list { + resp = append(resp, toResponse(u)) + } + c.JSON(http.StatusOK, gin.H{"units": resp}) +} + +type upsertRequest struct { + Name string `json:"name" binding:"required"` + Symbol string `json:"symbol" binding:"required"` +} + +func (h *Handler) Create(c *gin.Context) { + var req upsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + u, err := h.service.Create(c.Request.Context(), req.Name, req.Symbol) + if err != nil { + if errors.Is(err, ErrSymbolTaken) { + c.JSON(http.StatusConflict, gin.H{"error": "symbol already in use"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create unit"}) + return + } + c.JSON(http.StatusCreated, toResponse(u)) +} + +func (h *Handler) Update(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var req upsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + u, err := h.service.Update(c.Request.Context(), id, req.Name, req.Symbol) + if err != nil { + switch { + case errors.Is(err, ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "unit not found"}) + case errors.Is(err, ErrSymbolTaken): + c.JSON(http.StatusConflict, gin.H{"error": "symbol already in use"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update unit"}) + } + return + } + c.JSON(http.StatusOK, toResponse(u)) +} + +func (h *Handler) Delete(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + if err := h.service.Delete(c.Request.Context(), id); err != nil { + switch { + case errors.Is(err, ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "unit not found"}) + case errors.Is(err, ErrInUse): + c.JSON(http.StatusConflict, gin.H{"error": "unit is used by existing price tiers"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete unit"}) + } + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/modules/units/model.go b/backend/internal/modules/units/model.go new file mode 100644 index 0000000..26b983b --- /dev/null +++ b/backend/internal/modules/units/model.go @@ -0,0 +1,19 @@ +// Package units lets the admin define their own measurement units +// (kg, g, piece, carton, ...) instead of the code hard-coding a fixed list. +package units + +import ( + "time" + + "github.com/google/uuid" +) + +type Unit struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + Name string `gorm:"not null"` + Symbol string `gorm:"uniqueIndex;not null"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (Unit) TableName() string { return "units" } diff --git a/backend/internal/modules/units/repository.go b/backend/internal/modules/units/repository.go new file mode 100644 index 0000000..b5e0fa9 --- /dev/null +++ b/backend/internal/modules/units/repository.go @@ -0,0 +1,94 @@ +package units + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" +) + +var ( + ErrNotFound = errors.New("unit not found") + ErrSymbolTaken = errors.New("symbol already in use") + ErrInUse = errors.New("unit is referenced by existing price tiers") +) + +type Repository interface { + Create(ctx context.Context, u *Unit) error + FindByID(ctx context.Context, id uuid.UUID) (*Unit, error) + List(ctx context.Context) ([]*Unit, error) + Update(ctx context.Context, u *Unit) error + Delete(ctx context.Context, id uuid.UUID) error +} + +type gormRepository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) Repository { + return &gormRepository{db: db} +} + +func (r *gormRepository) Create(ctx context.Context, u *Unit) error { + if err := r.db.WithContext(ctx).Create(u).Error; err != nil { + if isUniqueViolation(err) { + return ErrSymbolTaken + } + return err + } + return nil +} + +func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Unit, error) { + var u Unit + err := r.db.WithContext(ctx).Where("id = ?", id).First(&u).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &u, nil +} + +func (r *gormRepository) List(ctx context.Context) ([]*Unit, error) { + var list []*Unit + if err := r.db.WithContext(ctx).Order("name asc").Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func (r *gormRepository) Update(ctx context.Context, u *Unit) error { + err := r.db.WithContext(ctx).Save(u).Error + if isUniqueViolation(err) { + return ErrSymbolTaken + } + return err +} + +func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error { + res := r.db.WithContext(ctx).Delete(&Unit{}, "id = ?", id) + if res.Error != nil { + if isForeignKeyViolation(res.Error) { + return ErrInUse + } + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + +func isForeignKeyViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23503" +} diff --git a/backend/internal/modules/units/routes.go b/backend/internal/modules/units/routes.go new file mode 100644 index 0000000..9b10382 --- /dev/null +++ b/backend/internal/modules/units/routes.go @@ -0,0 +1,11 @@ +package units + +import "github.com/gin-gonic/gin" + +func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) { + group := rg.Group("/admin/units", requireAdmin) + group.GET("", h.List) + group.POST("", h.Create) + group.PUT("/:id", h.Update) + group.DELETE("/:id", h.Delete) +} diff --git a/backend/internal/modules/units/service.go b/backend/internal/modules/units/service.go new file mode 100644 index 0000000..b11bb5a --- /dev/null +++ b/backend/internal/modules/units/service.go @@ -0,0 +1,48 @@ +package units + +import ( + "context" + + "github.com/google/uuid" +) + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) List(ctx context.Context) ([]*Unit, error) { + return s.repo.List(ctx) +} + +func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Unit, error) { + return s.repo.FindByID(ctx, id) +} + +func (s *Service) Create(ctx context.Context, name, symbol string) (*Unit, error) { + u := &Unit{ID: uuid.New(), Name: name, Symbol: symbol} + if err := s.repo.Create(ctx, u); err != nil { + return nil, err + } + return u, nil +} + +func (s *Service) Update(ctx context.Context, id uuid.UUID, name, symbol string) (*Unit, error) { + u, err := s.repo.FindByID(ctx, id) + if err != nil { + return nil, err + } + u.Name = name + u.Symbol = symbol + if err := s.repo.Update(ctx, u); err != nil { + return nil, err + } + return u, nil +} + +func (s *Service) Delete(ctx context.Context, id uuid.UUID) error { + return s.repo.Delete(ctx, id) +} diff --git a/backend/internal/modules/users/handler.go b/backend/internal/modules/users/handler.go new file mode 100644 index 0000000..385483a --- /dev/null +++ b/backend/internal/modules/users/handler.go @@ -0,0 +1,134 @@ +package users + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +type userResponse struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + IsActive bool `json:"is_active"` +} + +func toResponse(u *User) userResponse { + return userResponse{ID: u.ID, Email: u.Email, Role: u.Role, IsActive: u.IsActive} +} + +func (h *Handler) List(c *gin.Context) { + list, err := h.service.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"}) + return + } + resp := make([]userResponse, 0, len(list)) + for _, u := range list { + resp = append(resp, toResponse(u)) + } + c.JSON(http.StatusOK, gin.H{"users": resp}) +} + +type createUserRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=12"` + Role string `json:"role" binding:"required,oneof=admin customer"` +} + +func (h *Handler) Create(c *gin.Context) { + var req createUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + + user, err := h.service.Create(c.Request.Context(), req.Email, req.Password, req.Role) + if err != nil { + if errors.Is(err, ErrEmailTaken) { + c.JSON(http.StatusConflict, gin.H{"error": "email already in use"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) + return + } + c.JSON(http.StatusCreated, toResponse(user)) +} + +func (h *Handler) Get(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + user, err := h.service.FindByID(c.Request.Context(), id) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user"}) + return + } + c.JSON(http.StatusOK, toResponse(user)) +} + +type updateUserRequest struct { + Email string `json:"email" binding:"required,email"` + IsActive bool `json:"is_active"` +} + +func (h *Handler) Update(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var req updateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + + user, err := h.service.UpdateProfile(c.Request.Context(), id, req.Email, req.IsActive) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + if errors.Is(err, ErrEmailTaken) { + c.JSON(http.StatusConflict, gin.H{"error": "email already in use"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user"}) + return + } + c.JSON(http.StatusOK, toResponse(user)) +} + +func (h *Handler) Delete(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + if err := h.service.Delete(c.Request.Context(), id); err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"}) + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/modules/users/model.go b/backend/internal/modules/users/model.go new file mode 100644 index 0000000..6406d13 --- /dev/null +++ b/backend/internal/modules/users/model.go @@ -0,0 +1,27 @@ +// Package users owns the user account entity and its CRUD operations. +// The auth module depends on this package to look up credentials, but +// never owns or duplicates the User model itself. +package users + +import ( + "time" + + "github.com/google/uuid" +) + +const ( + RoleAdmin = "admin" + RoleCustomer = "customer" +) + +type User struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + Email string `gorm:"uniqueIndex;not null"` + PasswordHash string `gorm:"not null"` + Role string `gorm:"not null;default:admin"` + IsActive bool `gorm:"not null;default:true"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (User) TableName() string { return "users" } diff --git a/backend/internal/modules/users/repository.go b/backend/internal/modules/users/repository.go new file mode 100644 index 0000000..c1b8017 --- /dev/null +++ b/backend/internal/modules/users/repository.go @@ -0,0 +1,98 @@ +package users + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" +) + +var ErrNotFound = errors.New("user not found") +var ErrEmailTaken = errors.New("email already in use") + +type Repository interface { + Create(ctx context.Context, user *User) error + FindByEmail(ctx context.Context, email string) (*User, error) + FindByID(ctx context.Context, id uuid.UUID) (*User, error) + List(ctx context.Context) ([]*User, error) + Update(ctx context.Context, user *User) error + Delete(ctx context.Context, id uuid.UUID) error +} + +type gormRepository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) Repository { + return &gormRepository{db: db} +} + +func (r *gormRepository) Create(ctx context.Context, user *User) error { + if err := r.db.WithContext(ctx).Create(user).Error; err != nil { + if isUniqueViolation(err) { + return ErrEmailTaken + } + return err + } + return nil +} + +func (r *gormRepository) FindByEmail(ctx context.Context, email string) (*User, error) { + var user User + err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*User, error) { + var user User + err := r.db.WithContext(ctx).Where("id = ?", id).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *gormRepository) List(ctx context.Context) ([]*User, error) { + var list []*User + if err := r.db.WithContext(ctx).Order("created_at asc").Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func (r *gormRepository) Update(ctx context.Context, user *User) error { + err := r.db.WithContext(ctx).Save(user).Error + if isUniqueViolation(err) { + return ErrEmailTaken + } + return err +} + +func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error { + res := r.db.WithContext(ctx).Delete(&User{}, "id = ?", id) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil +} + +// isUniqueViolation reports whether err is a Postgres unique-constraint +// violation (SQLSTATE 23505), e.g. a duplicate email. +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} diff --git a/backend/internal/modules/users/routes.go b/backend/internal/modules/users/routes.go new file mode 100644 index 0000000..b5d9fd0 --- /dev/null +++ b/backend/internal/modules/users/routes.go @@ -0,0 +1,14 @@ +package users + +import "github.com/gin-gonic/gin" + +// RegisterAdminRoutes mounts the user-management endpoints under the given +// group, guarded by the requireAdmin middleware supplied by the caller. +func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) { + group := rg.Group("/admin/users", requireAdmin) + group.GET("", h.List) + group.POST("", h.Create) + group.GET("/:id", h.Get) + group.PUT("/:id", h.Update) + group.DELETE("/:id", h.Delete) +} diff --git a/backend/internal/modules/users/service.go b/backend/internal/modules/users/service.go new file mode 100644 index 0000000..e1cd4cc --- /dev/null +++ b/backend/internal/modules/users/service.go @@ -0,0 +1,83 @@ +package users + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "backend/internal/platform/security" +) + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) List(ctx context.Context) ([]*User, error) { + return s.repo.List(ctx) +} + +func (s *Service) FindByID(ctx context.Context, id uuid.UUID) (*User, error) { + return s.repo.FindByID(ctx, id) +} + +func (s *Service) FindByEmail(ctx context.Context, email string) (*User, error) { + return s.repo.FindByEmail(ctx, email) +} + +func (s *Service) Create(ctx context.Context, email, password, role string) (*User, error) { + if role != RoleAdmin && role != RoleCustomer { + return nil, fmt.Errorf("invalid role %q", role) + } + hash, err := security.HashPassword(password) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + user := &User{ + ID: uuid.New(), + Email: email, + PasswordHash: hash, + Role: role, + IsActive: true, + } + if err := s.repo.Create(ctx, user); err != nil { + return nil, err + } + return user, nil +} + +// UpdateProfile updates the mutable, non-security fields of a user. +func (s *Service) UpdateProfile(ctx context.Context, id uuid.UUID, email string, isActive bool) (*User, error) { + user, err := s.repo.FindByID(ctx, id) + if err != nil { + return nil, err + } + user.Email = email + user.IsActive = isActive + if err := s.repo.Update(ctx, user); err != nil { + return nil, err + } + return user, nil +} + +func (s *Service) SetPassword(ctx context.Context, id uuid.UUID, newPassword string) error { + user, err := s.repo.FindByID(ctx, id) + if err != nil { + return err + } + hash, err := security.HashPassword(newPassword) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + user.PasswordHash = hash + return s.repo.Update(ctx, user) +} + +func (s *Service) Delete(ctx context.Context, id uuid.UUID) error { + return s.repo.Delete(ctx, id) +} diff --git a/backend/internal/platform/config/config.go b/backend/internal/platform/config/config.go new file mode 100644 index 0000000..53b062d --- /dev/null +++ b/backend/internal/platform/config/config.go @@ -0,0 +1,166 @@ +// Package config loads application configuration from environment variables. +package config + +import ( + "fmt" + "os" + "strconv" + "time" + + "github.com/joho/godotenv" +) + +type Config struct { + Database DatabaseConfig + Redis RedisConfig + JWT JWTConfig + Server ServerConfig + Seed SeedConfig + Media MediaConfig +} + +type DatabaseConfig struct { + URL string +} + +type RedisConfig struct { + URL string +} + +type JWTConfig struct { + Secret string + AccessTTL time.Duration + RefreshTTL time.Duration +} + +type ServerConfig struct { + Port string + GinMode string + CORSOrigin string + CookieSecure bool +} + +type SeedConfig struct { + AdminEmail string + AdminPassword string +} + +// MediaConfig configures the pluggable media storage backend. Driver +// selects "local" (files on disk, served via /uploads) or "s3" (any +// S3-compatible object store). Only the fields relevant to the selected +// driver need to be set. +type MediaConfig struct { + Driver string + MaxUploadMB int64 + LocalDir string + LocalBaseURL string + + S3Bucket string + S3Region string + S3Endpoint string + S3AccessKeyID string + S3SecretKey string + S3UsePathStyle bool + S3PublicBaseURL string +} + +func Load() (*Config, error) { + // The .env file lives at the repo root (next to docker-compose.yml), + // but `go run` is commonly invoked from inside backend/. Try both so + // either working directory picks it up; missing files are ignored. + _ = godotenv.Load(".env") + _ = godotenv.Load("../.env") + + accessMinutes, err := strconv.Atoi(getEnv("ACCESS_TOKEN_TTL_MINUTES", "15")) + if err != nil { + return nil, fmt.Errorf("invalid ACCESS_TOKEN_TTL_MINUTES: %w", err) + } + refreshHours, err := strconv.Atoi(getEnv("REFRESH_TOKEN_TTL_HOURS", "168")) + if err != nil { + return nil, fmt.Errorf("invalid REFRESH_TOKEN_TTL_HOURS: %w", err) + } + + secret := os.Getenv("JWT_SECRET") + if secret == "" || secret == "change-me-to-a-long-random-secret" { + return nil, fmt.Errorf("JWT_SECRET must be set to a strong, unique value") + } + + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + return nil, fmt.Errorf("DATABASE_URL must be set") + } + redisURL := os.Getenv("REDIS_URL") + if redisURL == "" { + return nil, fmt.Errorf("REDIS_URL must be set") + } + + ginMode := getEnv("GIN_MODE", "debug") + cookieSecure, err := strconv.ParseBool(getEnv("COOKIE_SECURE", strconv.FormatBool(ginMode != "debug"))) + if err != nil { + return nil, fmt.Errorf("invalid COOKIE_SECURE: %w", err) + } + + mediaDriver := getEnv("MEDIA_STORAGE_DRIVER", "local") + if mediaDriver != "local" && mediaDriver != "s3" { + return nil, fmt.Errorf("invalid MEDIA_STORAGE_DRIVER %q: must be \"local\" or \"s3\"", mediaDriver) + } + maxUploadMB, err := strconv.ParseInt(getEnv("MEDIA_MAX_UPLOAD_MB", "10"), 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid MEDIA_MAX_UPLOAD_MB: %w", err) + } + s3UsePathStyle, err := strconv.ParseBool(getEnv("S3_USE_PATH_STYLE", "false")) + if err != nil { + return nil, fmt.Errorf("invalid S3_USE_PATH_STYLE: %w", err) + } + if mediaDriver == "s3" { + for _, key := range []string{"S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"} { + if os.Getenv(key) == "" { + return nil, fmt.Errorf("%s must be set when MEDIA_STORAGE_DRIVER=s3", key) + } + } + } + + return &Config{ + Database: DatabaseConfig{ + URL: dbURL, + }, + Redis: RedisConfig{ + URL: redisURL, + }, + JWT: JWTConfig{ + Secret: secret, + AccessTTL: time.Duration(accessMinutes) * time.Minute, + RefreshTTL: time.Duration(refreshHours) * time.Hour, + }, + Server: ServerConfig{ + Port: getEnv("PORT", "8080"), + GinMode: ginMode, + CORSOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"), + CookieSecure: cookieSecure, + }, + Seed: SeedConfig{ + AdminEmail: os.Getenv("SEED_ADMIN_EMAIL"), + AdminPassword: os.Getenv("SEED_ADMIN_PASSWORD"), + }, + Media: MediaConfig{ + Driver: mediaDriver, + MaxUploadMB: maxUploadMB, + LocalDir: getEnv("MEDIA_LOCAL_DIR", "./uploads"), + LocalBaseURL: getEnv("MEDIA_LOCAL_BASE_URL", "http://localhost:8080/uploads"), + S3Bucket: os.Getenv("S3_BUCKET"), + S3Region: os.Getenv("S3_REGION"), + S3Endpoint: os.Getenv("S3_ENDPOINT"), + S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"), + S3SecretKey: os.Getenv("S3_SECRET_ACCESS_KEY"), + S3UsePathStyle: s3UsePathStyle, + S3PublicBaseURL: os.Getenv("S3_PUBLIC_BASE_URL"), + }, + }, nil +} + +func getEnv(key, fallback string) string { + if v, ok := os.LookupEnv(key); ok && v != "" { + return v + } + return fallback +} diff --git a/backend/internal/platform/db/db.go b/backend/internal/platform/db/db.go new file mode 100644 index 0000000..2f50fee --- /dev/null +++ b/backend/internal/platform/db/db.go @@ -0,0 +1,34 @@ +// Package db provides the PostgreSQL connection via GORM. +package db + +import ( + "fmt" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +func Connect(databaseURL string, debug bool) (*gorm.DB, error) { + logLevel := gormlogger.Silent + if debug { + logLevel = gormlogger.Warn + } + + db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{ + Logger: gormlogger.Default.LogMode(logLevel), + }) + if err != nil { + return nil, fmt.Errorf("connect postgres: %w", err) + } + + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("get sql.DB: %w", err) + } + if err := sqlDB.Ping(); err != nil { + return nil, fmt.Errorf("ping postgres: %w", err) + } + + return db, nil +} diff --git a/backend/internal/platform/logger/logger.go b/backend/internal/platform/logger/logger.go new file mode 100644 index 0000000..2478e1a --- /dev/null +++ b/backend/internal/platform/logger/logger.go @@ -0,0 +1,16 @@ +// Package logger provides a minimal structured logger shared across modules. +package logger + +import ( + "log/slog" + "os" +) + +func New(debug bool) *slog.Logger { + level := slog.LevelInfo + if debug { + level = slog.LevelDebug + } + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}) + return slog.New(handler) +} diff --git a/backend/internal/platform/middleware/auth.go b/backend/internal/platform/middleware/auth.go new file mode 100644 index 0000000..55177dc --- /dev/null +++ b/backend/internal/platform/middleware/auth.go @@ -0,0 +1,100 @@ +// Package middleware provides HTTP middleware shared across modules, +// notably the admin/customer auth guards, CORS and rate limiting. +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "backend/internal/platform/security" +) + +const ( + ctxUserIDKey = "auth_user_id" + ctxRoleKey = "auth_role" +) + +func extractBearerToken(c *gin.Context) (string, bool) { + header := c.GetHeader("Authorization") + if header == "" { + return "", false + } + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || parts[1] == "" { + return "", false + } + return parts[1], true +} + +// requireAudience builds a guard that only accepts access tokens issued for +// the given audience (admin or customer space) and role. Because the JWT +// audience and the expected role are both checked, an admin-space token can +// never authenticate a customer-only route, and vice versa. +func requireAudience(secret string, aud security.Audience, role string) gin.HandlerFunc { + return func(c *gin.Context) { + tokenString, ok := extractBearerToken(c) + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"}) + return + } + + claims, err := security.ParseAccessToken(secret, tokenString, aud) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) + return + } + + if claims.Role != role { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient role"}) + return + } + + userID, err := uuid.Parse(claims.Subject) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid subject claim"}) + return + } + + c.Set(ctxUserIDKey, userID) + c.Set(ctxRoleKey, claims.Role) + c.Next() + } +} + +// RequireAdmin protects admin-panel routes. Only tokens issued in the admin +// audience with role "admin" pass. +func RequireAdmin(secret string) gin.HandlerFunc { + return requireAudience(secret, security.AudienceAdmin, "admin") +} + +// RequireCustomer protects storefront account routes. Only tokens issued in +// the customer audience with role "customer" pass. Not mounted yet in this +// phase (no customer-facing routes), but kept fully separate from +// RequireAdmin so activating the customer module later never risks sharing +// a session/cookie space with the admin panel. +func RequireCustomer(secret string) gin.HandlerFunc { + return requireAudience(secret, security.AudienceCustomer, "customer") +} + +// GetUserID returns the authenticated user's ID set by RequireAdmin/RequireCustomer. +func GetUserID(c *gin.Context) (uuid.UUID, bool) { + v, exists := c.Get(ctxUserIDKey) + if !exists { + return uuid.UUID{}, false + } + id, ok := v.(uuid.UUID) + return id, ok +} + +// GetRole returns the authenticated user's role set by RequireAdmin/RequireCustomer. +func GetRole(c *gin.Context) (string, bool) { + v, exists := c.Get(ctxRoleKey) + if !exists { + return "", false + } + role, ok := v.(string) + return role, ok +} diff --git a/backend/internal/platform/middleware/cors.go b/backend/internal/platform/middleware/cors.go new file mode 100644 index 0000000..1deadad --- /dev/null +++ b/backend/internal/platform/middleware/cors.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// CORS allows only the configured frontend origin, with credentials enabled +// (required for the httpOnly refresh-token cookie to be sent cross-origin +// between the Vite dev server and the API in development). +func CORS(allowedOrigin string) gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + if origin != "" && origin == allowedOrigin { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type") + c.Header("Vary", "Origin") + } + + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} diff --git a/backend/internal/platform/middleware/ratelimit.go b/backend/internal/platform/middleware/ratelimit.go new file mode 100644 index 0000000..531603d --- /dev/null +++ b/backend/internal/platform/middleware/ratelimit.go @@ -0,0 +1,66 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" +) + +// PerIPRateLimiter limits requests per client IP, intended for sensitive +// low-frequency endpoints like login (defense against credential stuffing +// / brute force). +type PerIPRateLimiter struct { + mu sync.Mutex + limiters map[string]*rate.Limiter + r rate.Limit + burst int +} + +func NewPerIPRateLimiter(requestsPerMinute int, burst int) *PerIPRateLimiter { + l := &PerIPRateLimiter{ + limiters: make(map[string]*rate.Limiter), + r: rate.Every(time.Minute / time.Duration(requestsPerMinute)), + burst: burst, + } + go l.cleanupLoop() + return l +} + +func (l *PerIPRateLimiter) getLimiter(ip string) *rate.Limiter { + l.mu.Lock() + defer l.mu.Unlock() + + limiter, exists := l.limiters[ip] + if !exists { + limiter = rate.NewLimiter(l.r, l.burst) + l.limiters[ip] = limiter + } + return limiter +} + +func (l *PerIPRateLimiter) cleanupLoop() { + for { + time.Sleep(10 * time.Minute) + l.mu.Lock() + for ip, limiter := range l.limiters { + if limiter.Tokens() >= float64(l.burst) { + delete(l.limiters, ip) + } + } + l.mu.Unlock() + } +} + +func (l *PerIPRateLimiter) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + if !l.getLimiter(ip).Allow() { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "too many requests, try again later"}) + return + } + c.Next() + } +} diff --git a/backend/internal/platform/redis/redis.go b/backend/internal/platform/redis/redis.go new file mode 100644 index 0000000..2920f59 --- /dev/null +++ b/backend/internal/platform/redis/redis.go @@ -0,0 +1,28 @@ +// Package redis provides the Redis client used for refresh-token/session state. +package redis + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +func Connect(redisURL string) (*redis.Client, error) { + opt, err := redis.ParseURL(redisURL) + if err != nil { + return nil, fmt.Errorf("parse redis url: %w", err) + } + + client := redis.NewClient(opt) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := client.Ping(ctx).Err(); err != nil { + return nil, fmt.Errorf("ping redis: %w", err) + } + + return client, nil +} diff --git a/backend/internal/platform/security/jwt.go b/backend/internal/platform/security/jwt.go new file mode 100644 index 0000000..813b217 --- /dev/null +++ b/backend/internal/platform/security/jwt.go @@ -0,0 +1,104 @@ +package security + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// Audience separates admin-space tokens from customer-space tokens so a +// token issued for one space can never be accepted by the other, even +// though both are signed with the same server secret. +type Audience string + +const ( + AudienceAdmin Audience = "admin" + AudienceCustomer Audience = "customer" +) + +// Claims are the JWT claims carried by access tokens issued by this platform. +type Claims struct { + Role string `json:"role"` + jwt.RegisteredClaims +} + +// IssueAccessToken creates a signed, short-lived access token for the given +// user, role and audience (admin or customer space). +func IssueAccessToken(secret string, ttl time.Duration, userID uuid.UUID, role string, aud Audience) (string, error) { + if role == "" { + return "", fmt.Errorf("role must not be empty") + } + + now := time.Now() + claims := Claims{ + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID.String(), + Audience: jwt.ClaimStrings{string(aud)}, + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + ID: uuid.NewString(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(secret)) + if err != nil { + return "", fmt.Errorf("sign token: %w", err) + } + return signed, nil +} + +// ParseAccessToken verifies signature, algorithm and every claim explicitly: +// - signing method must be HMAC (rejects "alg":"none" and any asymmetric swap attempt) +// - exp/iat/nbf must be present and internally consistent (no expired/not-yet-valid tokens) +// - aud must match exactly the expected audience (admin token can never pass as a customer token, or vice versa) +// - sub must be present and a well-formed UUID (matches our user ID format) +// - role must be present and non-empty +// - jti must be present (reserved for future revocation lookups) +func ParseAccessToken(secret string, tokenString string, expectedAud Audience) (*Claims, error) { + claims := &Claims{} + + token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return []byte(secret), nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) + + if err != nil { + return nil, fmt.Errorf("parse token: %w", err) + } + if !token.Valid { + return nil, errors.New("invalid token") + } + + if claims.ExpiresAt == nil { + return nil, errors.New("missing exp claim") + } + if claims.IssuedAt == nil { + return nil, errors.New("missing iat claim") + } + if claims.ID == "" { + return nil, errors.New("missing jti claim") + } + if claims.Subject == "" { + return nil, errors.New("missing sub claim") + } + if _, err := uuid.Parse(claims.Subject); err != nil { + return nil, fmt.Errorf("invalid sub claim: %w", err) + } + if claims.Role == "" { + return nil, errors.New("missing role claim") + } + + if len(claims.Audience) != 1 || claims.Audience[0] != string(expectedAud) { + return nil, fmt.Errorf("token audience does not match expected space %q", expectedAud) + } + + return claims, nil +} diff --git a/backend/internal/platform/security/password.go b/backend/internal/platform/security/password.go new file mode 100644 index 0000000..54e76c8 --- /dev/null +++ b/backend/internal/platform/security/password.go @@ -0,0 +1,82 @@ +// Package security provides password hashing and JWT issuing/verification +// shared across auth modules. +package security + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + argon2Time = 3 + argon2Memory = 64 * 1024 // 64 MB + argon2Threads = 2 + argon2SaltLen = 16 + argon2KeyLen = 32 +) + +// HashPassword hashes a plaintext password using argon2id and returns the +// PHC-formatted string ($argon2id$v=19$m=...,t=...,p=...$salt$hash). +func HashPassword(password string) (string, error) { + salt := make([]byte, argon2SaltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generate salt: %w", err) + } + + hash := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen) + + encoded := fmt.Sprintf( + "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, + argon2Memory, + argon2Time, + argon2Threads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(hash), + ) + return encoded, nil +} + +// VerifyPassword checks a plaintext password against a PHC-formatted argon2id hash. +func VerifyPassword(encodedHash, password string) (bool, error) { + parts := strings.Split(encodedHash, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false, fmt.Errorf("invalid hash format") + } + + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { + return false, fmt.Errorf("invalid hash version segment: %w", err) + } + if version != argon2.Version { + return false, fmt.Errorf("unsupported argon2 version: %d", version) + } + + var memory uint32 + var time uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return false, fmt.Errorf("invalid hash params segment: %w", err) + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false, fmt.Errorf("decode salt: %w", err) + } + wantHash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false, fmt.Errorf("decode hash: %w", err) + } + + gotHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(wantHash))) + + if subtle.ConstantTimeCompare(gotHash, wantHash) == 1 { + return true, nil + } + return false, nil +} diff --git a/backend/migrations/000001_create_users.down.sql b/backend/migrations/000001_create_users.down.sql new file mode 100644 index 0000000..c99ddcd --- /dev/null +++ b/backend/migrations/000001_create_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/backend/migrations/000001_create_users.up.sql b/backend/migrations/000001_create_users.up.sql new file mode 100644 index 0000000..9863ca4 --- /dev/null +++ b/backend/migrations/000001_create_users.up.sql @@ -0,0 +1,11 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin' CHECK (role IN ('admin', 'customer')), + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/backend/migrations/000002_create_site_settings.down.sql b/backend/migrations/000002_create_site_settings.down.sql new file mode 100644 index 0000000..9fb5931 --- /dev/null +++ b/backend/migrations/000002_create_site_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS site_settings; diff --git a/backend/migrations/000002_create_site_settings.up.sql b/backend/migrations/000002_create_site_settings.up.sql new file mode 100644 index 0000000..68e43de --- /dev/null +++ b/backend/migrations/000002_create_site_settings.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE site_settings ( + id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + slug TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO site_settings (id) VALUES (1); diff --git a/backend/migrations/000003_create_categories.down.sql b/backend/migrations/000003_create_categories.down.sql new file mode 100644 index 0000000..2e83fc0 --- /dev/null +++ b/backend/migrations/000003_create_categories.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS categories; diff --git a/backend/migrations/000003_create_categories.up.sql b/backend/migrations/000003_create_categories.up.sql new file mode 100644 index 0000000..105e0e5 --- /dev/null +++ b/backend/migrations/000003_create_categories.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT NOT NULL DEFAULT '', + position INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/backend/migrations/000004_create_units.down.sql b/backend/migrations/000004_create_units.down.sql new file mode 100644 index 0000000..1d97184 --- /dev/null +++ b/backend/migrations/000004_create_units.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS units; diff --git a/backend/migrations/000004_create_units.up.sql b/backend/migrations/000004_create_units.up.sql new file mode 100644 index 0000000..4af7384 --- /dev/null +++ b/backend/migrations/000004_create_units.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE units ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + symbol TEXT UNIQUE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/backend/migrations/000005_create_media.down.sql b/backend/migrations/000005_create_media.down.sql new file mode 100644 index 0000000..6c6d608 --- /dev/null +++ b/backend/migrations/000005_create_media.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS media; diff --git a/backend/migrations/000005_create_media.up.sql b/backend/migrations/000005_create_media.up.sql new file mode 100644 index 0000000..afc68cb --- /dev/null +++ b/backend/migrations/000005_create_media.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE media ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + filename TEXT NOT NULL, + storage_key TEXT NOT NULL, + url TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + alt_text TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/backend/migrations/000006_create_products.down.sql b/backend/migrations/000006_create_products.down.sql new file mode 100644 index 0000000..39a3c0e --- /dev/null +++ b/backend/migrations/000006_create_products.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS products; diff --git a/backend/migrations/000006_create_products.up.sql b/backend/migrations/000006_create_products.up.sql new file mode 100644 index 0000000..a18f832 --- /dev/null +++ b/backend/migrations/000006_create_products.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id UUID REFERENCES categories(id) ON DELETE SET NULL, + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + short_description TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + is_active BOOLEAN NOT NULL DEFAULT true, + is_featured BOOLEAN NOT NULL DEFAULT false, + primary_media_id UUID REFERENCES media(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_products_category_id ON products(category_id); diff --git a/backend/migrations/000007_create_product_media.down.sql b/backend/migrations/000007_create_product_media.down.sql new file mode 100644 index 0000000..0d7f13d --- /dev/null +++ b/backend/migrations/000007_create_product_media.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS product_media; diff --git a/backend/migrations/000007_create_product_media.up.sql b/backend/migrations/000007_create_product_media.up.sql new file mode 100644 index 0000000..23db9ad --- /dev/null +++ b/backend/migrations/000007_create_product_media.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE product_media ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, + media_id UUID NOT NULL REFERENCES media(id) ON DELETE CASCADE, + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (product_id, media_id) +); + +CREATE INDEX idx_product_media_product_id ON product_media(product_id); diff --git a/backend/migrations/000008_create_price_tiers.down.sql b/backend/migrations/000008_create_price_tiers.down.sql new file mode 100644 index 0000000..9d7e12a --- /dev/null +++ b/backend/migrations/000008_create_price_tiers.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS price_tiers; diff --git a/backend/migrations/000008_create_price_tiers.up.sql b/backend/migrations/000008_create_price_tiers.up.sql new file mode 100644 index 0000000..3fe930a --- /dev/null +++ b/backend/migrations/000008_create_price_tiers.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE price_tiers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, + unit_id UUID NOT NULL REFERENCES units(id) ON DELETE RESTRICT, + quantity NUMERIC(14, 3) NOT NULL CHECK (quantity > 0), + price_cents BIGINT NOT NULL CHECK (price_cents >= 0), + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_price_tiers_product_id ON price_tiers(product_id); diff --git a/backend/migrations/000009_create_telegram_settings.down.sql b/backend/migrations/000009_create_telegram_settings.down.sql new file mode 100644 index 0000000..6de25ee --- /dev/null +++ b/backend/migrations/000009_create_telegram_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS telegram_settings; diff --git a/backend/migrations/000009_create_telegram_settings.up.sql b/backend/migrations/000009_create_telegram_settings.up.sql new file mode 100644 index 0000000..be7ccd2 --- /dev/null +++ b/backend/migrations/000009_create_telegram_settings.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE telegram_settings ( + id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + enabled BOOLEAN NOT NULL DEFAULT false, + bot_token TEXT NOT NULL DEFAULT '', + chat_id TEXT NOT NULL DEFAULT '', + notify_new_order BOOLEAN NOT NULL DEFAULT true, + notify_status_change BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO telegram_settings (id) VALUES (1); diff --git a/backend/migrations/000010_create_orders.down.sql b/backend/migrations/000010_create_orders.down.sql new file mode 100644 index 0000000..488f4dc --- /dev/null +++ b/backend/migrations/000010_create_orders.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS order_items; +DROP TABLE IF EXISTS orders; diff --git a/backend/migrations/000010_create_orders.up.sql b/backend/migrations/000010_create_orders.up.sql new file mode 100644 index 0000000..1a7afc4 --- /dev/null +++ b/backend/migrations/000010_create_orders.up.sql @@ -0,0 +1,27 @@ +CREATE TABLE orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_name TEXT NOT NULL, + customer_email TEXT NOT NULL, + customer_phone TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'preparing', 'shipped', 'completed', 'cancelled')), + total_cents BIGINT NOT NULL DEFAULT 0, + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE order_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + product_id UUID REFERENCES products(id) ON DELETE SET NULL, + product_name TEXT NOT NULL, + price_tier_id UUID REFERENCES price_tiers(id) ON DELETE SET NULL, + unit_symbol TEXT NOT NULL, + tier_quantity NUMERIC(14, 3) NOT NULL, + multiplier BIGINT NOT NULL DEFAULT 1, + unit_price_cents BIGINT NOT NULL, + total_cents BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_order_items_order_id ON order_items(order_id); diff --git a/backend/migrations/000011_add_position_to_products.down.sql b/backend/migrations/000011_add_position_to_products.down.sql new file mode 100644 index 0000000..ba55ebc --- /dev/null +++ b/backend/migrations/000011_add_position_to_products.down.sql @@ -0,0 +1 @@ +ALTER TABLE products DROP COLUMN IF EXISTS position; diff --git a/backend/migrations/000011_add_position_to_products.up.sql b/backend/migrations/000011_add_position_to_products.up.sql new file mode 100644 index 0000000..fcb863c --- /dev/null +++ b/backend/migrations/000011_add_position_to_products.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE products ADD COLUMN position INT NOT NULL DEFAULT 0; + +CREATE INDEX idx_products_position ON products(position); diff --git a/backend/test/auth/service_test.go b/backend/test/auth/service_test.go new file mode 100644 index 0000000..edb015c --- /dev/null +++ b/backend/test/auth/service_test.go @@ -0,0 +1,217 @@ +package auth_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + + "backend/internal/modules/auth" + "backend/internal/modules/users" + "backend/internal/platform/security" +) + +// fakeUserFinder is an in-memory auth.UserFinder used to unit-test +// auth.Service without a real users repository/database. +type fakeUserFinder struct { + byID map[uuid.UUID]*users.User + byEmail map[string]*users.User +} + +func newFakeUserFinder() *fakeUserFinder { + return &fakeUserFinder{byID: map[uuid.UUID]*users.User{}, byEmail: map[string]*users.User{}} +} + +func (f *fakeUserFinder) add(email, password, role string, active bool) *users.User { + hash, err := security.HashPassword(password) + if err != nil { + panic(err) + } + u := &users.User{ID: uuid.New(), Email: email, PasswordHash: hash, Role: role, IsActive: active} + f.byID[u.ID] = u + f.byEmail[u.Email] = u + return u +} + +func (f *fakeUserFinder) FindByEmail(_ context.Context, email string) (*users.User, error) { + u, ok := f.byEmail[email] + if !ok { + return nil, users.ErrNotFound + } + return u, nil +} + +func (f *fakeUserFinder) FindByID(_ context.Context, id uuid.UUID) (*users.User, error) { + u, ok := f.byID[id] + if !ok { + return nil, users.ErrNotFound + } + return u, nil +} + +// fakeRefreshStore is an in-memory auth.RefreshStore used to unit-test +// rotation/revocation without a real Redis instance. +type fakeRefreshStore struct { + byToken map[string]uuid.UUID + byUser map[uuid.UUID]map[string]bool + nextID int +} + +func newFakeRefreshStore() *fakeRefreshStore { + return &fakeRefreshStore{byToken: map[string]uuid.UUID{}, byUser: map[uuid.UUID]map[string]bool{}} +} + +func (f *fakeRefreshStore) newToken() string { + f.nextID++ + return "token-" + uuid.NewString() +} + +func (f *fakeRefreshStore) Issue(_ context.Context, _ security.Audience, userID uuid.UUID, _ time.Duration) (string, error) { + token := f.newToken() + f.byToken[token] = userID + if f.byUser[userID] == nil { + f.byUser[userID] = map[string]bool{} + } + f.byUser[userID][token] = true + return token, nil +} + +func (f *fakeRefreshStore) Rotate(ctx context.Context, aud security.Audience, oldToken string, ttl time.Duration) (string, uuid.UUID, error) { + userID, ok := f.byToken[oldToken] + if !ok { + return "", uuid.UUID{}, auth.ErrInvalidRefreshToken + } + delete(f.byToken, oldToken) + delete(f.byUser[userID], oldToken) + newToken, err := f.Issue(ctx, aud, userID, ttl) + return newToken, userID, err +} + +func (f *fakeRefreshStore) Revoke(_ context.Context, _ security.Audience, token string) error { + userID, ok := f.byToken[token] + if !ok { + return nil + } + delete(f.byToken, token) + delete(f.byUser[userID], token) + return nil +} + +func (f *fakeRefreshStore) RevokeAllForUser(_ context.Context, _ security.Audience, userID uuid.UUID) error { + for token := range f.byUser[userID] { + delete(f.byToken, token) + } + delete(f.byUser, userID) + return nil +} + +func TestService_Login_Success(t *testing.T) { + finder := newFakeUserFinder() + finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) + + svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour) + + pair, user, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password") + if err != nil { + t.Fatalf("Login() error = %v", err) + } + if pair.AccessToken == "" || pair.RefreshToken == "" { + t.Fatal("Login() returned an empty token pair") + } + if user.Email != "admin@example.com" { + t.Fatalf("Login() user = %+v, want email admin@example.com", user) + } +} + +func TestService_Login_WrongPasswordRejected(t *testing.T) { + finder := newFakeUserFinder() + finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) + svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour) + + _, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "wrong-password") + if !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("Login() error = %v, want ErrInvalidCredentials", err) + } +} + +func TestService_Login_WrongRoleSpaceRejected(t *testing.T) { + // A customer account must not be able to log into the admin space, even + // with the correct password -- the two spaces are strictly separated. + finder := newFakeUserFinder() + finder.add("shopper@example.com", "correct-password", users.RoleCustomer, true) + svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour) + + _, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "shopper@example.com", "correct-password") + if !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("Login() error = %v, want ErrInvalidCredentials for a customer logging into the admin space", err) + } +} + +func TestService_Login_DisabledAccountRejected(t *testing.T) { + finder := newFakeUserFinder() + finder.add("admin@example.com", "correct-password", users.RoleAdmin, false) + svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour) + + _, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password") + if !errors.Is(err, auth.ErrAccountDisabled) { + t.Fatalf("Login() error = %v, want ErrAccountDisabled", err) + } +} + +func TestService_Refresh_RotatesToken(t *testing.T) { + finder := newFakeUserFinder() + finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) + svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour) + + pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password") + if err != nil { + t.Fatalf("Login() error = %v", err) + } + + newPair, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken) + if err != nil { + t.Fatalf("Refresh() error = %v", err) + } + if newPair.RefreshToken == pair.RefreshToken { + t.Fatal("Refresh() returned the same refresh token instead of rotating it") + } +} + +func TestService_Refresh_RejectsReusedToken(t *testing.T) { + finder := newFakeUserFinder() + finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) + svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour) + + pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password") + if err != nil { + t.Fatalf("Login() error = %v", err) + } + + if _, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken); err != nil { + t.Fatalf("first Refresh() error = %v", err) + } + // The old (already-rotated) refresh token must never work again. + if _, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken); err == nil { + t.Fatal("second Refresh() with the same (rotated-out) token succeeded, want error") + } +} + +func TestService_Logout_RevokesToken(t *testing.T) { + finder := newFakeUserFinder() + finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) + svc := auth.NewService(finder, newFakeRefreshStore(), "test-secret", time.Minute, time.Hour) + + pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin@example.com", "correct-password") + if err != nil { + t.Fatalf("Login() error = %v", err) + } + + if err := svc.Logout(context.Background(), security.AudienceAdmin, pair.RefreshToken); err != nil { + t.Fatalf("Logout() error = %v", err) + } + if _, err := svc.Refresh(context.Background(), security.AudienceAdmin, pair.RefreshToken); err == nil { + t.Fatal("Refresh() succeeded after logout, want error") + } +} diff --git a/backend/test/orders/service_test.go b/backend/test/orders/service_test.go new file mode 100644 index 0000000..2fafd0a --- /dev/null +++ b/backend/test/orders/service_test.go @@ -0,0 +1,244 @@ +package orders_test + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "backend/internal/modules/orders" + "backend/internal/modules/pricing" + "backend/internal/modules/products" + "backend/internal/modules/units" +) + +// fakeRepository is an in-memory orders.Repository used to unit-test +// orders.Service without a real database. +type fakeRepository struct { + orders map[uuid.UUID]*orders.Order + items map[uuid.UUID][]*orders.OrderItem +} + +func newFakeRepository() *fakeRepository { + return &fakeRepository{orders: map[uuid.UUID]*orders.Order{}, items: map[uuid.UUID][]*orders.OrderItem{}} +} + +func (r *fakeRepository) Create(_ context.Context, order *orders.Order, items []*orders.OrderItem) error { + cp := *order + r.orders[order.ID] = &cp + for _, item := range items { + item.OrderID = order.ID + } + r.items[order.ID] = items + return nil +} + +func (r *fakeRepository) FindByID(_ context.Context, id uuid.UUID) (*orders.Order, []*orders.OrderItem, error) { + o, ok := r.orders[id] + if !ok { + return nil, nil, orders.ErrNotFound + } + return o, r.items[id], nil +} + +func (r *fakeRepository) List(_ context.Context, status string) ([]*orders.Order, error) { + var list []*orders.Order + for _, o := range r.orders { + if status == "" || o.Status == status { + list = append(list, o) + } + } + return list, nil +} + +func (r *fakeRepository) UpdateStatus(_ context.Context, id uuid.UUID, status string) (*orders.Order, error) { + o, ok := r.orders[id] + if !ok { + return nil, orders.ErrNotFound + } + o.Status = status + return o, nil +} + +// fakeProducts / fakeUnits / fakePrices back the small consumer-defined +// interfaces orders.Service depends on. +type fakeProducts struct { + byID map[uuid.UUID]*products.Product +} + +func (f *fakeProducts) Get(_ context.Context, id uuid.UUID) (*products.Product, error) { + p, ok := f.byID[id] + if !ok { + return nil, products.ErrNotFound + } + return p, nil +} + +type fakeUnits struct { + byID map[uuid.UUID]*units.Unit +} + +func (f *fakeUnits) Get(_ context.Context, id uuid.UUID) (*units.Unit, error) { + u, ok := f.byID[id] + if !ok { + return nil, units.ErrNotFound + } + return u, nil +} + +type fakePrices struct { + byID map[uuid.UUID]*pricing.PriceTier +} + +func (f *fakePrices) PriceForQuantity(_ context.Context, tierID uuid.UUID, multiplier int64) (int64, *pricing.PriceTier, error) { + t, ok := f.byID[tierID] + if !ok { + return 0, nil, pricing.ErrNotFound + } + if multiplier < 1 { + multiplier = 1 + } + return t.PriceCents * multiplier, t, nil +} + +type fakeNotifier struct { + newOrderCalls []string + statusChangeCalls []string +} + +func (f *fakeNotifier) NotifyNewOrder(_ context.Context, summary string) { + f.newOrderCalls = append(f.newOrderCalls, summary) +} + +func (f *fakeNotifier) NotifyOrderStatusChange(_ context.Context, summary string) { + f.statusChangeCalls = append(f.statusChangeCalls, summary) +} + +// testFixture wires a full set of fakes with one product/unit/tier, ready +// for a service under test. +type testFixture struct { + repo *fakeRepository + products *fakeProducts + units *fakeUnits + prices *fakePrices + notifier *fakeNotifier + + productID uuid.UUID + unitID uuid.UUID + tierID uuid.UUID +} + +func newFixture() *testFixture { + productID := uuid.New() + unitID := uuid.New() + tierID := uuid.New() + + return &testFixture{ + repo: newFakeRepository(), + products: &fakeProducts{byID: map[uuid.UUID]*products.Product{ + productID: {ID: productID, Name: "Honey jar", Slug: "honey-jar", IsActive: true}, + }}, + units: &fakeUnits{byID: map[uuid.UUID]*units.Unit{ + unitID: {ID: unitID, Name: "Kilogram", Symbol: "kg"}, + }}, + prices: &fakePrices{byID: map[uuid.UUID]*pricing.PriceTier{ + tierID: {ID: tierID, ProductID: productID, UnitID: unitID, Quantity: 5, PriceCents: 4500}, + }}, + notifier: &fakeNotifier{}, + productID: productID, + unitID: unitID, + tierID: tierID, + } +} + +func (f *testFixture) service() *orders.Service { + return orders.NewService(f.repo, f.products, f.units, f.prices, f.notifier) +} + +func TestService_Create_ComputesTotalAndNotifies(t *testing.T) { + f := newFixture() + svc := f.service() + + order, items, err := svc.Create(context.Background(), orders.CreateInput{ + CustomerName: "Alice", + CustomerEmail: "alice@example.com", + Items: []orders.ItemInput{ + {ProductID: f.productID, PriceTierID: f.tierID, Multiplier: 2}, + }, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if order.TotalCents != 9000 { + t.Fatalf("Create() order.TotalCents = %d, want 9000 (2x 4500)", order.TotalCents) + } + if order.Status != orders.StatusPending { + t.Fatalf("Create() order.Status = %q, want %q", order.Status, orders.StatusPending) + } + if len(items) != 1 || items[0].ProductName != "Honey jar" || items[0].UnitSymbol != "kg" { + t.Fatalf("Create() items = %+v, want snapshot of product name/unit symbol", items) + } + if len(f.notifier.newOrderCalls) != 1 { + t.Fatalf("Create() triggered %d new-order notifications, want exactly 1", len(f.notifier.newOrderCalls)) + } +} + +func TestService_Create_RejectsEmptyOrder(t *testing.T) { + f := newFixture() + svc := f.service() + + _, _, err := svc.Create(context.Background(), orders.CreateInput{CustomerName: "Alice", CustomerEmail: "a@example.com"}) + if !errors.Is(err, orders.ErrEmptyOrder) { + t.Fatalf("Create() error = %v, want ErrEmptyOrder", err) + } +} + +func TestService_Create_RejectsTierProductMismatch(t *testing.T) { + f := newFixture() + svc := f.service() + + otherProduct := uuid.New() + f.products.byID[otherProduct] = &products.Product{ID: otherProduct, Name: "Other", Slug: "other", IsActive: true} + + _, _, err := svc.Create(context.Background(), orders.CreateInput{ + CustomerName: "Alice", + CustomerEmail: "alice@example.com", + Items: []orders.ItemInput{ + // tierID actually belongs to f.productID, not otherProduct. + {ProductID: otherProduct, PriceTierID: f.tierID, Multiplier: 1}, + }, + }) + if !errors.Is(err, orders.ErrProductMismatch) { + t.Fatalf("Create() error = %v, want ErrProductMismatch", err) + } +} + +func TestService_UpdateStatus_ValidatesStatus(t *testing.T) { + f := newFixture() + svc := f.service() + + order, _, err := svc.Create(context.Background(), orders.CreateInput{ + CustomerName: "Alice", + CustomerEmail: "alice@example.com", + Items: []orders.ItemInput{{ProductID: f.productID, PriceTierID: f.tierID, Multiplier: 1}}, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + if _, err := svc.UpdateStatus(context.Background(), order.ID, "not-a-real-status"); !errors.Is(err, orders.ErrInvalidStatus) { + t.Fatalf("UpdateStatus() error = %v, want ErrInvalidStatus", err) + } + + updated, err := svc.UpdateStatus(context.Background(), order.ID, orders.StatusConfirmed) + if err != nil { + t.Fatalf("UpdateStatus() error = %v", err) + } + if updated.Status != orders.StatusConfirmed { + t.Fatalf("UpdateStatus() status = %q, want %q", updated.Status, orders.StatusConfirmed) + } + if len(f.notifier.statusChangeCalls) != 1 { + t.Fatalf("UpdateStatus() triggered %d status-change notifications, want exactly 1", len(f.notifier.statusChangeCalls)) + } +} diff --git a/backend/test/pricing/service_test.go b/backend/test/pricing/service_test.go new file mode 100644 index 0000000..0d76491 --- /dev/null +++ b/backend/test/pricing/service_test.go @@ -0,0 +1,142 @@ +package pricing_test + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "backend/internal/modules/pricing" +) + +// fakeRepository is an in-memory pricing.Repository used to unit-test +// pricing.Service without a real database. +type fakeRepository struct { + tiers map[uuid.UUID]*pricing.PriceTier +} + +func newFakeRepository() *fakeRepository { + return &fakeRepository{tiers: map[uuid.UUID]*pricing.PriceTier{}} +} + +func (r *fakeRepository) Create(_ context.Context, t *pricing.PriceTier) error { + cp := *t + r.tiers[t.ID] = &cp + return nil +} + +func (r *fakeRepository) FindByID(_ context.Context, id uuid.UUID) (*pricing.PriceTier, error) { + t, ok := r.tiers[id] + if !ok { + return nil, pricing.ErrNotFound + } + cp := *t + return &cp, nil +} + +func (r *fakeRepository) ListByProduct(_ context.Context, productID uuid.UUID) ([]*pricing.PriceTier, error) { + var list []*pricing.PriceTier + for _, t := range r.tiers { + if t.ProductID == productID { + cp := *t + list = append(list, &cp) + } + } + return list, nil +} + +func (r *fakeRepository) Update(_ context.Context, t *pricing.PriceTier) error { + if _, ok := r.tiers[t.ID]; !ok { + return pricing.ErrNotFound + } + cp := *t + r.tiers[t.ID] = &cp + return nil +} + +func (r *fakeRepository) Delete(_ context.Context, id uuid.UUID) error { + if _, ok := r.tiers[id]; !ok { + return pricing.ErrNotFound + } + delete(r.tiers, id) + return nil +} + +func TestService_PriceForQuantity_MultipliesTierPrice(t *testing.T) { + repo := newFakeRepository() + svc := pricing.NewService(repo) + ctx := context.Background() + + productID := uuid.New() + unitID := uuid.New() + + // "5 kg -> 45.00" tier, spec section 16 example. + tier, err := svc.Create(ctx, productID, unitID, 5, 4500, 0) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + total, gotTier, err := svc.PriceForQuantity(ctx, tier.ID, 2) + if err != nil { + t.Fatalf("PriceForQuantity() error = %v", err) + } + if total != 9000 { + t.Fatalf("PriceForQuantity() total = %d, want 9000 (2x 4500)", total) + } + if gotTier.ID != tier.ID { + t.Fatalf("PriceForQuantity() returned tier %s, want %s", gotTier.ID, tier.ID) + } +} + +func TestService_PriceForQuantity_ZeroOrNegativeMultiplierTreatedAsOne(t *testing.T) { + repo := newFakeRepository() + svc := pricing.NewService(repo) + ctx := context.Background() + + tier, err := svc.Create(ctx, uuid.New(), uuid.New(), 1, 1000, 0) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + total, _, err := svc.PriceForQuantity(ctx, tier.ID, 0) + if err != nil { + t.Fatalf("PriceForQuantity() error = %v", err) + } + if total != 1000 { + t.Fatalf("PriceForQuantity() with multiplier=0 total = %d, want 1000 (treated as 1x)", total) + } +} + +func TestService_PriceForQuantity_UnknownTierRejected(t *testing.T) { + svc := pricing.NewService(newFakeRepository()) + _, _, err := svc.PriceForQuantity(context.Background(), uuid.New(), 1) + if !errors.Is(err, pricing.ErrNotFound) { + t.Fatalf("PriceForQuantity() error = %v, want ErrNotFound", err) + } +} + +func TestService_ListByProduct_FiltersByProduct(t *testing.T) { + repo := newFakeRepository() + svc := pricing.NewService(repo) + ctx := context.Background() + + productA := uuid.New() + productB := uuid.New() + unitID := uuid.New() + + if _, err := svc.Create(ctx, productA, unitID, 1, 1000, 0); err != nil { + t.Fatalf("Create() error = %v", err) + } + if _, err := svc.Create(ctx, productB, unitID, 1, 2000, 0); err != nil { + t.Fatalf("Create() error = %v", err) + } + + list, err := svc.ListByProduct(ctx, productA) + if err != nil { + t.Fatalf("ListByProduct() error = %v", err) + } + if len(list) != 1 || list[0].ProductID != productA { + t.Fatalf("ListByProduct(productA) = %+v, want exactly one tier for productA", list) + } +} diff --git a/backend/test/security/jwt_test.go b/backend/test/security/jwt_test.go new file mode 100644 index 0000000..cf4a2e5 --- /dev/null +++ b/backend/test/security/jwt_test.go @@ -0,0 +1,150 @@ +package security_test + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + + "backend/internal/platform/security" +) + +const testSecret = "test-secret-please-do-not-use-in-prod" + +func TestIssueAndParseAccessToken_RoundTrip(t *testing.T) { + userID := uuid.New() + + token, err := security.IssueAccessToken(testSecret, time.Minute, userID, "admin", security.AudienceAdmin) + if err != nil { + t.Fatalf("IssueAccessToken() error = %v", err) + } + + claims, err := security.ParseAccessToken(testSecret, token, security.AudienceAdmin) + if err != nil { + t.Fatalf("ParseAccessToken() error = %v", err) + } + + if claims.Subject != userID.String() { + t.Errorf("claims.Subject = %q, want %q", claims.Subject, userID.String()) + } + if claims.Role != "admin" { + t.Errorf("claims.Role = %q, want %q", claims.Role, "admin") + } + if claims.ID == "" { + t.Error("claims.ID (jti) is empty, want a non-empty token id") + } +} + +func TestIssueAccessToken_RejectsEmptyRole(t *testing.T) { + _, err := security.IssueAccessToken(testSecret, time.Minute, uuid.New(), "", security.AudienceAdmin) + if err == nil { + t.Fatal("IssueAccessToken() error = nil, want error for empty role") + } +} + +func TestParseAccessToken_WrongAudienceRejected(t *testing.T) { + userID := uuid.New() + token, err := security.IssueAccessToken(testSecret, time.Minute, userID, "admin", security.AudienceAdmin) + if err != nil { + t.Fatalf("IssueAccessToken() error = %v", err) + } + + // An admin-space token must never be accepted as a customer-space token, + // even though both spaces share the same signing secret. + if _, err := security.ParseAccessToken(testSecret, token, security.AudienceCustomer); err == nil { + t.Fatal("ParseAccessToken() error = nil, want error when audience does not match expected space") + } +} + +func TestParseAccessToken_WrongSecretRejected(t *testing.T) { + token, err := security.IssueAccessToken(testSecret, time.Minute, uuid.New(), "admin", security.AudienceAdmin) + if err != nil { + t.Fatalf("IssueAccessToken() error = %v", err) + } + + if _, err := security.ParseAccessToken("a-different-secret", token, security.AudienceAdmin); err == nil { + t.Fatal("ParseAccessToken() error = nil, want error for a token signed with a different secret") + } +} + +func TestParseAccessToken_ExpiredTokenRejected(t *testing.T) { + token, err := security.IssueAccessToken(testSecret, -time.Minute, uuid.New(), "admin", security.AudienceAdmin) + if err != nil { + t.Fatalf("IssueAccessToken() error = %v", err) + } + + if _, err := security.ParseAccessToken(testSecret, token, security.AudienceAdmin); err == nil { + t.Fatal("ParseAccessToken() error = nil, want error for an already-expired token") + } +} + +func TestParseAccessToken_RejectsNoneAlgorithm(t *testing.T) { + // Craft a token that declares "alg":"none" and carries otherwise valid + // claims, to make sure the parser refuses it outright rather than + // trusting an attacker-chosen algorithm. + claims := security.Claims{ + Role: "admin", + RegisteredClaims: jwt.RegisteredClaims{ + Subject: uuid.New().String(), + Audience: jwt.ClaimStrings{string(security.AudienceAdmin)}, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now()), + ID: uuid.NewString(), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodNone, claims) + unsigned, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatalf("craft none-alg token: %v", err) + } + + if _, err := security.ParseAccessToken(testSecret, unsigned, security.AudienceAdmin); err == nil { + t.Fatal("ParseAccessToken() error = nil, want error for an alg=none token") + } +} + +func TestParseAccessToken_RejectsInvalidSubject(t *testing.T) { + secretBytes := []byte(testSecret) + claims := security.Claims{ + Role: "admin", + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "not-a-uuid", + Audience: jwt.ClaimStrings{string(security.AudienceAdmin)}, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now()), + ID: uuid.NewString(), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(secretBytes) + if err != nil { + t.Fatalf("sign token: %v", err) + } + + if _, err := security.ParseAccessToken(testSecret, signed, security.AudienceAdmin); err == nil { + t.Fatal("ParseAccessToken() error = nil, want error for a non-UUID sub claim") + } +} + +func TestParseAccessToken_RejectsMissingRole(t *testing.T) { + secretBytes := []byte(testSecret) + claims := security.Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: uuid.New().String(), + Audience: jwt.ClaimStrings{string(security.AudienceAdmin)}, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now()), + ID: uuid.NewString(), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(secretBytes) + if err != nil { + t.Fatalf("sign token: %v", err) + } + + if _, err := security.ParseAccessToken(testSecret, signed, security.AudienceAdmin); err == nil { + t.Fatal("ParseAccessToken() error = nil, want error when role claim is missing") + } +} diff --git a/backend/test/security/password_test.go b/backend/test/security/password_test.go new file mode 100644 index 0000000..11d8a7f --- /dev/null +++ b/backend/test/security/password_test.go @@ -0,0 +1,71 @@ +package security_test + +import ( + "testing" + + "backend/internal/platform/security" +) + +func TestHashAndVerifyPassword_RoundTrip(t *testing.T) { + hash, err := security.HashPassword("correct-horse-battery-staple") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + + ok, err := security.VerifyPassword(hash, "correct-horse-battery-staple") + if err != nil { + t.Fatalf("VerifyPassword() error = %v", err) + } + if !ok { + t.Fatal("VerifyPassword() = false, want true for the correct password") + } +} + +func TestVerifyPassword_WrongPassword(t *testing.T) { + hash, err := security.HashPassword("correct-horse-battery-staple") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + + ok, err := security.VerifyPassword(hash, "wrong-password") + if err != nil { + t.Fatalf("VerifyPassword() error = %v", err) + } + if ok { + t.Fatal("VerifyPassword() = true, want false for a wrong password") + } +} + +func TestHashPassword_UniqueSaltPerCall(t *testing.T) { + hash1, err := security.HashPassword("same-password") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + hash2, err := security.HashPassword("same-password") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if hash1 == hash2 { + t.Fatal("two hashes of the same password with random salts must differ") + } +} + +func TestVerifyPassword_InvalidFormat(t *testing.T) { + _, err := security.VerifyPassword("not-a-valid-hash", "whatever") + if err == nil { + t.Fatal("VerifyPassword() error = nil, want error for malformed hash") + } +} + +func TestVerifyPassword_TamperedHash(t *testing.T) { + hash, err := security.HashPassword("correct-horse-battery-staple") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + + tampered := hash[:len(hash)-4] + "abcd" + ok, _ := security.VerifyPassword(tampered, "correct-horse-battery-staple") + if ok { + t.Fatal("VerifyPassword() = true for a tampered hash, want false") + } +} diff --git a/backend/test/site/service_test.go b/backend/test/site/service_test.go new file mode 100644 index 0000000..5d929e8 --- /dev/null +++ b/backend/test/site/service_test.go @@ -0,0 +1,62 @@ +package site_test + +import ( + "context" + "testing" + + "backend/internal/modules/site" +) + +// fakeRepository is an in-memory site.Repository backing the single +// site_settings row, used to unit-test site.Service without a real database. +type fakeRepository struct { + settings site.Settings +} + +func newFakeRepository() *fakeRepository { + return &fakeRepository{settings: site.Settings{ID: 1}} +} + +func (r *fakeRepository) Get(_ context.Context) (*site.Settings, error) { + cp := r.settings + return &cp, nil +} + +func (r *fakeRepository) Update(_ context.Context, s *site.Settings) error { + r.settings.Name = s.Name + r.settings.Description = s.Description + r.settings.Slug = s.Slug + return nil +} + +func TestService_Update_PersistsFields(t *testing.T) { + svc := site.NewService(newFakeRepository()) + ctx := context.Background() + + updated, err := svc.Update(ctx, "My Shop", "A small shop", "my-shop") + if err != nil { + t.Fatalf("Update() error = %v", err) + } + if updated.Name != "My Shop" || updated.Description != "A small shop" || updated.Slug != "my-shop" { + t.Fatalf("Update() = %+v, fields not persisted as expected", updated) + } + + fetched, err := svc.Get(ctx) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if fetched.Name != "My Shop" { + t.Fatalf("Get() after Update() = %+v, want name %q", fetched, "My Shop") + } +} + +func TestService_Get_DefaultsBeforeAnyUpdate(t *testing.T) { + svc := site.NewService(newFakeRepository()) + settings, err := svc.Get(context.Background()) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if settings.Name != "" || settings.Slug != "" { + t.Fatalf("Get() before any update = %+v, want empty defaults", settings) + } +} diff --git a/backend/test/telegram/service_test.go b/backend/test/telegram/service_test.go new file mode 100644 index 0000000..7172537 --- /dev/null +++ b/backend/test/telegram/service_test.go @@ -0,0 +1,157 @@ +package telegram_test + +import ( + "context" + "io" + "log/slog" + "testing" + + "backend/internal/modules/telegram" +) + +// fakeRepository is an in-memory telegram.Repository (the single settings +// row) used to unit-test telegram.Service without a real database. +type fakeRepository struct { + settings telegram.Settings +} + +func newFakeRepository() *fakeRepository { + return &fakeRepository{settings: telegram.Settings{ID: 1}} +} + +func (r *fakeRepository) Get(_ context.Context) (*telegram.Settings, error) { + cp := r.settings + return &cp, nil +} + +func (r *fakeRepository) Update(_ context.Context, s *telegram.Settings) error { + r.settings.Enabled = s.Enabled + r.settings.ChatID = s.ChatID + r.settings.NotifyNewOrder = s.NotifyNewOrder + r.settings.NotifyStatusChange = s.NotifyStatusChange + if s.BotToken != "" { + r.settings.BotToken = s.BotToken + } + return nil +} + +// fakeSender records every message it was asked to send, instead of making +// a real network call to the Telegram Bot API. +type fakeSender struct { + sent []string + err error +} + +func (s *fakeSender) Send(_ context.Context, botToken, chatID, text string) error { + if s.err != nil { + return s.err + } + s.sent = append(s.sent, text) + return nil +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestService_Update_KeepsExistingTokenWhenBlank(t *testing.T) { + repo := newFakeRepository() + svc := telegram.NewService(repo, &fakeSender{}, discardLogger()) + ctx := context.Background() + + if _, err := svc.Update(ctx, true, "secret-token", "12345", true, false); err != nil { + t.Fatalf("first Update() error = %v", err) + } + + // Flip a flag without resending the token. + settings, err := svc.Update(ctx, true, "", "12345", false, true) + if err != nil { + t.Fatalf("second Update() error = %v", err) + } + if !settings.NotifyStatusChange || settings.NotifyNewOrder { + t.Fatalf("Update() flags = %+v, want NotifyStatusChange=true NotifyNewOrder=false", settings) + } + if repo.settings.BotToken != "secret-token" { + t.Fatalf("Update() with blank bot_token overwrote the stored token: got %q", repo.settings.BotToken) + } +} + +func TestService_Get_NeverExposesRawToken(t *testing.T) { + // This mirrors the handler's toResponse() contract: the service layer + // itself returns the raw Settings (needed internally to call the + // Telegram API), but the HTTP handler must never marshal BotToken back + // to the client. This test guards the service-level data so a future + // change to the handler can't accidentally start doing so without + // resetting the flag on read. + repo := newFakeRepository() + svc := telegram.NewService(repo, &fakeSender{}, discardLogger()) + ctx := context.Background() + + if _, err := svc.Update(ctx, true, "super-secret", "12345", true, false); err != nil { + t.Fatalf("Update() error = %v", err) + } + + settings, err := svc.Get(ctx) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if settings.BotToken != "super-secret" { + t.Fatalf("Get() lost the stored bot token: got %q", settings.BotToken) + } +} + +func TestService_NotifyNewOrder_SkipsWhenDisabled(t *testing.T) { + repo := newFakeRepository() + sender := &fakeSender{} + svc := telegram.NewService(repo, sender, discardLogger()) + ctx := context.Background() + + if _, err := svc.Update(ctx, false, "token", "12345", true, false); err != nil { + t.Fatalf("Update() error = %v", err) + } + + svc.NotifyNewOrder(ctx, "new order!") + if len(sender.sent) != 0 { + t.Fatalf("NotifyNewOrder() sent a message while Telegram is disabled: %v", sender.sent) + } +} + +func TestService_NotifyNewOrder_SkipsWhenEventNotEnabled(t *testing.T) { + repo := newFakeRepository() + sender := &fakeSender{} + svc := telegram.NewService(repo, sender, discardLogger()) + ctx := context.Background() + + // Enabled overall, but the "new order" event specifically is off. + if _, err := svc.Update(ctx, true, "token", "12345", false, true); err != nil { + t.Fatalf("Update() error = %v", err) + } + + svc.NotifyNewOrder(ctx, "new order!") + if len(sender.sent) != 0 { + t.Fatalf("NotifyNewOrder() sent a message while notify_new_order=false: %v", sender.sent) + } +} + +func TestService_NotifyNewOrder_SendsWhenEnabled(t *testing.T) { + repo := newFakeRepository() + sender := &fakeSender{} + svc := telegram.NewService(repo, sender, discardLogger()) + ctx := context.Background() + + if _, err := svc.Update(ctx, true, "token", "12345", true, false); err != nil { + t.Fatalf("Update() error = %v", err) + } + + svc.NotifyNewOrder(ctx, "new order!") + if len(sender.sent) != 1 || sender.sent[0] != "new order!" { + t.Fatalf("NotifyNewOrder() sent = %v, want exactly one message \"new order!\"", sender.sent) + } +} + +func TestService_SendTestMessage_RequiresConfiguredCredentials(t *testing.T) { + svc := telegram.NewService(newFakeRepository(), &fakeSender{}, discardLogger()) + if err := svc.SendTestMessage(context.Background()); err == nil { + t.Fatal("SendTestMessage() error = nil, want error when bot token/chat id are not configured") + } +} diff --git a/backend/test/users/service_test.go b/backend/test/users/service_test.go new file mode 100644 index 0000000..3470924 --- /dev/null +++ b/backend/test/users/service_test.go @@ -0,0 +1,183 @@ +package users_test + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "backend/internal/modules/users" + "backend/internal/platform/security" +) + +// fakeRepository is an in-memory users.Repository used to unit-test +// users.Service without a real database. +type fakeRepository struct { + byID map[uuid.UUID]*users.User + byEmail map[string]uuid.UUID +} + +func newFakeRepository() *fakeRepository { + return &fakeRepository{ + byID: make(map[uuid.UUID]*users.User), + byEmail: make(map[string]uuid.UUID), + } +} + +func (r *fakeRepository) Create(_ context.Context, u *users.User) error { + if _, exists := r.byEmail[u.Email]; exists { + return users.ErrEmailTaken + } + cp := *u + r.byID[u.ID] = &cp + r.byEmail[u.Email] = u.ID + return nil +} + +func (r *fakeRepository) FindByEmail(_ context.Context, email string) (*users.User, error) { + id, ok := r.byEmail[email] + if !ok { + return nil, users.ErrNotFound + } + cp := *r.byID[id] + return &cp, nil +} + +func (r *fakeRepository) FindByID(_ context.Context, id uuid.UUID) (*users.User, error) { + u, ok := r.byID[id] + if !ok { + return nil, users.ErrNotFound + } + cp := *u + return &cp, nil +} + +func (r *fakeRepository) List(_ context.Context) ([]*users.User, error) { + list := make([]*users.User, 0, len(r.byID)) + for _, u := range r.byID { + cp := *u + list = append(list, &cp) + } + return list, nil +} + +func (r *fakeRepository) Update(_ context.Context, u *users.User) error { + existing, ok := r.byID[u.ID] + if !ok { + return users.ErrNotFound + } + if existing.Email != u.Email { + if _, taken := r.byEmail[u.Email]; taken { + return users.ErrEmailTaken + } + delete(r.byEmail, existing.Email) + r.byEmail[u.Email] = u.ID + } + cp := *u + r.byID[u.ID] = &cp + return nil +} + +func (r *fakeRepository) Delete(_ context.Context, id uuid.UUID) error { + u, ok := r.byID[id] + if !ok { + return users.ErrNotFound + } + delete(r.byEmail, u.Email) + delete(r.byID, id) + return nil +} + +func TestService_Create_HashesPasswordAndPersists(t *testing.T) { + svc := users.NewService(newFakeRepository()) + ctx := context.Background() + + user, err := svc.Create(ctx, "admin@example.com", "super-strong-password", users.RoleAdmin) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + if user.PasswordHash == "super-strong-password" { + t.Fatal("Create() stored the plaintext password instead of a hash") + } + ok, err := security.VerifyPassword(user.PasswordHash, "super-strong-password") + if err != nil || !ok { + t.Fatalf("stored password hash does not verify against the original password (ok=%v, err=%v)", ok, err) + } +} + +func TestService_Create_RejectsInvalidRole(t *testing.T) { + svc := users.NewService(newFakeRepository()) + if _, err := svc.Create(context.Background(), "a@example.com", "super-strong-password", "superadmin"); err == nil { + t.Fatal("Create() error = nil, want error for an invalid role") + } +} + +func TestService_Create_DuplicateEmailRejected(t *testing.T) { + svc := users.NewService(newFakeRepository()) + ctx := context.Background() + + if _, err := svc.Create(ctx, "dup@example.com", "super-strong-password", users.RoleAdmin); err != nil { + t.Fatalf("first Create() error = %v", err) + } + _, err := svc.Create(ctx, "dup@example.com", "another-strong-password", users.RoleAdmin) + if !errors.Is(err, users.ErrEmailTaken) { + t.Fatalf("second Create() error = %v, want ErrEmailTaken", err) + } +} + +func TestService_SetPassword_ChangesHash(t *testing.T) { + svc := users.NewService(newFakeRepository()) + ctx := context.Background() + + user, err := svc.Create(ctx, "user@example.com", "first-strong-password", users.RoleAdmin) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + oldHash := user.PasswordHash + + if err := svc.SetPassword(ctx, user.ID, "second-strong-password"); err != nil { + t.Fatalf("SetPassword() error = %v", err) + } + + updated, err := svc.FindByID(ctx, user.ID) + if err != nil { + t.Fatalf("FindByID() error = %v", err) + } + if updated.PasswordHash == oldHash { + t.Fatal("SetPassword() did not change the stored hash") + } + ok, _ := security.VerifyPassword(updated.PasswordHash, "second-strong-password") + if !ok { + t.Fatal("new password does not verify against the updated hash") + } + ok, _ = security.VerifyPassword(updated.PasswordHash, "first-strong-password") + if ok { + t.Fatal("old password still verifies after SetPassword()") + } +} + +func TestService_Delete_RemovesUser(t *testing.T) { + svc := users.NewService(newFakeRepository()) + ctx := context.Background() + + user, err := svc.Create(ctx, "todelete@example.com", "super-strong-password", users.RoleAdmin) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + if err := svc.Delete(ctx, user.ID); err != nil { + t.Fatalf("Delete() error = %v", err) + } + if _, err := svc.FindByID(ctx, user.ID); !errors.Is(err, users.ErrNotFound) { + t.Fatalf("FindByID() after delete error = %v, want ErrNotFound", err) + } +} + +func TestService_Delete_UnknownUserReturnsNotFound(t *testing.T) { + svc := users.NewService(newFakeRepository()) + if err := svc.Delete(context.Background(), uuid.New()); !errors.Is(err, users.ErrNotFound) { + t.Fatalf("Delete() error = %v, want ErrNotFound", err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c84998f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + postgres: + image: postgres:16-alpine + container_name: platform-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_NAME} + ports: + - "${DB_PORT}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: platform-redis + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"] + ports: + - "${REDIS_PORT}:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fca6f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d276a71 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1359 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.3" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.83.0.tgz", + "integrity": "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.83.0.tgz", + "integrity": "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.83.0.tgz", + "integrity": "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.83.0.tgz", + "integrity": "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.83.0.tgz", + "integrity": "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.83.0.tgz", + "integrity": "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.83.0.tgz", + "integrity": "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.83.0.tgz", + "integrity": "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.83.0.tgz", + "integrity": "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.83.0.tgz", + "integrity": "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.83.0.tgz", + "integrity": "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.83.0.tgz", + "integrity": "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.83.0.tgz", + "integrity": "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.83.0.tgz", + "integrity": "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.83.0.tgz", + "integrity": "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.83.0.tgz", + "integrity": "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.83.0.tgz", + "integrity": "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.83.0.tgz", + "integrity": "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.83.0.tgz", + "integrity": "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.83.0.tgz", + "integrity": "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.83.0", + "@oxlint/binding-android-arm64": "1.83.0", + "@oxlint/binding-darwin-arm64": "1.83.0", + "@oxlint/binding-darwin-x64": "1.83.0", + "@oxlint/binding-freebsd-x64": "1.83.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.83.0", + "@oxlint/binding-linux-arm-musleabihf": "1.83.0", + "@oxlint/binding-linux-arm64-gnu": "1.83.0", + "@oxlint/binding-linux-arm64-musl": "1.83.0", + "@oxlint/binding-linux-ppc64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-musl": "1.83.0", + "@oxlint/binding-linux-s390x-gnu": "1.83.0", + "@oxlint/binding-linux-x64-gnu": "1.83.0", + "@oxlint/binding-linux-x64-musl": "1.83.0", + "@oxlint/binding-openharmony-arm64": "1.83.0", + "@oxlint/binding-win32-arm64-msvc": "1.83.0", + "@oxlint/binding-win32-ia32-msvc": "1.83.0", + "@oxlint/binding-win32-x64-msvc": "1.83.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-router": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b4ec72b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.3" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..0d8d04b --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,50 @@ +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "./features/auth/AuthContext"; +import LoginPage from "./features/auth/LoginPage"; +import ProtectedRoute from "./router/ProtectedRoute"; +import AdminLayout from "./features/admin/AdminLayout"; +import DashboardPage from "./features/admin/DashboardPage"; +import SiteSettingsPage from "./features/admin/SiteSettingsPage"; +import UsersPage from "./features/admin/UsersPage"; +import CategoriesPage from "./features/admin/CategoriesPage"; +import UnitsPage from "./features/admin/UnitsPage"; +import ProductsPage from "./features/admin/ProductsPage"; +import ProductEditPage from "./features/admin/ProductEditPage"; +import MediaPage from "./features/admin/MediaPage"; +import OrdersPage from "./features/admin/OrdersPage"; +import OrderDetailPage from "./features/admin/OrderDetailPage"; +import TelegramSettingsPage from "./features/admin/TelegramSettingsPage"; + +export default function App() { + return ( + + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..35a12c0 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,86 @@ +// Central fetch wrapper: the access token is kept in memory only (never +// localStorage/sessionStorage, per the auth design), and a 401 triggers a +// single silent refresh-and-retry via the httpOnly refresh cookie. +let accessToken: string | null = null; + +export function setAccessToken(token: string | null): void { + accessToken = token; +} + +export function getAccessToken(): string | null { + return accessToken; +} + +export class ApiError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + +const NO_RETRY_PATHS = ["/api/auth/admin/refresh", "/api/auth/admin/login"]; + +async function rawFetch(path: string, init: RequestInit): Promise { + const headers = new Headers(init.headers); + if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); + if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + return fetch(path, { ...init, headers, credentials: "include" }); +} + +let refreshInFlight: Promise | null = null; + +async function refreshAccessToken(): Promise { + if (!refreshInFlight) { + refreshInFlight = (async () => { + try { + const res = await fetch("/api/auth/admin/refresh", { + method: "POST", + credentials: "include", + }); + if (!res.ok) return false; + const data = (await res.json()) as { access_token: string }; + setAccessToken(data.access_token); + return true; + } catch { + return false; + } finally { + refreshInFlight = null; + } + })(); + } + return refreshInFlight; +} + +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + let res = await rawFetch(path, init); + + if (res.status === 401 && !NO_RETRY_PATHS.includes(path)) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + res = await rawFetch(path, init); + } + } + + if (!res.ok) { + let message = `Request failed with status ${res.status}`; + try { + const body = (await res.json()) as { error?: string }; + if (body?.error) message = body.error; + } catch { + // response had no JSON body + } + throw new ApiError(message, res.status); + } + + if (res.status === 204) { + return undefined as T; + } + return (await res.json()) as T; +} + +export function apiUpload(path: string, formData: FormData): Promise { + return apiFetch(path, { method: "POST", body: formData }); +} diff --git a/frontend/src/api/reorder.ts b/frontend/src/api/reorder.ts new file mode 100644 index 0000000..d5eb417 --- /dev/null +++ b/frontend/src/api/reorder.ts @@ -0,0 +1,20 @@ +// Shared helper for admin lists that support manual reordering (categories, +// products, ...): swap the position of the item at `index` with its +// neighbor, then persist both via the given per-item PATCH call. +export async function swapPosition( + items: T[], + index: number, + direction: "up" | "down", + patchPosition: (id: string, position: number) => Promise, +): Promise { + const swapIndex = direction === "up" ? index - 1 : index + 1; + if (swapIndex < 0 || swapIndex >= items.length) return; + + const current = items[index]; + const neighbor = items[swapIndex]; + + await Promise.all([ + patchPosition(current.id, neighbor.position), + patchPosition(neighbor.id, current.position), + ]); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..9003d3f --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,100 @@ +export interface User { + id: string; + email: string; + role: string; + is_active?: boolean; +} + +export interface SiteSettings { + name: string; + description: string; + slug: string; +} + +export interface Category { + id: string; + name: string; + slug: string; + description: string; + position: number; + is_active: boolean; +} + +export interface Unit { + id: string; + name: string; + symbol: string; +} + +export interface Media { + id: string; + filename: string; + url: string; + mime_type: string; + size_bytes: number; + alt_text: string; +} + +export interface Product { + id: string; + category_id?: string | null; + name: string; + slug: string; + short_description: string; + description: string; + is_active: boolean; + is_featured: boolean; + primary_media_id?: string | null; + position: number; +} + +export interface PriceTier { + id: string; + product_id: string; + unit_id: string; + quantity: number; + price_cents: number; + position: number; +} + +export interface TelegramSettings { + enabled: boolean; + bot_token_configured: boolean; + chat_id: string; + notify_new_order: boolean; + notify_status_change: boolean; +} + +export interface OrderItem { + product_id?: string; + product_name: string; + unit_symbol: string; + tier_quantity: number; + multiplier: number; + unit_price_cents: number; + total_cents: number; +} + +export interface Order { + id: string; + customer_name: string; + customer_email: string; + customer_phone: string; + status: string; + total_cents: number; + notes: string; + items?: OrderItem[]; +} + +export const ORDER_STATUSES = [ + "pending", + "confirmed", + "preparing", + "shipped", + "completed", + "cancelled", +] as const; + +export function formatCents(cents: number): string { + return (cents / 100).toFixed(2); +} diff --git a/frontend/src/features/admin/AdminLayout.tsx b/frontend/src/features/admin/AdminLayout.tsx new file mode 100644 index 0000000..3c230b8 --- /dev/null +++ b/frontend/src/features/admin/AdminLayout.tsx @@ -0,0 +1,81 @@ +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { useAuth } from "../auth/AuthContext"; + +const NAV_SECTIONS: { title: string; items: { to: string; label: string }[] }[] = [ + { + title: "Site", + items: [ + { to: "/admin", label: "Dashboard" }, + { to: "/admin/site-settings", label: "General" }, + ], + }, + { + title: "Catalog", + items: [ + { to: "/admin/categories", label: "Categories" }, + { to: "/admin/units", label: "Units" }, + { to: "/admin/products", label: "Products" }, + { to: "/admin/media", label: "Media" }, + ], + }, + { + title: "Sales", + items: [{ to: "/admin/orders", label: "Orders" }], + }, + { + title: "Communication", + items: [{ to: "/admin/notifications/telegram", label: "Telegram" }], + }, + { + title: "System", + items: [{ to: "/admin/users", label: "Users" }], + }, +]; + +export default function AdminLayout() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + async function handleLogout() { + await logout(); + navigate("/login", { replace: true }); + } + + return ( +
+ +
+
+ {user?.email} + +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/features/admin/CategoriesPage.tsx b/frontend/src/features/admin/CategoriesPage.tsx new file mode 100644 index 0000000..c6daae2 --- /dev/null +++ b/frontend/src/features/admin/CategoriesPage.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { apiFetch } from "../../api/client"; +import { swapPosition } from "../../api/reorder"; +import type { Category } from "../../api/types"; + +const emptyForm = { name: "", slug: "", description: "", position: 0, is_active: true }; + +export default function CategoriesPage() { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(emptyForm); + const [saving, setSaving] = useState(false); + + function load() { + setLoading(true); + apiFetch<{ categories: Category[] }>("/api/admin/categories") + .then((data) => setCategories(data.categories)) + .catch(() => setError("Failed to load categories.")) + .finally(() => setLoading(false)); + } + + useEffect(load, []); + + function startEdit(cat: Category) { + setEditingId(cat.id); + setForm({ + name: cat.name, + slug: cat.slug, + description: cat.description, + position: cat.position, + is_active: cat.is_active, + }); + } + + function cancelEdit() { + setEditingId(null); + setForm(emptyForm); + } + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setSaving(true); + setError(null); + try { + if (editingId) { + await apiFetch(`/api/admin/categories/${editingId}`, { method: "PUT", body: JSON.stringify(form) }); + } else { + await apiFetch("/api/admin/categories", { method: "POST", body: JSON.stringify(form) }); + } + cancelEdit(); + load(); + } catch { + setError("Failed to save category (slug may already be in use)."); + } finally { + setSaving(false); + } + } + + async function handleDelete(id: string) { + if (!confirm("Delete this category?")) return; + try { + await apiFetch(`/api/admin/categories/${id}`, { method: "DELETE" }); + load(); + } catch { + setError("Failed to delete category (it may still have products attached)."); + } + } + + async function handleMove(index: number, direction: "up" | "down") { + try { + await swapPosition(categories, index, direction, (id, position) => + apiFetch(`/api/admin/categories/${id}/position`, { + method: "PATCH", + body: JSON.stringify({ position }), + }), + ); + load(); + } catch { + setError("Failed to reorder categories."); + } + } + + return ( +
+

Categories

+ {error &&
{error}
} + +
+

{editingId ? "Edit category" : "New category"}

+
+
+
+ + setForm({ ...form, name: e.target.value })} required /> +
+
+ + setForm({ ...form, slug: e.target.value })} required /> +
+
+ + setForm({ ...form, position: Number(e.target.value) })} + /> +
+
+
+ +