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