chore: build
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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"})
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user