chore: build
ci-api / test (push) Failing after 8m7s
ci-web / test (push) Failing after 5m5s

This commit is contained in:
Xor290
2026-09-20 12:18:33 +02:00
parent 8f4c7fa47a
commit 919c807004
174 changed files with 9669 additions and 1308 deletions
@@ -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) {
@@ -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) {
@@ -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)
+3 -3
View File
@@ -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
+14 -20
View File
@@ -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"})
}
+7 -7
View File
@@ -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
}
@@ -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"
@@ -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
}
@@ -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)
}
@@ -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" }
@@ -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
}
@@ -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)
}
@@ -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
}
@@ -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))
}
@@ -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" }
@@ -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)
}
@@ -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)
}
@@ -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
}
}
@@ -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
}
+54 -8
View File
@@ -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"})
}
+6 -2
View File
@@ -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" }
+26 -5
View File
@@ -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
}
+7
View File
@@ -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)
}
+63 -8
View File
@@ -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
}
+101
View File
@@ -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()
}
}
+38 -41
View File
@@ -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})
}
+15 -30
View File
@@ -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
}
+8 -19
View File
@@ -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
}
+14 -6
View File
@@ -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)
}
+6 -17
View File
@@ -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 {
@@ -0,0 +1,5 @@
package products
import "errors"
var ErrMediaNotOwned = errors.New("media does not belong to product")
+44 -46
View File
@@ -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"})
}
+10 -12
View File
@@ -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" }
@@ -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"
}
+5 -2
View File
@@ -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)
}
+46 -28
View File
@@ -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
+228 -13
View File
@@ -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))
}
+68 -7
View File
@@ -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" }
+36 -3
View File
@@ -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
}
+8
View File
@@ -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)
}
+187 -3
View File
@@ -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
}
+5 -8
View File
@@ -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
+7 -8
View File
@@ -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" }
@@ -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".
+5 -10
View File
@@ -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 {
+6
View File
@@ -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)
}
+9
View File
@@ -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")
)
+36 -11
View File
@@ -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"})
+1 -1
View File
@@ -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"`
+21 -9
View File
@@ -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"
+1
View File
@@ -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)
}
+54 -10
View File
@@ -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
+26 -10
View File
@@ -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
}