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