first
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/users"
|
||||
"backend/internal/platform/middleware"
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
const (
|
||||
adminRefreshCookieName = "admin_refresh_token"
|
||||
adminRefreshCookiePath = "/api/auth/admin"
|
||||
)
|
||||
|
||||
// AdminHandler exposes the admin-space auth endpoints. It is kept entirely
|
||||
// separate from CustomerHandler: different cookie name, different cookie
|
||||
// path, different JWT audience, so an admin session can never be replayed
|
||||
// against a future customer-facing endpoint.
|
||||
type AdminHandler struct {
|
||||
service *Service
|
||||
cookieSecure bool
|
||||
}
|
||||
|
||||
func NewAdminHandler(service *Service, cookieSecure bool) *AdminHandler {
|
||||
return &AdminHandler{service: service, cookieSecure: cookieSecure}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type authUserResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type accessTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
User *authUserResponse `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Login(c *gin.Context) {
|
||||
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.AudienceAdmin, users.RoleAdmin, req.Email, req.Password)
|
||||
if err != nil {
|
||||
h.respondLoginError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.setRefreshCookie(c, pair.RefreshToken)
|
||||
c.JSON(http.StatusOK, accessTokenResponse{
|
||||
AccessToken: pair.AccessToken,
|
||||
User: &authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Refresh(c *gin.Context) {
|
||||
token, err := c.Cookie(adminRefreshCookieName)
|
||||
if err != nil || token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
pair, err := h.service.Refresh(c.Request.Context(), security.AudienceAdmin, token)
|
||||
if err != nil {
|
||||
h.clearRefreshCookie(c)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
h.setRefreshCookie(c, pair.RefreshToken)
|
||||
c.JSON(http.StatusOK, accessTokenResponse{AccessToken: pair.AccessToken})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Logout(c *gin.Context) {
|
||||
if token, err := c.Cookie(adminRefreshCookieName); err == nil && token != "" {
|
||||
_ = h.service.Logout(c.Request.Context(), security.AudienceAdmin, token)
|
||||
}
|
||||
h.clearRefreshCookie(c)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Me(c *gin.Context) {
|
||||
userID, ok := middleware.GetUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
user, err := h.service.Me(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, users.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) respondLoginError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidCredentials), errors.Is(err, ErrAccountDisabled):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) setRefreshCookie(c *gin.Context, token string) {
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(adminRefreshCookieName, token, int(h.service.RefreshTTL().Seconds()), adminRefreshCookiePath, "", h.cookieSecure, true)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) clearRefreshCookie(c *gin.Context) {
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(adminRefreshCookieName, "", -1, adminRefreshCookiePath, "", h.cookieSecure, true)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package auth
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// RegisterAdminRoutes mounts the admin-space auth endpoints. loginRateLimit
|
||||
// is applied only to /login to blunt credential-stuffing/brute-force
|
||||
// attempts without throttling normal authenticated traffic.
|
||||
func RegisterAdminRoutes(rg *gin.RouterGroup, h *AdminHandler, requireAdmin gin.HandlerFunc, loginRateLimit gin.HandlerFunc) {
|
||||
group := rg.Group("/auth/admin")
|
||||
group.POST("/login", loginRateLimit, h.Login)
|
||||
group.POST("/refresh", h.Refresh)
|
||||
group.POST("/logout", h.Logout)
|
||||
group.GET("/me", requireAdmin, h.Me)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"backend/internal/modules/users"
|
||||
"backend/internal/platform/middleware"
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
// customer cookie name/path are intentionally distinct from the admin ones
|
||||
// (see admin_handler.go) so the two spaces never share a session cookie.
|
||||
const (
|
||||
customerRefreshCookieName = "customer_refresh_token"
|
||||
customerRefreshCookiePath = "/api/auth/customer"
|
||||
)
|
||||
|
||||
// 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.
|
||||
type CustomerHandler struct {
|
||||
service *Service
|
||||
cookieSecure bool
|
||||
}
|
||||
|
||||
func NewCustomerHandler(service *Service, cookieSecure bool) *CustomerHandler {
|
||||
return &CustomerHandler{service: service, cookieSecure: cookieSecure}
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) Login(c *gin.Context) {
|
||||
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)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidCredentials), errors.Is(err, ErrAccountDisabled):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
h.setRefreshCookie(c, pair.RefreshToken)
|
||||
c.JSON(http.StatusOK, accessTokenResponse{
|
||||
AccessToken: pair.AccessToken,
|
||||
User: &authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) Refresh(c *gin.Context) {
|
||||
token, err := c.Cookie(customerRefreshCookieName)
|
||||
if err != nil || token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
pair, err := h.service.Refresh(c.Request.Context(), security.AudienceCustomer, token)
|
||||
if err != nil {
|
||||
h.clearRefreshCookie(c)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
h.setRefreshCookie(c, pair.RefreshToken)
|
||||
c.JSON(http.StatusOK, accessTokenResponse{AccessToken: pair.AccessToken})
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) Logout(c *gin.Context) {
|
||||
if token, err := c.Cookie(customerRefreshCookieName); err == nil && token != "" {
|
||||
_ = h.service.Logout(c.Request.Context(), security.AudienceCustomer, token)
|
||||
}
|
||||
h.clearRefreshCookie(c)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) Me(c *gin.Context) {
|
||||
userID, ok := middleware.GetUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
user, err := h.service.Me(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, users.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) setRefreshCookie(c *gin.Context, token string) {
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(customerRefreshCookieName, token, int(h.service.RefreshTTL().Seconds()), customerRefreshCookiePath, "", h.cookieSecure, true)
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) clearRefreshCookie(c *gin.Context) {
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(customerRefreshCookieName, "", -1, customerRefreshCookiePath, "", h.cookieSecure, true)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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.
|
||||
func RegisterCustomerRoutes(rg *gin.RouterGroup, h *CustomerHandler, requireCustomer gin.HandlerFunc, loginRateLimit gin.HandlerFunc) {
|
||||
group := rg.Group("/auth/customer")
|
||||
group.POST("/login", loginRateLimit, h.Login)
|
||||
group.POST("/refresh", h.Refresh)
|
||||
group.POST("/logout", h.Logout)
|
||||
group.GET("/me", requireCustomer, h.Me)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Package auth issues and verifies sessions (access + refresh tokens) for
|
||||
// both the admin space and the (not-yet-mounted) customer space. It has no
|
||||
// database table of its own: user identity/credentials live in the users
|
||||
// module, and refresh-token/session state lives entirely in Redis.
|
||||
package auth
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrInvalidRefreshToken = errors.New("invalid refresh token")
|
||||
ErrAccountDisabled = errors.New("account disabled")
|
||||
)
|
||||
|
||||
// TokenPair is returned to the client on login/refresh: the access token is
|
||||
// meant to be kept in memory by the frontend, the refresh token is meant to
|
||||
// be set as an httpOnly cookie by the handler (never returned to JS).
|
||||
type TokenPair struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
// RefreshStore tracks live refresh tokens in Redis, scoped per audience
|
||||
// (admin/customer) so a session in one space is never visible to the other.
|
||||
type RefreshStore interface {
|
||||
Issue(ctx context.Context, aud security.Audience, userID uuid.UUID, ttl time.Duration) (string, error)
|
||||
Rotate(ctx context.Context, aud security.Audience, oldToken string, ttl time.Duration) (newToken string, userID uuid.UUID, err error)
|
||||
Revoke(ctx context.Context, aud security.Audience, token string) error
|
||||
RevokeAllForUser(ctx context.Context, aud security.Audience, userID uuid.UUID) error
|
||||
}
|
||||
|
||||
type redisRefreshStore struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func NewRefreshStore(client *redis.Client) RefreshStore {
|
||||
return &redisRefreshStore{client: client}
|
||||
}
|
||||
|
||||
func sessionKey(aud security.Audience, token string) string {
|
||||
return fmt.Sprintf("session:%s:%s", aud, token)
|
||||
}
|
||||
|
||||
func userSetKey(aud security.Audience, userID uuid.UUID) string {
|
||||
return fmt.Sprintf("session:%s:user:%s", aud, userID)
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func (s *redisRefreshStore) Issue(ctx context.Context, aud security.Audience, userID uuid.UUID, ttl time.Duration) (string, error) {
|
||||
token, err := randomToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate refresh token: %w", err)
|
||||
}
|
||||
if err := s.client.Set(ctx, sessionKey(aud, token), userID.String(), ttl).Err(); err != nil {
|
||||
return "", fmt.Errorf("store refresh token: %w", err)
|
||||
}
|
||||
// Best-effort secondary index for RevokeAllForUser; a token still present
|
||||
// here after its primary key has naturally expired is harmless (RevokeAllForUser
|
||||
// simply issues a DEL for an already-gone key).
|
||||
s.client.SAdd(ctx, userSetKey(aud, userID), token)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *redisRefreshStore) Rotate(ctx context.Context, aud security.Audience, oldToken string, ttl time.Duration) (string, uuid.UUID, error) {
|
||||
val, err := s.client.GetDel(ctx, sessionKey(aud, oldToken)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return "", uuid.UUID{}, ErrInvalidRefreshToken
|
||||
}
|
||||
if err != nil {
|
||||
return "", uuid.UUID{}, fmt.Errorf("lookup refresh token: %w", err)
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(val)
|
||||
if err != nil {
|
||||
return "", uuid.UUID{}, fmt.Errorf("corrupt session value: %w", err)
|
||||
}
|
||||
s.client.SRem(ctx, userSetKey(aud, userID), oldToken)
|
||||
|
||||
newToken, err := s.Issue(ctx, aud, userID, ttl)
|
||||
if err != nil {
|
||||
return "", uuid.UUID{}, err
|
||||
}
|
||||
return newToken, userID, nil
|
||||
}
|
||||
|
||||
func (s *redisRefreshStore) Revoke(ctx context.Context, aud security.Audience, token string) error {
|
||||
val, err := s.client.GetDel(ctx, sessionKey(aud, token)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil // already revoked/expired: logout is idempotent
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke refresh token: %w", err)
|
||||
}
|
||||
if userID, parseErr := uuid.Parse(val); parseErr == nil {
|
||||
s.client.SRem(ctx, userSetKey(aud, userID), token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *redisRefreshStore) RevokeAllForUser(ctx context.Context, aud security.Audience, userID uuid.UUID) error {
|
||||
setKey := userSetKey(aud, userID)
|
||||
tokens, err := s.client.SMembers(ctx, setKey).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
if len(tokens) > 0 {
|
||||
keys := make([]string, len(tokens))
|
||||
for i, t := range tokens {
|
||||
keys[i] = sessionKey(aud, t)
|
||||
}
|
||||
if err := s.client.Del(ctx, keys...).Err(); err != nil {
|
||||
return fmt.Errorf("revoke sessions: %w", err)
|
||||
}
|
||||
}
|
||||
s.client.Del(ctx, setKey)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/users"
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
// UserFinder is the minimal slice of the users module this service needs.
|
||||
// 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)
|
||||
FindByID(ctx context.Context, id uuid.UUID) (*users.User, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
users UserFinder
|
||||
refresh RefreshStore
|
||||
jwtSecret string
|
||||
accessTTL time.Duration
|
||||
refreshTTL time.Duration
|
||||
}
|
||||
|
||||
func NewService(userFinder UserFinder, refresh RefreshStore, jwtSecret string, accessTTL, refreshTTL time.Duration) *Service {
|
||||
return &Service{
|
||||
users: userFinder,
|
||||
refresh: refresh,
|
||||
jwtSecret: jwtSecret,
|
||||
accessTTL: accessTTL,
|
||||
refreshTTL: refreshTTL,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
if errors.Is(err, users.ErrNotFound) {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return nil, nil, ErrAccountDisabled
|
||||
}
|
||||
if user.Role != expectedRole {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
ok, err := security.VerifyPassword(user.PasswordHash, password)
|
||||
if err != nil || !ok {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
pair, err := s.issuePair(ctx, aud, user)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pair, user, nil
|
||||
}
|
||||
|
||||
// Refresh rotates a refresh token (single use) and issues a fresh pair. If
|
||||
// the presented token is unknown to the store (already used, revoked, or
|
||||
// expired) it is treated as invalid; genuine reuse of a stolen token simply
|
||||
// fails from that point on since the old token was deleted at issuance time.
|
||||
func (s *Service) Refresh(ctx context.Context, aud security.Audience, refreshToken string) (*TokenPair, error) {
|
||||
newRefreshToken, userID, err := s.refresh.Rotate(ctx, aud, refreshToken, s.refreshTTL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := s.users.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup user for refresh: %w", err)
|
||||
}
|
||||
if !user.IsActive {
|
||||
_ = s.refresh.RevokeAllForUser(ctx, aud, userID)
|
||||
return nil, ErrAccountDisabled
|
||||
}
|
||||
|
||||
access, err := security.IssueAccessToken(s.jwtSecret, s.accessTTL, user.ID, user.Role, aud)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue access token: %w", err)
|
||||
}
|
||||
|
||||
return &TokenPair{AccessToken: access, RefreshToken: newRefreshToken}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, aud security.Audience, refreshToken string) error {
|
||||
return s.refresh.Revoke(ctx, aud, refreshToken)
|
||||
}
|
||||
|
||||
func (s *Service) LogoutAll(ctx context.Context, aud security.Audience, userID uuid.UUID) error {
|
||||
return s.refresh.RevokeAllForUser(ctx, aud, userID)
|
||||
}
|
||||
|
||||
func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*users.User, error) {
|
||||
return s.users.FindByID(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) issuePair(ctx context.Context, aud security.Audience, user *users.User) (*TokenPair, error) {
|
||||
access, err := security.IssueAccessToken(s.jwtSecret, s.accessTTL, user.ID, user.Role, aud)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue access token: %w", err)
|
||||
}
|
||||
refreshToken, err := s.refresh.Issue(ctx, aud, user.ID, s.refreshTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
||||
}
|
||||
return &TokenPair{AccessToken: access, RefreshToken: refreshToken}, nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package categories
|
||||
|
||||
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 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"`
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// ListAdmin returns every category (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 categories"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"categories": toResponseList(list)})
|
||||
}
|
||||
|
||||
// ListPublic returns only active categories, for storefront navigation.
|
||||
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 categories"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"categories": toResponseList(list)})
|
||||
}
|
||||
|
||||
func toResponseList(list []*Category) []categoryResponse {
|
||||
resp := make([]categoryResponse, 0, len(list))
|
||||
for _, cat := range list {
|
||||
resp = append(resp, toResponse(cat))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
cat, err := h.service.Create(c.Request.Context(), req.Name, req.Slug, req.Description, req.Position, req.IsActive)
|
||||
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
|
||||
}
|
||||
c.JSON(http.StatusCreated, toResponse(cat))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
cat, err := h.service.Update(c.Request.Context(), id, req.Name, req.Slug, req.Description, req.Position, req.IsActive)
|
||||
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"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(cat))
|
||||
}
|
||||
|
||||
type updatePositionRequest struct {
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
// UpdatePosition lets the admin reorder the category display order (e.g.
|
||||
// move up/move down in the admin list) without resending the whole
|
||||
// category 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
|
||||
}
|
||||
cat, 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": "category not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update position"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(cat))
|
||||
}
|
||||
|
||||
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 {
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "category not found"})
|
||||
case errors.Is(err, ErrInUse):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "category is used by existing products"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete category"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Package categories lets the admin organize products into their own
|
||||
// categories (name, slug, description, ordering) without touching code.
|
||||
package categories
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
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"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Category) TableName() string { return "categories" }
|
||||
@@ -0,0 +1,98 @@
|
||||
package categories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("category not found")
|
||||
ErrSlugTaken = errors.New("slug already in use")
|
||||
ErrInUse = errors.New("category is referenced by existing products")
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, cat *Category) error
|
||||
FindByID(ctx context.Context, id uuid.UUID) (*Category, error)
|
||||
List(ctx context.Context, activeOnly bool) ([]*Category, error)
|
||||
Update(ctx context.Context, cat *Category) 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, cat *Category) error {
|
||||
if err := r.db.WithContext(ctx).Create(cat).Error; err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return ErrSlugTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Category, error) {
|
||||
var cat Category
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&cat).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cat, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) List(ctx context.Context, activeOnly bool) ([]*Category, error) {
|
||||
q := r.db.WithContext(ctx).Order("position asc, name asc")
|
||||
if activeOnly {
|
||||
q = q.Where("is_active = ?", true)
|
||||
}
|
||||
var list []*Category
|
||||
if err := q.Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
res := r.db.WithContext(ctx).Delete(&Category{}, "id = ?", id)
|
||||
if res.Error != nil {
|
||||
if isForeignKeyViolation(res.Error) {
|
||||
return ErrInUse
|
||||
}
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package categories
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
|
||||
group := rg.Group("/admin/categories", 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 category list
|
||||
// used by the storefront navigation/filtering.
|
||||
func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) {
|
||||
rg.GET("/categories", h.ListPublic)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package categories
|
||||
|
||||
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) ([]*Category, error) {
|
||||
return s.repo.List(ctx, activeOnly)
|
||||
}
|
||||
|
||||
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) {
|
||||
cat := &Category{
|
||||
ID: uuid.New(),
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Description: description,
|
||||
Position: position,
|
||||
IsActive: isActive,
|
||||
}
|
||||
if err := s.repo.Create(ctx, cat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cat, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id uuid.UUID, name, slug, description string, position int, isActive bool) (*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
|
||||
if err := s.repo.Update(ctx, cat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cat, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// UpdatePosition lets the admin reorder the catalog display order without
|
||||
// resending the full category payload.
|
||||
func (s *Service) UpdatePosition(ctx context.Context, id uuid.UUID, position int) (*Category, error) {
|
||||
cat, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cat.Position = position
|
||||
if err := s.repo.Update(ctx, cat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cat, nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package media
|
||||
|
||||
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 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"`
|
||||
}
|
||||
|
||||
func toResponse(m *Media) mediaResponse {
|
||||
return mediaResponse{
|
||||
ID: m.ID,
|
||||
Filename: m.Filename,
|
||||
URL: m.URL,
|
||||
MimeType: m.MimeType,
|
||||
SizeBytes: m.SizeBytes,
|
||||
AltText: m.AltText,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
list, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list media"})
|
||||
return
|
||||
}
|
||||
resp := make([]mediaResponse, 0, len(list))
|
||||
for _, m := range list {
|
||||
resp = append(resp, toResponse(m))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"media": resp})
|
||||
}
|
||||
|
||||
func (h *Handler) Upload(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to open uploaded file"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
contentType := fileHeader.Header.Get("Content-Type")
|
||||
altText := c.PostForm("alt_text")
|
||||
|
||||
m, err := h.service.Upload(c.Request.Context(), fileHeader.Filename, file, fileHeader.Size, contentType, altText)
|
||||
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"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upload file"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, toResponse(m))
|
||||
}
|
||||
|
||||
type updateRequest struct {
|
||||
AltText string `json:"alt_text"`
|
||||
}
|
||||
|
||||
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 updateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
m, err := h.service.UpdateAltText(c.Request.Context(), id, req.AltText)
|
||||
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 update media"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(m))
|
||||
}
|
||||
|
||||
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": "media not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete media"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Package media manages uploaded files (images/videos) and abstracts the
|
||||
// storage backend behind a Storage interface so the admin can switch
|
||||
// between local disk and any S3-compatible bucket via configuration only.
|
||||
package media
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Media struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||
Filename string `gorm:"not null"`
|
||||
StorageKey string `gorm:"not null"`
|
||||
URL string `gorm:"not null"`
|
||||
MimeType string `gorm:"not null"`
|
||||
SizeBytes int64 `gorm:"not null"`
|
||||
AltText string `gorm:"not null;default:''"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Media) TableName() string { return "media" }
|
||||
@@ -0,0 +1,66 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
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)
|
||||
Update(ctx context.Context, m *Media) 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, m *Media) error {
|
||||
return r.db.WithContext(ctx).Create(m).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
|
||||
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) {
|
||||
var list []*Media
|
||||
if err := r.db.WithContext(ctx).Order("created_at desc").Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) Update(ctx context.Context, m *Media) error {
|
||||
return r.db.WithContext(ctx).Save(m).Error
|
||||
}
|
||||
|
||||
func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
res := r.db.WithContext(ctx).Delete(&Media{}, "id = ?", id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package media
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
|
||||
group := rg.Group("/admin/media", requireAdmin)
|
||||
group.GET("", h.List)
|
||||
group.POST("", h.Upload)
|
||||
group.PUT("/:id", h.Update)
|
||||
group.DELETE("/:id", h.Delete)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFileTooLarge = errors.New("file exceeds the maximum allowed size")
|
||||
ErrUnsupportedType = errors.New("unsupported file type")
|
||||
)
|
||||
|
||||
// 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.
|
||||
var allowedTypes = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
storage Storage
|
||||
maxSizeByte int64
|
||||
}
|
||||
|
||||
func NewService(repo Repository, storage Storage, maxSizeBytes int64) *Service {
|
||||
return &Service{repo: repo, storage: storage, maxSizeByte: maxSizeBytes}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]*Media, error) {
|
||||
return s.repo.List(ctx)
|
||||
}
|
||||
|
||||
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) {
|
||||
if size > s.maxSizeByte {
|
||||
return nil, ErrFileTooLarge
|
||||
}
|
||||
ext, ok := allowedTypes[contentType]
|
||||
if !ok {
|
||||
return nil, ErrUnsupportedType
|
||||
}
|
||||
|
||||
key := uuid.NewString() + ext
|
||||
url, err := s.storage.Save(ctx, key, reader, size, contentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("save file: %w", err)
|
||||
}
|
||||
|
||||
m := &Media{
|
||||
ID: uuid.New(),
|
||||
Filename: filename,
|
||||
StorageKey: key,
|
||||
URL: url,
|
||||
MimeType: contentType,
|
||||
SizeBytes: size,
|
||||
AltText: altText,
|
||||
}
|
||||
if err := s.repo.Create(ctx, m); err != nil {
|
||||
_ = s.storage.Delete(ctx, key)
|
||||
return nil, fmt.Errorf("save metadata: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateAltText(ctx context.Context, id uuid.UUID, altText string) (*Media, error) {
|
||||
m, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.AltText = altText
|
||||
if err := s.repo.Update(ctx, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
m, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.storage.Delete(ctx, m.StorageKey); err != nil {
|
||||
return fmt.Errorf("delete stored file: %w", err)
|
||||
}
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Storage is the pluggable backend that actually persists uploaded bytes.
|
||||
// The service/handler layers never know whether files end up on local disk
|
||||
// or in an S3 bucket -- only main.go decides which implementation to wire
|
||||
// in, based on MEDIA_STORAGE_DRIVER.
|
||||
type Storage interface {
|
||||
// Save persists the content under the given key and returns the
|
||||
// publicly reachable URL for it.
|
||||
Save(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (url string, err error)
|
||||
// Delete removes the object identified by key. Deleting a
|
||||
// already-removed key must not be treated as an error.
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LocalStorage saves uploads under a directory on disk and serves them via
|
||||
// a static file route (mounted by main.go at /uploads when this driver is
|
||||
// active).
|
||||
type LocalStorage struct {
|
||||
dir string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewLocalStorage(dir, baseURL string) (*LocalStorage, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create upload dir: %w", err)
|
||||
}
|
||||
return &LocalStorage{dir: dir, baseURL: strings.TrimRight(baseURL, "/")}, nil
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Save(_ context.Context, key string, reader io.Reader, _ int64, _ string) (string, error) {
|
||||
// key is always a freshly generated UUID-based name (see service.go),
|
||||
// never derived from user input, so path traversal is not reachable here.
|
||||
dest := filepath.Join(s.dir, filepath.Base(key))
|
||||
|
||||
f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
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 s.baseURL + "/" + filepath.Base(key), nil
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Delete(_ context.Context, key string) error {
|
||||
err := os.Remove(filepath.Join(s.dir, filepath.Base(key)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("delete file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/smithy-go"
|
||||
)
|
||||
|
||||
// S3Config holds the settings needed to talk to any S3-compatible bucket
|
||||
// (AWS S3 itself, MinIO, Cloudflare R2, ...).
|
||||
type S3Config struct {
|
||||
Bucket string
|
||||
Region string
|
||||
Endpoint string // leave empty for real AWS S3
|
||||
AccessKeyID string
|
||||
SecretKey string
|
||||
UsePathStyle bool
|
||||
PublicBaseURL string // e.g. https://cdn.example.com or https://bucket.s3.region.amazonaws.com
|
||||
}
|
||||
|
||||
type S3Storage struct {
|
||||
client *s3.Client
|
||||
bucket string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewS3Storage(ctx context.Context, cfg S3Config) (*S3Storage, error) {
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
|
||||
awsconfig.WithRegion(cfg.Region),
|
||||
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretKey, "")),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load aws config: %w", err)
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
||||
if cfg.Endpoint != "" {
|
||||
o.BaseEndpoint = &cfg.Endpoint
|
||||
}
|
||||
o.UsePathStyle = cfg.UsePathStyle
|
||||
})
|
||||
|
||||
baseURL := cfg.PublicBaseURL
|
||||
if baseURL == "" {
|
||||
if cfg.Endpoint != "" {
|
||||
baseURL = strings.TrimRight(cfg.Endpoint, "/") + "/" + cfg.Bucket
|
||||
} else {
|
||||
baseURL = fmt.Sprintf("https://%s.s3.%s.amazonaws.com", cfg.Bucket, cfg.Region)
|
||||
}
|
||||
}
|
||||
|
||||
return &S3Storage{client: client, bucket: cfg.Bucket, baseURL: strings.TrimRight(baseURL, "/")}, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Save(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (string, error) {
|
||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &s.bucket,
|
||||
Key: &key,
|
||||
Body: reader,
|
||||
ContentType: &contentType,
|
||||
ContentLength: &size,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("s3 put object: %w", err)
|
||||
}
|
||||
return s.baseURL + "/" + key, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) Delete(ctx context.Context, key string) error {
|
||||
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: &s.bucket,
|
||||
Key: &key,
|
||||
})
|
||||
if err != nil {
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NoSuchKey" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("s3 delete object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package orders
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/pricing"
|
||||
"backend/internal/modules/products"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
type itemRequest struct {
|
||||
ProductID uuid.UUID `json:"product_id" binding:"required"`
|
||||
PriceTierID uuid.UUID `json:"price_tier_id" binding:"required"`
|
||||
Multiplier int64 `json:"multiplier" binding:"required,gte=1"`
|
||||
}
|
||||
|
||||
type createRequest struct {
|
||||
CustomerName string `json:"customer_name" binding:"required"`
|
||||
CustomerEmail string `json:"customer_email" binding:"required,email"`
|
||||
CustomerPhone string `json:"customer_phone"`
|
||||
Notes string `json:"notes"`
|
||||
Items []itemRequest `json:"items" binding:"required,min=1,dive"`
|
||||
}
|
||||
|
||||
type orderItemResponse struct {
|
||||
ProductID *uuid.UUID `json:"product_id,omitempty"`
|
||||
ProductName string `json:"product_name"`
|
||||
UnitSymbol string `json:"unit_symbol"`
|
||||
TierQuantity float64 `json:"tier_quantity"`
|
||||
Multiplier int64 `json:"multiplier"`
|
||||
UnitPriceCents int64 `json:"unit_price_cents"`
|
||||
TotalCents int64 `json:"total_cents"`
|
||||
}
|
||||
|
||||
type orderResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func toResponse(order *Order, items []*OrderItem) orderResponse {
|
||||
resp := orderResponse{
|
||||
ID: order.ID,
|
||||
CustomerName: order.CustomerName,
|
||||
CustomerEmail: order.CustomerEmail,
|
||||
CustomerPhone: order.CustomerPhone,
|
||||
Status: order.Status,
|
||||
TotalCents: order.TotalCents,
|
||||
Notes: order.Notes,
|
||||
}
|
||||
for _, item := range items {
|
||||
resp.Items = append(resp.Items, orderItemResponse{
|
||||
ProductID: item.ProductID,
|
||||
ProductName: item.ProductName,
|
||||
UnitSymbol: item.UnitSymbol,
|
||||
TierQuantity: item.TierQuantity,
|
||||
Multiplier: item.Multiplier,
|
||||
UnitPriceCents: item.UnitPriceCents,
|
||||
TotalCents: item.TotalCents,
|
||||
})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// Create is public: a visitor (connected or not, per spec section 17) can
|
||||
// place an order without an account.
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
var req createRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]ItemInput, 0, len(req.Items))
|
||||
for _, item := range req.Items {
|
||||
items = append(items, ItemInput{
|
||||
ProductID: item.ProductID,
|
||||
PriceTierID: item.PriceTierID,
|
||||
Multiplier: item.Multiplier,
|
||||
})
|
||||
}
|
||||
|
||||
order, orderItems, err := h.service.Create(c.Request.Context(), CreateInput{
|
||||
CustomerName: req.CustomerName,
|
||||
CustomerEmail: req.CustomerEmail,
|
||||
CustomerPhone: req.CustomerPhone,
|
||||
Notes: req.Notes,
|
||||
Items: items,
|
||||
})
|
||||
if err != nil {
|
||||
h.respondCreateError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, toResponse(order, orderItems))
|
||||
}
|
||||
|
||||
func (h *Handler) respondCreateError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrEmptyOrder), errors.Is(err, ErrProductMismatch):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, pricing.ErrNotFound):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown price tier"})
|
||||
case errors.Is(err, products.ErrNotFound):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown product"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create order"})
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
order, items, err := h.service.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "order not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load order"})
|
||||
return
|
||||
}
|
||||
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"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var req updateStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"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:''"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Order) TableName() string { return "orders" }
|
||||
|
||||
// OrderItem snapshots the product name, unit symbol and unit price at order
|
||||
// time, so the order stays accurate even if the product/unit is later
|
||||
// renamed or deleted.
|
||||
type OrderItem struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||
OrderID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||
ProductID *uuid.UUID `gorm:"type:uuid"`
|
||||
ProductName string `gorm:"not null"`
|
||||
PriceTierID *uuid.UUID `gorm:"type:uuid"`
|
||||
UnitSymbol string `gorm:"not null"`
|
||||
TierQuantity float64 `gorm:"not null"`
|
||||
Multiplier int64 `gorm:"not null;default:1"`
|
||||
UnitPriceCents int64 `gorm:"not null"`
|
||||
TotalCents int64 `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (OrderItem) TableName() string { return "order_items" }
|
||||
@@ -0,0 +1,85 @@
|
||||
package orders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
type gormRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &gormRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *gormRepository) Create(ctx context.Context, order *Order, items []*OrderItem) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
item.OrderID = order.ID
|
||||
}
|
||||
if len(items) > 0 {
|
||||
if err := tx.Create(&items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Order, []*OrderItem, error) {
|
||||
var order Order
|
||||
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
var items []*OrderItem
|
||||
if err := r.db.WithContext(ctx).Where("order_id = ?", id).Order("created_at asc").Find(&items).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
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)
|
||||
}
|
||||
var list []*Order
|
||||
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 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 {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package orders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/pricing"
|
||||
"backend/internal/modules/products"
|
||||
"backend/internal/modules/units"
|
||||
)
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
// The dependencies below are the minimal slices of other modules' services
|
||||
// this module needs, defined on the consumer side (Go idiom) so orders
|
||||
// never has to import their concrete handler/repository types.
|
||||
type ProductFinder interface {
|
||||
Get(ctx context.Context, id uuid.UUID) (*products.Product, error)
|
||||
}
|
||||
|
||||
type UnitFinder interface {
|
||||
Get(ctx context.Context, id uuid.UUID) (*units.Unit, error)
|
||||
}
|
||||
|
||||
type PriceResolver interface {
|
||||
PriceForQuantity(ctx context.Context, tierID uuid.UUID, multiplier int64) (int64, *pricing.PriceTier, error)
|
||||
}
|
||||
|
||||
// OrderNotifier is satisfied by the telegram module's Service (and, later,
|
||||
// any other notification channel) without orders ever depending on it
|
||||
// directly.
|
||||
type OrderNotifier interface {
|
||||
NotifyNewOrder(ctx context.Context, summary string)
|
||||
NotifyOrderStatusChange(ctx context.Context, summary string)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
products ProductFinder
|
||||
units UnitFinder
|
||||
prices PriceResolver
|
||||
notifier OrderNotifier
|
||||
}
|
||||
|
||||
func NewService(repo Repository, products ProductFinder, units UnitFinder, prices PriceResolver, notifier OrderNotifier) *Service {
|
||||
return &Service{repo: repo, products: products, units: units, prices: prices, notifier: notifier}
|
||||
}
|
||||
|
||||
type ItemInput struct {
|
||||
ProductID uuid.UUID
|
||||
PriceTierID uuid.UUID
|
||||
Multiplier int64
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
CustomerName string
|
||||
CustomerEmail string
|
||||
CustomerPhone string
|
||||
Notes string
|
||||
Items []ItemInput
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, in CreateInput) (*Order, []*OrderItem, error) {
|
||||
if len(in.Items) == 0 {
|
||||
return nil, nil, ErrEmptyOrder
|
||||
}
|
||||
|
||||
order := &Order{
|
||||
ID: uuid.New(),
|
||||
CustomerName: in.CustomerName,
|
||||
CustomerEmail: in.CustomerEmail,
|
||||
CustomerPhone: in.CustomerPhone,
|
||||
Status: StatusPending,
|
||||
Notes: in.Notes,
|
||||
}
|
||||
|
||||
items := make([]*OrderItem, 0, len(in.Items))
|
||||
var total int64
|
||||
|
||||
for _, in := range in.Items {
|
||||
multiplier := in.Multiplier
|
||||
if multiplier < 1 {
|
||||
multiplier = 1
|
||||
}
|
||||
|
||||
lineTotal, tier, err := s.prices.PriceForQuantity(ctx, in.PriceTierID, multiplier)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve price tier %s: %w", in.PriceTierID, err)
|
||||
}
|
||||
if tier.ProductID != in.ProductID {
|
||||
return nil, nil, ErrProductMismatch
|
||||
}
|
||||
|
||||
product, err := s.products.Get(ctx, in.ProductID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve product %s: %w", in.ProductID, err)
|
||||
}
|
||||
unit, err := s.units.Get(ctx, tier.UnitID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve unit %s: %w", tier.UnitID, err)
|
||||
}
|
||||
|
||||
items = append(items, &OrderItem{
|
||||
ID: uuid.New(),
|
||||
ProductID: &product.ID,
|
||||
ProductName: product.Name,
|
||||
PriceTierID: &tier.ID,
|
||||
UnitSymbol: unit.Symbol,
|
||||
TierQuantity: tier.Quantity,
|
||||
Multiplier: multiplier,
|
||||
UnitPriceCents: tier.PriceCents,
|
||||
TotalCents: lineTotal,
|
||||
})
|
||||
total += lineTotal
|
||||
}
|
||||
|
||||
order.TotalCents = total
|
||||
|
||||
if err := s.repo.Create(ctx, order, items); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
s.notifier.NotifyNewOrder(context.Background(), summarizeNewOrder(order, items))
|
||||
|
||||
return order, items, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Order, []*OrderItem, error) {
|
||||
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) 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 summarizeNewOrder(order *Order, items []*OrderItem) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "New order from %s (%s)\n", order.CustomerName, order.CustomerEmail)
|
||||
for _, item := range items {
|
||||
fmt.Fprintf(&b, "- %dx %s (%.2f %s) = %.2f\n",
|
||||
item.Multiplier, item.ProductName, item.TierQuantity, item.UnitSymbol, centsToUnits(item.TotalCents))
|
||||
}
|
||||
fmt.Fprintf(&b, "Total: %.2f", centsToUnits(order.TotalCents))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func centsToUnits(cents int64) float64 {
|
||||
return float64(cents) / 100
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package pricing
|
||||
|
||||
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 tierResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
PriceCents int64 `json:"price_cents"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
func toResponse(t *PriceTier) tierResponse {
|
||||
return tierResponse{
|
||||
ID: t.ID,
|
||||
ProductID: t.ProductID,
|
||||
UnitID: t.UnitID,
|
||||
Quantity: t.Quantity,
|
||||
PriceCents: t.PriceCents,
|
||||
Position: t.Position,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListByProduct(c *gin.Context) {
|
||||
productID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product id"})
|
||||
return
|
||||
}
|
||||
list, err := h.service.ListByProduct(c.Request.Context(), productID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list price tiers"})
|
||||
return
|
||||
}
|
||||
resp := make([]tierResponse, 0, len(list))
|
||||
for _, t := range list {
|
||||
resp = append(resp, toResponse(t))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"price_tiers": resp})
|
||||
}
|
||||
|
||||
type upsertRequest struct {
|
||||
UnitID uuid.UUID `json:"unit_id" binding:"required"`
|
||||
Quantity float64 `json:"quantity" binding:"required,gt=0"`
|
||||
PriceCents int64 `json:"price_cents" binding:"gte=0"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
productID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product 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
|
||||
}
|
||||
t, err := h.service.Create(c.Request.Context(), productID, req.UnitID, req.Quantity, req.PriceCents, req.Position)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidReference) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "product or unit does not exist"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create price tier"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, toResponse(t))
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("tierId"))
|
||||
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
|
||||
}
|
||||
t, err := h.service.Update(c.Request.Context(), id, req.UnitID, req.Quantity, req.PriceCents, req.Position)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "price tier not found"})
|
||||
case errors.Is(err, ErrInvalidReference):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "product or unit does not exist"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update price tier"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(t))
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("tierId"))
|
||||
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": "price tier not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete price tier"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package pricing implements quantity-based pricing for products (spec
|
||||
// section 16): a product can have several price tiers, each pairing a
|
||||
// quantity with a unit (from the units module) and a price. Pricing logic
|
||||
// (computing an order line's total from a chosen tier) is centralized here
|
||||
// in the backend -- the frontend only ever displays tiers and sends back a
|
||||
// tier ID + multiplier; it never sends a price the backend has to trust.
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PriceTier struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||
UnitID uuid.UUID `gorm:"type:uuid;not null"`
|
||||
Quantity float64 `gorm:"not null"`
|
||||
PriceCents int64 `gorm:"not null"`
|
||||
Position int `gorm:"not null;default:0"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (PriceTier) TableName() string { return "price_tiers" }
|
||||
@@ -0,0 +1,86 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("price tier not found")
|
||||
ErrInvalidReference = errors.New("product or unit does not exist")
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, t *PriceTier) error
|
||||
FindByID(ctx context.Context, id uuid.UUID) (*PriceTier, error)
|
||||
ListByProduct(ctx context.Context, productID uuid.UUID) ([]*PriceTier, error)
|
||||
Update(ctx context.Context, t *PriceTier) 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, t *PriceTier) error {
|
||||
if err := r.db.WithContext(ctx).Create(t).Error; err != nil {
|
||||
if isForeignKeyViolation(err) {
|
||||
return ErrInvalidReference
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*PriceTier, error) {
|
||||
var t PriceTier
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&t).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) ListByProduct(ctx context.Context, productID uuid.UUID) ([]*PriceTier, error) {
|
||||
var list []*PriceTier
|
||||
err := r.db.WithContext(ctx).Where("product_id = ?", productID).Order("position asc, quantity asc").Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) Update(ctx context.Context, t *PriceTier) error {
|
||||
err := r.db.WithContext(ctx).Save(t).Error
|
||||
if isForeignKeyViolation(err) {
|
||||
return ErrInvalidReference
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
res := r.db.WithContext(ctx).Delete(&PriceTier{}, "id = ?", id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isForeignKeyViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23503"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package pricing
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// RegisterAdminRoutes nests price-tier management under a product, e.g.
|
||||
// PUT /api/admin/products/:id/price-tiers/:tierId, so tiers are always
|
||||
// managed in the context of the product they belong to.
|
||||
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
|
||||
group := rg.Group("/admin/products/:id/price-tiers", requireAdmin)
|
||||
group.GET("", h.ListByProduct)
|
||||
group.POST("", h.Create)
|
||||
group.PUT("/:tierId", h.Update)
|
||||
group.DELETE("/:tierId", h.Delete)
|
||||
}
|
||||
|
||||
// RegisterPublicRoutes exposes read-only tiers so the storefront can render
|
||||
// the available quantity/price options for a product. Kept as its own
|
||||
// top-level path (rather than nested under /products/:slug) because the
|
||||
// products module's public detail route uses a :slug wildcard at that same
|
||||
// position -- gin does not allow two different wildcard names at the same
|
||||
// path segment.
|
||||
func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) {
|
||||
rg.GET("/price-tiers/by-product/:id", h.ListByProduct)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) ListByProduct(ctx context.Context, productID uuid.UUID) ([]*PriceTier, error) {
|
||||
return s.repo.ListByProduct(ctx, productID)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id uuid.UUID) (*PriceTier, error) {
|
||||
return s.repo.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, productID, unitID uuid.UUID, quantity float64, priceCents int64, position int) (*PriceTier, error) {
|
||||
t := &PriceTier{
|
||||
ID: uuid.New(),
|
||||
ProductID: productID,
|
||||
UnitID: unitID,
|
||||
Quantity: quantity,
|
||||
PriceCents: priceCents,
|
||||
Position: position,
|
||||
}
|
||||
if err := s.repo.Create(ctx, t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id uuid.UUID, unitID uuid.UUID, quantity float64, priceCents int64, position int) (*PriceTier, error) {
|
||||
t, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.UnitID = unitID
|
||||
t.Quantity = quantity
|
||||
t.PriceCents = priceCents
|
||||
t.Position = position
|
||||
if err := s.repo.Update(ctx, t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// PriceForQuantity is the centralized pricing calculation used by the
|
||||
// orders module: given a chosen tier and how many of that tier package the
|
||||
// customer wants, it returns the authoritative total in cents. The
|
||||
// frontend never gets to supply a price directly.
|
||||
func (s *Service) PriceForQuantity(ctx context.Context, tierID uuid.UUID, multiplier int64) (totalCents int64, tier *PriceTier, err error) {
|
||||
t, err := s.repo.FindByID(ctx, tierID)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if multiplier < 1 {
|
||||
multiplier = 1
|
||||
}
|
||||
return t.PriceCents * multiplier, t, nil
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package products
|
||||
|
||||
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 galleryItemResponse struct {
|
||||
MediaID uuid.UUID `json:"media_id"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
func parseCategoryFilter(c *gin.Context) *uuid.UUID {
|
||||
raw := c.Query("category_id")
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &id
|
||||
}
|
||||
|
||||
func (h *Handler) ListAdmin(c *gin.Context) {
|
||||
list, err := h.service.List(c.Request.Context(), false, parseCategoryFilter(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list products"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"products": toResponseList(list)})
|
||||
}
|
||||
|
||||
func (h *Handler) ListPublic(c *gin.Context) {
|
||||
list, err := h.service.List(c.Request.Context(), true, parseCategoryFilter(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list products"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"products": toResponseList(list)})
|
||||
}
|
||||
|
||||
func toResponseList(list []*Product) []productResponse {
|
||||
resp := make([]productResponse, 0, len(list))
|
||||
for _, p := range list {
|
||||
resp = append(resp, toResponse(p))
|
||||
}
|
||||
return 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
|
||||
}
|
||||
p, err := h.service.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
h.respondGetError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(p))
|
||||
}
|
||||
|
||||
func (h *Handler) GetPublicBySlug(c *gin.Context) {
|
||||
p, err := h.service.GetBySlug(c.Request.Context(), c.Param("slug"))
|
||||
if err != nil {
|
||||
h.respondGetError(c, err)
|
||||
return
|
||||
}
|
||||
if !p.IsActive {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "product not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(p))
|
||||
}
|
||||
|
||||
func (h *Handler) respondGetError(c *gin.Context, err error) {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "product not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load product"})
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create product"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, toResponse(p))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
p, err := h.service.Update(c.Request.Context(), id, req.toInput())
|
||||
if err != nil {
|
||||
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"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update product"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(p))
|
||||
}
|
||||
|
||||
type updatePositionRequest struct {
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
// UpdatePosition lets the admin reorder the catalog display order (e.g.
|
||||
// move up/move down in the admin list) without resending the whole
|
||||
// product 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
|
||||
}
|
||||
p, 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": "product not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update position"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(p))
|
||||
}
|
||||
|
||||
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": "product not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete product"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type addGalleryRequest struct {
|
||||
MediaID uuid.UUID `json:"media_id" binding:"required"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
func (h *Handler) AddGalleryItem(c *gin.Context) {
|
||||
productID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var req addGalleryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.service.AddGalleryItem(c.Request.Context(), productID, req.MediaID, req.Position); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to attach media"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func (h *Handler) RemoveGalleryItem(c *gin.Context) {
|
||||
productID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
mediaID, err := uuid.Parse(c.Param("mediaId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid media id"})
|
||||
return
|
||||
}
|
||||
if err := h.service.RemoveGalleryItem(c.Request.Context(), productID, mediaID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "gallery item not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to detach media"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ListGallery(c *gin.Context) {
|
||||
productID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListGallery(c.Request.Context(), productID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list gallery"})
|
||||
return
|
||||
}
|
||||
resp := make([]galleryItemResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
resp = append(resp, galleryItemResponse{MediaID: item.MediaID, Position: item.Position})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"gallery": resp})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Package products owns the product catalog entity itself (identity,
|
||||
// description, category, media). Quantity-based pricing lives in the
|
||||
// pricing module, which references products by ID -- keeping "what a
|
||||
// product is" separate from "how it's priced", per spec section 13/16.
|
||||
package products
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (Product) TableName() string { return "products" }
|
||||
|
||||
// GalleryItem attaches a media item to a product's gallery, ordered by
|
||||
// Position. A product's primary image (PrimaryMediaID) is separate from
|
||||
// the gallery so it can be picked from unrelated media too.
|
||||
type GalleryItem struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||
MediaID uuid.UUID `gorm:"type:uuid;not null"`
|
||||
Position int `gorm:"not null;default:0"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (GalleryItem) TableName() string { return "product_media" }
|
||||
@@ -0,0 +1,132 @@
|
||||
package products
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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")
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
AddGalleryItem(ctx context.Context, item *GalleryItem) error
|
||||
RemoveGalleryItem(ctx context.Context, productID, mediaID uuid.UUID) error
|
||||
ListGallery(ctx context.Context, productID uuid.UUID) ([]*GalleryItem, error)
|
||||
}
|
||||
|
||||
type gormRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &gormRepository{db: db}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Product, error) {
|
||||
var p Product
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&p).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
q = q.Where("is_active = ?", true)
|
||||
}
|
||||
if categoryID != nil {
|
||||
q = q.Where("category_id = ?", *categoryID)
|
||||
}
|
||||
var list []*Product
|
||||
if err := q.Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
res := r.db.WithContext(ctx).Delete(&Product{}, "id = ?", id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) AddGalleryItem(ctx context.Context, item *GalleryItem) error {
|
||||
return r.db.WithContext(ctx).Create(item).Error
|
||||
}
|
||||
|
||||
func (r *gormRepository) RemoveGalleryItem(ctx context.Context, productID, mediaID uuid.UUID) error {
|
||||
res := r.db.WithContext(ctx).Delete(&GalleryItem{}, "product_id = ? AND media_id = ?", productID, mediaID)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) ListGallery(ctx context.Context, productID uuid.UUID) ([]*GalleryItem, error) {
|
||||
var list []*GalleryItem
|
||||
if err := r.db.WithContext(ctx).Where("product_id = ?", productID).Order("position asc").Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package products
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
|
||||
group := rg.Group("/admin/products", requireAdmin)
|
||||
group.GET("", h.ListAdmin)
|
||||
group.POST("", h.Create)
|
||||
group.GET("/:id", h.GetAdmin)
|
||||
group.PUT("/:id", h.Update)
|
||||
group.PATCH("/:id/position", h.UpdatePosition)
|
||||
group.DELETE("/:id", h.Delete)
|
||||
|
||||
group.GET("/:id/gallery", h.ListGallery)
|
||||
group.POST("/:id/gallery", h.AddGalleryItem)
|
||||
group.DELETE("/:id/gallery/:mediaId", h.RemoveGalleryItem)
|
||||
}
|
||||
|
||||
// RegisterPublicRoutes exposes the read-only, active-only product catalog
|
||||
// used by the storefront (site vitrine / boutique).
|
||||
func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) {
|
||||
rg.GET("/products", h.ListPublic)
|
||||
rg.GET("/products/:slug", h.GetPublicBySlug)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package products
|
||||
|
||||
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, categoryID *uuid.UUID) ([]*Product, error) {
|
||||
return s.repo.List(ctx, activeOnly, categoryID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, in UpsertInput) (*Product, error) {
|
||||
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,
|
||||
}
|
||||
if err := s.repo.Create(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id uuid.UUID, in UpsertInput) (*Product, error) {
|
||||
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
|
||||
p.PrimaryMediaID = in.PrimaryMediaID
|
||||
p.Position = in.Position
|
||||
if err := s.repo.Update(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// UpdatePosition lets the admin reorder the catalog display order without
|
||||
// resending the full product payload.
|
||||
func (s *Service) UpdatePosition(ctx context.Context, id uuid.UUID, position int) (*Product, error) {
|
||||
p, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Position = position
|
||||
if err := s.repo.Update(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) AddGalleryItem(ctx context.Context, productID, mediaID uuid.UUID, position int) error {
|
||||
return s.repo.AddGalleryItem(ctx, &GalleryItem{
|
||||
ID: uuid.New(),
|
||||
ProductID: productID,
|
||||
MediaID: mediaID,
|
||||
Position: position,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) RemoveGalleryItem(ctx context.Context, productID, mediaID uuid.UUID) error {
|
||||
return s.repo.RemoveGalleryItem(ctx, productID, mediaID)
|
||||
}
|
||||
|
||||
func (s *Service) ListGallery(ctx context.Context, productID uuid.UUID) ([]*GalleryItem, error) {
|
||||
return s.repo.ListGallery(ctx, productID)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
type settingsResponse struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
func toResponse(s *Settings) settingsResponse {
|
||||
return settingsResponse{Name: s.Name, Description: s.Description, Slug: s.Slug}
|
||||
}
|
||||
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
settings, err := h.service.Get(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load site settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(settings))
|
||||
}
|
||||
|
||||
type updateRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Slug string `json:"slug" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
var req updateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update site settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(settings))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 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 +
|
||||
// admin-only read/write endpoints).
|
||||
package site
|
||||
|
||||
import "time"
|
||||
|
||||
type Settings struct {
|
||||
ID int16 `gorm:"primaryKey"`
|
||||
Name string
|
||||
Description string
|
||||
Slug string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Settings) TableName() string { return "site_settings" }
|
||||
@@ -0,0 +1,37 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Get(ctx context.Context) (*Settings, error)
|
||||
Update(ctx context.Context, s *Settings) error
|
||||
}
|
||||
|
||||
type gormRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &gormRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *gormRepository) Get(ctx context.Context) (*Settings, error) {
|
||||
var s Settings
|
||||
if err := r.db.WithContext(ctx).First(&s, "id = 1").Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
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,
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package site
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
|
||||
group := rg.Group("/admin/site-settings")
|
||||
group.GET("", h.Get)
|
||||
group.PUT("", requireAdmin, h.Update)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package site
|
||||
|
||||
import "context"
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
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}
|
||||
if err := s.repo.Update(ctx, settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.Get(ctx)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// settingsResponse never includes the raw bot token (spec section 32: secrets
|
||||
// must never be exposed to the frontend) -- only whether one is configured.
|
||||
type settingsResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
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 {
|
||||
return settingsResponse{
|
||||
Enabled: s.Enabled,
|
||||
BotTokenConfigured: s.BotToken != "",
|
||||
ChatID: s.ChatID,
|
||||
NotifyNewOrder: s.NotifyNewOrder,
|
||||
NotifyStatusChange: s.NotifyStatusChange,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
settings, err := h.service.Get(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load telegram settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(settings))
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
var req updateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update telegram settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(settings))
|
||||
}
|
||||
|
||||
func (h *Handler) Test(c *gin.Context) {
|
||||
if err := h.service.SendTestMessage(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "test notification sent"})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Package telegram is the notifications module (spec section 20): the
|
||||
// admin configures a bot token + chat id and toggles which events trigger a
|
||||
// message. It is a pluggable notifier -- other modules (orders) depend on
|
||||
// the small OrderNotifier interface it satisfies, never on Telegram
|
||||
// directly, so a future module (email, WhatsApp, ...) can be swapped in the
|
||||
// same way.
|
||||
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
|
||||
}
|
||||
|
||||
func (Settings) TableName() string { return "telegram_settings" }
|
||||
@@ -0,0 +1,44 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Get(ctx context.Context) (*Settings, error)
|
||||
Update(ctx context.Context, s *Settings) error
|
||||
}
|
||||
|
||||
type gormRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &gormRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *gormRepository) Get(ctx context.Context) (*Settings, error) {
|
||||
var s Settings
|
||||
if err := r.db.WithContext(ctx).First(&s, "id = 1").Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
// bot_token is only overwritten when explicitly provided (see service.go):
|
||||
// a blank value in the update request means "keep the existing secret".
|
||||
if s.BotToken != "" {
|
||||
updates["bot_token"] = s.BotToken
|
||||
}
|
||||
return r.db.WithContext(ctx).Model(&Settings{}).Where("id = 1").Updates(updates).Error
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package telegram
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
|
||||
group := rg.Group("/admin/notifications/telegram", requireAdmin)
|
||||
group.GET("", h.Get)
|
||||
group.PUT("", h.Update)
|
||||
group.POST("/test", h.Test)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sender abstracts the actual Telegram Bot API call so the service layer
|
||||
// can be unit-tested without making real network requests.
|
||||
type Sender interface {
|
||||
Send(ctx context.Context, botToken, chatID, text string) error
|
||||
}
|
||||
|
||||
type httpSender struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPSender() Sender {
|
||||
return &httpSender{client: &http.Client{Timeout: 10 * time.Second}}
|
||||
}
|
||||
|
||||
func (s *httpSender) Send(ctx context.Context, botToken, chatID, text string) error {
|
||||
if botToken == "" || chatID == "" {
|
||||
return fmt.Errorf("telegram bot token and chat id must be configured")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||
form := url.Values{
|
||||
"chat_id": {chatID},
|
||||
"text": {text},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build telegram request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("call telegram api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("telegram api returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
sender Sender
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewService(repo Repository, sender Sender, logger *slog.Logger) *Service {
|
||||
return &Service{repo: repo, sender: sender, logger: logger}
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context) (*Settings, error) {
|
||||
return s.repo.Get(ctx)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
settings := &Settings{
|
||||
Enabled: enabled,
|
||||
BotToken: botToken,
|
||||
ChatID: chatID,
|
||||
NotifyNewOrder: notifyNewOrder,
|
||||
NotifyStatusChange: notifyStatusChange,
|
||||
}
|
||||
if err := s.repo.Update(ctx, settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.Get(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) SendTestMessage(ctx context.Context) error {
|
||||
settings, err := s.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if settings.BotToken == "" || settings.ChatID == "" {
|
||||
return fmt.Errorf("bot token and chat id must be configured before testing")
|
||||
}
|
||||
return s.sender.Send(ctx, settings.BotToken, settings.ChatID, "Test notification: your Telegram integration is working.")
|
||||
}
|
||||
|
||||
// NotifyNewOrder implements orders.OrderNotifier. It never returns an error
|
||||
// to the caller: a broken/misconfigured Telegram integration must not
|
||||
// prevent a customer's order from being created, so failures are only
|
||||
// logged.
|
||||
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 {
|
||||
s.logger.Error("telegram: failed to load settings", "error", err)
|
||||
return
|
||||
}
|
||||
if !settings.Enabled || !shouldSend(settings) {
|
||||
return
|
||||
}
|
||||
if err := s.sender.Send(ctx, settings.BotToken, settings.ChatID, text); err != nil {
|
||||
s.logger.Error("telegram: failed to send notification", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package units
|
||||
|
||||
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 unitResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Symbol string `json:"symbol"`
|
||||
}
|
||||
|
||||
func toResponse(u *Unit) unitResponse {
|
||||
return unitResponse{ID: u.ID, Name: u.Name, Symbol: u.Symbol}
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
list, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list units"})
|
||||
return
|
||||
}
|
||||
resp := make([]unitResponse, 0, len(list))
|
||||
for _, u := range list {
|
||||
resp = append(resp, toResponse(u))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"units": resp})
|
||||
}
|
||||
|
||||
type upsertRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Symbol string `json:"symbol" binding:"required"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
u, err := h.service.Create(c.Request.Context(), req.Name, req.Symbol)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSymbolTaken) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "symbol already in use"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create unit"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, toResponse(u))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
u, err := h.service.Update(c.Request.Context(), id, req.Name, req.Symbol)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "unit not found"})
|
||||
case errors.Is(err, ErrSymbolTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "symbol already in use"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update unit"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(u))
|
||||
}
|
||||
|
||||
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 {
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "unit not found"})
|
||||
case errors.Is(err, ErrInUse):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "unit is used by existing price tiers"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete unit"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package units lets the admin define their own measurement units
|
||||
// (kg, g, piece, carton, ...) instead of the code hard-coding a fixed list.
|
||||
package units
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Unit struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||
Name string `gorm:"not null"`
|
||||
Symbol string `gorm:"uniqueIndex;not null"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Unit) TableName() string { return "units" }
|
||||
@@ -0,0 +1,94 @@
|
||||
package units
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("unit not found")
|
||||
ErrSymbolTaken = errors.New("symbol already in use")
|
||||
ErrInUse = errors.New("unit is referenced by existing price tiers")
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, u *Unit) error
|
||||
FindByID(ctx context.Context, id uuid.UUID) (*Unit, error)
|
||||
List(ctx context.Context) ([]*Unit, error)
|
||||
Update(ctx context.Context, u *Unit) 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, u *Unit) error {
|
||||
if err := r.db.WithContext(ctx).Create(u).Error; err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return ErrSymbolTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Unit, error) {
|
||||
var u Unit
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) List(ctx context.Context) ([]*Unit, error) {
|
||||
var list []*Unit
|
||||
if err := r.db.WithContext(ctx).Order("name asc").Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) Update(ctx context.Context, u *Unit) error {
|
||||
err := r.db.WithContext(ctx).Save(u).Error
|
||||
if isUniqueViolation(err) {
|
||||
return ErrSymbolTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
res := r.db.WithContext(ctx).Delete(&Unit{}, "id = ?", id)
|
||||
if res.Error != nil {
|
||||
if isForeignKeyViolation(res.Error) {
|
||||
return ErrInUse
|
||||
}
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package units
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
|
||||
group := rg.Group("/admin/units", requireAdmin)
|
||||
group.GET("", h.List)
|
||||
group.POST("", h.Create)
|
||||
group.PUT("/:id", h.Update)
|
||||
group.DELETE("/:id", h.Delete)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package units
|
||||
|
||||
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) ([]*Unit, error) {
|
||||
return s.repo.List(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Unit, error) {
|
||||
return s.repo.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, name, symbol string) (*Unit, error) {
|
||||
u := &Unit{ID: uuid.New(), Name: name, Symbol: symbol}
|
||||
if err := s.repo.Create(ctx, u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id uuid.UUID, name, symbol string) (*Unit, error) {
|
||||
u, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Name = name
|
||||
u.Symbol = symbol
|
||||
if err := s.repo.Update(ctx, u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package users
|
||||
|
||||
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 userResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
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}
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
list, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"})
|
||||
return
|
||||
}
|
||||
resp := make([]userResponse, 0, len(list))
|
||||
for _, u := range list {
|
||||
resp = append(resp, toResponse(u))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"users": resp})
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=12"`
|
||||
Role string `json:"role" binding:"required,oneof=admin customer"`
|
||||
}
|
||||
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
var req createUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailTaken) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "email already in use"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, toResponse(user))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
user, err := h.service.FindByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(user))
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
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 updateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.service.UpdateProfile(c.Request.Context(), id, req.Email, 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"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(user))
|
||||
}
|
||||
|
||||
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": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Package users owns the user account entity and its CRUD operations.
|
||||
// The auth module depends on this package to look up credentials, but
|
||||
// never owns or duplicates the User model itself.
|
||||
package users
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleAdmin = "admin"
|
||||
RoleCustomer = "customer"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||
Email string `gorm:"uniqueIndex;not null"`
|
||||
PasswordHash string `gorm:"not null"`
|
||||
Role string `gorm:"not null;default:admin"`
|
||||
IsActive bool `gorm:"not null;default:true"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "users" }
|
||||
@@ -0,0 +1,98 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"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)
|
||||
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
|
||||
}
|
||||
|
||||
type gormRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &gormRepository{db: db}
|
||||
}
|
||||
|
||||
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 err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
|
||||
var user User
|
||||
err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
||||
var user User
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, 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 {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) Update(ctx context.Context, user *User) error {
|
||||
err := r.db.WithContext(ctx).Save(user).Error
|
||||
if isUniqueViolation(err) {
|
||||
return ErrEmailTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
res := r.db.WithContext(ctx).Delete(&User{}, "id = ?", id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a Postgres unique-constraint
|
||||
// violation (SQLSTATE 23505), e.g. a duplicate email.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package users
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// RegisterAdminRoutes mounts the user-management endpoints under the given
|
||||
// group, guarded by the requireAdmin middleware supplied by the caller.
|
||||
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
|
||||
group := rg.Group("/admin/users", requireAdmin)
|
||||
group.GET("", h.List)
|
||||
group.POST("", h.Create)
|
||||
group.GET("/:id", h.Get)
|
||||
group.PUT("/:id", h.Update)
|
||||
group.DELETE("/:id", h.Delete)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]*User, error) {
|
||||
return s.repo.List(ctx)
|
||||
}
|
||||
|
||||
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) Create(ctx context.Context, email, password, role string) (*User, error) {
|
||||
if role != RoleAdmin && role != RoleCustomer {
|
||||
return nil, fmt.Errorf("invalid role %q", role)
|
||||
}
|
||||
hash, err := security.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
user := &User{
|
||||
ID: uuid.New(),
|
||||
Email: email,
|
||||
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) {
|
||||
user, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email
|
||||
user.IsActive = isActive
|
||||
if err := s.repo.Update(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetPassword(ctx context.Context, id uuid.UUID, newPassword string) error {
|
||||
user, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := security.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
user.PasswordHash = hash
|
||||
return s.repo.Update(ctx, user)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Package config loads application configuration from environment variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Database DatabaseConfig
|
||||
Redis RedisConfig
|
||||
JWT JWTConfig
|
||||
Server ServerConfig
|
||||
Seed SeedConfig
|
||||
Media MediaConfig
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string
|
||||
AccessTTL time.Duration
|
||||
RefreshTTL time.Duration
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port string
|
||||
GinMode string
|
||||
CORSOrigin string
|
||||
CookieSecure bool
|
||||
}
|
||||
|
||||
type SeedConfig struct {
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
}
|
||||
|
||||
// MediaConfig configures the pluggable media storage backend. Driver
|
||||
// selects "local" (files on disk, served via /uploads) or "s3" (any
|
||||
// S3-compatible object store). Only the fields relevant to the selected
|
||||
// driver need to be set.
|
||||
type MediaConfig struct {
|
||||
Driver string
|
||||
MaxUploadMB int64
|
||||
LocalDir string
|
||||
LocalBaseURL string
|
||||
|
||||
S3Bucket string
|
||||
S3Region string
|
||||
S3Endpoint string
|
||||
S3AccessKeyID string
|
||||
S3SecretKey string
|
||||
S3UsePathStyle bool
|
||||
S3PublicBaseURL 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")
|
||||
|
||||
accessMinutes, err := strconv.Atoi(getEnv("ACCESS_TOKEN_TTL_MINUTES", "15"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid ACCESS_TOKEN_TTL_MINUTES: %w", err)
|
||||
}
|
||||
refreshHours, err := strconv.Atoi(getEnv("REFRESH_TOKEN_TTL_HOURS", "168"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid REFRESH_TOKEN_TTL_HOURS: %w", err)
|
||||
}
|
||||
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
if secret == "" || secret == "change-me-to-a-long-random-secret" {
|
||||
return nil, fmt.Errorf("JWT_SECRET must be set to a strong, unique value")
|
||||
}
|
||||
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
return nil, fmt.Errorf("DATABASE_URL must be set")
|
||||
}
|
||||
redisURL := os.Getenv("REDIS_URL")
|
||||
if redisURL == "" {
|
||||
return nil, fmt.Errorf("REDIS_URL must be set")
|
||||
}
|
||||
|
||||
ginMode := getEnv("GIN_MODE", "debug")
|
||||
cookieSecure, err := strconv.ParseBool(getEnv("COOKIE_SECURE", strconv.FormatBool(ginMode != "debug")))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid COOKIE_SECURE: %w", err)
|
||||
}
|
||||
|
||||
mediaDriver := getEnv("MEDIA_STORAGE_DRIVER", "local")
|
||||
if mediaDriver != "local" && mediaDriver != "s3" {
|
||||
return nil, fmt.Errorf("invalid MEDIA_STORAGE_DRIVER %q: must be \"local\" or \"s3\"", mediaDriver)
|
||||
}
|
||||
maxUploadMB, err := strconv.ParseInt(getEnv("MEDIA_MAX_UPLOAD_MB", "10"), 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid MEDIA_MAX_UPLOAD_MB: %w", err)
|
||||
}
|
||||
s3UsePathStyle, err := strconv.ParseBool(getEnv("S3_USE_PATH_STYLE", "false"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid S3_USE_PATH_STYLE: %w", err)
|
||||
}
|
||||
if mediaDriver == "s3" {
|
||||
for _, key := range []string{"S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"} {
|
||||
if os.Getenv(key) == "" {
|
||||
return nil, fmt.Errorf("%s must be set when MEDIA_STORAGE_DRIVER=s3", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Database: DatabaseConfig{
|
||||
URL: dbURL,
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
URL: redisURL,
|
||||
},
|
||||
JWT: JWTConfig{
|
||||
Secret: secret,
|
||||
AccessTTL: time.Duration(accessMinutes) * time.Minute,
|
||||
RefreshTTL: time.Duration(refreshHours) * time.Hour,
|
||||
},
|
||||
Server: ServerConfig{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
GinMode: ginMode,
|
||||
CORSOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"),
|
||||
CookieSecure: cookieSecure,
|
||||
},
|
||||
Seed: SeedConfig{
|
||||
AdminEmail: os.Getenv("SEED_ADMIN_EMAIL"),
|
||||
AdminPassword: os.Getenv("SEED_ADMIN_PASSWORD"),
|
||||
},
|
||||
Media: MediaConfig{
|
||||
Driver: mediaDriver,
|
||||
MaxUploadMB: maxUploadMB,
|
||||
LocalDir: getEnv("MEDIA_LOCAL_DIR", "./uploads"),
|
||||
LocalBaseURL: getEnv("MEDIA_LOCAL_BASE_URL", "http://localhost:8080/uploads"),
|
||||
S3Bucket: os.Getenv("S3_BUCKET"),
|
||||
S3Region: os.Getenv("S3_REGION"),
|
||||
S3Endpoint: os.Getenv("S3_ENDPOINT"),
|
||||
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
|
||||
S3SecretKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
|
||||
S3UsePathStyle: s3UsePathStyle,
|
||||
S3PublicBaseURL: os.Getenv("S3_PUBLIC_BASE_URL"),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package db provides the PostgreSQL connection via GORM.
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func Connect(databaseURL string, debug bool) (*gorm.DB, error) {
|
||||
logLevel := gormlogger.Silent
|
||||
if debug {
|
||||
logLevel = gormlogger.Warn
|
||||
}
|
||||
|
||||
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(logLevel),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect postgres: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get sql.DB: %w", err)
|
||||
}
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Package logger provides a minimal structured logger shared across modules.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
func New(debug bool) *slog.Logger {
|
||||
level := slog.LevelInfo
|
||||
if debug {
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
|
||||
return slog.New(handler)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Package middleware provides HTTP middleware shared across modules,
|
||||
// notably the admin/customer auth guards, CORS and rate limiting.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
const (
|
||||
ctxUserIDKey = "auth_user_id"
|
||||
ctxRoleKey = "auth_role"
|
||||
)
|
||||
|
||||
func extractBearerToken(c *gin.Context) (string, bool) {
|
||||
header := c.GetHeader("Authorization")
|
||||
if header == "" {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || parts[1] == "" {
|
||||
return "", false
|
||||
}
|
||||
return parts[1], true
|
||||
}
|
||||
|
||||
// requireAudience builds a guard that only accepts access tokens issued for
|
||||
// the given audience (admin or customer space) and role. Because the JWT
|
||||
// audience and the expected role are both checked, an admin-space token can
|
||||
// never authenticate a customer-only route, and vice versa.
|
||||
func requireAudience(secret string, aud security.Audience, role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenString, ok := extractBearerToken(c)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := security.ParseAccessToken(secret, tokenString, aud)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
if claims.Role != role {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient role"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(claims.Subject)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid subject claim"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(ctxUserIDKey, userID)
|
||||
c.Set(ctxRoleKey, claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin protects admin-panel routes. Only tokens issued in the admin
|
||||
// audience with role "admin" pass.
|
||||
func RequireAdmin(secret string) gin.HandlerFunc {
|
||||
return requireAudience(secret, security.AudienceAdmin, "admin")
|
||||
}
|
||||
|
||||
// RequireCustomer protects storefront account routes. Only tokens issued in
|
||||
// the customer audience with role "customer" pass. Not mounted yet in this
|
||||
// phase (no customer-facing routes), but kept fully separate from
|
||||
// RequireAdmin so activating the customer module later never risks sharing
|
||||
// a session/cookie space with the admin panel.
|
||||
func RequireCustomer(secret string) gin.HandlerFunc {
|
||||
return requireAudience(secret, security.AudienceCustomer, "customer")
|
||||
}
|
||||
|
||||
// GetUserID returns the authenticated user's ID set by RequireAdmin/RequireCustomer.
|
||||
func GetUserID(c *gin.Context) (uuid.UUID, bool) {
|
||||
v, exists := c.Get(ctxUserIDKey)
|
||||
if !exists {
|
||||
return uuid.UUID{}, false
|
||||
}
|
||||
id, ok := v.(uuid.UUID)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// GetRole returns the authenticated user's role set by RequireAdmin/RequireCustomer.
|
||||
func GetRole(c *gin.Context) (string, bool) {
|
||||
v, exists := c.Get(ctxRoleKey)
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
role, ok := v.(string)
|
||||
return role, ok
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS allows only the configured frontend origin, with credentials enabled
|
||||
// (required for the httpOnly refresh-token cookie to be sent cross-origin
|
||||
// between the Vite dev server and the API in development).
|
||||
func CORS(allowedOrigin string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" && origin == allowedOrigin {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
c.Header("Vary", "Origin")
|
||||
}
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// PerIPRateLimiter limits requests per client IP, intended for sensitive
|
||||
// low-frequency endpoints like login (defense against credential stuffing
|
||||
// / brute force).
|
||||
type PerIPRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
limiters map[string]*rate.Limiter
|
||||
r rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
func NewPerIPRateLimiter(requestsPerMinute int, burst int) *PerIPRateLimiter {
|
||||
l := &PerIPRateLimiter{
|
||||
limiters: make(map[string]*rate.Limiter),
|
||||
r: rate.Every(time.Minute / time.Duration(requestsPerMinute)),
|
||||
burst: burst,
|
||||
}
|
||||
go l.cleanupLoop()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *PerIPRateLimiter) getLimiter(ip string) *rate.Limiter {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
limiter, exists := l.limiters[ip]
|
||||
if !exists {
|
||||
limiter = rate.NewLimiter(l.r, l.burst)
|
||||
l.limiters[ip] = limiter
|
||||
}
|
||||
return limiter
|
||||
}
|
||||
|
||||
func (l *PerIPRateLimiter) cleanupLoop() {
|
||||
for {
|
||||
time.Sleep(10 * time.Minute)
|
||||
l.mu.Lock()
|
||||
for ip, limiter := range l.limiters {
|
||||
if limiter.Tokens() >= float64(l.burst) {
|
||||
delete(l.limiters, ip)
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *PerIPRateLimiter) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
if !l.getLimiter(ip).Allow() {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "too many requests, try again later"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Package redis provides the Redis client used for refresh-token/session state.
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func Connect(redisURL string) (*redis.Client, error) {
|
||||
opt, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse redis url: %w", err)
|
||||
}
|
||||
|
||||
client := redis.NewClient(opt)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return nil, fmt.Errorf("ping redis: %w", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Audience separates admin-space tokens from customer-space tokens so a
|
||||
// token issued for one space can never be accepted by the other, even
|
||||
// though both are signed with the same server secret.
|
||||
type Audience string
|
||||
|
||||
const (
|
||||
AudienceAdmin Audience = "admin"
|
||||
AudienceCustomer Audience = "customer"
|
||||
)
|
||||
|
||||
// Claims are the JWT claims carried by access tokens issued by this platform.
|
||||
type Claims struct {
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// IssueAccessToken creates a signed, short-lived access token for the given
|
||||
// user, role and audience (admin or customer space).
|
||||
func IssueAccessToken(secret string, ttl time.Duration, userID uuid.UUID, role string, aud Audience) (string, error) {
|
||||
if role == "" {
|
||||
return "", fmt.Errorf("role must not be empty")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID.String(),
|
||||
Audience: jwt.ClaimStrings{string(aud)},
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
ID: uuid.NewString(),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign token: %w", err)
|
||||
}
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
// ParseAccessToken verifies signature, algorithm and every claim explicitly:
|
||||
// - signing method must be HMAC (rejects "alg":"none" and any asymmetric swap attempt)
|
||||
// - exp/iat/nbf must be present and internally consistent (no expired/not-yet-valid tokens)
|
||||
// - aud must match exactly the expected audience (admin token can never pass as a customer token, or vice versa)
|
||||
// - sub must be present and a well-formed UUID (matches our user ID format)
|
||||
// - role must be present and non-empty
|
||||
// - jti must be present (reserved for future revocation lookups)
|
||||
func ParseAccessToken(secret string, tokenString string, expectedAud Audience) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return []byte(secret), nil
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse token: %w", err)
|
||||
}
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
if claims.ExpiresAt == nil {
|
||||
return nil, errors.New("missing exp claim")
|
||||
}
|
||||
if claims.IssuedAt == nil {
|
||||
return nil, errors.New("missing iat claim")
|
||||
}
|
||||
if claims.ID == "" {
|
||||
return nil, errors.New("missing jti claim")
|
||||
}
|
||||
if claims.Subject == "" {
|
||||
return nil, errors.New("missing sub claim")
|
||||
}
|
||||
if _, err := uuid.Parse(claims.Subject); err != nil {
|
||||
return nil, fmt.Errorf("invalid sub claim: %w", err)
|
||||
}
|
||||
if claims.Role == "" {
|
||||
return nil, errors.New("missing role claim")
|
||||
}
|
||||
|
||||
if len(claims.Audience) != 1 || claims.Audience[0] != string(expectedAud) {
|
||||
return nil, fmt.Errorf("token audience does not match expected space %q", expectedAud)
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package security provides password hashing and JWT issuing/verification
|
||||
// shared across auth modules.
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
argon2Time = 3
|
||||
argon2Memory = 64 * 1024 // 64 MB
|
||||
argon2Threads = 2
|
||||
argon2SaltLen = 16
|
||||
argon2KeyLen = 32
|
||||
)
|
||||
|
||||
// HashPassword hashes a plaintext password using argon2id and returns the
|
||||
// PHC-formatted string ($argon2id$v=19$m=...,t=...,p=...$salt$hash).
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, argon2SaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("generate salt: %w", err)
|
||||
}
|
||||
|
||||
hash := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
|
||||
|
||||
encoded := fmt.Sprintf(
|
||||
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version,
|
||||
argon2Memory,
|
||||
argon2Time,
|
||||
argon2Threads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
)
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks a plaintext password against a PHC-formatted argon2id hash.
|
||||
func VerifyPassword(encodedHash, password string) (bool, error) {
|
||||
parts := strings.Split(encodedHash, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, fmt.Errorf("invalid hash format")
|
||||
}
|
||||
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return false, fmt.Errorf("invalid hash version segment: %w", err)
|
||||
}
|
||||
if version != argon2.Version {
|
||||
return false, fmt.Errorf("unsupported argon2 version: %d", version)
|
||||
}
|
||||
|
||||
var memory uint32
|
||||
var time uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
|
||||
return false, fmt.Errorf("invalid hash params segment: %w", err)
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode salt: %w", err)
|
||||
}
|
||||
wantHash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode hash: %w", err)
|
||||
}
|
||||
|
||||
gotHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(wantHash)))
|
||||
|
||||
if subtle.ConstantTimeCompare(gotHash, wantHash) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
Reference in New Issue
Block a user