This commit is contained in:
CFOU
2026-09-14 20:50:19 +02:00
commit 3091fb4020
135 changed files with 10262 additions and 0 deletions
@@ -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)
}
+21
View File
@@ -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
}
+118
View File
@@ -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
}
+123
View File
@@ -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
}
+126
View File
@@ -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)
}
+24
View File
@@ -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
}
+11
View File
@@ -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)
}
+99
View File
@@ -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)
}
+19
View File
@@ -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
}
+185
View File
@@ -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))
}
+63
View File
@@ -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
}
+17
View File
@@ -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)
}
+171
View File
@@ -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
}
+128
View File
@@ -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)
}
+26
View File
@@ -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)
}
+62
View File
@@ -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))
}
+19
View File
@@ -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
}
+9
View File
@@ -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)
}
+23
View File
@@ -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)
}
}
+109
View File
@@ -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)
}
+19
View File
@@ -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"
}
+11
View File
@@ -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)
}
+48
View File
@@ -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)
}
+134
View File
@@ -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)
}
+27
View File
@@ -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"
}
+14
View File
@@ -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)
}
+83
View File
@@ -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)
}