first
This commit is contained in:
@@ -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=
|
||||
+19
@@ -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/
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
+168
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS users;
|
||||
@@ -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()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS site_settings;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS categories;
|
||||
@@ -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()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS units;
|
||||
@@ -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()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS media;
|
||||
@@ -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()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS products;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS product_media;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS price_tiers;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS telegram_settings;
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS order_items;
|
||||
DROP TABLE IF EXISTS orders;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE products DROP COLUMN IF EXISTS position;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE products ADD COLUMN position INT NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX idx_products_position ON products(position);
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user