diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..d31ec6a --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,9 @@ +bin/ +tmp/ +uploads/ +verification-uploads/ +*.log +test/ +.git/ +.env +.env.local diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1c37362 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1 + +# --- Build stage ------------------------------------------------------- +# golang:1.26 matches the "go 1.26.0" directive in go.mod (GOTOOLCHAIN=auto +# would otherwise re-download the right toolchain anyway, but pinning here +# keeps builds hermetic and fast). +FROM golang:1.26-alpine AS builder + +WORKDIR /src + +# Cache deps in their own layer. +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +COPY . . + +# CGO disabled -> static binary, runs on the scratch-ish alpine base below +# with no libc surprises. Both entrypoints are built from the same module: +# cmd/api is the long-running server, cmd/seed is the one-shot admin +# bootstrap job (see charts/backend/templates/seed-job.yaml). +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api && \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/seed ./cmd/seed + +# --- Runtime stage ------------------------------------------------------- +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata && \ + addgroup -S app && adduser -S app -G app + +WORKDIR /app + +COPY --from=builder /out/api /out/seed ./ +# Migrations are baked into the image so the migration Job (see +# charts/backend/templates/migration-job.yaml) always runs the set that +# matches the running server, without needing a separate artifact. +COPY --from=builder /src/migrations ./migrations + +# Local-disk media storage (MEDIA_STORAGE_DRIVER=local) writes here; the +# chart mounts a PVC at this path when persistence is enabled. Owned by the +# non-root "app" user created above. +RUN mkdir -p /app/uploads /app/verification-uploads && \ + chown -R app:app /app + +USER app +EXPOSE 8080 + +ENTRYPOINT ["/app/api"] diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index ef6d918..31fe8c3 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -9,6 +9,8 @@ import ( "backend/internal/modules/auth" "backend/internal/modules/categories" + "backend/internal/modules/contactlinks" + "backend/internal/modules/customerverification" "backend/internal/modules/media" "backend/internal/modules/orders" "backend/internal/modules/pricing" @@ -43,9 +45,15 @@ func main() { log.Fatalf("connect redis: %v", err) } + // --- Site module (wired first: users.Service needs it as its + // customer-accounts gate) --- + siteRepo := site.NewRepository(database) + siteService := site.NewService(siteRepo) + siteHandler := site.NewHandler(siteService) + // --- Users module --- usersRepo := users.NewRepository(database) - usersService := users.NewService(usersRepo) + usersService := users.NewService(usersRepo, siteService, cfg.Seed) usersHandler := users.NewHandler(usersService) // --- Auth module (admin space mounted; customer space wired but not mounted) --- @@ -53,19 +61,19 @@ func main() { 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) + // Customer auth space: registration/login are gated behind + // site.Settings.CustomerAccountsEnabled (see auth.CustomerHandler). + customerAuthHandler := auth.NewCustomerHandler(authService, usersService, siteService, cfg.Server.CookieSecure) - // --- Media module (pluggable local/S3 storage) --- - mediaStorage, err := buildMediaStorage(cfg.Media) + // --- Customer identity verification module (spec: age/ID-restricted + // goods gate). Documents are stored privately, never through media/. --- + verificationStorage, err := customerverification.NewStorage(cfg.Verification.UploadDir) if err != nil { - log.Fatalf("init media storage: %v", err) + log.Fatalf("init verification storage: %v", err) } - mediaRepo := media.NewRepository(database) - mediaService := media.NewService(mediaRepo, mediaStorage, cfg.Media.MaxUploadMB*1024*1024) - mediaHandler := media.NewHandler(mediaService) + verificationRepo := customerverification.NewRepository(database) + verificationService := customerverification.NewService(verificationRepo, verificationStorage) + verificationHandler := customerverification.NewHandler(verificationService) // --- Categories module --- categoriesRepo := categories.NewRepository(database) @@ -79,14 +87,32 @@ func main() { // --- Products module --- productsRepo := products.NewRepository(database) - productsService := products.NewService(productsRepo) + productsService := products.NewService(productsRepo, categoriesRepo) productsHandler := products.NewHandler(productsService) + // --- Media module (pluggable local/S3 storage). Wired after products: + // each media file is isolated to the single product it was uploaded + // for (or to none, e.g. contact link icons) -- Upload validates + // product_id via productsService, there is no shared media library. --- + mediaStorage, err := buildMediaStorage(cfg.Media) + if err != nil { + log.Fatalf("init media storage: %v", err) + } + mediaRepo := media.NewRepository(database) + mediaService := media.NewService(mediaRepo, mediaStorage, productsService, categoriesRepo, cfg.Media.MaxUploadMB*1024*1024) + mediaHandler := media.NewHandler(mediaService) + // --- Pricing module (quantity-based tiers, spec section 16) --- pricingRepo := pricing.NewRepository(database) pricingService := pricing.NewService(pricingRepo) pricingHandler := pricing.NewHandler(pricingService) + // --- Contact links module (admin-defined list of communication apps + // shown to customers on the storefront: WhatsApp, Telegram, Signal, ...) --- + contactLinksRepo := contactlinks.NewRepository(database) + contactLinksService := contactlinks.NewService(contactLinksRepo) + contactLinksHandler := contactlinks.NewHandler(contactLinksService) + // --- Telegram module (notifications, spec section 20) --- telegramRepo := telegram.NewRepository(database) telegramService := telegram.NewService(telegramRepo, telegram.NewHTTPSender(), appLogger) @@ -99,9 +125,14 @@ func main() { ordersHandler := orders.NewHandler(ordersService) requireAdmin := appmiddleware.RequireAdmin(cfg.JWT.Secret) + requireCustomer := appmiddleware.RequireCustomer(cfg.JWT.Secret) loginRateLimit := appmiddleware.NewPerIPRateLimiter(5, 5).Middleware() orderRateLimit := appmiddleware.NewPerIPRateLimiter(10, 10).Middleware() + requireOrdersEnabled := orders.RequireOrdersEnabled(siteService) + requireAccountsEnabled := orders.RequireAccountsEnabled(siteService) + requireVerifiedIfNeeded := orders.RequireApprovedVerificationIfNeeded(siteService, verificationService) + gin.SetMode(cfg.Server.GinMode) router := gin.New() router.Use(gin.Recovery()) @@ -119,14 +150,21 @@ func main() { api := router.Group("/api") auth.RegisterAdminRoutes(api, adminAuthHandler, requireAdmin, loginRateLimit) + auth.RegisterCustomerRoutes(api, customerAuthHandler, requireCustomer, loginRateLimit) users.RegisterAdminRoutes(api, usersHandler, requireAdmin) site.RegisterRoutes(api, siteHandler, requireAdmin) + site.RegisterPublicRoutes(api, siteHandler) media.RegisterAdminRoutes(api, mediaHandler, requireAdmin) + media.RegisterPublicRoutes(api, mediaHandler) categories.RegisterAdminRoutes(api, categoriesHandler, requireAdmin) categories.RegisterPublicRoutes(api, categoriesHandler) + contactlinks.RegisterAdminRoutes(api, contactLinksHandler, requireAdmin) + contactlinks.RegisterPublicRoutes(api, contactLinksHandler) + units.RegisterAdminRoutes(api, unitsHandler, requireAdmin) + units.RegisterPublicRoutes(api, unitsHandler) products.RegisterAdminRoutes(api, productsHandler, requireAdmin) products.RegisterPublicRoutes(api, productsHandler) @@ -136,7 +174,11 @@ func main() { telegram.RegisterAdminRoutes(api, telegramHandler, requireAdmin) - orders.RegisterPublicRoutes(api, ordersHandler, orderRateLimit) + customerverification.RegisterCustomerRoutes(api, verificationHandler, requireCustomer) + customerverification.RegisterAdminRoutes(api, verificationHandler, requireAdmin) + + orders.RegisterPublicRoutes(api, ordersHandler, orderRateLimit, requireOrdersEnabled, requireAccountsEnabled, requireCustomer, requireVerifiedIfNeeded) + orders.RegisterCustomerRoutes(api, ordersHandler, requireCustomer) orders.RegisterAdminRoutes(api, ordersHandler, requireAdmin) appLogger.Info("starting server", "port", cfg.Server.Port) diff --git a/backend/cmd/api/uploads/0bcfd539-1b4a-4477-a99f-ca371236b165.png b/backend/cmd/api/uploads/0bcfd539-1b4a-4477-a99f-ca371236b165.png new file mode 100644 index 0000000..07e0468 Binary files /dev/null and b/backend/cmd/api/uploads/0bcfd539-1b4a-4477-a99f-ca371236b165.png differ diff --git a/backend/cmd/api/uploads/25ada0ce-c9e3-4c3d-9f96-f614dc6871bd.png b/backend/cmd/api/uploads/25ada0ce-c9e3-4c3d-9f96-f614dc6871bd.png new file mode 100644 index 0000000..e25c5e5 Binary files /dev/null and b/backend/cmd/api/uploads/25ada0ce-c9e3-4c3d-9f96-f614dc6871bd.png differ diff --git a/backend/cmd/api/uploads/a7c7f702-5dfb-49bf-a321-0f00b3eb57cd.png b/backend/cmd/api/uploads/a7c7f702-5dfb-49bf-a321-0f00b3eb57cd.png new file mode 100644 index 0000000..1618d55 Binary files /dev/null and b/backend/cmd/api/uploads/a7c7f702-5dfb-49bf-a321-0f00b3eb57cd.png differ diff --git a/backend/cmd/api/uploads/cb219367-8b3c-4324-886b-f778f4acca68.png b/backend/cmd/api/uploads/cb219367-8b3c-4324-886b-f778f4acca68.png new file mode 100644 index 0000000..d978e95 Binary files /dev/null and b/backend/cmd/api/uploads/cb219367-8b3c-4324-886b-f778f4acca68.png differ diff --git a/backend/cmd/api/uploads/ddb9661f-a29e-43f1-a183-879f8ec59c74.png b/backend/cmd/api/uploads/ddb9661f-a29e-43f1-a183-879f8ec59c74.png new file mode 100644 index 0000000..1618d55 Binary files /dev/null and b/backend/cmd/api/uploads/ddb9661f-a29e-43f1-a183-879f8ec59c74.png differ diff --git a/backend/cmd/api/uploads/e59e1838-1f07-4c80-bee3-edd954d69f37.png b/backend/cmd/api/uploads/e59e1838-1f07-4c80-bee3-edd954d69f37.png new file mode 100644 index 0000000..1618d55 Binary files /dev/null and b/backend/cmd/api/uploads/e59e1838-1f07-4c80-bee3-edd954d69f37.png differ diff --git a/backend/cmd/api/uploads/fb2c2ebf-de0b-4b7e-b423-dd9946292e47.png b/backend/cmd/api/uploads/fb2c2ebf-de0b-4b7e-b423-dd9946292e47.png new file mode 100644 index 0000000..dccc4d1 Binary files /dev/null and b/backend/cmd/api/uploads/fb2c2ebf-de0b-4b7e-b423-dd9946292e47.png differ diff --git a/backend/cmd/api/uploads/fde77c0f-4ca4-453c-93a3-8db37bcd29fe.png b/backend/cmd/api/uploads/fde77c0f-4ca4-453c-93a3-8db37bcd29fe.png new file mode 100644 index 0000000..a1c08c0 Binary files /dev/null and b/backend/cmd/api/uploads/fde77c0f-4ca4-453c-93a3-8db37bcd29fe.png differ diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go index 6c058a2..9665a2e 100644 --- a/backend/cmd/seed/main.go +++ b/backend/cmd/seed/main.go @@ -12,14 +12,23 @@ import ( "backend/internal/platform/db" ) +// alwaysAvailableGate satisfies users.AccountsGate for the seed CLI, which +// only ever creates the initial admin account (never a customer), so the +// gate is never actually consulted. +type alwaysAvailableGate struct{} + +func (alwaysAvailableGate) CustomerAccountsAvailable(context.Context) (bool, error) { + return true, nil +} + 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 cfg.Seed.AdminUsername == "" || cfg.Seed.AdminPassword == "" { + log.Fatal("SEED_ADMIN_USERNAME and SEED_ADMIN_PASSWORD must be set") } if len(cfg.Seed.AdminPassword) < 12 { log.Fatal("SEED_ADMIN_PASSWORD must be at least 12 characters") @@ -31,20 +40,20 @@ func main() { } repo := users.NewRepository(database) - service := users.NewService(repo) + service := users.NewService(repo, alwaysAvailableGate{}, cfg.Seed) 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) + if existing, err := repo.FindByUsername(ctx, cfg.Seed.AdminUsername); err == nil && existing != nil { + log.Fatalf("an account with username %q already exists (id=%s)", cfg.Seed.AdminUsername, 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) + admin, err := service.Create(ctx, cfg.Seed.AdminUsername, 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) + log.Printf("admin account created: username=%s id=%s", admin.Username, admin.ID) } diff --git a/backend/internal/modules/auth/admin_handler.go b/backend/internal/modules/auth/admin_handler.go index ecaf1f0..244272a 100644 --- a/backend/internal/modules/auth/admin_handler.go +++ b/backend/internal/modules/auth/admin_handler.go @@ -31,14 +31,14 @@ func NewAdminHandler(service *Service, cookieSecure bool) *AdminHandler { } type loginRequest struct { - Email string `json:"email" binding:"required,email"` + Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` } type authUserResponse struct { - ID uuid.UUID `json:"id"` - Email string `json:"email"` - Role string `json:"role"` + ID uuid.UUID `json:"id"` + Username string `json:"username"` + Role string `json:"role"` } type accessTokenResponse struct { @@ -53,7 +53,7 @@ func (h *AdminHandler) Login(c *gin.Context) { return } - pair, user, err := h.service.Login(c.Request.Context(), security.AudienceAdmin, users.RoleAdmin, req.Email, req.Password) + pair, user, err := h.service.Login(c.Request.Context(), security.AudienceAdmin, users.RoleAdmin, req.Username, req.Password) if err != nil { h.respondLoginError(c, err) return @@ -62,7 +62,7 @@ func (h *AdminHandler) Login(c *gin.Context) { h.setRefreshCookie(c, pair.RefreshToken) c.JSON(http.StatusOK, accessTokenResponse{ AccessToken: pair.AccessToken, - User: &authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role}, + User: &authUserResponse{ID: user.ID, Username: user.Username, Role: user.Role}, }) } @@ -107,7 +107,7 @@ func (h *AdminHandler) Me(c *gin.Context) { 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}) + c.JSON(http.StatusOK, authUserResponse{ID: user.ID, Username: user.Username, Role: user.Role}) } func (h *AdminHandler) respondLoginError(c *gin.Context, err error) { diff --git a/backend/internal/modules/auth/customer_handler.go b/backend/internal/modules/auth/customer_handler.go index df217e8..c80a767 100644 --- a/backend/internal/modules/auth/customer_handler.go +++ b/backend/internal/modules/auth/customer_handler.go @@ -1,6 +1,7 @@ package auth import ( + "context" "errors" "net/http" @@ -18,28 +19,123 @@ const ( customerRefreshCookiePath = "/api/auth/customer" ) +// UserCreator is the minimal slice of users.Service that customer +// registration needs (create-only, consumer-defined per the project's Go +// interface convention). +type UserCreator interface { + Create(ctx context.Context, username, password, role string) (*users.User, error) +} + +// AccountsGate reports whether the admin has turned on the customer login +// page and the customer registration page. CustomerRegistrationEnabled is a +// sub-toggle of CustomerLoginEnabled (site.Settings.CustomerLoginEnabled / +// CustomerRegistrationEnabled): an admin can allow login for already-existing +// accounts while keeping self-service registration closed, but turning login +// off always closes registration too, since a newly registered account is +// signed in immediately. +type AccountsGate interface { + CustomerLoginEnabled(ctx context.Context) (bool, error) + CustomerRegistrationEnabled(ctx context.Context) (bool, error) +} + // 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. +// customer-audience tokens, and additionally gates on AccountsGate since +// this space, unlike the admin one, can be turned off entirely by the +// site's admin. type CustomerHandler struct { service *Service + users UserCreator + gate AccountsGate cookieSecure bool } -func NewCustomerHandler(service *Service, cookieSecure bool) *CustomerHandler { - return &CustomerHandler{service: service, cookieSecure: cookieSecure} +func NewCustomerHandler(service *Service, users UserCreator, gate AccountsGate, cookieSecure bool) *CustomerHandler { + return &CustomerHandler{service: service, users: users, gate: gate, cookieSecure: cookieSecure} +} + +func (h *CustomerHandler) loginEnabledOrAbort(c *gin.Context) bool { + enabled, err := h.gate.CustomerLoginEnabled(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check customer login setting"}) + return false + } + if !enabled { + c.JSON(http.StatusForbidden, gin.H{"error": "customer login is currently disabled"}) + return false + } + return true +} + +func (h *CustomerHandler) registrationEnabledOrAbort(c *gin.Context) bool { + enabled, err := h.gate.CustomerRegistrationEnabled(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check customer registration setting"}) + return false + } + if !enabled { + c.JSON(http.StatusForbidden, gin.H{"error": "customer registration is currently disabled"}) + return false + } + return true +} + +type registerRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required,min=8"` +} + +func (h *CustomerHandler) Register(c *gin.Context) { + // Registration is a sub-toggle of login: creating an account is pointless + // (and issuePair below would hand out a session) if the admin has turned + // customer login off, so both gates must pass. + if !h.loginEnabledOrAbort(c) { + return + } + if !h.registrationEnabledOrAbort(c) { + return + } + + var req registerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + + user, err := h.users.Create(c.Request.Context(), req.Username, req.Password, users.RoleCustomer) + if err != nil { + if errors.Is(err, users.ErrUsernameTaken) { + c.JSON(http.StatusConflict, gin.H{"error": "username already taken"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create account"}) + return + } + + pair, err := h.service.issuePair(c.Request.Context(), security.AudienceCustomer, user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "account created but failed to sign in"}) + return + } + + h.setRefreshCookie(c, pair.RefreshToken) + c.JSON(http.StatusCreated, accessTokenResponse{ + AccessToken: pair.AccessToken, + User: &authUserResponse{ID: user.ID, Username: user.Username, Role: user.Role}, + }) } func (h *CustomerHandler) Login(c *gin.Context) { + if !h.loginEnabledOrAbort(c) { + return + } + 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) + pair, user, err := h.service.Login(c.Request.Context(), security.AudienceCustomer, users.RoleCustomer, req.Username, req.Password) if err != nil { switch { case errors.Is(err, ErrInvalidCredentials), errors.Is(err, ErrAccountDisabled): @@ -53,7 +149,7 @@ func (h *CustomerHandler) Login(c *gin.Context) { h.setRefreshCookie(c, pair.RefreshToken) c.JSON(http.StatusOK, accessTokenResponse{ AccessToken: pair.AccessToken, - User: &authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role}, + User: &authUserResponse{ID: user.ID, Username: user.Username, Role: user.Role}, }) } @@ -98,7 +194,7 @@ func (h *CustomerHandler) Me(c *gin.Context) { 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}) + c.JSON(http.StatusOK, authUserResponse{ID: user.ID, Username: user.Username, Role: user.Role}) } func (h *CustomerHandler) setRefreshCookie(c *gin.Context, token string) { diff --git a/backend/internal/modules/auth/customer_routes.go b/backend/internal/modules/auth/customer_routes.go index 166303f..6499b15 100644 --- a/backend/internal/modules/auth/customer_routes.go +++ b/backend/internal/modules/auth/customer_routes.go @@ -2,12 +2,14 @@ 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. +// RegisterCustomerRoutes mounts the customer-space auth endpoints. Always +// mounted (unlike earlier phases), but Register/Login reject every request +// with 403 while the admin has customer accounts turned off +// (CustomerHandler.accountsEnabledOrAbort), so the surface is a no-op until +// then. func RegisterCustomerRoutes(rg *gin.RouterGroup, h *CustomerHandler, requireCustomer gin.HandlerFunc, loginRateLimit gin.HandlerFunc) { group := rg.Group("/auth/customer") + group.POST("/register", loginRateLimit, h.Register) group.POST("/login", loginRateLimit, h.Login) group.POST("/refresh", h.Refresh) group.POST("/logout", h.Logout) diff --git a/backend/internal/modules/auth/service.go b/backend/internal/modules/auth/service.go index 8585e18..cfb26b0 100644 --- a/backend/internal/modules/auth/service.go +++ b/backend/internal/modules/auth/service.go @@ -16,7 +16,7 @@ import ( // 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) + FindByUsername(ctx context.Context, username string) (*users.User, error) FindByID(ctx context.Context, id uuid.UUID) (*users.User, error) } @@ -43,8 +43,8 @@ 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) +func (s *Service) Login(ctx context.Context, aud security.Audience, expectedRole, username, password string) (*TokenPair, *users.User, error) { + user, err := s.users.FindByUsername(ctx, username) if err != nil { if errors.Is(err, users.ErrNotFound) { return nil, nil, ErrInvalidCredentials diff --git a/backend/internal/modules/categories/handler.go b/backend/internal/modules/categories/handler.go index 3a51377..ac4df0d 100644 --- a/backend/internal/modules/categories/handler.go +++ b/backend/internal/modules/categories/handler.go @@ -17,22 +17,22 @@ func NewHandler(service *Service) *Handler { } 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"` + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Position int `json:"position"` + IsActive bool `json:"is_active"` + MediaID *uuid.UUID `json:"media_id"` } 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, + MediaID: cat.MediaID, } } @@ -65,11 +65,11 @@ func toResponseList(list []*Category) []categoryResponse { } 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"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Position int `json:"position"` + IsActive bool `json:"is_active"` + MediaID *uuid.UUID `json:"media_id"` } func (h *Handler) Create(c *gin.Context) { @@ -78,12 +78,8 @@ func (h *Handler) Create(c *gin.Context) { 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) + cat, err := h.service.Create(c.Request.Context(), req.Name, req.Description, req.Position, req.IsActive, req.MediaID) 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 } @@ -101,13 +97,11 @@ func (h *Handler) Update(c *gin.Context) { 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) + cat, err := h.service.Update(c.Request.Context(), id, req.Name, req.Description, req.Position, req.IsActive, req.MediaID) 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"}) } diff --git a/backend/internal/modules/categories/model.go b/backend/internal/modules/categories/model.go index dbbfdba..902b69f 100644 --- a/backend/internal/modules/categories/model.go +++ b/backend/internal/modules/categories/model.go @@ -1,5 +1,5 @@ // Package categories lets the admin organize products into their own -// categories (name, slug, description, ordering) without touching code. +// categories (name, description, ordering) without touching code. package categories import ( @@ -9,12 +9,12 @@ import ( ) 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"` + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + Name string `gorm:"not null"` + Description string `gorm:"not null;default:''"` + Position int `gorm:"not null;default:0"` + IsActive bool `gorm:"not null;default:true"` + MediaID *uuid.UUID `gorm:"type:uuid"` CreatedAt time.Time UpdatedAt time.Time } diff --git a/backend/internal/modules/categories/repository.go b/backend/internal/modules/categories/repository.go index d626267..70823bc 100644 --- a/backend/internal/modules/categories/repository.go +++ b/backend/internal/modules/categories/repository.go @@ -10,9 +10,8 @@ import ( ) var ( - ErrNotFound = errors.New("category not found") - ErrSlugTaken = errors.New("slug already in use") - ErrInUse = errors.New("category is referenced by existing products") + ErrNotFound = errors.New("category not found") + ErrInUse = errors.New("category is referenced by existing products") ) type Repository interface { @@ -32,13 +31,7 @@ func NewRepository(db *gorm.DB) Repository { } 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 + return r.db.WithContext(ctx).Create(cat).Error } func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Category, error) { @@ -66,11 +59,7 @@ func (r *gormRepository) List(ctx context.Context, activeOnly bool) ([]*Category } 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 + return r.db.WithContext(ctx).Save(cat).Error } func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error { @@ -87,11 +76,6 @@ func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error { return nil } -func isUniqueViolation(err error) bool { - var pgErr *pgconn.PgError - return errors.As(err, &pgErr) && pgErr.Code == "23505" -} - func isForeignKeyViolation(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == "23503" diff --git a/backend/internal/modules/categories/service.go b/backend/internal/modules/categories/service.go index eddeb73..330c743 100644 --- a/backend/internal/modules/categories/service.go +++ b/backend/internal/modules/categories/service.go @@ -22,14 +22,14 @@ 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) { +func (s *Service) Create(ctx context.Context, name, description string, position int, isActive bool, mediaID *uuid.UUID) (*Category, error) { cat := &Category{ ID: uuid.New(), Name: name, - Slug: slug, Description: description, Position: position, IsActive: isActive, + MediaID: mediaID, } if err := s.repo.Create(ctx, cat); err != nil { return nil, err @@ -37,16 +37,16 @@ func (s *Service) Create(ctx context.Context, name, slug, description string, po return cat, nil } -func (s *Service) Update(ctx context.Context, id uuid.UUID, name, slug, description string, position int, isActive bool) (*Category, error) { +func (s *Service) Update(ctx context.Context, id uuid.UUID, name, description string, position int, isActive bool, mediaID *uuid.UUID) (*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 + cat.MediaID = mediaID if err := s.repo.Update(ctx, cat); err != nil { return nil, err } diff --git a/backend/internal/modules/contactlinks/handler.go b/backend/internal/modules/contactlinks/handler.go new file mode 100644 index 0000000..30f415f --- /dev/null +++ b/backend/internal/modules/contactlinks/handler.go @@ -0,0 +1,163 @@ +package contactlinks + +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 contactLinkResponse struct { + ID uuid.UUID `json:"id"` + Label string `json:"label"` + URL string `json:"url"` + IconKey string `json:"icon_key"` + IconMediaID *uuid.UUID `json:"icon_media_id"` + Color string `json:"color"` + Position int `json:"position"` + IsActive bool `json:"is_active"` +} + +func toResponse(link *ContactLink) contactLinkResponse { + return contactLinkResponse{ + ID: link.ID, + Label: link.Label, + URL: link.URL, + IconKey: link.IconKey, + IconMediaID: link.IconMediaID, + Color: link.Color, + Position: link.Position, + IsActive: link.IsActive, + } +} + +func toResponseList(list []*ContactLink) []contactLinkResponse { + resp := make([]contactLinkResponse, 0, len(list)) + for _, link := range list { + resp = append(resp, toResponse(link)) + } + return resp +} + +// ListAdmin returns every contact link (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 contact links"}) + return + } + c.JSON(http.StatusOK, gin.H{"contact_links": toResponseList(list)}) +} + +// ListPublic returns only active contact links, for the storefront to display. +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 contact links"}) + return + } + c.JSON(http.StatusOK, gin.H{"contact_links": toResponseList(list)}) +} + +type upsertRequest struct { + Label string `json:"label" binding:"required"` + URL string `json:"url" binding:"required"` + IconKey string `json:"icon_key"` + IconMediaID *uuid.UUID `json:"icon_media_id"` + Color string `json:"color"` + 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 + } + link, err := h.service.Create(c.Request.Context(), req.Label, req.URL, req.IconKey, req.IconMediaID, req.Color, req.Position, req.IsActive) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create contact link"}) + return + } + c.JSON(http.StatusCreated, toResponse(link)) +} + +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 + } + link, err := h.service.Update(c.Request.Context(), id, req.Label, req.URL, req.IconKey, req.IconMediaID, req.Color, req.Position, req.IsActive) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "contact link not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update contact link"}) + return + } + c.JSON(http.StatusOK, toResponse(link)) +} + +type updatePositionRequest struct { + Position int `json:"position"` +} + +// UpdatePosition lets the admin reorder the contact link display order +// (e.g. move up/move down in the admin list) without resending the whole +// contact link 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 + } + link, 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": "contact link not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update position"}) + return + } + c.JSON(http.StatusOK, toResponse(link)) +} + +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": "contact link not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete contact link"}) + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/modules/contactlinks/model.go b/backend/internal/modules/contactlinks/model.go new file mode 100644 index 0000000..e25add0 --- /dev/null +++ b/backend/internal/modules/contactlinks/model.go @@ -0,0 +1,29 @@ +// Package contactlinks lets the admin publish an arbitrary list of +// communication channels (WhatsApp, Telegram, Signal, Discord, email, ...) +// for customers to reach the shop through. Each entry is a label, a URL, +// and its own style: either a built-in brand icon (IconKey, matched against +// the frontend's curated icon set) or a custom image picked from the media +// library (IconMediaID), plus a color -- so no per-app integration is +// needed to add a new one. +package contactlinks + +import ( + "time" + + "github.com/google/uuid" +) + +type ContactLink struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + Label string `gorm:"not null"` + URL string `gorm:"not null"` + IconKey string `gorm:"not null;default:''"` + IconMediaID *uuid.UUID `gorm:"type:uuid"` + Color 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 (ContactLink) TableName() string { return "contact_links" } diff --git a/backend/internal/modules/contactlinks/repository.go b/backend/internal/modules/contactlinks/repository.go new file mode 100644 index 0000000..de45075 --- /dev/null +++ b/backend/internal/modules/contactlinks/repository.go @@ -0,0 +1,70 @@ +package contactlinks + +import ( + "context" + "errors" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +var ErrNotFound = errors.New("contact link not found") + +type Repository interface { + Create(ctx context.Context, link *ContactLink) error + FindByID(ctx context.Context, id uuid.UUID) (*ContactLink, error) + List(ctx context.Context, activeOnly bool) ([]*ContactLink, error) + Update(ctx context.Context, link *ContactLink) 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, link *ContactLink) error { + return r.db.WithContext(ctx).Create(link).Error +} + +func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*ContactLink, error) { + var link ContactLink + err := r.db.WithContext(ctx).Where("id = ?", id).First(&link).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &link, nil +} + +func (r *gormRepository) List(ctx context.Context, activeOnly bool) ([]*ContactLink, error) { + q := r.db.WithContext(ctx).Order("position asc, label asc") + if activeOnly { + q = q.Where("is_active = ?", true) + } + var list []*ContactLink + if err := q.Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func (r *gormRepository) Update(ctx context.Context, link *ContactLink) error { + return r.db.WithContext(ctx).Save(link).Error +} + +func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error { + res := r.db.WithContext(ctx).Delete(&ContactLink{}, "id = ?", id) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrNotFound + } + return nil +} diff --git a/backend/internal/modules/contactlinks/routes.go b/backend/internal/modules/contactlinks/routes.go new file mode 100644 index 0000000..39f68cb --- /dev/null +++ b/backend/internal/modules/contactlinks/routes.go @@ -0,0 +1,18 @@ +package contactlinks + +import "github.com/gin-gonic/gin" + +func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) { + group := rg.Group("/admin/contact-links", 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 contact link list +// used by the storefront to let customers reach the shop. +func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) { + rg.GET("/contact-links", h.ListPublic) +} diff --git a/backend/internal/modules/contactlinks/service.go b/backend/internal/modules/contactlinks/service.go new file mode 100644 index 0000000..95aac0c --- /dev/null +++ b/backend/internal/modules/contactlinks/service.go @@ -0,0 +1,76 @@ +package contactlinks + +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) ([]*ContactLink, error) { + return s.repo.List(ctx, activeOnly) +} + +func (s *Service) Get(ctx context.Context, id uuid.UUID) (*ContactLink, error) { + return s.repo.FindByID(ctx, id) +} + +func (s *Service) Create(ctx context.Context, label, url, iconKey string, iconMediaID *uuid.UUID, color string, position int, isActive bool) (*ContactLink, error) { + link := &ContactLink{ + ID: uuid.New(), + Label: label, + URL: url, + IconKey: iconKey, + IconMediaID: iconMediaID, + Color: color, + Position: position, + IsActive: isActive, + } + if err := s.repo.Create(ctx, link); err != nil { + return nil, err + } + return link, nil +} + +func (s *Service) Update(ctx context.Context, id uuid.UUID, label, url, iconKey string, iconMediaID *uuid.UUID, color string, position int, isActive bool) (*ContactLink, error) { + link, err := s.repo.FindByID(ctx, id) + if err != nil { + return nil, err + } + link.Label = label + link.URL = url + link.IconKey = iconKey + link.IconMediaID = iconMediaID + link.Color = color + link.Position = position + link.IsActive = isActive + if err := s.repo.Update(ctx, link); err != nil { + return nil, err + } + return link, nil +} + +func (s *Service) Delete(ctx context.Context, id uuid.UUID) error { + return s.repo.Delete(ctx, id) +} + +// UpdatePosition lets the admin reorder the displayed list without +// resending the full contact link payload. +func (s *Service) UpdatePosition(ctx context.Context, id uuid.UUID, position int) (*ContactLink, error) { + link, err := s.repo.FindByID(ctx, id) + if err != nil { + return nil, err + } + link.Position = position + if err := s.repo.Update(ctx, link); err != nil { + return nil, err + } + return link, nil +} diff --git a/backend/internal/modules/customerverification/handler.go b/backend/internal/modules/customerverification/handler.go new file mode 100644 index 0000000..57b4c9d --- /dev/null +++ b/backend/internal/modules/customerverification/handler.go @@ -0,0 +1,212 @@ +package customerverification + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "backend/internal/platform/middleware" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +type verificationResponse struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Status string `json:"status"` + AdminNote string `json:"admin_note"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toResponse(v *CustomerVerification) verificationResponse { + return verificationResponse{ + ID: v.ID, + UserID: v.UserID, + Status: v.Status, + AdminNote: v.AdminNote, + CreatedAt: v.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: v.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } +} + +// Submit lets a logged-in customer upload their ID document (front + back) +// for review. Whether this step is required at all is decided by the admin +// (site.Settings.CustomerVerificationRequired) and enforced at checkout, +// not here -- a customer may always submit early. +func (h *Handler) Submit(c *gin.Context) { + userID, ok := middleware.GetUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + frontFile, frontHeader, err := c.Request.FormFile("front") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing 'front' file"}) + return + } + defer frontFile.Close() + backFile, backHeader, err := c.Request.FormFile("back") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing 'back' file"}) + return + } + defer backFile.Close() + + v, err := h.service.Submit(c.Request.Context(), userID, + DocumentInput{Reader: frontFile, ContentType: frontHeader.Header.Get("Content-Type")}, + DocumentInput{Reader: backFile, ContentType: backHeader.Header.Get("Content-Type")}, + ) + if err != nil { + if errors.Is(err, ErrUnsupportedType) { + c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "documents must be JPEG or PNG images"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to submit verification"}) + return + } + c.JSON(http.StatusCreated, toResponse(v)) +} + +// Me returns the caller's own verification status, or 404 if they haven't +// submitted anything yet (not an error -- the storefront treats this as +// "not started"). +func (h *Handler) Me(c *gin.Context) { + userID, ok := middleware.GetUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + v, err := h.service.GetByUser(c.Request.Context(), userID) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "no verification submitted"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load verification"}) + return + } + c.JSON(http.StatusOK, toResponse(v)) +} + +// MyDocument streams the caller's own submitted document back to them +// (e.g. so the storefront can show what was uploaded). +func (h *Handler) MyDocument(c *gin.Context) { + userID, ok := middleware.GetUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + v, err := h.service.GetByUser(c.Request.Context(), userID) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "no verification submitted"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load verification"}) + return + } + h.serveDocument(c, v) +} + +// ListAdmin lists submissions, optionally filtered by ?status=pending. +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 verifications"}) + return + } + resp := make([]verificationResponse, 0, len(list)) + for _, v := range list { + resp = append(resp, toResponse(v)) + } + c.JSON(http.StatusOK, gin.H{"verifications": 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 + } + v, err := h.service.Get(c.Request.Context(), id) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "verification not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load verification"}) + return + } + c.JSON(http.StatusOK, toResponse(v)) +} + +// AdminDocument streams a specific submission's document (side=front|back) +// to the admin for review. +func (h *Handler) AdminDocument(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + v, err := h.service.Get(c.Request.Context(), id) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "verification not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load verification"}) + return + } + h.serveDocument(c, v) +} + +func (h *Handler) serveDocument(c *gin.Context, v *CustomerVerification) { + path, err := h.service.DocumentPath(v, c.Param("side")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.File(path) +} + +type reviewRequest struct { + Status string `json:"status" binding:"required"` + AdminNote string `json:"admin_note"` +} + +func (h *Handler) Review(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 reviewRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + if !ValidStatuses[req.Status] { + c.JSON(http.StatusBadRequest, gin.H{"error": "status must be one of: pending, approved, rejected"}) + return + } + v, err := h.service.Review(c.Request.Context(), id, req.Status, req.AdminNote) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "verification not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to review verification"}) + return + } + c.JSON(http.StatusOK, toResponse(v)) +} diff --git a/backend/internal/modules/customerverification/model.go b/backend/internal/modules/customerverification/model.go new file mode 100644 index 0000000..5b5fccd --- /dev/null +++ b/backend/internal/modules/customerverification/model.go @@ -0,0 +1,38 @@ +// Package customerverification lets a customer submit an identity document +// (front/back) for manual admin review, gating checkout when the admin has +// turned on site.Settings.CustomerVerificationRequired -- e.g. for +// age/ID-restricted goods. Documents are stored privately (never through +// the public media module) and served only to the admin and the owning +// customer through authenticated endpoints. +package customerverification + +import ( + "time" + + "github.com/google/uuid" +) + +const ( + StatusPending = "pending" + StatusApproved = "approved" + StatusRejected = "rejected" +) + +var ValidStatuses = map[string]bool{ + StatusPending: true, + StatusApproved: true, + StatusRejected: true, +} + +type CustomerVerification struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + UserID uuid.UUID `gorm:"type:uuid;uniqueIndex;not null"` + FrontKey string `gorm:"not null"` + BackKey string `gorm:"not null"` + Status string `gorm:"not null;default:pending"` + AdminNote string `gorm:"not null;default:''"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (CustomerVerification) TableName() string { return "customer_verifications" } diff --git a/backend/internal/modules/customerverification/repository.go b/backend/internal/modules/customerverification/repository.go new file mode 100644 index 0000000..875d076 --- /dev/null +++ b/backend/internal/modules/customerverification/repository.go @@ -0,0 +1,92 @@ +package customerverification + +import ( + "context" + "errors" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +var ErrNotFound = errors.New("verification not found") + +type Repository interface { + Upsert(ctx context.Context, v *CustomerVerification) error + FindByUserID(ctx context.Context, userID uuid.UUID) (*CustomerVerification, error) + FindByID(ctx context.Context, id uuid.UUID) (*CustomerVerification, error) + List(ctx context.Context, status string) ([]*CustomerVerification, error) + UpdateStatus(ctx context.Context, id uuid.UUID, status, adminNote string) (*CustomerVerification, error) +} + +type gormRepository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) Repository { + return &gormRepository{db: db} +} + +// Upsert inserts a new submission or overwrites the customer's existing one +// (a resubmission after rejection reuses the same row, reset to pending). +func (r *gormRepository) Upsert(ctx context.Context, v *CustomerVerification) error { + existing, err := r.FindByUserID(ctx, v.UserID) + if errors.Is(err, ErrNotFound) { + return r.db.WithContext(ctx).Create(v).Error + } + if err != nil { + return err + } + v.ID = existing.ID + v.CreatedAt = existing.CreatedAt + return r.db.WithContext(ctx).Save(v).Error +} + +func (r *gormRepository) FindByUserID(ctx context.Context, userID uuid.UUID) (*CustomerVerification, error) { + var v CustomerVerification + err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&v).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &v, nil +} + +func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*CustomerVerification, error) { + var v CustomerVerification + err := r.db.WithContext(ctx).Where("id = ?", id).First(&v).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &v, nil +} + +func (r *gormRepository) List(ctx context.Context, status string) ([]*CustomerVerification, error) { + q := r.db.WithContext(ctx).Order("created_at asc") + if status != "" { + q = q.Where("status = ?", status) + } + var list []*CustomerVerification + 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, adminNote string) (*CustomerVerification, error) { + res := r.db.WithContext(ctx).Model(&CustomerVerification{}).Where("id = ?", id).Updates(map[string]any{ + "status": status, + "admin_note": adminNote, + }) + if res.Error != nil { + return nil, res.Error + } + if res.RowsAffected == 0 { + return nil, ErrNotFound + } + return r.FindByID(ctx, id) +} diff --git a/backend/internal/modules/customerverification/routes.go b/backend/internal/modules/customerverification/routes.go new file mode 100644 index 0000000..16b48af --- /dev/null +++ b/backend/internal/modules/customerverification/routes.go @@ -0,0 +1,21 @@ +package customerverification + +import "github.com/gin-gonic/gin" + +// RegisterCustomerRoutes mounts the logged-in customer's own verification +// endpoints, nested under the customer auth space for consistency with +// /api/auth/customer/me. +func RegisterCustomerRoutes(rg *gin.RouterGroup, h *Handler, requireCustomer gin.HandlerFunc) { + group := rg.Group("/auth/customer/verification", requireCustomer) + group.POST("", h.Submit) + group.GET("", h.Me) + group.GET("/document/:side", h.MyDocument) +} + +func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) { + group := rg.Group("/admin/customer-verifications", requireAdmin) + group.GET("", h.ListAdmin) + group.GET("/:id", h.GetAdmin) + group.GET("/:id/document/:side", h.AdminDocument) + group.PATCH("/:id", h.Review) +} diff --git a/backend/internal/modules/customerverification/service.go b/backend/internal/modules/customerverification/service.go new file mode 100644 index 0000000..3189775 --- /dev/null +++ b/backend/internal/modules/customerverification/service.go @@ -0,0 +1,112 @@ +package customerverification + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/google/uuid" +) + +var ( + ErrUnsupportedType = errors.New("unsupported file type") + ErrInvalidSide = errors.New("side must be 'front' or 'back'") +) + +type Service struct { + repo Repository + storage *Storage +} + +func NewService(repo Repository, storage *Storage) *Service { + return &Service{repo: repo, storage: storage} +} + +type DocumentInput struct { + Reader io.Reader + ContentType string +} + +// Submit stores the front/back document images and (re)creates the +// customer's verification record in "pending" status -- including on +// resubmission after a rejection, so admins always review the latest pair. +func (s *Service) Submit(ctx context.Context, userID uuid.UUID, front, back DocumentInput) (*CustomerVerification, error) { + frontExt, ok := allowedTypes[front.ContentType] + if !ok { + return nil, ErrUnsupportedType + } + backExt, ok := allowedTypes[back.ContentType] + if !ok { + return nil, ErrUnsupportedType + } + + frontKey := uuid.NewString() + frontExt + if err := s.storage.Save(frontKey, front.Reader); err != nil { + return nil, fmt.Errorf("save front document: %w", err) + } + backKey := uuid.NewString() + backExt + if err := s.storage.Save(backKey, back.Reader); err != nil { + _ = s.storage.Delete(frontKey) + return nil, fmt.Errorf("save back document: %w", err) + } + + v := &CustomerVerification{ + ID: uuid.New(), + UserID: userID, + FrontKey: frontKey, + BackKey: backKey, + Status: StatusPending, + } + if err := s.repo.Upsert(ctx, v); err != nil { + _ = s.storage.Delete(frontKey) + _ = s.storage.Delete(backKey) + return nil, err + } + return v, nil +} + +func (s *Service) GetByUser(ctx context.Context, userID uuid.UUID) (*CustomerVerification, error) { + return s.repo.FindByUserID(ctx, userID) +} + +func (s *Service) Get(ctx context.Context, id uuid.UUID) (*CustomerVerification, error) { + return s.repo.FindByID(ctx, id) +} + +func (s *Service) List(ctx context.Context, status string) ([]*CustomerVerification, error) { + return s.repo.List(ctx, status) +} + +func (s *Service) Review(ctx context.Context, id uuid.UUID, status, adminNote string) (*CustomerVerification, error) { + if !ValidStatuses[status] { + return nil, fmt.Errorf("invalid status %q", status) + } + return s.repo.UpdateStatus(ctx, id, status, adminNote) +} + +// IsApproved satisfies orders.VerificationChecker: an unsubmitted customer +// (no row at all) is simply "not approved yet", not an error. +func (s *Service) IsApproved(ctx context.Context, userID uuid.UUID) (bool, error) { + v, err := s.repo.FindByUserID(ctx, userID) + if errors.Is(err, ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return v.Status == StatusApproved, nil +} + +// DocumentPath resolves which side of a verification record to read from +// disk, for the handler to stream after checking the caller is authorized. +func (s *Service) DocumentPath(v *CustomerVerification, side string) (string, error) { + switch side { + case "front": + return s.storage.Path(v.FrontKey), nil + case "back": + return s.storage.Path(v.BackKey), nil + default: + return "", ErrInvalidSide + } +} diff --git a/backend/internal/modules/customerverification/storage.go b/backend/internal/modules/customerverification/storage.go new file mode 100644 index 0000000..66c35ff --- /dev/null +++ b/backend/internal/modules/customerverification/storage.go @@ -0,0 +1,58 @@ +package customerverification + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// allowedTypes mirrors media.allowedTypes but is kept separate on purpose: +// verification documents are never routed through the public media module +// (whose storage backends always return a publicly reachable URL -- fine +// for product photos, never acceptable for an ID document). This is a +// small, private, local-disk-only store instead. +var allowedTypes = map[string]string{ + "image/jpeg": ".jpg", + "image/png": ".png", +} + +// Storage persists ID document images under a directory that main.go must +// never mount as a static file route. Files are only ever read back through +// Handler.serveDocument, which checks the caller is the admin or the +// document's own customer before streaming bytes. +type Storage struct { + dir string +} + +func NewStorage(dir string) (*Storage, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create verification upload dir: %w", err) + } + return &Storage{dir: dir}, nil +} + +func (s *Storage) Save(key string, reader io.Reader) error { + dest := filepath.Join(s.dir, filepath.Base(key)) + f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + 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 nil +} + +func (s *Storage) Path(key string) string { + return filepath.Join(s.dir, filepath.Base(key)) +} + +func (s *Storage) Delete(key string) error { + err := os.Remove(s.Path(key)) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("delete file: %w", err) + } + return nil +} diff --git a/backend/internal/modules/media/handler.go b/backend/internal/modules/media/handler.go index f4b2324..8d1a523 100644 --- a/backend/internal/modules/media/handler.go +++ b/backend/internal/modules/media/handler.go @@ -17,12 +17,13 @@ func NewHandler(service *Service) *Handler { } 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"` + 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"` + ProductID *uuid.UUID `json:"product_id,omitempty"` } func toResponse(m *Media) mediaResponse { @@ -33,11 +34,49 @@ func toResponse(m *Media) mediaResponse { MimeType: m.MimeType, SizeBytes: m.SizeBytes, AltText: m.AltText, + ProductID: m.ProductID, } } +// parseOptionalProductID reads the product_id query/form value, if any. +// Absent means "media not tied to a product" (e.g. contact link icons), +// distinct from an explicit product scope. +func parseOptionalProductID(raw string) (*uuid.UUID, error) { + if raw == "" { + return nil, nil + } + id, err := uuid.Parse(raw) + if err != nil { + return nil, err + } + return &id, nil +} + +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 + } + m, err := h.service.Get(c.Request.Context(), id) + 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 load media"}) + return + } + c.JSON(http.StatusOK, toResponse(m)) +} + func (h *Handler) List(c *gin.Context) { - list, err := h.service.List(c.Request.Context()) + productID, err := parseOptionalProductID(c.Query("product_id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product_id"}) + return + } + list, err := h.service.List(c.Request.Context(), productID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list media"}) return @@ -65,14 +104,21 @@ func (h *Handler) Upload(c *gin.Context) { contentType := fileHeader.Header.Get("Content-Type") altText := c.PostForm("alt_text") + productID, err := parseOptionalProductID(c.PostForm("product_id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product_id"}) + return + } - m, err := h.service.Upload(c.Request.Context(), fileHeader.Filename, file, fileHeader.Size, contentType, altText) + m, err := h.service.Upload(c.Request.Context(), fileHeader.Filename, file, fileHeader.Size, contentType, altText, productID) 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"}) + case errors.Is(err, ErrProductNotFound): + c.JSON(http.StatusBadRequest, gin.H{"error": "product not found"}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upload file"}) } diff --git a/backend/internal/modules/media/model.go b/backend/internal/modules/media/model.go index d55e764..0965aaa 100644 --- a/backend/internal/modules/media/model.go +++ b/backend/internal/modules/media/model.go @@ -17,8 +17,12 @@ type Media struct { MimeType string `gorm:"not null"` SizeBytes int64 `gorm:"not null"` AltText string `gorm:"not null;default:''"` - CreatedAt time.Time - UpdatedAt time.Time + // ProductID isolates each media file to the single product it was + // uploaded for (nil for media not tied to any product, e.g. contact + // link icons) -- there is no shared cross-product media library. + ProductID *uuid.UUID `gorm:"type:uuid;index"` + CreatedAt time.Time + UpdatedAt time.Time } func (Media) TableName() string { return "media" } diff --git a/backend/internal/modules/media/repository.go b/backend/internal/modules/media/repository.go index 22b3360..7cd347c 100644 --- a/backend/internal/modules/media/repository.go +++ b/backend/internal/modules/media/repository.go @@ -13,7 +13,7 @@ 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) + List(ctx context.Context, productID *uuid.UUID) ([]*Media, error) Update(ctx context.Context, m *Media) error Delete(ctx context.Context, id uuid.UUID) error } @@ -32,21 +32,38 @@ func (r *gormRepository) Create(ctx context.Context, m *Media) 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 + + 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) { +func (r *gormRepository) List(ctx context.Context, productID *uuid.UUID) ([]*Media, error) { var list []*Media - if err := r.db.WithContext(ctx).Order("created_at desc").Find(&list).Error; err != nil { + + q := r.db.WithContext(ctx). + Order("created_at desc") + + if productID != nil { + q = q.Where("product_id = ?", *productID) + } else { + q = q.Where("product_id IS NULL") + } + + if err := q.Find(&list).Error; err != nil { return nil, err } + return list, nil } @@ -55,12 +72,16 @@ func (r *gormRepository) Update(ctx context.Context, m *Media) error { } func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error { - res := r.db.WithContext(ctx).Delete(&Media{}, "id = ?", id) + res := r.db.WithContext(ctx). + Delete(&Media{}, "id = ?", id) + if res.Error != nil { return res.Error } + if res.RowsAffected == 0 { return ErrNotFound } + return nil } diff --git a/backend/internal/modules/media/routes.go b/backend/internal/modules/media/routes.go index 426d298..2fa992e 100644 --- a/backend/internal/modules/media/routes.go +++ b/backend/internal/modules/media/routes.go @@ -9,3 +9,10 @@ func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.Handl group.PUT("/:id", h.Update) group.DELETE("/:id", h.Delete) } + +// RegisterPublicRoutes exposes read-only single-item lookup so the +// storefront can resolve a product's primary_media_id / gallery media_ids +// to a URL without needing admin access. +func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) { + rg.GET("/media/:id", h.Get) +} diff --git a/backend/internal/modules/media/service.go b/backend/internal/modules/media/service.go index fe38bb6..f59c13b 100644 --- a/backend/internal/modules/media/service.go +++ b/backend/internal/modules/media/service.go @@ -7,13 +7,25 @@ import ( "io" "github.com/google/uuid" + + "backend/internal/modules/categories" + "backend/internal/modules/products" ) var ( ErrFileTooLarge = errors.New("file exceeds the maximum allowed size") ErrUnsupportedType = errors.New("unsupported file type") + ErrProductNotFound = errors.New("product not found") ) +// ProductFinder is the minimal slice of products.Service this module needs, +// defined on the consumer side so media never imports the products +// module's handler/repository/service internals -- only used to validate +// that a product_id passed on upload actually exists. +type ProductFinder interface { + Get(ctx context.Context, id uuid.UUID) (*products.Product, error) +} + // 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. @@ -27,24 +39,38 @@ var allowedTypes = map[string]string{ } type Service struct { - repo Repository - storage Storage - maxSizeByte int64 + repo Repository + storage Storage + maxSizeByte int64 + products ProductFinder + categoriesRepo categories.Repository } -func NewService(repo Repository, storage Storage, maxSizeBytes int64) *Service { - return &Service{repo: repo, storage: storage, maxSizeByte: maxSizeBytes} +func NewService( + repo Repository, + storage Storage, + products ProductFinder, + categoriesRepo categories.Repository, + maxSizeByte int64, +) *Service { + return &Service{ + repo: repo, + storage: storage, + products: products, + categoriesRepo: categoriesRepo, + maxSizeByte: maxSizeByte, + } } -func (s *Service) List(ctx context.Context) ([]*Media, error) { - return s.repo.List(ctx) +func (s *Service) List(ctx context.Context, productID *uuid.UUID) ([]*Media, error) { + return s.repo.List(ctx, productID) } 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) { +func (s *Service) Upload(ctx context.Context, filename string, reader io.Reader, size int64, contentType, altText string, productID *uuid.UUID) (*Media, error) { if size > s.maxSizeByte { return nil, ErrFileTooLarge } @@ -52,6 +78,14 @@ func (s *Service) Upload(ctx context.Context, filename string, reader io.Reader, if !ok { return nil, ErrUnsupportedType } + if productID != nil { + if _, err := s.products.Get(ctx, *productID); err != nil { + if errors.Is(err, products.ErrNotFound) { + return nil, ErrProductNotFound + } + return nil, fmt.Errorf("check product: %w", err) + } + } key := uuid.NewString() + ext url, err := s.storage.Save(ctx, key, reader, size, contentType) @@ -67,6 +101,7 @@ func (s *Service) Upload(ctx context.Context, filename string, reader io.Reader, MimeType: contentType, SizeBytes: size, AltText: altText, + ProductID: productID, } if err := s.repo.Create(ctx, m); err != nil { _ = s.storage.Delete(ctx, key) @@ -97,3 +132,23 @@ func (s *Service) Delete(ctx context.Context, id uuid.UUID) error { } return s.repo.Delete(ctx, id) } + +func (s *Service) BelongsToProduct( + ctx context.Context, + mediaID uuid.UUID, + productID uuid.UUID, +) (bool, error) { + m, err := s.repo.FindByID(ctx, mediaID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return false, nil + } + return false, err + } + + if m.ProductID == nil { + return false, nil + } + + return *m.ProductID == productID, nil +} diff --git a/backend/internal/modules/orders/gate.go b/backend/internal/modules/orders/gate.go new file mode 100644 index 0000000..8296593 --- /dev/null +++ b/backend/internal/modules/orders/gate.go @@ -0,0 +1,101 @@ +package orders + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "backend/internal/platform/middleware" +) + +// SettingsProvider and VerificationChecker are defined on the consumer +// side (this package), matching ProductFinder/UnitFinder/PriceResolver +// above: orders never imports the site or customerverification packages +// directly, main.go just needs to supply something satisfying these shapes. +type SettingsProvider interface { + CustomerCheckoutSettings(ctx context.Context) (accountsEnabled, verificationRequired bool, err error) +} + +type VerificationChecker interface { + IsApproved(ctx context.Context, userID uuid.UUID) (bool, error) +} + +// OrdersGate reports the site-wide "vitrine vs boutique" switch +// (site.Settings.OrdersEnabled). It runs before every other order gate: a +// pure showcase site rejects order creation outright, regardless of the +// customer-account settings below. +type OrdersGate interface { + OrdersEnabled(ctx context.Context) (bool, error) +} + +// RequireOrdersEnabled blocks order creation entirely while the admin has +// switched the site to showcase-only mode. +func RequireOrdersEnabled(gate OrdersGate) gin.HandlerFunc { + return func(c *gin.Context) { + enabled, err := gate.OrdersEnabled(c.Request.Context()) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check orders setting"}) + return + } + if !enabled { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ordering is currently disabled for this shop"}) + return + } + c.Next() + } +} + +// RequireAccountsEnabled blocks order creation entirely while the admin +// hasn't turned on customer accounts. This is intentionally stricter than +// "guest checkout allowed by default": some shops (e.g. selling +// age/ID-restricted goods) need every order tied to a verified account, so +// the admin opts into that by flipping this one setting, and until they +// do, nobody -- guest or not -- can check out. +func RequireAccountsEnabled(settings SettingsProvider) gin.HandlerFunc { + return func(c *gin.Context) { + enabled, _, err := settings.CustomerCheckoutSettings(c.Request.Context()) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check checkout settings"}) + return + } + if !enabled { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ordering requires a customer account, which is currently disabled"}) + return + } + c.Next() + } +} + +// RequireApprovedVerificationIfNeeded must run after RequireAccountsEnabled +// and middleware.RequireCustomer (so a userID is already in context). It is +// a no-op unless the admin also turned on identity verification. +func RequireApprovedVerificationIfNeeded(settings SettingsProvider, verification VerificationChecker) gin.HandlerFunc { + return func(c *gin.Context) { + _, verificationRequired, err := settings.CustomerCheckoutSettings(c.Request.Context()) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check checkout settings"}) + return + } + if !verificationRequired { + c.Next() + return + } + userID, ok := middleware.GetUserID(c) + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + approved, err := verification.IsApproved(c.Request.Context(), userID) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check verification status"}) + return + } + if !approved { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "identity verification must be approved before ordering"}) + return + } + c.Next() + } +} diff --git a/backend/internal/modules/orders/handler.go b/backend/internal/modules/orders/handler.go index 049b330..40d6aba 100644 --- a/backend/internal/modules/orders/handler.go +++ b/backend/internal/modules/orders/handler.go @@ -9,6 +9,7 @@ import ( "backend/internal/modules/pricing" "backend/internal/modules/products" + "backend/internal/platform/middleware" ) type Handler struct { @@ -48,7 +49,6 @@ type orderResponse struct { 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"` @@ -60,7 +60,6 @@ func toResponse(order *Order, items []*OrderItem) orderResponse { CustomerName: order.CustomerName, CustomerEmail: order.CustomerEmail, CustomerPhone: order.CustomerPhone, - Status: order.Status, TotalCents: order.TotalCents, Notes: order.Notes, } @@ -78,8 +77,10 @@ func toResponse(order *Order, items []*OrderItem) orderResponse { return resp } -// Create is public: a visitor (connected or not, per spec section 17) can -// place an order without an account. +// Create is gated by RequireAccountsEnabled/RequireCustomer/ +// RequireApprovedVerificationIfNeeded (see routes.go): rejected outright +// until the admin enables customer accounts, then requires a logged-in +// customer (and an approved verification too, if that's also required). func (h *Handler) Create(c *gin.Context) { var req createRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -96,7 +97,13 @@ func (h *Handler) Create(c *gin.Context) { }) } + var customerID *uuid.UUID + if uid, ok := middleware.GetUserID(c); ok { + customerID = &uid + } + order, orderItems, err := h.service.Create(c.Request.Context(), CreateInput{ + CustomerID: customerID, CustomerName: req.CustomerName, CustomerEmail: req.CustomerEmail, CustomerPhone: req.CustomerPhone, @@ -110,6 +117,25 @@ func (h *Handler) Create(c *gin.Context) { c.JSON(http.StatusCreated, toResponse(order, orderItems)) } +// ListMine returns the authenticated customer's own order history. +func (h *Handler) ListMine(c *gin.Context) { + userID, ok := middleware.GetUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + list, err := h.service.ListByCustomer(c.Request.Context(), userID) + 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) respondCreateError(c *gin.Context, err error) { switch { case errors.Is(err, ErrEmptyOrder), errors.Is(err, ErrProductMismatch): @@ -123,19 +149,6 @@ func (h *Handler) respondCreateError(c *gin.Context, err error) { } } -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 { @@ -154,32 +167,16 @@ func (h *Handler) GetAdmin(c *gin.Context) { 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")) +// ListAdmin returns every order, most recent first. +func (h *Handler) ListAdmin(c *gin.Context) { + list, err := h.service.List(c.Request.Context()) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orders"}) return } - var req updateStatusRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) - return + resp := make([]orderResponse, 0, len(list)) + for _, order := range list { + resp = append(resp, toResponse(order, nil)) } - 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)) + c.JSON(http.StatusOK, gin.H{"orders": resp}) } diff --git a/backend/internal/modules/orders/model.go b/backend/internal/modules/orders/model.go index bdb474d..7b858d6 100644 --- a/backend/internal/modules/orders/model.go +++ b/backend/internal/modules/orders/model.go @@ -1,8 +1,11 @@ -// 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 lets a customer 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. +// +// Whether an order requires a logged-in (and possibly identity-verified) +// customer account is an admin-configurable gate, not a fixed rule of this +// package -- see gate.go and site.Settings.CustomerAccountsEnabled. package orders import ( @@ -11,32 +14,14 @@ import ( "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:''"` + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + CustomerID *uuid.UUID `gorm:"type:uuid;index"` + CustomerName string `gorm:"not null"` + CustomerEmail string `gorm:"not null"` + CustomerPhone string `gorm:"not null;default:''"` + TotalCents int64 `gorm:"not null;default:0"` + Notes string `gorm:"not null;default:''"` CreatedAt time.Time UpdatedAt time.Time } diff --git a/backend/internal/modules/orders/repository.go b/backend/internal/modules/orders/repository.go index bee810a..706d5f1 100644 --- a/backend/internal/modules/orders/repository.go +++ b/backend/internal/modules/orders/repository.go @@ -13,8 +13,8 @@ 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) + List(ctx context.Context) ([]*Order, error) + ListByCustomer(ctx context.Context, customerID uuid.UUID) ([]*Order, error) } type gormRepository struct { @@ -57,29 +57,18 @@ func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Order, [] 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) - } +func (r *gormRepository) List(ctx context.Context) ([]*Order, error) { var list []*Order - if err := q.Find(&list).Error; err != nil { + if err := r.db.WithContext(ctx).Order("created_at desc").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 { +func (r *gormRepository) ListByCustomer(ctx context.Context, customerID uuid.UUID) ([]*Order, error) { + var list []*Order + if err := r.db.WithContext(ctx).Where("customer_id = ?", customerID).Order("created_at desc").Find(&list).Error; err != nil { return nil, err } - return &order, nil + return list, nil } diff --git a/backend/internal/modules/orders/routes.go b/backend/internal/modules/orders/routes.go index d9750cc..f87417e 100644 --- a/backend/internal/modules/orders/routes.go +++ b/backend/internal/modules/orders/routes.go @@ -2,16 +2,24 @@ 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) +// RegisterPublicRoutes exposes order creation. rateLimit throttles it to +// blunt spam/abuse. requireOrdersEnabled rejects every request with 403 +// while the site is in showcase-only mode; requireAccountsEnabled then +// rejects every remaining request (guest or not) with 403 until the admin +// turns on customer accounts (see gate.go); once on, requireCustomer +// enforces a logged-in customer and requireVerified additionally requires +// an approved identity verification if the admin also turned that on. +func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler, rateLimit, requireOrdersEnabled, requireAccountsEnabled, requireCustomer, requireVerified gin.HandlerFunc) { + rg.POST("/orders", rateLimit, requireOrdersEnabled, requireAccountsEnabled, requireCustomer, requireVerified, h.Create) +} + +// RegisterCustomerRoutes exposes a logged-in customer's own order history. +func RegisterCustomerRoutes(rg *gin.RouterGroup, h *Handler, requireCustomer gin.HandlerFunc) { + rg.GET("/customer/orders", requireCustomer, h.ListMine) } func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) { group := rg.Group("/admin/orders", requireAdmin) group.GET("", h.ListAdmin) group.GET("/:id", h.GetAdmin) - group.PATCH("/:id/status", h.UpdateStatus) } diff --git a/backend/internal/modules/orders/service.go b/backend/internal/modules/orders/service.go index c051e49..959893c 100644 --- a/backend/internal/modules/orders/service.go +++ b/backend/internal/modules/orders/service.go @@ -15,7 +15,6 @@ import ( 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") ) @@ -39,7 +38,6 @@ type PriceResolver interface { // directly. type OrderNotifier interface { NotifyNewOrder(ctx context.Context, summary string) - NotifyOrderStatusChange(ctx context.Context, summary string) } type Service struct { @@ -61,6 +59,7 @@ type ItemInput struct { } type CreateInput struct { + CustomerID *uuid.UUID CustomerName string CustomerEmail string CustomerPhone string @@ -75,10 +74,10 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (*Order, []*OrderI order := &Order{ ID: uuid.New(), + CustomerID: in.CustomerID, CustomerName: in.CustomerName, CustomerEmail: in.CustomerEmail, CustomerPhone: in.CustomerPhone, - Status: StatusPending, Notes: in.Notes, } @@ -137,22 +136,12 @@ func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Order, []*OrderItem, 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) List(ctx context.Context) ([]*Order, error) { + return s.repo.List(ctx) } -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 (s *Service) ListByCustomer(ctx context.Context, customerID uuid.UUID) ([]*Order, error) { + return s.repo.ListByCustomer(ctx, customerID) } func summarizeNewOrder(order *Order, items []*OrderItem) string { diff --git a/backend/internal/modules/products/errors.go b/backend/internal/modules/products/errors.go new file mode 100644 index 0000000..ae0d420 --- /dev/null +++ b/backend/internal/modules/products/errors.go @@ -0,0 +1,5 @@ +package products + +import "errors" + +var ErrMediaNotOwned = errors.New("media does not belong to product") diff --git a/backend/internal/modules/products/handler.go b/backend/internal/modules/products/handler.go index 2a40aab..5e44f14 100644 --- a/backend/internal/modules/products/handler.go +++ b/backend/internal/modules/products/handler.go @@ -22,30 +22,26 @@ type galleryItemResponse struct { } 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"` + ID uuid.UUID `json:"id"` + CategoryID uuid.UUID `json:"category_id"` + Name string `json:"name"` + 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, + ID: p.ID, + CategoryID: p.CategoryID, + Name: p.Name, + Description: p.Description, + IsActive: p.IsActive, + IsFeatured: p.IsFeatured, + PrimaryMediaID: p.PrimaryMediaID, + Position: p.Position, } } @@ -101,8 +97,13 @@ func (h *Handler) GetAdmin(c *gin.Context) { c.JSON(http.StatusOK, toResponse(p)) } -func (h *Handler) GetPublicBySlug(c *gin.Context) { - p, err := h.service.GetBySlug(c.Request.Context(), c.Param("slug")) +func (h *Handler) GetPublic(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 @@ -123,28 +124,24 @@ func (h *Handler) respondGetError(c *gin.Context, err error) { } 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"` + CategoryID uuid.UUID `json:"category_id" binding:"required"` + Name string `json:"name" binding:"required"` + 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, + CategoryID: req.CategoryID, + Name: req.Name, + Description: req.Description, + IsActive: req.IsActive, + IsFeatured: req.IsFeatured, + PrimaryMediaID: req.PrimaryMediaID, + Position: req.Position, } } @@ -156,11 +153,12 @@ func (h *Handler) Create(c *gin.Context) { } 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 + switch { + case errors.Is(err, ErrCategoryNotFound): + c.JSON(http.StatusBadRequest, gin.H{"error": "category does not exist"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create product"}) } - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create product"}) return } c.JSON(http.StatusCreated, toResponse(p)) @@ -182,8 +180,8 @@ func (h *Handler) Update(c *gin.Context) { 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"}) + case errors.Is(err, ErrCategoryNotFound): + c.JSON(http.StatusBadRequest, gin.H{"error": "category does not exist"}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update product"}) } diff --git a/backend/internal/modules/products/model.go b/backend/internal/modules/products/model.go index 8263020..791087c 100644 --- a/backend/internal/modules/products/model.go +++ b/backend/internal/modules/products/model.go @@ -11,18 +11,16 @@ import ( ) 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 + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + CategoryID uuid.UUID `gorm:"type:uuid;not null"` + Name string `gorm:"not null"` + 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" } diff --git a/backend/internal/modules/products/repository.go b/backend/internal/modules/products/repository.go index 84cce66..350b80f 100644 --- a/backend/internal/modules/products/repository.go +++ b/backend/internal/modules/products/repository.go @@ -5,19 +5,14 @@ import ( "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") -) +var ErrNotFound = errors.New("product not found") 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 @@ -26,6 +21,9 @@ type Repository interface { RemoveGalleryItem(ctx context.Context, productID, mediaID uuid.UUID) error ListGallery(ctx context.Context, productID uuid.UUID) ([]*GalleryItem, error) } +type MediaOwnershipChecker interface { + BelongsToProduct(ctx context.Context, mediaID, productID uuid.UUID) (bool, error) +} type gormRepository struct { db *gorm.DB @@ -36,13 +34,7 @@ func NewRepository(db *gorm.DB) Repository { } 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 + return r.db.WithContext(ctx).Create(p).Error } func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Product, error) { @@ -57,18 +49,6 @@ func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Product, 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 { @@ -85,11 +65,7 @@ func (r *gormRepository) List(ctx context.Context, activeOnly bool, categoryID * } 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 + return r.db.WithContext(ctx).Save(p).Error } func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error { @@ -125,8 +101,3 @@ func (r *gormRepository) ListGallery(ctx context.Context, productID uuid.UUID) ( } return list, nil } - -func isUniqueViolation(err error) bool { - var pgErr *pgconn.PgError - return errors.As(err, &pgErr) && pgErr.Code == "23505" -} diff --git a/backend/internal/modules/products/routes.go b/backend/internal/modules/products/routes.go index 3a21d0b..9abe76c 100644 --- a/backend/internal/modules/products/routes.go +++ b/backend/internal/modules/products/routes.go @@ -17,8 +17,11 @@ func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.Handl } // RegisterPublicRoutes exposes the read-only, active-only product catalog -// used by the storefront (site vitrine / boutique). +// used by the storefront (site vitrine / boutique). The gallery lookup is +// kept as its own top-level path (like pricing's by-product route) to +// mirror the admin routes' shape. func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) { rg.GET("/products", h.ListPublic) - rg.GET("/products/:slug", h.GetPublicBySlug) + rg.GET("/products/:id", h.GetPublic) + rg.GET("/product-gallery/:id", h.ListGallery) } diff --git a/backend/internal/modules/products/service.go b/backend/internal/modules/products/service.go index b420fff..bcd8cf2 100644 --- a/backend/internal/modules/products/service.go +++ b/backend/internal/modules/products/service.go @@ -2,16 +2,38 @@ package products import ( "context" + "errors" "github.com/google/uuid" + + "backend/internal/modules/categories" ) +// ErrCategoryNotFound is returned when a product references a category_id +// that doesn't exist -- every product must belong to a real category. +var ErrCategoryNotFound = errors.New("category not found") + type Service struct { - repo Repository + repo Repository + categoriesRepo categories.Repository + mediaOwnership MediaOwnershipChecker } -func NewService(repo Repository) *Service { - return &Service{repo: repo} +func NewService(repo Repository, categoriesRepo categories.Repository) *Service { + return &Service{ + repo: repo, + categoriesRepo: categoriesRepo, + } +} + +func (s *Service) checkCategory(ctx context.Context, categoryID uuid.UUID) error { + if _, err := s.categoriesRepo.FindByID(ctx, categoryID); err != nil { + if errors.Is(err, categories.ErrNotFound) { + return ErrCategoryNotFound + } + return err + } + return nil } func (s *Service) List(ctx context.Context, activeOnly bool, categoryID *uuid.UUID) ([]*Product, error) { @@ -22,34 +44,29 @@ 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 + CategoryID uuid.UUID + Name string + Description string + IsActive bool + IsFeatured bool + PrimaryMediaID *uuid.UUID + Position int } func (s *Service) Create(ctx context.Context, in UpsertInput) (*Product, error) { + if err := s.checkCategory(ctx, in.CategoryID); err != nil { + return nil, err + } 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, + ID: uuid.New(), + CategoryID: in.CategoryID, + Name: in.Name, + 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 @@ -58,14 +75,15 @@ func (s *Service) Create(ctx context.Context, in UpsertInput) (*Product, error) } func (s *Service) Update(ctx context.Context, id uuid.UUID, in UpsertInput) (*Product, error) { + if err := s.checkCategory(ctx, in.CategoryID); err != nil { + return nil, err + } 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 diff --git a/backend/internal/modules/site/handler.go b/backend/internal/modules/site/handler.go index 3b6b8b8..5f5c9a8 100644 --- a/backend/internal/modules/site/handler.go +++ b/backend/internal/modules/site/handler.go @@ -3,11 +3,40 @@ package site import ( "net/http" "regexp" + "strings" "github.com/gin-gonic/gin" + "github.com/google/uuid" ) -var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) +var ( + colorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + + validProductLayouts = map[string]bool{"grid": true, "list": true, "grid-overlay": true, "grid-minimal": true} + validProductColumns = map[int]bool{0: true, 2: true, 3: true, 4: true, 5: true} + // validProductScrolls controls whether the storefront product list + // scrolls the normal way (down the page) or sideways as a swipeable + // row -- independent from ProductLayout (the card style), so any card + // style can be browsed either way. + validProductScrolls = map[string]bool{"vertical": true, "horizontal": true} + + // validHeroPages are the storefront pages the hero banner can be + // toggled on for -- kept to a fixed, known set (rather than arbitrary + // paths) so a stale/invalid value can never sneak into a page that + // doesn't know what to do with it. + validHeroPages = map[string]bool{"catalog": true, "home": true, "cart": true, "checkout": true, "account": true, "contact": true} +) + +func heroPagesToString(pages []string) string { + return strings.Join(pages, ",") +} + +func heroPagesFromString(s string) []string { + if s == "" { + return []string{} + } + return strings.Split(s, ",") +} type Handler struct { service *Service @@ -18,13 +47,59 @@ func NewHandler(service *Service) *Handler { } type settingsResponse struct { - Name string `json:"name"` - Description string `json:"description"` - Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + OrdersEnabled bool `json:"orders_enabled"` + + HeaderBgColor string `json:"header_bg_color"` + HeaderTextColor string `json:"header_text_color"` + BodyBgColor string `json:"body_bg_color"` + BodyTextColor string `json:"body_text_color"` + FooterBgColor string `json:"footer_bg_color"` + FooterTextColor string `json:"footer_text_color"` + AccentColor string `json:"accent_color"` + ProductLayout string `json:"product_layout"` + ProductColumns int `json:"product_columns"` + ProductScroll string `json:"product_scroll"` + + ContactCardTransparent bool `json:"contact_card_transparent"` + ContactCardBgColor string `json:"contact_card_bg_color"` + + LogoMediaID *uuid.UUID `json:"logo_media_id"` + HeroMediaID *uuid.UUID `json:"hero_media_id"` + HeroPages []string `json:"hero_pages"` + + CustomerLoginEnabled bool `json:"customer_login_enabled"` + CustomerRegistrationEnabled bool `json:"customer_registration_enabled"` + CustomerVerificationRequired bool `json:"customer_verification_required"` + VerificationContactLinkID *uuid.UUID `json:"verification_contact_link_id"` } func toResponse(s *Settings) settingsResponse { - return settingsResponse{Name: s.Name, Description: s.Description, Slug: s.Slug} + return settingsResponse{ + Name: s.Name, + Description: s.Description, + OrdersEnabled: s.OrdersEnabled, + HeaderBgColor: s.HeaderBgColor, + HeaderTextColor: s.HeaderTextColor, + BodyBgColor: s.BodyBgColor, + BodyTextColor: s.BodyTextColor, + FooterBgColor: s.FooterBgColor, + FooterTextColor: s.FooterTextColor, + AccentColor: s.AccentColor, + ProductLayout: s.ProductLayout, + ProductColumns: s.ProductColumns, + ProductScroll: s.ProductScroll, + ContactCardTransparent: s.ContactCardTransparent, + ContactCardBgColor: s.ContactCardBgColor, + LogoMediaID: s.LogoMediaID, + HeroMediaID: s.HeroMediaID, + HeroPages: heroPagesFromString(s.HeroPages), + CustomerLoginEnabled: s.CustomerLoginEnabled, + CustomerRegistrationEnabled: s.CustomerRegistrationEnabled, + CustomerVerificationRequired: s.CustomerVerificationRequired, + VerificationContactLinkID: s.VerificationContactLinkID, + } } func (h *Handler) Get(c *gin.Context) { @@ -37,9 +112,9 @@ func (h *Handler) Get(c *gin.Context) { } type updateRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Slug string `json:"slug" binding:"required"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + OrdersEnabled bool `json:"orders_enabled"` } func (h *Handler) Update(c *gin.Context) { @@ -48,15 +123,155 @@ func (h *Handler) Update(c *gin.Context) { 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) + settings, err := h.service.Update(c.Request.Context(), req.Name, req.Description, req.OrdersEnabled) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update site settings"}) return } c.JSON(http.StatusOK, toResponse(settings)) } + +// updateAppearanceRequest fields are all optional pointers: the admin can +// send just the one option they changed (e.g. {"product_layout": "list"}) +// and every other appearance setting keeps its current value. +type updateAppearanceRequest struct { + HeaderBgColor *string `json:"header_bg_color"` + HeaderTextColor *string `json:"header_text_color"` + BodyBgColor *string `json:"body_bg_color"` + BodyTextColor *string `json:"body_text_color"` + FooterBgColor *string `json:"footer_bg_color"` + FooterTextColor *string `json:"footer_text_color"` + AccentColor *string `json:"accent_color"` + ProductLayout *string `json:"product_layout"` + ProductColumns *int `json:"product_columns"` + ProductScroll *string `json:"product_scroll"` + + ContactCardTransparent *bool `json:"contact_card_transparent"` + ContactCardBgColor *string `json:"contact_card_bg_color"` + + LogoMediaID *uuid.UUID `json:"logo_media_id"` + ClearLogo bool `json:"clear_logo"` + HeroMediaID *uuid.UUID `json:"hero_media_id"` + ClearHero bool `json:"clear_hero"` + HeroPages *[]string `json:"hero_pages"` +} + +func (h *Handler) UpdateAppearance(c *gin.Context) { + var req updateAppearanceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + for _, color := range []*string{ + req.HeaderBgColor, req.HeaderTextColor, + req.BodyBgColor, req.BodyTextColor, + req.FooterBgColor, req.FooterTextColor, + req.AccentColor, req.ContactCardBgColor, + } { + if color != nil && !colorPattern.MatchString(*color) { + c.JSON(http.StatusBadRequest, gin.H{"error": "colors must be hex values like #RRGGBB"}) + return + } + } + if req.ProductLayout != nil && !validProductLayouts[*req.ProductLayout] { + c.JSON(http.StatusBadRequest, gin.H{"error": "product_layout must be one of: grid, list, grid-overlay, grid-minimal"}) + return + } + if req.ProductColumns != nil && !validProductColumns[*req.ProductColumns] { + c.JSON(http.StatusBadRequest, gin.H{"error": "product_columns must be one of: 0 (auto), 2, 3, 4, 5"}) + return + } + if req.ProductScroll != nil && !validProductScrolls[*req.ProductScroll] { + c.JSON(http.StatusBadRequest, gin.H{"error": "product_scroll must be one of: vertical, horizontal"}) + return + } + var heroPagesStr *string + if req.HeroPages != nil { + for _, page := range *req.HeroPages { + if !validHeroPages[page] { + c.JSON(http.StatusBadRequest, gin.H{"error": "hero_pages must only contain: home, cart, checkout, account, contact"}) + return + } + } + joined := heroPagesToString(*req.HeroPages) + heroPagesStr = &joined + } + + in := AppearanceInput{ + HeaderBgColor: req.HeaderBgColor, + HeaderTextColor: req.HeaderTextColor, + BodyBgColor: req.BodyBgColor, + BodyTextColor: req.BodyTextColor, + FooterBgColor: req.FooterBgColor, + FooterTextColor: req.FooterTextColor, + AccentColor: req.AccentColor, + ProductLayout: req.ProductLayout, + ProductColumns: req.ProductColumns, + ContactCardTransparent: req.ContactCardTransparent, + ContactCardBgColor: req.ContactCardBgColor, + ProductScroll: req.ProductScroll, + HeroPages: heroPagesStr, + } + if req.ClearLogo { + in.LogoMediaIDSet = true + in.LogoMediaID = nil + } else if req.LogoMediaID != nil { + in.LogoMediaIDSet = true + in.LogoMediaID = req.LogoMediaID + } + if req.ClearHero { + in.HeroMediaIDSet = true + in.HeroMediaID = nil + } else if req.HeroMediaID != nil { + in.HeroMediaIDSet = true + in.HeroMediaID = req.HeroMediaID + } + + settings, err := h.service.UpdateAppearance(c.Request.Context(), in) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update appearance settings"}) + return + } + c.JSON(http.StatusOK, toResponse(settings)) +} + +// updateCustomerAuthRequest fields are all optional: the admin can flip +// just one toggle at a time. ClearVerificationContactLink is a separate +// explicit flag (rather than overloading a nil ID) so "field omitted" and +// "admin picked no link" are unambiguous. +type updateCustomerAuthRequest struct { + LoginEnabled *bool `json:"login_enabled"` + RegistrationEnabled *bool `json:"registration_enabled"` + VerificationRequired *bool `json:"verification_required"` + VerificationContactLinkID *uuid.UUID `json:"verification_contact_link_id"` + ClearVerificationContactLink bool `json:"clear_verification_contact_link"` +} + +func (h *Handler) UpdateCustomerAuth(c *gin.Context) { + var req updateCustomerAuthRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()}) + return + } + + in := CustomerAuthInput{ + LoginEnabled: req.LoginEnabled, + RegistrationEnabled: req.RegistrationEnabled, + VerificationRequired: req.VerificationRequired, + } + if req.ClearVerificationContactLink { + in.VerificationContactLinkSet = true + in.VerificationContactLinkID = nil + } else if req.VerificationContactLinkID != nil { + in.VerificationContactLinkSet = true + in.VerificationContactLinkID = req.VerificationContactLinkID + } + + settings, err := h.service.UpdateCustomerAuth(c.Request.Context(), in) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update customer auth settings"}) + return + } + c.JSON(http.StatusOK, toResponse(settings)) +} diff --git a/backend/internal/modules/site/model.go b/backend/internal/modules/site/model.go index 7bdf7d7..8003866 100644 --- a/backend/internal/modules/site/model.go +++ b/backend/internal/modules/site/model.go @@ -1,19 +1,80 @@ // 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 + +// description). 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" +import ( + "time" + + "github.com/google/uuid" +) type Settings struct { ID int16 `gorm:"primaryKey"` Name string Description string - Slug string - CreatedAt time.Time - UpdatedAt time.Time + + // OrdersEnabled is the "vitrine vs boutique" site-mode switch (spec + // section 6-8): off hides the cart/checkout entirely on the storefront + // (showcase-only, prices shown as indicative), on exposes the full + // cart/order flow (still subject to CustomerLoginEnabled below). On by + // default so a fresh deployment behaves like a shop out of the box. + OrdersEnabled bool + + // Appearance: lets the admin restyle the storefront (header/body/footer + // colors, accent color, product listing layout) without touching code. + HeaderBgColor string + HeaderTextColor string + BodyBgColor string + BodyTextColor string + FooterBgColor string + FooterTextColor string + AccentColor string + ProductLayout string + // ProductColumns pins the storefront product grid to a fixed number of + // columns per row (2-5). 0 means "auto" -- the grid falls back to + // filling as many columns as fit the viewport (see .product-grid in + // index.css). + ProductColumns int + // ProductScroll is "vertical" (products flow down the page, the normal + // grid/list layouts) or "horizontal" (the product list becomes a + // swipeable sideways-scrolling row) -- independent from ProductLayout, + // so any card style can be browsed either way. + ProductScroll string + + // Contact link cards (storefront Contact page): transparent by default + // so a hero image or the body background shows through the cards + // instead of a solid block; ContactCardBgColor is only used when the + // admin turns transparency off in favor of a solid color. + ContactCardTransparent bool + ContactCardBgColor string + + // LogoMediaID replaces the text site name in the storefront header when + // set. HeroMediaID is a full-width banner image shown above the normal + // page content, only on the pages listed in HeroPages (comma-separated + // page keys, e.g. "home,contact" -- see handler.go's validHeroPages). + LogoMediaID *uuid.UUID `gorm:"type:uuid"` + HeroMediaID *uuid.UUID `gorm:"type:uuid"` + HeroPages string + + // Customer accounts: the login page and the registration page are two + // independent switches the admin can flip separately (e.g. login-only + // for an invite-only shop where accounts are created from the admin + // panel, or registration-only during a pre-launch signup window). + // CustomerLoginEnabled is also what ordering checks (see + // orders.RequireAccountsEnabled): off by default, meaning nobody -- + // guest or not -- can check out until the admin turns it on, optionally + // alongside an admin-approved identity verification too (e.g. for + // age/ID-restricted goods). + CustomerLoginEnabled bool + CustomerRegistrationEnabled bool + CustomerVerificationRequired bool + VerificationContactLinkID *uuid.UUID `gorm:"type:uuid"` + + CreatedAt time.Time + UpdatedAt time.Time } func (Settings) TableName() string { return "site_settings" } diff --git a/backend/internal/modules/site/repository.go b/backend/internal/modules/site/repository.go index 1588efe..a3e0423 100644 --- a/backend/internal/modules/site/repository.go +++ b/backend/internal/modules/site/repository.go @@ -9,6 +9,8 @@ import ( type Repository interface { Get(ctx context.Context) (*Settings, error) Update(ctx context.Context, s *Settings) error + UpdateAppearance(ctx context.Context, s *Settings) error + UpdateCustomerAuth(ctx context.Context, s *Settings) error } type gormRepository struct { @@ -30,8 +32,39 @@ func (r *gormRepository) Get(ctx context.Context) (*Settings, error) { 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, + "name": s.Name, + "description": s.Description, + "orders_enabled": s.OrdersEnabled, + }).Error +} + +func (r *gormRepository) UpdateAppearance(ctx context.Context, s *Settings) error { + s.ID = 1 + return r.db.WithContext(ctx).Model(&Settings{}).Where("id = 1").Updates(map[string]any{ + "header_bg_color": s.HeaderBgColor, + "header_text_color": s.HeaderTextColor, + "body_bg_color": s.BodyBgColor, + "body_text_color": s.BodyTextColor, + "footer_bg_color": s.FooterBgColor, + "footer_text_color": s.FooterTextColor, + "accent_color": s.AccentColor, + "product_layout": s.ProductLayout, + "product_columns": s.ProductColumns, + "product_scroll": s.ProductScroll, + "contact_card_transparent": s.ContactCardTransparent, + "contact_card_bg_color": s.ContactCardBgColor, + "logo_media_id": s.LogoMediaID, + "hero_media_id": s.HeroMediaID, + "hero_pages": s.HeroPages, + }).Error +} + +func (r *gormRepository) UpdateCustomerAuth(ctx context.Context, s *Settings) error { + s.ID = 1 + return r.db.WithContext(ctx).Model(&Settings{}).Where("id = 1").Updates(map[string]any{ + "customer_login_enabled": s.CustomerLoginEnabled, + "customer_registration_enabled": s.CustomerRegistrationEnabled, + "customer_verification_required": s.CustomerVerificationRequired, + "verification_contact_link_id": s.VerificationContactLinkID, }).Error } diff --git a/backend/internal/modules/site/routes.go b/backend/internal/modules/site/routes.go index 91482f5..51c6b4f 100644 --- a/backend/internal/modules/site/routes.go +++ b/backend/internal/modules/site/routes.go @@ -6,4 +6,12 @@ func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFun group := rg.Group("/admin/site-settings") group.GET("", h.Get) group.PUT("", requireAdmin, h.Update) + group.PUT("/appearance", requireAdmin, h.UpdateAppearance) + group.PUT("/customer-auth", requireAdmin, h.UpdateCustomerAuth) +} + +// RegisterPublicRoutes exposes the site identity (name, description) +// under a plain, non-admin-prefixed path for the storefront to read. +func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) { + rg.GET("/site-settings", h.Get) } diff --git a/backend/internal/modules/site/service.go b/backend/internal/modules/site/service.go index dc5ccf0..345805a 100644 --- a/backend/internal/modules/site/service.go +++ b/backend/internal/modules/site/service.go @@ -1,6 +1,10 @@ package site -import "context" +import ( + "context" + + "github.com/google/uuid" +) type Service struct { repo Repository @@ -14,10 +18,190 @@ 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} +func (s *Service) Update(ctx context.Context, name, description string, ordersEnabled bool) (*Settings, error) { + settings := &Settings{Name: name, Description: description, OrdersEnabled: ordersEnabled} if err := s.repo.Update(ctx, settings); err != nil { return nil, err } return s.repo.Get(ctx) } + +// AppearanceInput is updated independently from site identity (name/slug): +// the "Appearance" admin page only ever touches these fields, so it must +// not be blocked by identity validation (e.g. an unset slug) or clobber +// name/description/slug on save. Each field is a pointer so the admin can +// change a single option (e.g. just the product layout) without resending +// every color -- nil fields keep their current, already-saved value. +type AppearanceInput struct { + HeaderBgColor *string + HeaderTextColor *string + BodyBgColor *string + BodyTextColor *string + FooterBgColor *string + FooterTextColor *string + AccentColor *string + ProductLayout *string + ProductColumns *int + ProductScroll *string + + ContactCardTransparent *bool + ContactCardBgColor *string + + // LogoMediaIDSet/HeroMediaIDSet distinguish "the admin didn't touch + // this field" (false: keep the current value) from "the admin + // explicitly chose an image, possibly clearing it to nil" (true: use + // the ID as-is, nil included) -- same reasoning as + // CustomerAuthInput.VerificationContactLinkSet. + LogoMediaIDSet bool + LogoMediaID *uuid.UUID + HeroMediaIDSet bool + HeroMediaID *uuid.UUID + HeroPages *string +} + +func (s *Service) UpdateAppearance(ctx context.Context, in AppearanceInput) (*Settings, error) { + current, err := s.repo.Get(ctx) + if err != nil { + return nil, err + } + + settings := &Settings{ + HeaderBgColor: applyIfSet(in.HeaderBgColor, current.HeaderBgColor), + HeaderTextColor: applyIfSet(in.HeaderTextColor, current.HeaderTextColor), + BodyBgColor: applyIfSet(in.BodyBgColor, current.BodyBgColor), + BodyTextColor: applyIfSet(in.BodyTextColor, current.BodyTextColor), + FooterBgColor: applyIfSet(in.FooterBgColor, current.FooterBgColor), + FooterTextColor: applyIfSet(in.FooterTextColor, current.FooterTextColor), + AccentColor: applyIfSet(in.AccentColor, current.AccentColor), + ProductLayout: applyIfSet(in.ProductLayout, current.ProductLayout), + ProductColumns: applyIntIfSet(in.ProductColumns, current.ProductColumns), + ProductScroll: applyIfSet(in.ProductScroll, current.ProductScroll), + ContactCardTransparent: applyBoolIfSet(in.ContactCardTransparent, current.ContactCardTransparent), + ContactCardBgColor: applyIfSet(in.ContactCardBgColor, current.ContactCardBgColor), + LogoMediaID: current.LogoMediaID, + HeroMediaID: current.HeroMediaID, + HeroPages: applyIfSet(in.HeroPages, current.HeroPages), + } + if in.LogoMediaIDSet { + settings.LogoMediaID = in.LogoMediaID + } + if in.HeroMediaIDSet { + settings.HeroMediaID = in.HeroMediaID + } + if err := s.repo.UpdateAppearance(ctx, settings); err != nil { + return nil, err + } + return s.repo.Get(ctx) +} + +func applyIfSet(value *string, fallback string) string { + if value == nil { + return fallback + } + return *value +} + +func applyIntIfSet(value *int, fallback int) int { + if value == nil { + return fallback + } + return *value +} + +// CustomerAuthInput is updated independently from identity/appearance, same +// reasoning as AppearanceInput: the "Customer accounts" admin page only +// ever touches these fields. LoginEnabled and RegistrationEnabled are two +// independent switches (e.g. an invite-only shop can enable login while +// keeping registration off), so each is its own optional pointer. +type CustomerAuthInput struct { + LoginEnabled *bool + RegistrationEnabled *bool + VerificationRequired *bool + // VerificationContactLinkSet distinguishes "the admin didn't touch this + // field" (false: keep the current value) from "the admin explicitly + // chose a link, possibly clearing it to nil" (true: use + // VerificationContactLinkID as-is, nil included). + VerificationContactLinkSet bool + VerificationContactLinkID *uuid.UUID +} + +func (s *Service) UpdateCustomerAuth(ctx context.Context, in CustomerAuthInput) (*Settings, error) { + current, err := s.repo.Get(ctx) + if err != nil { + return nil, err + } + + settings := &Settings{ + CustomerLoginEnabled: applyBoolIfSet(in.LoginEnabled, current.CustomerLoginEnabled), + CustomerRegistrationEnabled: applyBoolIfSet(in.RegistrationEnabled, current.CustomerRegistrationEnabled), + CustomerVerificationRequired: applyBoolIfSet(in.VerificationRequired, current.CustomerVerificationRequired), + VerificationContactLinkID: current.VerificationContactLinkID, + } + if in.VerificationContactLinkSet { + settings.VerificationContactLinkID = in.VerificationContactLinkID + } + if err := s.repo.UpdateCustomerAuth(ctx, settings); err != nil { + return nil, err + } + return s.repo.Get(ctx) +} + +func applyBoolIfSet(value *bool, fallback bool) bool { + if value == nil { + return fallback + } + return *value +} + +// CustomerLoginEnabled is consumed by auth.CustomerHandler to gate the +// customer login page/endpoint behind the admin's toggle. +func (s *Service) CustomerLoginEnabled(ctx context.Context) (bool, error) { + settings, err := s.repo.Get(ctx) + if err != nil { + return false, err + } + return settings.CustomerLoginEnabled, nil +} + +// CustomerRegistrationEnabled is consumed by auth.CustomerHandler to gate +// the customer registration page/endpoint behind the admin's toggle, +// independently of CustomerLoginEnabled. +func (s *Service) CustomerRegistrationEnabled(ctx context.Context) (bool, error) { + settings, err := s.repo.Get(ctx) + if err != nil { + return false, err + } + return settings.CustomerRegistrationEnabled, nil +} + +// CustomerAccountsAvailable is consumed by users.Service.Create to prevent +// creating customer-role users when neither login nor registration is +// enabled -- such an account could never be used to sign in. +func (s *Service) CustomerAccountsAvailable(ctx context.Context) (bool, error) { + settings, err := s.repo.Get(ctx) + if err != nil { + return false, err + } + return settings.CustomerLoginEnabled || settings.CustomerRegistrationEnabled, nil +} + +// CustomerCheckoutSettings is consumed by orders.RequireAccountsEnabled / +// RequireApprovedVerificationIfNeeded to gate order creation. +func (s *Service) CustomerCheckoutSettings(ctx context.Context) (accountsEnabled, verificationRequired bool, err error) { + settings, err := s.repo.Get(ctx) + if err != nil { + return false, false, err + } + return settings.CustomerLoginEnabled, settings.CustomerVerificationRequired, nil +} + +// OrdersEnabled is consumed by orders.RequireOrdersEnabled: the site-wide +// "vitrine vs boutique" switch, checked before any of the customer-account +// gates so a pure showcase site never has to think about accounts at all. +func (s *Service) OrdersEnabled(ctx context.Context) (bool, error) { + settings, err := s.repo.Get(ctx) + if err != nil { + return false, err + } + return settings.OrdersEnabled, nil +} diff --git a/backend/internal/modules/telegram/handler.go b/backend/internal/modules/telegram/handler.go index dc9145e..1c6f13a 100644 --- a/backend/internal/modules/telegram/handler.go +++ b/backend/internal/modules/telegram/handler.go @@ -21,7 +21,6 @@ type settingsResponse struct { 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 { @@ -30,7 +29,6 @@ func toResponse(s *Settings) settingsResponse { BotTokenConfigured: s.BotToken != "", ChatID: s.ChatID, NotifyNewOrder: s.NotifyNewOrder, - NotifyStatusChange: s.NotifyStatusChange, } } @@ -44,11 +42,10 @@ func (h *Handler) Get(c *gin.Context) { } 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"` + 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"` } func (h *Handler) Update(c *gin.Context) { @@ -57,7 +54,7 @@ func (h *Handler) Update(c *gin.Context) { 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) + settings, err := h.service.Update(c.Request.Context(), req.Enabled, req.BotToken, req.ChatID, req.NotifyNewOrder) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update telegram settings"}) return diff --git a/backend/internal/modules/telegram/model.go b/backend/internal/modules/telegram/model.go index 32151e0..bfdc366 100644 --- a/backend/internal/modules/telegram/model.go +++ b/backend/internal/modules/telegram/model.go @@ -9,14 +9,13 @@ 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 + ID int16 `gorm:"primaryKey"` + Enabled bool + BotToken string + ChatID string + NotifyNewOrder bool + CreatedAt time.Time + UpdatedAt time.Time } func (Settings) TableName() string { return "telegram_settings" } diff --git a/backend/internal/modules/telegram/repository.go b/backend/internal/modules/telegram/repository.go index c5789f9..ff810d2 100644 --- a/backend/internal/modules/telegram/repository.go +++ b/backend/internal/modules/telegram/repository.go @@ -30,10 +30,9 @@ func (r *gormRepository) Get(ctx context.Context) (*Settings, error) { 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, + "enabled": s.Enabled, + "chat_id": s.ChatID, + "notify_new_order": s.NotifyNewOrder, } // bot_token is only overwritten when explicitly provided (see service.go): // a blank value in the update request means "keep the existing secret". diff --git a/backend/internal/modules/telegram/service.go b/backend/internal/modules/telegram/service.go index 62822a0..aaff333 100644 --- a/backend/internal/modules/telegram/service.go +++ b/backend/internal/modules/telegram/service.go @@ -23,13 +23,12 @@ func (s *Service) Get(ctx context.Context) (*Settings, error) { // 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) { +func (s *Service) Update(ctx context.Context, enabled bool, botToken, chatID string, notifyNewOrder bool) (*Settings, error) { settings := &Settings{ - Enabled: enabled, - BotToken: botToken, - ChatID: chatID, - NotifyNewOrder: notifyNewOrder, - NotifyStatusChange: notifyStatusChange, + Enabled: enabled, + BotToken: botToken, + ChatID: chatID, + NotifyNewOrder: notifyNewOrder, } if err := s.repo.Update(ctx, settings); err != nil { return nil, err @@ -56,10 +55,6 @@ 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 { diff --git a/backend/internal/modules/units/routes.go b/backend/internal/modules/units/routes.go index 9b10382..e987ab9 100644 --- a/backend/internal/modules/units/routes.go +++ b/backend/internal/modules/units/routes.go @@ -9,3 +9,9 @@ func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.Handl group.PUT("/:id", h.Update) group.DELETE("/:id", h.Delete) } + +// RegisterPublicRoutes exposes the read-only unit list so the storefront +// can render price-tier labels (e.g. "3.5 g", "10 mg"). +func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) { + rg.GET("/units", h.List) +} diff --git a/backend/internal/modules/users/errors.go b/backend/internal/modules/users/errors.go new file mode 100644 index 0000000..6bd81f0 --- /dev/null +++ b/backend/internal/modules/users/errors.go @@ -0,0 +1,9 @@ +package users + +import "errors" + +var ( + ErrAdminLimitReached = errors.New("admin limit reached") + ErrNotFound = errors.New("user not found") + ErrUsernameTaken = errors.New("username already in use") +) diff --git a/backend/internal/modules/users/handler.go b/backend/internal/modules/users/handler.go index 385483a..7c4919b 100644 --- a/backend/internal/modules/users/handler.go +++ b/backend/internal/modules/users/handler.go @@ -18,13 +18,13 @@ func NewHandler(service *Service) *Handler { type userResponse struct { ID uuid.UUID `json:"id"` - Email string `json:"email"` + Username string `json:"username"` 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} + return userResponse{ID: u.ID, Username: u.Username, Role: u.Role, IsActive: u.IsActive} } func (h *Handler) List(c *gin.Context) { @@ -41,7 +41,7 @@ func (h *Handler) List(c *gin.Context) { } type createUserRequest struct { - Email string `json:"email" binding:"required,email"` + Username string `json:"username" binding:"required,min=3,max=50"` Password string `json:"password" binding:"required,min=12"` Role string `json:"role" binding:"required,oneof=admin customer"` } @@ -52,11 +52,17 @@ func (h *Handler) Create(c *gin.Context) { 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) + user, err := h.service.Create(c.Request.Context(), req.Username, req.Password, req.Role) if err != nil { - if errors.Is(err, ErrEmailTaken) { - c.JSON(http.StatusConflict, gin.H{"error": "email already in use"}) + if errors.Is(err, ErrAdminLimitReached) { + c.JSON(http.StatusConflict, gin.H{"error": "admin limit reached"}) + } + if errors.Is(err, ErrUsernameTaken) { + c.JSON(http.StatusConflict, gin.H{"error": "username already in use"}) + return + } + if errors.Is(err, ErrCustomerAccountsDisabled) { + c.JSON(http.StatusForbidden, gin.H{"error": "customer accounts are disabled: enable customer login or registration first"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) @@ -83,8 +89,27 @@ func (h *Handler) Get(c *gin.Context) { c.JSON(http.StatusOK, toResponse(user)) } +func (h *Handler) GetRole(c *gin.Context) { + role := c.Param("role") + + if role == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role"}) + return + } + + count, err := h.service.CountByRole(c.Request.Context(), role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to count users", + }) + return + } + + c.JSON(http.StatusOK, count) +} + type updateUserRequest struct { - Email string `json:"email" binding:"required,email"` + Username string `json:"username" binding:"required,min=3,max=50"` IsActive bool `json:"is_active"` } @@ -100,14 +125,14 @@ func (h *Handler) Update(c *gin.Context) { return } - user, err := h.service.UpdateProfile(c.Request.Context(), id, req.Email, req.IsActive) + user, err := h.service.UpdateProfile(c.Request.Context(), id, req.Username, 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"}) + if errors.Is(err, ErrUsernameTaken) { + c.JSON(http.StatusConflict, gin.H{"error": "username already in use"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user"}) diff --git a/backend/internal/modules/users/model.go b/backend/internal/modules/users/model.go index 6406d13..0d7947a 100644 --- a/backend/internal/modules/users/model.go +++ b/backend/internal/modules/users/model.go @@ -16,7 +16,7 @@ const ( type User struct { ID uuid.UUID `gorm:"type:uuid;primaryKey"` - Email string `gorm:"uniqueIndex;not null"` + Username string `gorm:"uniqueIndex;not null"` PasswordHash string `gorm:"not null"` Role string `gorm:"not null;default:admin"` IsActive bool `gorm:"not null;default:true"` diff --git a/backend/internal/modules/users/repository.go b/backend/internal/modules/users/repository.go index c1b8017..ec20d0b 100644 --- a/backend/internal/modules/users/repository.go +++ b/backend/internal/modules/users/repository.go @@ -9,16 +9,14 @@ import ( "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) + FindByUsername(ctx context.Context, username 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 + CountByRole(ctx context.Context, role string) (int64, error) } type gormRepository struct { @@ -32,16 +30,16 @@ func NewRepository(db *gorm.DB) Repository { 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 ErrUsernameTaken } return err } return nil } -func (r *gormRepository) FindByEmail(ctx context.Context, email string) (*User, error) { +func (r *gormRepository) FindByUsername(ctx context.Context, username string) (*User, error) { var user User - err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error + err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } @@ -63,6 +61,20 @@ func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*User, err return &user, nil } +func (r *gormRepository) CountByRole(ctx context.Context, role string) (int64, error) { + var count int64 + + err := r.db.WithContext(ctx). + Model(&User{}). + Where("role = ?", role). + Count(&count).Error + + if err != nil { + return 0, err + } + + return count, 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 { @@ -74,7 +86,7 @@ func (r *gormRepository) List(ctx context.Context) ([]*User, error) { func (r *gormRepository) Update(ctx context.Context, user *User) error { err := r.db.WithContext(ctx).Save(user).Error if isUniqueViolation(err) { - return ErrEmailTaken + return ErrUsernameTaken } return err } @@ -91,7 +103,7 @@ func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error { } // isUniqueViolation reports whether err is a Postgres unique-constraint -// violation (SQLSTATE 23505), e.g. a duplicate email. +// violation (SQLSTATE 23505), e.g. a duplicate username. func isUniqueViolation(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == "23505" diff --git a/backend/internal/modules/users/routes.go b/backend/internal/modules/users/routes.go index b5d9fd0..6e363f8 100644 --- a/backend/internal/modules/users/routes.go +++ b/backend/internal/modules/users/routes.go @@ -11,4 +11,5 @@ func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.Handl group.GET("/:id", h.Get) group.PUT("/:id", h.Update) group.DELETE("/:id", h.Delete) + group.GET("/verify/:role", h.GetRole) } diff --git a/backend/internal/modules/users/service.go b/backend/internal/modules/users/service.go index e1cd4cc..daafa7b 100644 --- a/backend/internal/modules/users/service.go +++ b/backend/internal/modules/users/service.go @@ -2,19 +2,34 @@ package users import ( "context" + "errors" "fmt" "github.com/google/uuid" + "backend/internal/platform/config" "backend/internal/platform/security" ) -type Service struct { - repo Repository +// ErrCustomerAccountsDisabled is returned by Create when trying to create a +// customer-role user while neither customer login nor registration is +// enabled -- such an account would have no way to sign in. +var ErrCustomerAccountsDisabled = errors.New("customer accounts are disabled") + +// AccountsGate lets the users module check the site-wide customer-accounts +// toggles without importing the site module directly. +type AccountsGate interface { + CustomerAccountsAvailable(ctx context.Context) (bool, error) } -func NewService(repo Repository) *Service { - return &Service{repo: repo} +type Service struct { + repo Repository + gate AccountsGate + cfg config.SeedConfig +} + +func NewService(repo Repository, gate AccountsGate, cfg config.SeedConfig) *Service { + return &Service{repo: repo, gate: gate, cfg: cfg} } func (s *Service) List(ctx context.Context) ([]*User, error) { @@ -25,14 +40,41 @@ 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) CountByRole(ctx context.Context, role string) (int64, error) { + return s.repo.CountByRole(ctx, role) } -func (s *Service) Create(ctx context.Context, email, password, role string) (*User, error) { +func (s *Service) FindByUsername(ctx context.Context, username string) (*User, error) { + return s.repo.FindByUsername(ctx, username) +} + +func (s *Service) Create(ctx context.Context, username, password, role string) (*User, error) { if role != RoleAdmin && role != RoleCustomer { return nil, fmt.Errorf("invalid role %q", role) } + + if role == RoleAdmin { + count, err := s.repo.CountByRole(ctx, RoleAdmin) + if err != nil { + return nil, err + } + + if count >= s.cfg.AdminNumber { + return nil, ErrAdminLimitReached + } + } + + if role == RoleCustomer { + available, err := s.gate.CustomerAccountsAvailable(ctx) + if err != nil { + return nil, err + } + + if !available { + return nil, ErrCustomerAccountsDisabled + } + } + hash, err := security.HashPassword(password) if err != nil { return nil, fmt.Errorf("hash password: %w", err) @@ -40,24 +82,26 @@ func (s *Service) Create(ctx context.Context, email, password, role string) (*Us user := &User{ ID: uuid.New(), - Email: email, + Username: username, 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) { +func (s *Service) UpdateProfile(ctx context.Context, id uuid.UUID, username string, isActive bool) (*User, error) { user, err := s.repo.FindByID(ctx, id) if err != nil { return nil, err } - user.Email = email + user.Username = username user.IsActive = isActive if err := s.repo.Update(ctx, user); err != nil { return nil, err diff --git a/backend/internal/platform/config/config.go b/backend/internal/platform/config/config.go index 53b062d..9b11840 100644 --- a/backend/internal/platform/config/config.go +++ b/backend/internal/platform/config/config.go @@ -11,12 +11,13 @@ import ( ) type Config struct { - Database DatabaseConfig - Redis RedisConfig - JWT JWTConfig - Server ServerConfig - Seed SeedConfig - Media MediaConfig + Database DatabaseConfig + Redis RedisConfig + JWT JWTConfig + Server ServerConfig + Seed SeedConfig + Media MediaConfig + Verification VerificationConfig } type DatabaseConfig struct { @@ -41,8 +42,9 @@ type ServerConfig struct { } type SeedConfig struct { - AdminEmail string + AdminUsername string AdminPassword string + AdminNumber int64 } // MediaConfig configures the pluggable media storage backend. Driver @@ -64,12 +66,19 @@ type MediaConfig struct { S3PublicBaseURL string } +// VerificationConfig configures where customer identity-verification +// documents (front/back ID photos) are stored on disk. Unlike media, this +// is never served through a public URL -- see customerverification.Storage. +type VerificationConfig struct { + UploadDir 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") + _ = godotenv.Load("../../../.env") accessMinutes, err := strconv.Atoi(getEnv("ACCESS_TOKEN_TTL_MINUTES", "15")) if err != nil { @@ -119,7 +128,10 @@ func Load() (*Config, error) { } } } - + adminNumber, err := strconv.ParseInt(getEnv("ADMIN_NUMBER", "2"), 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid ADMIN_NUMBER: %w", err) + } return &Config{ Database: DatabaseConfig{ URL: dbURL, @@ -139,8 +151,9 @@ func Load() (*Config, error) { CookieSecure: cookieSecure, }, Seed: SeedConfig{ - AdminEmail: os.Getenv("SEED_ADMIN_EMAIL"), + AdminUsername: os.Getenv("SEED_ADMIN_USERNAME"), AdminPassword: os.Getenv("SEED_ADMIN_PASSWORD"), + AdminNumber: adminNumber, }, Media: MediaConfig{ Driver: mediaDriver, @@ -155,6 +168,9 @@ func Load() (*Config, error) { S3UsePathStyle: s3UsePathStyle, S3PublicBaseURL: os.Getenv("S3_PUBLIC_BASE_URL"), }, + Verification: VerificationConfig{ + UploadDir: getEnv("VERIFICATION_UPLOAD_DIR", "./verification-uploads"), + }, }, nil } diff --git a/backend/migrations/000012_require_product_category.down.sql b/backend/migrations/000012_require_product_category.down.sql new file mode 100644 index 0000000..0fc6fff --- /dev/null +++ b/backend/migrations/000012_require_product_category.down.sql @@ -0,0 +1,9 @@ +ALTER TABLE products + DROP CONSTRAINT products_category_id_fkey; + +ALTER TABLE products + ALTER COLUMN category_id DROP NOT NULL; + +ALTER TABLE products + ADD CONSTRAINT products_category_id_fkey + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL; diff --git a/backend/migrations/000012_require_product_category.up.sql b/backend/migrations/000012_require_product_category.up.sql new file mode 100644 index 0000000..71e95ff --- /dev/null +++ b/backend/migrations/000012_require_product_category.up.sql @@ -0,0 +1,9 @@ +ALTER TABLE products + DROP CONSTRAINT products_category_id_fkey; + +ALTER TABLE products + ALTER COLUMN category_id SET NOT NULL; + +ALTER TABLE products + ADD CONSTRAINT products_category_id_fkey + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT; diff --git a/backend/migrations/000013_rename_users_email_to_username.down.sql b/backend/migrations/000013_rename_users_email_to_username.down.sql new file mode 100644 index 0000000..7fb2cf9 --- /dev/null +++ b/backend/migrations/000013_rename_users_email_to_username.down.sql @@ -0,0 +1 @@ +ALTER TABLE users RENAME COLUMN username TO email; diff --git a/backend/migrations/000013_rename_users_email_to_username.up.sql b/backend/migrations/000013_rename_users_email_to_username.up.sql new file mode 100644 index 0000000..b2e94c4 --- /dev/null +++ b/backend/migrations/000013_rename_users_email_to_username.up.sql @@ -0,0 +1 @@ +ALTER TABLE users RENAME COLUMN email TO username; diff --git a/backend/migrations/000014_create_contact_links.down.sql b/backend/migrations/000014_create_contact_links.down.sql new file mode 100644 index 0000000..3fed18a --- /dev/null +++ b/backend/migrations/000014_create_contact_links.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS contact_links; diff --git a/backend/migrations/000014_create_contact_links.up.sql b/backend/migrations/000014_create_contact_links.up.sql new file mode 100644 index 0000000..58a204a --- /dev/null +++ b/backend/migrations/000014_create_contact_links.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE contact_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + label TEXT NOT NULL, + url TEXT NOT NULL, + icon_media_id UUID REFERENCES media(id) ON DELETE SET NULL, + color TEXT NOT NULL DEFAULT '', + position INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/backend/migrations/000015_add_appearance_to_site_settings.down.sql b/backend/migrations/000015_add_appearance_to_site_settings.down.sql new file mode 100644 index 0000000..1f0202e --- /dev/null +++ b/backend/migrations/000015_add_appearance_to_site_settings.down.sql @@ -0,0 +1,10 @@ +ALTER TABLE site_settings + DROP COLUMN header_bg_color, + DROP COLUMN header_text_color, + DROP COLUMN body_bg_color, + DROP COLUMN body_text_color, + DROP COLUMN footer_bg_color, + DROP COLUMN footer_text_color, + DROP COLUMN accent_color, + DROP COLUMN product_layout, + DROP COLUMN menu_style; diff --git a/backend/migrations/000015_add_appearance_to_site_settings.up.sql b/backend/migrations/000015_add_appearance_to_site_settings.up.sql new file mode 100644 index 0000000..36ec784 --- /dev/null +++ b/backend/migrations/000015_add_appearance_to_site_settings.up.sql @@ -0,0 +1,10 @@ +ALTER TABLE site_settings + ADD COLUMN header_bg_color TEXT NOT NULL DEFAULT '#232323', + ADD COLUMN header_text_color TEXT NOT NULL DEFAULT '#f2f2f2', + ADD COLUMN body_bg_color TEXT NOT NULL DEFAULT '#1a1a1a', + ADD COLUMN body_text_color TEXT NOT NULL DEFAULT '#f2f2f2', + ADD COLUMN footer_bg_color TEXT NOT NULL DEFAULT '#232323', + ADD COLUMN footer_text_color TEXT NOT NULL DEFAULT '#f2f2f2', + ADD COLUMN accent_color TEXT NOT NULL DEFAULT '#ffd700', + ADD COLUMN product_layout TEXT NOT NULL DEFAULT 'grid', + ADD COLUMN menu_style TEXT NOT NULL DEFAULT 'horizontal'; diff --git a/backend/migrations/000016_add_icon_key_to_contact_links.down.sql b/backend/migrations/000016_add_icon_key_to_contact_links.down.sql new file mode 100644 index 0000000..95e50ab --- /dev/null +++ b/backend/migrations/000016_add_icon_key_to_contact_links.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE contact_links + DROP COLUMN icon_key; diff --git a/backend/migrations/000016_add_icon_key_to_contact_links.up.sql b/backend/migrations/000016_add_icon_key_to_contact_links.up.sql new file mode 100644 index 0000000..5836e67 --- /dev/null +++ b/backend/migrations/000016_add_icon_key_to_contact_links.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE contact_links + ADD COLUMN icon_key TEXT NOT NULL DEFAULT ''; diff --git a/backend/migrations/000017_add_customer_auth_to_site_settings.down.sql b/backend/migrations/000017_add_customer_auth_to_site_settings.down.sql new file mode 100644 index 0000000..1d5e80a --- /dev/null +++ b/backend/migrations/000017_add_customer_auth_to_site_settings.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE site_settings + DROP COLUMN customer_accounts_enabled, + DROP COLUMN customer_verification_required, + DROP COLUMN verification_contact_link_id; diff --git a/backend/migrations/000017_add_customer_auth_to_site_settings.up.sql b/backend/migrations/000017_add_customer_auth_to_site_settings.up.sql new file mode 100644 index 0000000..812c7fd --- /dev/null +++ b/backend/migrations/000017_add_customer_auth_to_site_settings.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE site_settings + ADD COLUMN customer_accounts_enabled BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN customer_verification_required BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN verification_contact_link_id UUID REFERENCES contact_links(id) ON DELETE SET NULL; diff --git a/backend/migrations/000018_create_customer_verifications.down.sql b/backend/migrations/000018_create_customer_verifications.down.sql new file mode 100644 index 0000000..5f11900 --- /dev/null +++ b/backend/migrations/000018_create_customer_verifications.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS customer_verifications; diff --git a/backend/migrations/000018_create_customer_verifications.up.sql b/backend/migrations/000018_create_customer_verifications.up.sql new file mode 100644 index 0000000..0544fcd --- /dev/null +++ b/backend/migrations/000018_create_customer_verifications.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE customer_verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + front_key TEXT NOT NULL, + back_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + admin_note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (status IN ('pending', 'approved', 'rejected')) +); diff --git a/backend/migrations/000019_add_customer_id_to_orders.down.sql b/backend/migrations/000019_add_customer_id_to_orders.down.sql new file mode 100644 index 0000000..efbbf97 --- /dev/null +++ b/backend/migrations/000019_add_customer_id_to_orders.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_orders_customer_id; + +ALTER TABLE orders + DROP COLUMN customer_id; diff --git a/backend/migrations/000019_add_customer_id_to_orders.up.sql b/backend/migrations/000019_add_customer_id_to_orders.up.sql new file mode 100644 index 0000000..ae51eb7 --- /dev/null +++ b/backend/migrations/000019_add_customer_id_to_orders.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE orders + ADD COLUMN customer_id UUID REFERENCES users(id) ON DELETE SET NULL; + +CREATE INDEX idx_orders_customer_id ON orders(customer_id); diff --git a/backend/migrations/000020_split_customer_accounts_toggle.down.sql b/backend/migrations/000020_split_customer_accounts_toggle.down.sql new file mode 100644 index 0000000..5a40124 --- /dev/null +++ b/backend/migrations/000020_split_customer_accounts_toggle.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE site_settings + DROP COLUMN customer_registration_enabled; + +ALTER TABLE site_settings + RENAME COLUMN customer_login_enabled TO customer_accounts_enabled; diff --git a/backend/migrations/000020_split_customer_accounts_toggle.up.sql b/backend/migrations/000020_split_customer_accounts_toggle.up.sql new file mode 100644 index 0000000..83dfec3 --- /dev/null +++ b/backend/migrations/000020_split_customer_accounts_toggle.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE site_settings + RENAME COLUMN customer_accounts_enabled TO customer_login_enabled; + +ALTER TABLE site_settings + ADD COLUMN customer_registration_enabled BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/migrations/000021_add_orders_enabled_to_site_settings.down.sql b/backend/migrations/000021_add_orders_enabled_to_site_settings.down.sql new file mode 100644 index 0000000..ae00f05 --- /dev/null +++ b/backend/migrations/000021_add_orders_enabled_to_site_settings.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE site_settings + DROP COLUMN orders_enabled; diff --git a/backend/migrations/000021_add_orders_enabled_to_site_settings.up.sql b/backend/migrations/000021_add_orders_enabled_to_site_settings.up.sql new file mode 100644 index 0000000..9f36580 --- /dev/null +++ b/backend/migrations/000021_add_orders_enabled_to_site_settings.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE site_settings + ADD COLUMN orders_enabled BOOLEAN NOT NULL DEFAULT true; diff --git a/backend/migrations/000022_add_product_id_to_media.down.sql b/backend/migrations/000022_add_product_id_to_media.down.sql new file mode 100644 index 0000000..8dce8bd --- /dev/null +++ b/backend/migrations/000022_add_product_id_to_media.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_media_product_id; +ALTER TABLE media DROP COLUMN IF EXISTS product_id; diff --git a/backend/migrations/000022_add_product_id_to_media.up.sql b/backend/migrations/000022_add_product_id_to_media.up.sql new file mode 100644 index 0000000..d665fe0 --- /dev/null +++ b/backend/migrations/000022_add_product_id_to_media.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE media ADD COLUMN product_id UUID NULL REFERENCES products(id) ON DELETE CASCADE; +CREATE INDEX idx_media_product_id ON media(product_id); diff --git a/backend/migrations/000023_remove_menu_style_from_site_settings.down.sql b/backend/migrations/000023_remove_menu_style_from_site_settings.down.sql new file mode 100644 index 0000000..3ca78cf --- /dev/null +++ b/backend/migrations/000023_remove_menu_style_from_site_settings.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE site_settings + ADD COLUMN menu_style TEXT NOT NULL DEFAULT 'horizontal'; diff --git a/backend/migrations/000023_remove_menu_style_from_site_settings.up.sql b/backend/migrations/000023_remove_menu_style_from_site_settings.up.sql new file mode 100644 index 0000000..d102da7 --- /dev/null +++ b/backend/migrations/000023_remove_menu_style_from_site_settings.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE site_settings + DROP COLUMN menu_style; diff --git a/backend/migrations/000024_drop_slug_and_short_description.down.sql b/backend/migrations/000024_drop_slug_and_short_description.down.sql new file mode 100644 index 0000000..51ce187 --- /dev/null +++ b/backend/migrations/000024_drop_slug_and_short_description.down.sql @@ -0,0 +1,11 @@ +-- Re-added as nullable, no uniqueness: a perfect rollback would need +-- per-row slug values, which no longer exist once this migration has run. +ALTER TABLE products + ADD COLUMN slug TEXT, + ADD COLUMN short_description TEXT NOT NULL DEFAULT ''; + +ALTER TABLE categories + ADD COLUMN slug TEXT; + +ALTER TABLE site_settings + ADD COLUMN slug TEXT NOT NULL DEFAULT ''; diff --git a/backend/migrations/000024_drop_slug_and_short_description.up.sql b/backend/migrations/000024_drop_slug_and_short_description.up.sql new file mode 100644 index 0000000..a8d65e4 --- /dev/null +++ b/backend/migrations/000024_drop_slug_and_short_description.up.sql @@ -0,0 +1,9 @@ +ALTER TABLE products + DROP COLUMN slug, + DROP COLUMN short_description; + +ALTER TABLE categories + DROP COLUMN slug; + +ALTER TABLE site_settings + DROP COLUMN slug; diff --git a/backend/migrations/000025_add_logo_and_hero_to_site_settings.down.sql b/backend/migrations/000025_add_logo_and_hero_to_site_settings.down.sql new file mode 100644 index 0000000..84e9f5c --- /dev/null +++ b/backend/migrations/000025_add_logo_and_hero_to_site_settings.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE site_settings + DROP COLUMN logo_media_id, + DROP COLUMN hero_media_id, + DROP COLUMN hero_pages; diff --git a/backend/migrations/000025_add_logo_and_hero_to_site_settings.up.sql b/backend/migrations/000025_add_logo_and_hero_to_site_settings.up.sql new file mode 100644 index 0000000..ab3e963 --- /dev/null +++ b/backend/migrations/000025_add_logo_and_hero_to_site_settings.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE site_settings + ADD COLUMN logo_media_id UUID REFERENCES media(id) ON DELETE SET NULL, + ADD COLUMN hero_media_id UUID REFERENCES media(id) ON DELETE SET NULL, + ADD COLUMN hero_pages TEXT NOT NULL DEFAULT ''; diff --git a/backend/migrations/000026_add_media_to_categories.down.sql b/backend/migrations/000026_add_media_to_categories.down.sql new file mode 100644 index 0000000..d4a233e --- /dev/null +++ b/backend/migrations/000026_add_media_to_categories.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE categories + DROP COLUMN media_id; diff --git a/backend/migrations/000026_add_media_to_categories.up.sql b/backend/migrations/000026_add_media_to_categories.up.sql new file mode 100644 index 0000000..29a1960 --- /dev/null +++ b/backend/migrations/000026_add_media_to_categories.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE categories + ADD COLUMN media_id UUID REFERENCES media(id) ON DELETE SET NULL; diff --git a/backend/migrations/000027_drop_status_from_orders.down.sql b/backend/migrations/000027_drop_status_from_orders.down.sql new file mode 100644 index 0000000..1c1656a --- /dev/null +++ b/backend/migrations/000027_drop_status_from_orders.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE orders + ADD COLUMN status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'preparing', 'shipped', 'completed', 'cancelled')); diff --git a/backend/migrations/000027_drop_status_from_orders.up.sql b/backend/migrations/000027_drop_status_from_orders.up.sql new file mode 100644 index 0000000..a8a3d29 --- /dev/null +++ b/backend/migrations/000027_drop_status_from_orders.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE orders + DROP COLUMN status; diff --git a/backend/migrations/000028_add_product_columns_to_site_settings.down.sql b/backend/migrations/000028_add_product_columns_to_site_settings.down.sql new file mode 100644 index 0000000..3cd4709 --- /dev/null +++ b/backend/migrations/000028_add_product_columns_to_site_settings.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE site_settings + DROP COLUMN product_columns; diff --git a/backend/migrations/000028_add_product_columns_to_site_settings.up.sql b/backend/migrations/000028_add_product_columns_to_site_settings.up.sql new file mode 100644 index 0000000..5ba85ff --- /dev/null +++ b/backend/migrations/000028_add_product_columns_to_site_settings.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE site_settings + ADD COLUMN product_columns INT NOT NULL DEFAULT 0; diff --git a/backend/migrations/000029_add_product_scroll_to_site_settings.down.sql b/backend/migrations/000029_add_product_scroll_to_site_settings.down.sql new file mode 100644 index 0000000..c7e1164 --- /dev/null +++ b/backend/migrations/000029_add_product_scroll_to_site_settings.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE site_settings + DROP COLUMN product_scroll; diff --git a/backend/migrations/000029_add_product_scroll_to_site_settings.up.sql b/backend/migrations/000029_add_product_scroll_to_site_settings.up.sql new file mode 100644 index 0000000..ad5bc36 --- /dev/null +++ b/backend/migrations/000029_add_product_scroll_to_site_settings.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE site_settings + ADD COLUMN product_scroll TEXT NOT NULL DEFAULT 'vertical'; diff --git a/backend/migrations/000030_add_contact_card_style_to_site_settings.down.sql b/backend/migrations/000030_add_contact_card_style_to_site_settings.down.sql new file mode 100644 index 0000000..0f99337 --- /dev/null +++ b/backend/migrations/000030_add_contact_card_style_to_site_settings.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE site_settings + DROP COLUMN contact_card_transparent, + DROP COLUMN contact_card_bg_color; diff --git a/backend/migrations/000030_add_contact_card_style_to_site_settings.up.sql b/backend/migrations/000030_add_contact_card_style_to_site_settings.up.sql new file mode 100644 index 0000000..739648d --- /dev/null +++ b/backend/migrations/000030_add_contact_card_style_to_site_settings.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE site_settings + ADD COLUMN contact_card_transparent BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN contact_card_bg_color TEXT NOT NULL DEFAULT '#232323'; diff --git a/backend/test/auth/service_test.go b/backend/test/auth/service_test.go index edb015c..684f6a6 100644 --- a/backend/test/auth/service_test.go +++ b/backend/test/auth/service_test.go @@ -16,27 +16,27 @@ import ( // 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 + byID map[uuid.UUID]*users.User + byUsername map[string]*users.User } func newFakeUserFinder() *fakeUserFinder { - return &fakeUserFinder{byID: map[uuid.UUID]*users.User{}, byEmail: map[string]*users.User{}} + return &fakeUserFinder{byID: map[uuid.UUID]*users.User{}, byUsername: map[string]*users.User{}} } -func (f *fakeUserFinder) add(email, password, role string, active bool) *users.User { +func (f *fakeUserFinder) add(username, 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} + u := &users.User{ID: uuid.New(), Username: username, PasswordHash: hash, Role: role, IsActive: active} f.byID[u.ID] = u - f.byEmail[u.Email] = u + f.byUsername[u.Username] = u return u } -func (f *fakeUserFinder) FindByEmail(_ context.Context, email string) (*users.User, error) { - u, ok := f.byEmail[email] +func (f *fakeUserFinder) FindByUsername(_ context.Context, username string) (*users.User, error) { + u, ok := f.byUsername[username] if !ok { return nil, users.ErrNotFound } @@ -109,28 +109,28 @@ func (f *fakeRefreshStore) RevokeAllForUser(_ context.Context, _ security.Audien func TestService_Login_Success(t *testing.T) { finder := newFakeUserFinder() - finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) + finder.add("admin", "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") + pair, user, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin", "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) + if user.Username != "admin" { + t.Fatalf("Login() user = %+v, want username admin", user) } } func TestService_Login_WrongPasswordRejected(t *testing.T) { finder := newFakeUserFinder() - finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) + finder.add("admin", "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") + _, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin", "wrong-password") if !errors.Is(err, auth.ErrInvalidCredentials) { t.Fatalf("Login() error = %v, want ErrInvalidCredentials", err) } @@ -140,10 +140,10 @@ 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) + finder.add("shopper", "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") + _, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "shopper", "correct-password") if !errors.Is(err, auth.ErrInvalidCredentials) { t.Fatalf("Login() error = %v, want ErrInvalidCredentials for a customer logging into the admin space", err) } @@ -151,10 +151,10 @@ func TestService_Login_WrongRoleSpaceRejected(t *testing.T) { func TestService_Login_DisabledAccountRejected(t *testing.T) { finder := newFakeUserFinder() - finder.add("admin@example.com", "correct-password", users.RoleAdmin, false) + finder.add("admin", "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") + _, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin", "correct-password") if !errors.Is(err, auth.ErrAccountDisabled) { t.Fatalf("Login() error = %v, want ErrAccountDisabled", err) } @@ -162,10 +162,10 @@ func TestService_Login_DisabledAccountRejected(t *testing.T) { func TestService_Refresh_RotatesToken(t *testing.T) { finder := newFakeUserFinder() - finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) + finder.add("admin", "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") + pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin", "correct-password") if err != nil { t.Fatalf("Login() error = %v", err) } @@ -181,10 +181,10 @@ func TestService_Refresh_RotatesToken(t *testing.T) { func TestService_Refresh_RejectsReusedToken(t *testing.T) { finder := newFakeUserFinder() - finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) + finder.add("admin", "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") + pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin", "correct-password") if err != nil { t.Fatalf("Login() error = %v", err) } @@ -200,10 +200,10 @@ func TestService_Refresh_RejectsReusedToken(t *testing.T) { func TestService_Logout_RevokesToken(t *testing.T) { finder := newFakeUserFinder() - finder.add("admin@example.com", "correct-password", users.RoleAdmin, true) + finder.add("admin", "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") + pair, _, err := svc.Login(context.Background(), security.AudienceAdmin, users.RoleAdmin, "admin", "correct-password") if err != nil { t.Fatalf("Login() error = %v", err) } diff --git a/backend/test/orders/service_test.go b/backend/test/orders/service_test.go index 2fafd0a..59c12e2 100644 --- a/backend/test/orders/service_test.go +++ b/backend/test/orders/service_test.go @@ -42,23 +42,22 @@ func (r *fakeRepository) FindByID(_ context.Context, id uuid.UUID) (*orders.Orde return o, r.items[id], nil } -func (r *fakeRepository) List(_ context.Context, status string) ([]*orders.Order, error) { +func (r *fakeRepository) List(_ context.Context) ([]*orders.Order, error) { var list []*orders.Order for _, o := range r.orders { - if status == "" || o.Status == status { - list = append(list, o) - } + 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 +func (r *fakeRepository) ListByCustomer(_ context.Context, customerID uuid.UUID) ([]*orders.Order, error) { + var list []*orders.Order + for _, o := range r.orders { + if o.CustomerID != nil && *o.CustomerID == customerID { + list = append(list, o) + } } - o.Status = status - return o, nil + return list, nil } // fakeProducts / fakeUnits / fakePrices back the small consumer-defined @@ -103,18 +102,13 @@ func (f *fakePrices) PriceForQuantity(_ context.Context, tierID uuid.UUID, multi } type fakeNotifier struct { - newOrderCalls []string - statusChangeCalls []string + newOrderCalls []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 { @@ -137,7 +131,7 @@ func newFixture() *testFixture { return &testFixture{ repo: newFakeRepository(), products: &fakeProducts{byID: map[uuid.UUID]*products.Product{ - productID: {ID: productID, Name: "Honey jar", Slug: "honey-jar", IsActive: true}, + productID: {ID: productID, Name: "Honey jar", IsActive: true}, }}, units: &fakeUnits{byID: map[uuid.UUID]*units.Unit{ unitID: {ID: unitID, Name: "Kilogram", Symbol: "kg"}, @@ -173,9 +167,6 @@ func TestService_Create_ComputesTotalAndNotifies(t *testing.T) { 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) } @@ -199,7 +190,7 @@ func TestService_Create_RejectsTierProductMismatch(t *testing.T) { svc := f.service() otherProduct := uuid.New() - f.products.byID[otherProduct] = &products.Product{ID: otherProduct, Name: "Other", Slug: "other", IsActive: true} + f.products.byID[otherProduct] = &products.Product{ID: otherProduct, Name: "Other", IsActive: true} _, _, err := svc.Create(context.Background(), orders.CreateInput{ CustomerName: "Alice", @@ -214,31 +205,25 @@ func TestService_Create_RejectsTierProductMismatch(t *testing.T) { } } -func TestService_UpdateStatus_ValidatesStatus(t *testing.T) { +func TestService_List_ReturnsAllOrders(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) + for range 2 { + if _, _, err := svc.Create(context.Background(), orders.CreateInput{ + CustomerName: "Alice", + CustomerEmail: "alice@example.com", + Items: []orders.ItemInput{{ProductID: f.productID, PriceTierID: f.tierID, Multiplier: 1}}, + }); 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) + list, err := svc.List(context.Background()) if err != nil { - t.Fatalf("UpdateStatus() error = %v", err) + t.Fatalf("List() 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)) + if len(list) != 2 { + t.Fatalf("List() returned %d orders, want 2", len(list)) } } diff --git a/backend/test/site/service_test.go b/backend/test/site/service_test.go index 5d929e8..649f752 100644 --- a/backend/test/site/service_test.go +++ b/backend/test/site/service_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/google/uuid" + "backend/internal/modules/site" ) @@ -17,6 +19,9 @@ func newFakeRepository() *fakeRepository { return &fakeRepository{settings: site.Settings{ID: 1}} } +func strPtr(s string) *string { return &s } +func boolPtr(b bool) *bool { return &b } + func (r *fakeRepository) Get(_ context.Context) (*site.Settings, error) { cp := r.settings return &cp, nil @@ -25,7 +30,34 @@ func (r *fakeRepository) Get(_ context.Context) (*site.Settings, error) { 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 + r.settings.OrdersEnabled = s.OrdersEnabled + return nil +} + +func (r *fakeRepository) UpdateAppearance(_ context.Context, s *site.Settings) error { + r.settings.HeaderBgColor = s.HeaderBgColor + r.settings.HeaderTextColor = s.HeaderTextColor + r.settings.BodyBgColor = s.BodyBgColor + r.settings.BodyTextColor = s.BodyTextColor + r.settings.FooterBgColor = s.FooterBgColor + r.settings.FooterTextColor = s.FooterTextColor + r.settings.AccentColor = s.AccentColor + r.settings.ProductLayout = s.ProductLayout + r.settings.ProductColumns = s.ProductColumns + r.settings.ProductScroll = s.ProductScroll + r.settings.ContactCardTransparent = s.ContactCardTransparent + r.settings.ContactCardBgColor = s.ContactCardBgColor + r.settings.LogoMediaID = s.LogoMediaID + r.settings.HeroMediaID = s.HeroMediaID + r.settings.HeroPages = s.HeroPages + return nil +} + +func (r *fakeRepository) UpdateCustomerAuth(_ context.Context, s *site.Settings) error { + r.settings.CustomerLoginEnabled = s.CustomerLoginEnabled + r.settings.CustomerRegistrationEnabled = s.CustomerRegistrationEnabled + r.settings.CustomerVerificationRequired = s.CustomerVerificationRequired + r.settings.VerificationContactLinkID = s.VerificationContactLinkID return nil } @@ -33,11 +65,11 @@ 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") + updated, err := svc.Update(ctx, "My Shop", "A small shop", true) if err != nil { t.Fatalf("Update() error = %v", err) } - if updated.Name != "My Shop" || updated.Description != "A small shop" || updated.Slug != "my-shop" { + if updated.Name != "My Shop" || updated.Description != "A small shop" || !updated.OrdersEnabled { t.Fatalf("Update() = %+v, fields not persisted as expected", updated) } @@ -50,13 +82,164 @@ func TestService_Update_PersistsFields(t *testing.T) { } } +func TestService_UpdateAppearance_DoesNotRequireIdentityFields(t *testing.T) { + svc := site.NewService(newFakeRepository()) + ctx := context.Background() + + // The Appearance admin page never touches name/description, so it must + // be able to save even when the site identity was never configured + // (name still empty) -- this used to fail because both concerns shared + // one PUT that validated identity fields on every save. + updated, err := svc.UpdateAppearance(ctx, site.AppearanceInput{ + HeaderBgColor: strPtr("#111111"), + HeaderTextColor: strPtr("#ffffff"), + BodyBgColor: strPtr("#222222"), + BodyTextColor: strPtr("#eeeeee"), + FooterBgColor: strPtr("#333333"), + FooterTextColor: strPtr("#dddddd"), + AccentColor: strPtr("#ff00ff"), + ProductLayout: strPtr("list"), + }) + if err != nil { + t.Fatalf("UpdateAppearance() error = %v", err) + } + if updated.AccentColor != "#ff00ff" || updated.ProductLayout != "list" { + t.Fatalf("UpdateAppearance() = %+v, fields not persisted as expected", updated) + } + if updated.Name != "" { + t.Fatalf("UpdateAppearance() = %+v, should not touch identity fields", updated) + } +} + +func TestService_UpdateAppearance_PartialUpdateKeepsOtherFields(t *testing.T) { + svc := site.NewService(newFakeRepository()) + ctx := context.Background() + + _, err := svc.UpdateAppearance(ctx, site.AppearanceInput{ + HeaderBgColor: strPtr("#111111"), + HeaderTextColor: strPtr("#ffffff"), + BodyBgColor: strPtr("#222222"), + BodyTextColor: strPtr("#eeeeee"), + FooterBgColor: strPtr("#333333"), + FooterTextColor: strPtr("#dddddd"), + AccentColor: strPtr("#ff00ff"), + ProductLayout: strPtr("grid"), + }) + if err != nil { + t.Fatalf("UpdateAppearance() error = %v", err) + } + + // The admin only changes the product layout; every color must keep its + // previously saved value, not be wiped out. + updated, err := svc.UpdateAppearance(ctx, site.AppearanceInput{ProductLayout: strPtr("list")}) + if err != nil { + t.Fatalf("UpdateAppearance() partial error = %v", err) + } + if updated.ProductLayout != "list" { + t.Fatalf("UpdateAppearance() partial = %+v, want product_layout list", updated) + } + if updated.AccentColor != "#ff00ff" || updated.HeaderBgColor != "#111111" { + t.Fatalf("UpdateAppearance() partial = %+v, unrelated fields should be unchanged", updated) + } +} + +func TestService_UpdateAppearance_LogoAndHeroClearedOnlyWhenExplicit(t *testing.T) { + svc := site.NewService(newFakeRepository()) + ctx := context.Background() + + logoID := uuid.New() + updated, err := svc.UpdateAppearance(ctx, site.AppearanceInput{ + LogoMediaIDSet: true, + LogoMediaID: &logoID, + HeroPages: strPtr("home,contact"), + }) + if err != nil { + t.Fatalf("UpdateAppearance() error = %v", err) + } + if updated.LogoMediaID == nil || *updated.LogoMediaID != logoID { + t.Fatalf("UpdateAppearance() = %+v, want logo set", updated) + } + if updated.HeroPages != "home,contact" { + t.Fatalf("UpdateAppearance() = %+v, want hero_pages persisted", updated) + } + + // Saving an unrelated field (no LogoMediaIDSet) must not wipe the logo. + updated, err = svc.UpdateAppearance(ctx, site.AppearanceInput{ProductLayout: strPtr("list")}) + if err != nil { + t.Fatalf("UpdateAppearance() second call error = %v", err) + } + if updated.LogoMediaID == nil || *updated.LogoMediaID != logoID { + t.Fatalf("UpdateAppearance() = %+v, logo should survive an unrelated save", updated) + } + + // Explicitly clearing the logo (Set=true, ID=nil) must remove it. + updated, err = svc.UpdateAppearance(ctx, site.AppearanceInput{LogoMediaIDSet: true, LogoMediaID: nil}) + if err != nil { + t.Fatalf("UpdateAppearance() clear error = %v", err) + } + if updated.LogoMediaID != nil { + t.Fatalf("UpdateAppearance() = %+v, want logo cleared", updated) + } +} + +func TestService_UpdateCustomerAuth_PartialUpdateKeepsOtherFields(t *testing.T) { + svc := site.NewService(newFakeRepository()) + ctx := context.Background() + + linkID := uuid.New() + _, err := svc.UpdateCustomerAuth(ctx, site.CustomerAuthInput{ + LoginEnabled: boolPtr(true), + VerificationRequired: boolPtr(true), + VerificationContactLinkSet: true, + VerificationContactLinkID: &linkID, + }) + if err != nil { + t.Fatalf("UpdateCustomerAuth() error = %v", err) + } + + // Flipping just login_enabled must not disturb the verification + // requirement or the chosen contact link. + updated, err := svc.UpdateCustomerAuth(ctx, site.CustomerAuthInput{LoginEnabled: boolPtr(false)}) + if err != nil { + t.Fatalf("UpdateCustomerAuth() partial error = %v", err) + } + if updated.CustomerLoginEnabled { + t.Fatalf("UpdateCustomerAuth() partial = %+v, want login disabled", updated) + } + if !updated.CustomerVerificationRequired { + t.Fatalf("UpdateCustomerAuth() partial = %+v, verification_required should be unchanged", updated) + } + if updated.VerificationContactLinkID == nil || *updated.VerificationContactLinkID != linkID { + t.Fatalf("UpdateCustomerAuth() partial = %+v, contact link should be unchanged", updated) + } +} + +func TestService_UpdateCustomerAuth_CanClearContactLink(t *testing.T) { + svc := site.NewService(newFakeRepository()) + ctx := context.Background() + + linkID := uuid.New() + _, err := svc.UpdateCustomerAuth(ctx, site.CustomerAuthInput{VerificationContactLinkSet: true, VerificationContactLinkID: &linkID}) + if err != nil { + t.Fatalf("UpdateCustomerAuth() error = %v", err) + } + + updated, err := svc.UpdateCustomerAuth(ctx, site.CustomerAuthInput{VerificationContactLinkSet: true, VerificationContactLinkID: nil}) + if err != nil { + t.Fatalf("UpdateCustomerAuth() clear error = %v", err) + } + if updated.VerificationContactLinkID != nil { + t.Fatalf("UpdateCustomerAuth() clear = %+v, want nil contact link", updated) + } +} + 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 != "" { + if settings.Name != "" { t.Fatalf("Get() before any update = %+v, want empty defaults", settings) } } diff --git a/backend/test/telegram/service_test.go b/backend/test/telegram/service_test.go index 7172537..55588b6 100644 --- a/backend/test/telegram/service_test.go +++ b/backend/test/telegram/service_test.go @@ -28,7 +28,6 @@ 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 } @@ -59,16 +58,16 @@ func TestService_Update_KeepsExistingTokenWhenBlank(t *testing.T) { svc := telegram.NewService(repo, &fakeSender{}, discardLogger()) ctx := context.Background() - if _, err := svc.Update(ctx, true, "secret-token", "12345", true, false); err != nil { + if _, err := svc.Update(ctx, true, "secret-token", "12345", true); 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) + settings, err := svc.Update(ctx, true, "", "12345", false) if err != nil { t.Fatalf("second Update() error = %v", err) } - if !settings.NotifyStatusChange || settings.NotifyNewOrder { + if settings.NotifyNewOrder { t.Fatalf("Update() flags = %+v, want NotifyStatusChange=true NotifyNewOrder=false", settings) } if repo.settings.BotToken != "secret-token" { @@ -87,7 +86,7 @@ func TestService_Get_NeverExposesRawToken(t *testing.T) { svc := telegram.NewService(repo, &fakeSender{}, discardLogger()) ctx := context.Background() - if _, err := svc.Update(ctx, true, "super-secret", "12345", true, false); err != nil { + if _, err := svc.Update(ctx, true, "super-secret", "12345", true); err != nil { t.Fatalf("Update() error = %v", err) } @@ -106,7 +105,7 @@ func TestService_NotifyNewOrder_SkipsWhenDisabled(t *testing.T) { svc := telegram.NewService(repo, sender, discardLogger()) ctx := context.Background() - if _, err := svc.Update(ctx, false, "token", "12345", true, false); err != nil { + if _, err := svc.Update(ctx, false, "token", "12345", true); err != nil { t.Fatalf("Update() error = %v", err) } @@ -123,7 +122,7 @@ func TestService_NotifyNewOrder_SkipsWhenEventNotEnabled(t *testing.T) { 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 { + if _, err := svc.Update(ctx, true, "token", "12345", false); err != nil { t.Fatalf("Update() error = %v", err) } @@ -139,7 +138,7 @@ func TestService_NotifyNewOrder_SendsWhenEnabled(t *testing.T) { svc := telegram.NewService(repo, sender, discardLogger()) ctx := context.Background() - if _, err := svc.Update(ctx, true, "token", "12345", true, false); err != nil { + if _, err := svc.Update(ctx, true, "token", "12345", true); err != nil { t.Fatalf("Update() error = %v", err) } diff --git a/backend/test/users/service_test.go b/backend/test/users/service_test.go index 3470924..62f2697 100644 --- a/backend/test/users/service_test.go +++ b/backend/test/users/service_test.go @@ -14,29 +14,29 @@ import ( // fakeRepository is an in-memory users.Repository used to unit-test // users.Service without a real database. type fakeRepository struct { - byID map[uuid.UUID]*users.User - byEmail map[string]uuid.UUID + byID map[uuid.UUID]*users.User + byUsername map[string]uuid.UUID } func newFakeRepository() *fakeRepository { return &fakeRepository{ - byID: make(map[uuid.UUID]*users.User), - byEmail: make(map[string]uuid.UUID), + byID: make(map[uuid.UUID]*users.User), + byUsername: make(map[string]uuid.UUID), } } func (r *fakeRepository) Create(_ context.Context, u *users.User) error { - if _, exists := r.byEmail[u.Email]; exists { - return users.ErrEmailTaken + if _, exists := r.byUsername[u.Username]; exists { + return users.ErrUsernameTaken } cp := *u r.byID[u.ID] = &cp - r.byEmail[u.Email] = u.ID + r.byUsername[u.Username] = u.ID return nil } -func (r *fakeRepository) FindByEmail(_ context.Context, email string) (*users.User, error) { - id, ok := r.byEmail[email] +func (r *fakeRepository) FindByUsername(_ context.Context, username string) (*users.User, error) { + id, ok := r.byUsername[username] if !ok { return nil, users.ErrNotFound } @@ -67,12 +67,12 @@ func (r *fakeRepository) Update(_ context.Context, u *users.User) error { if !ok { return users.ErrNotFound } - if existing.Email != u.Email { - if _, taken := r.byEmail[u.Email]; taken { - return users.ErrEmailTaken + if existing.Username != u.Username { + if _, taken := r.byUsername[u.Username]; taken { + return users.ErrUsernameTaken } - delete(r.byEmail, existing.Email) - r.byEmail[u.Email] = u.ID + delete(r.byUsername, existing.Username) + r.byUsername[u.Username] = u.ID } cp := *u r.byID[u.ID] = &cp @@ -84,16 +84,26 @@ func (r *fakeRepository) Delete(_ context.Context, id uuid.UUID) error { if !ok { return users.ErrNotFound } - delete(r.byEmail, u.Email) + delete(r.byUsername, u.Username) delete(r.byID, id) return nil } +// fakeAccountsGate is an in-memory users.AccountsGate used to unit-test the +// customer-role creation gate without a real site.Service. +type fakeAccountsGate struct { + available bool +} + +func (g fakeAccountsGate) CustomerAccountsAvailable(context.Context) (bool, error) { + return g.available, nil +} + func TestService_Create_HashesPasswordAndPersists(t *testing.T) { - svc := users.NewService(newFakeRepository()) + svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}) ctx := context.Background() - user, err := svc.Create(ctx, "admin@example.com", "super-strong-password", users.RoleAdmin) + user, err := svc.Create(ctx, "admin", "super-strong-password", users.RoleAdmin) if err != nil { t.Fatalf("Create() error = %v", err) } @@ -108,30 +118,30 @@ func TestService_Create_HashesPasswordAndPersists(t *testing.T) { } func TestService_Create_RejectsInvalidRole(t *testing.T) { - svc := users.NewService(newFakeRepository()) - if _, err := svc.Create(context.Background(), "a@example.com", "super-strong-password", "superadmin"); err == nil { + svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}) + if _, err := svc.Create(context.Background(), "user-a", "super-strong-password", "superadmin"); err == nil { t.Fatal("Create() error = nil, want error for an invalid role") } } -func TestService_Create_DuplicateEmailRejected(t *testing.T) { - svc := users.NewService(newFakeRepository()) +func TestService_Create_DuplicateUsernameRejected(t *testing.T) { + svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}) ctx := context.Background() - if _, err := svc.Create(ctx, "dup@example.com", "super-strong-password", users.RoleAdmin); err != nil { + if _, err := svc.Create(ctx, "dup-user", "super-strong-password", users.RoleAdmin); err != nil { t.Fatalf("first Create() error = %v", err) } - _, err := svc.Create(ctx, "dup@example.com", "another-strong-password", users.RoleAdmin) - if !errors.Is(err, users.ErrEmailTaken) { - t.Fatalf("second Create() error = %v, want ErrEmailTaken", err) + _, err := svc.Create(ctx, "dup-user", "another-strong-password", users.RoleAdmin) + if !errors.Is(err, users.ErrUsernameTaken) { + t.Fatalf("second Create() error = %v, want ErrUsernameTaken", err) } } func TestService_SetPassword_ChangesHash(t *testing.T) { - svc := users.NewService(newFakeRepository()) + svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}) ctx := context.Background() - user, err := svc.Create(ctx, "user@example.com", "first-strong-password", users.RoleAdmin) + user, err := svc.Create(ctx, "user1", "first-strong-password", users.RoleAdmin) if err != nil { t.Fatalf("Create() error = %v", err) } @@ -159,10 +169,10 @@ func TestService_SetPassword_ChangesHash(t *testing.T) { } func TestService_Delete_RemovesUser(t *testing.T) { - svc := users.NewService(newFakeRepository()) + svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}) ctx := context.Background() - user, err := svc.Create(ctx, "todelete@example.com", "super-strong-password", users.RoleAdmin) + user, err := svc.Create(ctx, "todelete", "super-strong-password", users.RoleAdmin) if err != nil { t.Fatalf("Create() error = %v", err) } @@ -176,8 +186,27 @@ func TestService_Delete_RemovesUser(t *testing.T) { } func TestService_Delete_UnknownUserReturnsNotFound(t *testing.T) { - svc := users.NewService(newFakeRepository()) + svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}) if err := svc.Delete(context.Background(), uuid.New()); !errors.Is(err, users.ErrNotFound) { t.Fatalf("Delete() error = %v, want ErrNotFound", err) } } + +func TestService_Create_CustomerRoleRejectedWhenAccountsDisabled(t *testing.T) { + svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: false}) + _, err := svc.Create(context.Background(), "some-customer", "super-strong-password", users.RoleCustomer) + if !errors.Is(err, users.ErrCustomerAccountsDisabled) { + t.Fatalf("Create() error = %v, want ErrCustomerAccountsDisabled", err) + } +} + +func TestService_Create_CustomerRoleAllowedWhenAccountsEnabled(t *testing.T) { + svc := users.NewService(newFakeRepository(), fakeAccountsGate{available: true}) + user, err := svc.Create(context.Background(), "some-customer", "super-strong-password", users.RoleCustomer) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if user.Role != users.RoleCustomer { + t.Fatalf("Create() role = %q, want %q", user.Role, users.RoleCustomer) + } +} diff --git a/backend/uploads/8d34481d-5e41-4f43-bb5a-68983fc204eb.png b/backend/uploads/8d34481d-5e41-4f43-bb5a-68983fc204eb.png new file mode 100644 index 0000000..dccc4d1 Binary files /dev/null and b/backend/uploads/8d34481d-5e41-4f43-bb5a-68983fc204eb.png differ diff --git a/backend/uploads/fb2c2ebf-de0b-4b7e-b423-dd9946292e47.png b/backend/uploads/fb2c2ebf-de0b-4b7e-b423-dd9946292e47.png new file mode 100644 index 0000000..dccc4d1 Binary files /dev/null and b/backend/uploads/fb2c2ebf-de0b-4b7e-b423-dd9946292e47.png differ diff --git a/backend/verification-uploads/b37face1-ead9-4140-9949-3ecdb9bc65c5.jpg b/backend/verification-uploads/b37face1-ead9-4140-9949-3ecdb9bc65c5.jpg new file mode 100644 index 0000000..9fe5017 --- /dev/null +++ b/backend/verification-uploads/b37face1-ead9-4140-9949-3ecdb9bc65c5.jpg @@ -0,0 +1 @@ +fake-front-image-bytes diff --git a/backend/verification-uploads/c0209bf9-4530-410b-8530-e7c2511383f1.jpg b/backend/verification-uploads/c0209bf9-4530-410b-8530-e7c2511383f1.jpg new file mode 100644 index 0000000..2904188 --- /dev/null +++ b/backend/verification-uploads/c0209bf9-4530-410b-8530-e7c2511383f1.jpg @@ -0,0 +1 @@ +fake-back-image-bytes diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..02c42e9 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.vite/ +*.log +.git/ diff --git a/frontend/.gitignore b/frontend/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/frontend/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json deleted file mode 100644 index 6fa991d..0000000 --- a/frontend/.oxlintrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["react", "typescript", "oxc"], - "rules": { - "react/rules-of-hooks": "error", - "react/only-export-components": ["warn", { "allowConstantExport": true }] - } -} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..879238c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1 + +# --- Build stage ------------------------------------------------------- +FROM node:24-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci + +COPY . . +RUN npm run build + +# --- Runtime stage ------------------------------------------------------- +# Static export served by nginx. /api and /uploads are NOT proxied here -- +# in-cluster routing sends those paths straight to the backend Service (see +# charts/*/templates/ingress.yaml), the same way vite.config.ts's dev +# proxy makes /api and /uploads look same-origin to the browser. That keeps +# the refresh-token cookie same-site without any CORS config in prod. +# +# nginx-unprivileged listens on 8080 and runs as a non-root user out of the +# box (no chown/setuid dance needed to satisfy the chart's +# runAsNonRoot securityContext). +FROM nginxinc/nginx-unprivileged:1.27-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 8080 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..20f7f4b --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Liveness/readiness probe target -- doesn't touch the filesystem or + # fall through to the SPA fallback below. + location = /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + # Hashed build assets (Vite fingerprints these) can be cached forever. + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # BrowserRouter (see src/App.tsx) needs every unknown path to fall back + # to index.html so client-side routing can take over. + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d276a71..4ae6db3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -623,7 +623,6 @@ "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -634,7 +633,6 @@ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1084,7 +1082,6 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1126,7 +1123,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -1136,7 +1132,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.28.0" }, @@ -1282,7 +1277,6 @@ "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d8d04b..4669946 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,50 +1,93 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { LanguageProvider } from "./i18n/LanguageContext"; +import { ThemeProvider } from "./theme/ThemeContext"; +import { ConfirmProvider } from "./ui/ConfirmContext"; +import { ToastProvider } from "./ui/ToastContext"; import { AuthProvider } from "./features/auth/AuthContext"; import LoginPage from "./features/auth/LoginPage"; import ProtectedRoute from "./router/ProtectedRoute"; import AdminLayout from "./features/admin/AdminLayout"; import DashboardPage from "./features/admin/DashboardPage"; import SiteSettingsPage from "./features/admin/SiteSettingsPage"; +import AppearanceSettingsPage from "./features/admin/AppearanceSettingsPage"; import UsersPage from "./features/admin/UsersPage"; import CategoriesPage from "./features/admin/CategoriesPage"; import UnitsPage from "./features/admin/UnitsPage"; import ProductsPage from "./features/admin/ProductsPage"; import ProductEditPage from "./features/admin/ProductEditPage"; -import MediaPage from "./features/admin/MediaPage"; import OrdersPage from "./features/admin/OrdersPage"; import OrderDetailPage from "./features/admin/OrderDetailPage"; import TelegramSettingsPage from "./features/admin/TelegramSettingsPage"; +import ContactLinksPage from "./features/admin/ContactLinksPage"; +import { CartProvider } from "./features/storefront/CartContext"; +import { CustomerAuthProvider } from "./features/storefront/CustomerAuthContext"; +import StorefrontLayout from "./features/storefront/StorefrontLayout"; +import HomePage from "./features/storefront/HomePage"; +import ProductDetailPage from "./features/storefront/ProductDetailPage"; +import CartPage from "./features/storefront/CartPage"; +import CheckoutPage from "./features/storefront/CheckoutPage"; +import OrderConfirmationPage from "./features/storefront/OrderConfirmationPage"; +import AccountPage from "./features/storefront/AccountPage"; +import ContactPage from "./features/storefront/ContactPage"; +import SimpleHomePage from "./features/storefront/SimpleHomePage"; +import CustomerAccountsPage from "./features/admin/CustomerAccountsPage"; +import CustomerVerificationsPage from "./features/admin/CustomerVerificationsPage"; export default function App() { return ( - - - } /> - - - - } - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - } /> - - + + + + + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + + + + + + ); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 35a12c0..9e97579 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,15 +1,12 @@ // Central fetch wrapper: the access token is kept in memory only (never // localStorage/sessionStorage, per the auth design), and a 401 triggers a // single silent refresh-and-retry via the httpOnly refresh cookie. -let accessToken: string | null = null; - -export function setAccessToken(token: string | null): void { - accessToken = token; -} - -export function getAccessToken(): string | null { - return accessToken; -} +// +// Admin and customer sessions are two entirely separate spaces (separate +// cookies, separate JWT audience -- see backend/internal/modules/auth) so +// each gets its own token store and its own refresh endpoint here too: a +// customer's expired access token must never be "refreshed" against the +// admin endpoint (it would just fail, since that reads the admin cookie). export class ApiError extends Error { status: number; @@ -19,68 +16,110 @@ export class ApiError extends Error { } } -const NO_RETRY_PATHS = ["/api/auth/admin/refresh", "/api/auth/admin/login"]; - -async function rawFetch(path: string, init: RequestInit): Promise { - const headers = new Headers(init.headers); - if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); - if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) { - headers.set("Content-Type", "application/json"); - } - return fetch(path, { ...init, headers, credentials: "include" }); +interface AuthClient { + apiFetch(path: string, init?: RequestInit): Promise; + apiUpload(path: string, formData: FormData): Promise; + setAccessToken(token: string | null): void; + getAccessToken(): string | null; } -let refreshInFlight: Promise | null = null; +function createAuthClient(refreshPath: string, noRetryPaths: string[]): AuthClient { + let accessToken: string | null = null; + let refreshInFlight: Promise | null = null; -async function refreshAccessToken(): Promise { - if (!refreshInFlight) { - refreshInFlight = (async () => { - try { - const res = await fetch("/api/auth/admin/refresh", { - method: "POST", - credentials: "include", - }); - if (!res.ok) return false; - const data = (await res.json()) as { access_token: string }; - setAccessToken(data.access_token); - return true; - } catch { - return false; - } finally { - refreshInFlight = null; + async function rawFetch(path: string, init: RequestInit): Promise { + const headers = new Headers(init.headers); + if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); + if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + return fetch(path, { ...init, headers, credentials: "include" }); + } + + async function refreshAccessToken(): Promise { + if (!refreshInFlight) { + refreshInFlight = (async () => { + try { + const res = await fetch(refreshPath, { method: "POST", credentials: "include" }); + if (!res.ok) return false; + const data = (await res.json()) as { access_token: string }; + accessToken = data.access_token; + return true; + } catch { + return false; + } finally { + refreshInFlight = null; + } + })(); + } + return refreshInFlight; + } + + async function apiFetch(path: string, init: RequestInit = {}): Promise { + let res = await rawFetch(path, init); + + if (res.status === 401 && !noRetryPaths.includes(path)) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + res = await rawFetch(path, init); } - })(); - } - return refreshInFlight; -} - -export async function apiFetch(path: string, init: RequestInit = {}): Promise { - let res = await rawFetch(path, init); - - if (res.status === 401 && !NO_RETRY_PATHS.includes(path)) { - const refreshed = await refreshAccessToken(); - if (refreshed) { - res = await rawFetch(path, init); } - } - if (!res.ok) { - let message = `Request failed with status ${res.status}`; - try { - const body = (await res.json()) as { error?: string }; - if (body?.error) message = body.error; - } catch { - // response had no JSON body + if (!res.ok) { + let message = `Request failed with status ${res.status}`; + try { + const body = (await res.json()) as { error?: string }; + if (body?.error) message = body.error; + } catch { + // response had no JSON body + } + throw new ApiError(message, res.status); } - throw new ApiError(message, res.status); + + // Some endpoints reply 201/200 with no body at all (e.g. attaching a + // gallery item just does c.Status(201)), not only 204 -- res.json() on + // an empty body throws a SyntaxError, which would otherwise surface as + // a spurious "failed to create/save" error even though the request + // actually succeeded. Treat any empty body as success with no payload. + const text = await res.text(); + if (!text) { + return undefined as T; + } + return JSON.parse(text) as T; } - if (res.status === 204) { - return undefined as T; - } - return (await res.json()) as T; + return { + apiFetch, + apiUpload: (path, formData) => apiFetch(path, { method: "POST", body: formData }), + setAccessToken: (token) => { + accessToken = token; + }, + getAccessToken: () => accessToken, + }; } -export function apiUpload(path: string, formData: FormData): Promise { - return apiFetch(path, { method: "POST", body: formData }); -} +const adminClient = createAuthClient("/api/auth/admin/refresh", [ + "/api/auth/admin/refresh", + "/api/auth/admin/login", +]); + +const customerClient = createAuthClient("/api/auth/customer/refresh", [ + "/api/auth/customer/refresh", + "/api/auth/customer/login", + "/api/auth/customer/register", +]); + +// Default export used throughout the admin panel (unchanged call sites). +export const apiFetch = adminClient.apiFetch; +export const apiUpload = adminClient.apiUpload; +export const setAccessToken = adminClient.setAccessToken; +export const getAccessToken = adminClient.getAccessToken; + +// Customer-space equivalents, used by the storefront's customer auth/account +// pages. Public storefront reads (catalog, site settings, ...) need no +// token at all and can use either client -- they use the admin one above by +// convention since it's already imported everywhere. +export const customerApiFetch = customerClient.apiFetch; +export const customerApiUpload = customerClient.apiUpload; +export const setCustomerAccessToken = customerClient.setAccessToken; +export const getCustomerAccessToken = customerClient.getAccessToken; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 9003d3f..bf1ca33 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1,6 +1,6 @@ export interface User { id: string; - email: string; + username: string; role: string; is_active?: boolean; } @@ -8,16 +8,44 @@ export interface User { export interface SiteSettings { name: string; description: string; - slug: string; + orders_enabled: boolean; + + header_bg_color: string; + header_text_color: string; + body_bg_color: string; + body_text_color: string; + footer_bg_color: string; + footer_text_color: string; + accent_color: string; + product_layout: "grid" | "list" | "grid-overlay" | "grid-minimal"; + product_columns: number; + product_scroll: "vertical" | "horizontal"; + + contact_card_transparent: boolean; + contact_card_bg_color: string; + + logo_media_id: string | null; + hero_media_id: string | null; + hero_pages: string[]; + + customer_login_enabled: boolean; + customer_registration_enabled: boolean; + customer_verification_required: boolean; + verification_contact_link_id: string | null; } +export const PRODUCT_LAYOUTS = ["grid", "grid-overlay", "grid-minimal", "list"] as const; +export const PRODUCT_COLUMNS = [0, 2, 3, 4, 5] as const; +export const PRODUCT_SCROLL_DIRECTIONS = ["vertical", "horizontal"] as const; +export const HERO_PAGES = ["catalog", "home", "cart", "checkout", "account", "contact"] as const; + export interface Category { id: string; name: string; - slug: string; description: string; position: number; is_active: boolean; + media_id: string | null; } export interface Unit { @@ -33,14 +61,15 @@ export interface Media { mime_type: string; size_bytes: number; alt_text: string; + product_id: string; + created_at: string; + updated_at: string; } export interface Product { id: string; - category_id?: string | null; + category_id: string; name: string; - slug: string; - short_description: string; description: string; is_active: boolean; is_featured: boolean; @@ -57,6 +86,26 @@ export interface PriceTier { position: number; } +export interface ContactLink { + id: string; + label: string; + url: string; + icon_key: string; + icon_media_id: string | null; + color: string; + position: number; + is_active: boolean; +} + +export interface CustomerVerification { + id: string; + user_id: string; + status: "pending" | "approved" | "rejected"; + admin_note: string; + created_at: string; + updated_at: string; +} + export interface TelegramSettings { enabled: boolean; bot_token_configured: boolean; @@ -80,21 +129,11 @@ export interface Order { customer_name: string; customer_email: string; customer_phone: string; - status: string; total_cents: number; notes: string; items?: OrderItem[]; } -export const ORDER_STATUSES = [ - "pending", - "confirmed", - "preparing", - "shipped", - "completed", - "cancelled", -] as const; - export function formatCents(cents: number): string { return (cents / 100).toFixed(2); } diff --git a/frontend/src/features/admin/AdminLayout.tsx b/frontend/src/features/admin/AdminLayout.tsx index 3c230b8..d4313d4 100644 --- a/frontend/src/features/admin/AdminLayout.tsx +++ b/frontend/src/features/admin/AdminLayout.tsx @@ -1,75 +1,122 @@ +import { useState } from "react"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { useAuth } from "../auth/AuthContext"; +import { useI18n } from "../../i18n/LanguageContext"; +import LanguageSwitcher from "../../i18n/LanguageSwitcher"; +import ThemeToggle from "../../theme/ThemeToggle"; +import type { MessageKey } from "../../i18n/messages"; +import { LogOutIcon } from "../../ui/icons"; -const NAV_SECTIONS: { title: string; items: { to: string; label: string }[] }[] = [ +import { AdminSiteSettingsProvider, useAdminSiteSettings } from "./AdminSiteSettingsContext"; + +const NAV_SECTIONS: { titleKey: MessageKey; items: { to: string; labelKey: MessageKey }[] }[] = [ { - title: "Site", + titleKey: "layout.admin.nav.site", items: [ - { to: "/admin", label: "Dashboard" }, - { to: "/admin/site-settings", label: "General" }, + { to: "/admin", labelKey: "layout.admin.nav.dashboard" }, + { to: "/admin/site-settings", labelKey: "layout.admin.nav.general" }, + { to: "/admin/appearance", labelKey: "layout.admin.nav.appearance" }, ], }, { - title: "Catalog", + titleKey: "layout.admin.nav.catalog", items: [ - { to: "/admin/categories", label: "Categories" }, - { to: "/admin/units", label: "Units" }, - { to: "/admin/products", label: "Products" }, - { to: "/admin/media", label: "Media" }, + { to: "/admin/categories", labelKey: "layout.admin.nav.categories" }, + { to: "/admin/units", labelKey: "layout.admin.nav.units" }, + { to: "/admin/products", labelKey: "layout.admin.nav.products" }, ], }, { - title: "Sales", - items: [{ to: "/admin/orders", label: "Orders" }], + titleKey: "layout.admin.nav.sales", + items: [{ to: "/admin/orders", labelKey: "layout.admin.nav.orders" }], }, { - title: "Communication", - items: [{ to: "/admin/notifications/telegram", label: "Telegram" }], + titleKey: "layout.admin.nav.communication", + items: [ + { to: "/admin/notifications/telegram", labelKey: "layout.admin.nav.telegram" }, + { to: "/admin/contact-links", labelKey: "layout.admin.nav.contactLinks" }, + ], }, { - title: "System", - items: [{ to: "/admin/users", label: "Users" }], + titleKey: "layout.admin.nav.system", + items: [{ to: "/admin/users", labelKey: "layout.admin.nav.users" }, { to: "/admin/customer-accounts", labelKey: "layout.admin.nav.customerAccounts" }], }, ]; -export default function AdminLayout() { +function AdminShell() { const { user, logout } = useAuth(); + const { t } = useI18n(); + const { settings } = useAdminSiteSettings(); const navigate = useNavigate(); + const [sidebarOpen, setSidebarOpen] = useState(false); async function handleLogout() { await logout(); navigate("/login", { replace: true }); } + // "Customer verifications" only makes sense -- and is only shown -- once + // the admin has actually turned on identity verification in Customer + // accounts; otherwise there is nothing to review there. + const navSections = settings.customer_verification_required + ? NAV_SECTIONS.map((section) => + section.titleKey === "layout.admin.nav.system" + ? { + ...section, + items: [...section.items, { to: "/admin/customer-verifications", labelKey: "layout.admin.nav.customerVerifications" as MessageKey }], + } + : section, + ) + : NAV_SECTIONS; + return (
-