This commit is contained in:
CFOU
2026-09-14 20:50:19 +02:00
commit 3091fb4020
135 changed files with 10262 additions and 0 deletions
+48
View File
@@ -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
View File
@@ -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/
+161
View File
@@ -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)
}
+50
View File
@@ -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)
}
+73
View File
@@ -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
View File
@@ -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)
}
+21
View File
@@ -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
}
+118
View File
@@ -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
}
+123
View File
@@ -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
}
+126
View File
@@ -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)
}
+24
View File
@@ -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
}
+11
View File
@@ -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)
}
+99
View File
@@ -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)
}
+19
View File
@@ -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
}
+185
View File
@@ -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))
}
+63
View File
@@ -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
}
+17
View File
@@ -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)
}
+171
View File
@@ -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
}
+128
View File
@@ -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)
}
+26
View File
@@ -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)
}
+62
View File
@@ -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))
}
+19
View File
@@ -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
}
+9
View File
@@ -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)
}
+23
View File
@@ -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)
}
}
+109
View File
@@ -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)
}
+19
View File
@@ -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"
}
+11
View File
@@ -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)
}
+48
View File
@@ -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)
}
+134
View File
@@ -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)
}
+27
View File
@@ -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"
}
+14
View File
@@ -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)
}
+83
View File
@@ -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)
}
+166
View File
@@ -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
}
+34
View File
@@ -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()
}
}
+28
View File
@@ -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
}
+104
View File
@@ -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);
+217
View File
@@ -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")
}
}
+244
View File
@@ -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))
}
}
+142
View File
@@ -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)
}
}
+150
View File
@@ -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")
}
}
+71
View File
@@ -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")
}
}
+62
View File
@@ -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)
}
}
+157
View File
@@ -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