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
@@ -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
}