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

This commit is contained in:
Xor290
2026-09-20 12:18:33 +02:00
parent 8f4c7fa47a
commit 919c807004
174 changed files with 9669 additions and 1308 deletions
+101
View File
@@ -0,0 +1,101 @@
package orders
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"backend/internal/platform/middleware"
)
// SettingsProvider and VerificationChecker are defined on the consumer
// side (this package), matching ProductFinder/UnitFinder/PriceResolver
// above: orders never imports the site or customerverification packages
// directly, main.go just needs to supply something satisfying these shapes.
type SettingsProvider interface {
CustomerCheckoutSettings(ctx context.Context) (accountsEnabled, verificationRequired bool, err error)
}
type VerificationChecker interface {
IsApproved(ctx context.Context, userID uuid.UUID) (bool, error)
}
// OrdersGate reports the site-wide "vitrine vs boutique" switch
// (site.Settings.OrdersEnabled). It runs before every other order gate: a
// pure showcase site rejects order creation outright, regardless of the
// customer-account settings below.
type OrdersGate interface {
OrdersEnabled(ctx context.Context) (bool, error)
}
// RequireOrdersEnabled blocks order creation entirely while the admin has
// switched the site to showcase-only mode.
func RequireOrdersEnabled(gate OrdersGate) gin.HandlerFunc {
return func(c *gin.Context) {
enabled, err := gate.OrdersEnabled(c.Request.Context())
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check orders setting"})
return
}
if !enabled {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ordering is currently disabled for this shop"})
return
}
c.Next()
}
}
// RequireAccountsEnabled blocks order creation entirely while the admin
// hasn't turned on customer accounts. This is intentionally stricter than
// "guest checkout allowed by default": some shops (e.g. selling
// age/ID-restricted goods) need every order tied to a verified account, so
// the admin opts into that by flipping this one setting, and until they
// do, nobody -- guest or not -- can check out.
func RequireAccountsEnabled(settings SettingsProvider) gin.HandlerFunc {
return func(c *gin.Context) {
enabled, _, err := settings.CustomerCheckoutSettings(c.Request.Context())
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check checkout settings"})
return
}
if !enabled {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ordering requires a customer account, which is currently disabled"})
return
}
c.Next()
}
}
// RequireApprovedVerificationIfNeeded must run after RequireAccountsEnabled
// and middleware.RequireCustomer (so a userID is already in context). It is
// a no-op unless the admin also turned on identity verification.
func RequireApprovedVerificationIfNeeded(settings SettingsProvider, verification VerificationChecker) gin.HandlerFunc {
return func(c *gin.Context) {
_, verificationRequired, err := settings.CustomerCheckoutSettings(c.Request.Context())
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check checkout settings"})
return
}
if !verificationRequired {
c.Next()
return
}
userID, ok := middleware.GetUserID(c)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
approved, err := verification.IsApproved(c.Request.Context(), userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check verification status"})
return
}
if !approved {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "identity verification must be approved before ordering"})
return
}
c.Next()
}
}
+38 -41
View File
@@ -9,6 +9,7 @@ import (
"backend/internal/modules/pricing"
"backend/internal/modules/products"
"backend/internal/platform/middleware"
)
type Handler struct {
@@ -48,7 +49,6 @@ type orderResponse struct {
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
CustomerPhone string `json:"customer_phone"`
Status string `json:"status"`
TotalCents int64 `json:"total_cents"`
Notes string `json:"notes"`
Items []orderItemResponse `json:"items,omitempty"`
@@ -60,7 +60,6 @@ func toResponse(order *Order, items []*OrderItem) orderResponse {
CustomerName: order.CustomerName,
CustomerEmail: order.CustomerEmail,
CustomerPhone: order.CustomerPhone,
Status: order.Status,
TotalCents: order.TotalCents,
Notes: order.Notes,
}
@@ -78,8 +77,10 @@ func toResponse(order *Order, items []*OrderItem) orderResponse {
return resp
}
// Create is public: a visitor (connected or not, per spec section 17) can
// place an order without an account.
// Create is gated by RequireAccountsEnabled/RequireCustomer/
// RequireApprovedVerificationIfNeeded (see routes.go): rejected outright
// until the admin enables customer accounts, then requires a logged-in
// customer (and an approved verification too, if that's also required).
func (h *Handler) Create(c *gin.Context) {
var req createRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -96,7 +97,13 @@ func (h *Handler) Create(c *gin.Context) {
})
}
var customerID *uuid.UUID
if uid, ok := middleware.GetUserID(c); ok {
customerID = &uid
}
order, orderItems, err := h.service.Create(c.Request.Context(), CreateInput{
CustomerID: customerID,
CustomerName: req.CustomerName,
CustomerEmail: req.CustomerEmail,
CustomerPhone: req.CustomerPhone,
@@ -110,6 +117,25 @@ func (h *Handler) Create(c *gin.Context) {
c.JSON(http.StatusCreated, toResponse(order, orderItems))
}
// ListMine returns the authenticated customer's own order history.
func (h *Handler) ListMine(c *gin.Context) {
userID, ok := middleware.GetUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
list, err := h.service.ListByCustomer(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orders"})
return
}
resp := make([]orderResponse, 0, len(list))
for _, order := range list {
resp = append(resp, toResponse(order, nil))
}
c.JSON(http.StatusOK, gin.H{"orders": resp})
}
func (h *Handler) respondCreateError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrEmptyOrder), errors.Is(err, ErrProductMismatch):
@@ -123,19 +149,6 @@ func (h *Handler) respondCreateError(c *gin.Context, err error) {
}
}
func (h *Handler) ListAdmin(c *gin.Context) {
list, err := h.service.List(c.Request.Context(), c.Query("status"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orders"})
return
}
resp := make([]orderResponse, 0, len(list))
for _, order := range list {
resp = append(resp, toResponse(order, nil))
}
c.JSON(http.StatusOK, gin.H{"orders": resp})
}
func (h *Handler) GetAdmin(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
@@ -154,32 +167,16 @@ func (h *Handler) GetAdmin(c *gin.Context) {
c.JSON(http.StatusOK, toResponse(order, items))
}
type updateStatusRequest struct {
Status string `json:"status" binding:"required"`
}
func (h *Handler) UpdateStatus(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
// ListAdmin returns every order, most recent first.
func (h *Handler) ListAdmin(c *gin.Context) {
list, err := h.service.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orders"})
return
}
var req updateStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
return
resp := make([]orderResponse, 0, len(list))
for _, order := range list {
resp = append(resp, toResponse(order, nil))
}
order, err := h.service.UpdateStatus(c.Request.Context(), id, req.Status)
if err != nil {
switch {
case errors.Is(err, ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "order not found"})
case errors.Is(err, ErrInvalidStatus):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update order status"})
}
return
}
c.JSON(http.StatusOK, toResponse(order, nil))
c.JSON(http.StatusOK, gin.H{"orders": resp})
}
+15 -30
View File
@@ -1,8 +1,11 @@
// Package orders lets a customer (connected or not, per spec section 17-19)
// place an order directly from the storefront, and lets the admin track
// and update it. Pricing is never trusted from the client: each line is
// resolved against the pricing module's authoritative price tier at
// creation time.
// Package orders lets a customer place an order directly from the
// storefront, and lets the admin track and update it. Pricing is never
// trusted from the client: each line is resolved against the pricing
// module's authoritative price tier at creation time.
//
// Whether an order requires a logged-in (and possibly identity-verified)
// customer account is an admin-configurable gate, not a fixed rule of this
// package -- see gate.go and site.Settings.CustomerAccountsEnabled.
package orders
import (
@@ -11,32 +14,14 @@ import (
"github.com/google/uuid"
)
const (
StatusPending = "pending"
StatusConfirmed = "confirmed"
StatusPreparing = "preparing"
StatusShipped = "shipped"
StatusCompleted = "completed"
StatusCancelled = "cancelled"
)
var ValidStatuses = map[string]bool{
StatusPending: true,
StatusConfirmed: true,
StatusPreparing: true,
StatusShipped: true,
StatusCompleted: true,
StatusCancelled: true,
}
type Order struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
CustomerName string `gorm:"not null"`
CustomerEmail string `gorm:"not null"`
CustomerPhone string `gorm:"not null;default:''"`
Status string `gorm:"not null;default:pending"`
TotalCents int64 `gorm:"not null;default:0"`
Notes string `gorm:"not null;default:''"`
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
CustomerID *uuid.UUID `gorm:"type:uuid;index"`
CustomerName string `gorm:"not null"`
CustomerEmail string `gorm:"not null"`
CustomerPhone string `gorm:"not null;default:''"`
TotalCents int64 `gorm:"not null;default:0"`
Notes string `gorm:"not null;default:''"`
CreatedAt time.Time
UpdatedAt time.Time
}
+8 -19
View File
@@ -13,8 +13,8 @@ var ErrNotFound = errors.New("order not found")
type Repository interface {
Create(ctx context.Context, order *Order, items []*OrderItem) error
FindByID(ctx context.Context, id uuid.UUID) (*Order, []*OrderItem, error)
List(ctx context.Context, status string) ([]*Order, error)
UpdateStatus(ctx context.Context, id uuid.UUID, status string) (*Order, error)
List(ctx context.Context) ([]*Order, error)
ListByCustomer(ctx context.Context, customerID uuid.UUID) ([]*Order, error)
}
type gormRepository struct {
@@ -57,29 +57,18 @@ func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Order, []
return &order, items, nil
}
func (r *gormRepository) List(ctx context.Context, status string) ([]*Order, error) {
q := r.db.WithContext(ctx).Order("created_at desc")
if status != "" {
q = q.Where("status = ?", status)
}
func (r *gormRepository) List(ctx context.Context) ([]*Order, error) {
var list []*Order
if err := q.Find(&list).Error; err != nil {
if err := r.db.WithContext(ctx).Order("created_at desc").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (r *gormRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status string) (*Order, error) {
res := r.db.WithContext(ctx).Model(&Order{}).Where("id = ?", id).Update("status", status)
if res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return nil, ErrNotFound
}
var order Order
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&order).Error; err != nil {
func (r *gormRepository) ListByCustomer(ctx context.Context, customerID uuid.UUID) ([]*Order, error) {
var list []*Order
if err := r.db.WithContext(ctx).Where("customer_id = ?", customerID).Order("created_at desc").Find(&list).Error; err != nil {
return nil, err
}
return &order, nil
return list, nil
}
+14 -6
View File
@@ -2,16 +2,24 @@ package orders
import "github.com/gin-gonic/gin"
// RegisterPublicRoutes exposes order creation to any visitor, connected or
// not (spec section 17-18). rateLimit throttles it to blunt spam/abuse
// since no authentication gates this endpoint.
func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler, rateLimit gin.HandlerFunc) {
rg.POST("/orders", rateLimit, h.Create)
// RegisterPublicRoutes exposes order creation. rateLimit throttles it to
// blunt spam/abuse. requireOrdersEnabled rejects every request with 403
// while the site is in showcase-only mode; requireAccountsEnabled then
// rejects every remaining request (guest or not) with 403 until the admin
// turns on customer accounts (see gate.go); once on, requireCustomer
// enforces a logged-in customer and requireVerified additionally requires
// an approved identity verification if the admin also turned that on.
func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler, rateLimit, requireOrdersEnabled, requireAccountsEnabled, requireCustomer, requireVerified gin.HandlerFunc) {
rg.POST("/orders", rateLimit, requireOrdersEnabled, requireAccountsEnabled, requireCustomer, requireVerified, h.Create)
}
// RegisterCustomerRoutes exposes a logged-in customer's own order history.
func RegisterCustomerRoutes(rg *gin.RouterGroup, h *Handler, requireCustomer gin.HandlerFunc) {
rg.GET("/customer/orders", requireCustomer, h.ListMine)
}
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
group := rg.Group("/admin/orders", requireAdmin)
group.GET("", h.ListAdmin)
group.GET("/:id", h.GetAdmin)
group.PATCH("/:id/status", h.UpdateStatus)
}
+6 -17
View File
@@ -15,7 +15,6 @@ import (
var (
ErrEmptyOrder = errors.New("order must contain at least one item")
ErrInvalidStatus = errors.New("invalid order status")
ErrProductMismatch = errors.New("price tier does not belong to the requested product")
)
@@ -39,7 +38,6 @@ type PriceResolver interface {
// directly.
type OrderNotifier interface {
NotifyNewOrder(ctx context.Context, summary string)
NotifyOrderStatusChange(ctx context.Context, summary string)
}
type Service struct {
@@ -61,6 +59,7 @@ type ItemInput struct {
}
type CreateInput struct {
CustomerID *uuid.UUID
CustomerName string
CustomerEmail string
CustomerPhone string
@@ -75,10 +74,10 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (*Order, []*OrderI
order := &Order{
ID: uuid.New(),
CustomerID: in.CustomerID,
CustomerName: in.CustomerName,
CustomerEmail: in.CustomerEmail,
CustomerPhone: in.CustomerPhone,
Status: StatusPending,
Notes: in.Notes,
}
@@ -137,22 +136,12 @@ func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Order, []*OrderItem,
return s.repo.FindByID(ctx, id)
}
func (s *Service) List(ctx context.Context, status string) ([]*Order, error) {
return s.repo.List(ctx, status)
func (s *Service) List(ctx context.Context) ([]*Order, error) {
return s.repo.List(ctx)
}
func (s *Service) UpdateStatus(ctx context.Context, id uuid.UUID, status string) (*Order, error) {
if !ValidStatuses[status] {
return nil, ErrInvalidStatus
}
order, err := s.repo.UpdateStatus(ctx, id, status)
if err != nil {
return nil, err
}
s.notifier.NotifyOrderStatusChange(context.Background(), fmt.Sprintf(
"Order %s status changed to %q (customer: %s)", order.ID, order.Status, order.CustomerName,
))
return order, nil
func (s *Service) ListByCustomer(ctx context.Context, customerID uuid.UUID) ([]*Order, error) {
return s.repo.ListByCustomer(ctx, customerID)
}
func summarizeNewOrder(order *Order, items []*OrderItem) string {