feat(backend): implement category stack
Add category repo, service, handler, and wire all /categories endpoints including list, create, get, update, delete, and list-tags. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
04d2dfa16e
commit
21debf626d
@ -65,6 +65,7 @@ func main() {
|
|||||||
auditRepo := postgres.NewAuditRepo(pool)
|
auditRepo := postgres.NewAuditRepo(pool)
|
||||||
tagRepo := postgres.NewTagRepo(pool)
|
tagRepo := postgres.NewTagRepo(pool)
|
||||||
tagRuleRepo := postgres.NewTagRuleRepo(pool)
|
tagRuleRepo := postgres.NewTagRuleRepo(pool)
|
||||||
|
categoryRepo := postgres.NewCategoryRepo(pool)
|
||||||
transactor := postgres.NewTransactor(pool)
|
transactor := postgres.NewTransactor(pool)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
@ -78,6 +79,7 @@ func main() {
|
|||||||
aclSvc := service.NewACLService(aclRepo)
|
aclSvc := service.NewACLService(aclRepo)
|
||||||
auditSvc := service.NewAuditService(auditRepo)
|
auditSvc := service.NewAuditService(auditRepo)
|
||||||
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
||||||
|
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
|
||||||
fileSvc := service.NewFileService(
|
fileSvc := service.NewFileService(
|
||||||
fileRepo,
|
fileRepo,
|
||||||
mimeRepo,
|
mimeRepo,
|
||||||
@ -94,8 +96,9 @@ func main() {
|
|||||||
authHandler := handler.NewAuthHandler(authSvc)
|
authHandler := handler.NewAuthHandler(authSvc)
|
||||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
||||||
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
||||||
|
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
||||||
|
|
||||||
r := handler.NewRouter(authMiddleware, authHandler, fileHandler, tagHandler)
|
r := handler.NewRouter(authMiddleware, authHandler, fileHandler, tagHandler, categoryHandler)
|
||||||
|
|
||||||
slog.Info("starting server", "addr", cfg.ListenAddr)
|
slog.Info("starting server", "addr", cfg.ListenAddr)
|
||||||
if err := r.Run(cfg.ListenAddr); err != nil {
|
if err := r.Run(cfg.ListenAddr); err != nil {
|
||||||
|
|||||||
297
backend/internal/db/postgres/category_repo.go
Normal file
297
backend/internal/db/postgres/category_repo.go
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
"tanabata/backend/internal/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Row struct
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type categoryRow struct {
|
||||||
|
ID uuid.UUID `db:"id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
Notes *string `db:"notes"`
|
||||||
|
Color *string `db:"color"`
|
||||||
|
Metadata []byte `db:"metadata"`
|
||||||
|
CreatorID int16 `db:"creator_id"`
|
||||||
|
CreatorName string `db:"creator_name"`
|
||||||
|
IsPublic bool `db:"is_public"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type categoryRowWithTotal struct {
|
||||||
|
categoryRow
|
||||||
|
Total int `db:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Converter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func toCategory(r categoryRow) domain.Category {
|
||||||
|
c := domain.Category{
|
||||||
|
ID: r.ID,
|
||||||
|
Name: r.Name,
|
||||||
|
Notes: r.Notes,
|
||||||
|
Color: r.Color,
|
||||||
|
CreatorID: r.CreatorID,
|
||||||
|
CreatorName: r.CreatorName,
|
||||||
|
IsPublic: r.IsPublic,
|
||||||
|
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
||||||
|
}
|
||||||
|
if len(r.Metadata) > 0 && string(r.Metadata) != "null" {
|
||||||
|
c.Metadata = json.RawMessage(r.Metadata)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared SQL
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const categorySelectFrom = `
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.name,
|
||||||
|
c.notes,
|
||||||
|
c.color,
|
||||||
|
c.metadata,
|
||||||
|
c.creator_id,
|
||||||
|
u.name AS creator_name,
|
||||||
|
c.is_public
|
||||||
|
FROM data.categories c
|
||||||
|
JOIN core.users u ON u.id = c.creator_id`
|
||||||
|
|
||||||
|
func categorySortColumn(s string) string {
|
||||||
|
if s == "name" {
|
||||||
|
return "c.name"
|
||||||
|
}
|
||||||
|
return "c.id" // "created"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CategoryRepo — implements port.CategoryRepo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// CategoryRepo handles category CRUD using PostgreSQL.
|
||||||
|
type CategoryRepo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ port.CategoryRepo = (*CategoryRepo)(nil)
|
||||||
|
|
||||||
|
// NewCategoryRepo creates a CategoryRepo backed by pool.
|
||||||
|
func NewCategoryRepo(pool *pgxpool.Pool) *CategoryRepo {
|
||||||
|
return &CategoryRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// List
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (r *CategoryRepo) List(ctx context.Context, params port.OffsetParams) (*domain.CategoryOffsetPage, error) {
|
||||||
|
order := "ASC"
|
||||||
|
if strings.ToLower(params.Order) == "desc" {
|
||||||
|
order = "DESC"
|
||||||
|
}
|
||||||
|
sortCol := categorySortColumn(params.Sort)
|
||||||
|
|
||||||
|
args := []any{}
|
||||||
|
n := 1
|
||||||
|
var conditions []string
|
||||||
|
|
||||||
|
if params.Search != "" {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("lower(c.name) LIKE lower($%d)", n))
|
||||||
|
args = append(args, "%"+params.Search+"%")
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
|
||||||
|
where := ""
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
where = "WHERE " + strings.Join(conditions, " AND ")
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := params.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
offset := params.Offset
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
c.id, c.name, c.notes, c.color, c.metadata,
|
||||||
|
c.creator_id, u.name AS creator_name, c.is_public,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM data.categories c
|
||||||
|
JOIN core.users u ON u.id = c.creator_id
|
||||||
|
%s
|
||||||
|
ORDER BY %s %s NULLS LAST, c.id ASC
|
||||||
|
LIMIT $%d OFFSET $%d`, where, sortCol, order, n, n+1)
|
||||||
|
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CategoryRepo.List query: %w", err)
|
||||||
|
}
|
||||||
|
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[categoryRowWithTotal])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CategoryRepo.List scan: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]domain.Category, len(collected))
|
||||||
|
total := 0
|
||||||
|
for i, row := range collected {
|
||||||
|
items[i] = toCategory(row.categoryRow)
|
||||||
|
total = row.Total
|
||||||
|
}
|
||||||
|
return &domain.CategoryOffsetPage{
|
||||||
|
Items: items,
|
||||||
|
Total: total,
|
||||||
|
Offset: offset,
|
||||||
|
Limit: limit,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GetByID
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (r *CategoryRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Category, error) {
|
||||||
|
const query = categorySelectFrom + `
|
||||||
|
WHERE c.id = $1`
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CategoryRepo.GetByID: %w", err)
|
||||||
|
}
|
||||||
|
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[categoryRow])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("CategoryRepo.GetByID scan: %w", err)
|
||||||
|
}
|
||||||
|
c := toCategory(row)
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Create
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (r *CategoryRepo) Create(ctx context.Context, c *domain.Category) (*domain.Category, error) {
|
||||||
|
const query = `
|
||||||
|
WITH ins AS (
|
||||||
|
INSERT INTO data.categories (name, notes, color, metadata, creator_id, is_public)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *
|
||||||
|
)
|
||||||
|
SELECT ins.id, ins.name, ins.notes, ins.color, ins.metadata,
|
||||||
|
ins.creator_id, u.name AS creator_name, ins.is_public
|
||||||
|
FROM ins
|
||||||
|
JOIN core.users u ON u.id = ins.creator_id`
|
||||||
|
|
||||||
|
var meta any
|
||||||
|
if len(c.Metadata) > 0 {
|
||||||
|
meta = c.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, query,
|
||||||
|
c.Name, c.Notes, c.Color, meta, c.CreatorID, c.IsPublic)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CategoryRepo.Create: %w", err)
|
||||||
|
}
|
||||||
|
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[categoryRow])
|
||||||
|
if err != nil {
|
||||||
|
if isPgUniqueViolation(err) {
|
||||||
|
return nil, domain.ErrConflict
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("CategoryRepo.Create scan: %w", err)
|
||||||
|
}
|
||||||
|
created := toCategory(row)
|
||||||
|
return &created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Update
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Update replaces all mutable fields. The caller must merge current values
|
||||||
|
// with the patch before calling (read-then-write semantics).
|
||||||
|
func (r *CategoryRepo) Update(ctx context.Context, id uuid.UUID, c *domain.Category) (*domain.Category, error) {
|
||||||
|
const query = `
|
||||||
|
WITH upd AS (
|
||||||
|
UPDATE data.categories SET
|
||||||
|
name = $2,
|
||||||
|
notes = $3,
|
||||||
|
color = $4,
|
||||||
|
metadata = COALESCE($5, metadata),
|
||||||
|
is_public = $6
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *
|
||||||
|
)
|
||||||
|
SELECT upd.id, upd.name, upd.notes, upd.color, upd.metadata,
|
||||||
|
upd.creator_id, u.name AS creator_name, upd.is_public
|
||||||
|
FROM upd
|
||||||
|
JOIN core.users u ON u.id = upd.creator_id`
|
||||||
|
|
||||||
|
var meta any
|
||||||
|
if len(c.Metadata) > 0 {
|
||||||
|
meta = c.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
rows, err := q.Query(ctx, query,
|
||||||
|
id, c.Name, c.Notes, c.Color, meta, c.IsPublic)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CategoryRepo.Update: %w", err)
|
||||||
|
}
|
||||||
|
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[categoryRow])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
if isPgUniqueViolation(err) {
|
||||||
|
return nil, domain.ErrConflict
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("CategoryRepo.Update scan: %w", err)
|
||||||
|
}
|
||||||
|
updated := toCategory(row)
|
||||||
|
return &updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (r *CategoryRepo) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
const query = `DELETE FROM data.categories WHERE id = $1`
|
||||||
|
|
||||||
|
q := connOrTx(ctx, r.pool)
|
||||||
|
ct, err := q.Exec(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("CategoryRepo.Delete: %w", err)
|
||||||
|
}
|
||||||
|
if ct.RowsAffected() == 0 {
|
||||||
|
return domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
235
backend/internal/handler/category_handler.go
Normal file
235
backend/internal/handler/category_handler.go
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
"tanabata/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CategoryHandler handles all /categories endpoints.
|
||||||
|
type CategoryHandler struct {
|
||||||
|
categorySvc *service.CategoryService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCategoryHandler creates a CategoryHandler.
|
||||||
|
func NewCategoryHandler(categorySvc *service.CategoryService) *CategoryHandler {
|
||||||
|
return &CategoryHandler{categorySvc: categorySvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type categoryJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
Color *string `json:"color"`
|
||||||
|
CreatorID int16 `json:"creator_id"`
|
||||||
|
CreatorName string `json:"creator_name"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toCategoryJSON(c domain.Category) categoryJSON {
|
||||||
|
return categoryJSON{
|
||||||
|
ID: c.ID.String(),
|
||||||
|
Name: c.Name,
|
||||||
|
Notes: c.Notes,
|
||||||
|
Color: c.Color,
|
||||||
|
CreatorID: c.CreatorID,
|
||||||
|
CreatorName: c.CreatorName,
|
||||||
|
IsPublic: c.IsPublic,
|
||||||
|
CreatedAt: c.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func parseCategoryID(c *gin.Context) (uuid.UUID, bool) {
|
||||||
|
id, err := uuid.Parse(c.Param("category_id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return uuid.UUID{}, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /categories
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *CategoryHandler) List(c *gin.Context) {
|
||||||
|
params := parseOffsetParams(c, "created")
|
||||||
|
|
||||||
|
page, err := h.categorySvc.List(c.Request.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]categoryJSON, len(page.Items))
|
||||||
|
for i, cat := range page.Items {
|
||||||
|
items[i] = toCategoryJSON(cat)
|
||||||
|
}
|
||||||
|
respondJSON(c, http.StatusOK, gin.H{
|
||||||
|
"items": items,
|
||||||
|
"total": page.Total,
|
||||||
|
"offset": page.Offset,
|
||||||
|
"limit": page.Limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /categories
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *CategoryHandler) Create(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
Color *string `json:"color"`
|
||||||
|
IsPublic *bool `json:"is_public"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := h.categorySvc.Create(c.Request.Context(), service.CategoryParams{
|
||||||
|
Name: body.Name,
|
||||||
|
Notes: body.Notes,
|
||||||
|
Color: body.Color,
|
||||||
|
IsPublic: body.IsPublic,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(c, http.StatusCreated, toCategoryJSON(*created))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /categories/:category_id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *CategoryHandler) Get(c *gin.Context) {
|
||||||
|
id, ok := parseCategoryID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cat, err := h.categorySvc.Get(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(c, http.StatusOK, toCategoryJSON(*cat))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PATCH /categories/:category_id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *CategoryHandler) Update(c *gin.Context) {
|
||||||
|
id, ok := parseCategoryID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a raw map to detect explicitly-null fields.
|
||||||
|
var raw map[string]any
|
||||||
|
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := service.CategoryParams{}
|
||||||
|
|
||||||
|
if v, ok := raw["name"]; ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
params.Name = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := raw["notes"]; ok {
|
||||||
|
if raw["notes"] == nil {
|
||||||
|
empty := ""
|
||||||
|
params.Notes = &empty
|
||||||
|
} else if s, ok := raw["notes"].(string); ok {
|
||||||
|
params.Notes = &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := raw["color"]; ok {
|
||||||
|
if raw["color"] == nil {
|
||||||
|
empty := ""
|
||||||
|
params.Color = &empty
|
||||||
|
} else if s, ok := raw["color"].(string); ok {
|
||||||
|
params.Color = &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := raw["is_public"]; ok {
|
||||||
|
if b, ok := v.(bool); ok {
|
||||||
|
params.IsPublic = &b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := h.categorySvc.Update(c.Request.Context(), id, params)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(c, http.StatusOK, toCategoryJSON(*updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /categories/:category_id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *CategoryHandler) Delete(c *gin.Context) {
|
||||||
|
id, ok := parseCategoryID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.categorySvc.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /categories/:category_id/tags
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *CategoryHandler) ListTags(c *gin.Context) {
|
||||||
|
id, ok := parseCategoryID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := parseOffsetParams(c, "created")
|
||||||
|
|
||||||
|
page, err := h.categorySvc.ListTags(c.Request.Context(), id, params)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]tagJSON, len(page.Items))
|
||||||
|
for i, t := range page.Items {
|
||||||
|
items[i] = toTagJSON(t)
|
||||||
|
}
|
||||||
|
respondJSON(c, http.StatusOK, gin.H{
|
||||||
|
"items": items,
|
||||||
|
"total": page.Total,
|
||||||
|
"offset": page.Offset,
|
||||||
|
"limit": page.Limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ func NewRouter(
|
|||||||
authHandler *AuthHandler,
|
authHandler *AuthHandler,
|
||||||
fileHandler *FileHandler,
|
fileHandler *FileHandler,
|
||||||
tagHandler *TagHandler,
|
tagHandler *TagHandler,
|
||||||
|
categoryHandler *CategoryHandler,
|
||||||
) *gin.Engine {
|
) *gin.Engine {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Logger(), gin.Recovery())
|
r.Use(gin.Logger(), gin.Recovery())
|
||||||
@ -91,5 +92,20 @@ func NewRouter(
|
|||||||
tags.DELETE("/:tag_id/rules/:then_tag_id", tagHandler.DeleteRule)
|
tags.DELETE("/:tag_id/rules/:then_tag_id", tagHandler.DeleteRule)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Categories (all require auth)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
categories := v1.Group("/categories", auth.Handle())
|
||||||
|
{
|
||||||
|
categories.GET("", categoryHandler.List)
|
||||||
|
categories.POST("", categoryHandler.Create)
|
||||||
|
|
||||||
|
categories.GET("/:category_id", categoryHandler.Get)
|
||||||
|
categories.PATCH("/:category_id", categoryHandler.Update)
|
||||||
|
categories.DELETE("/:category_id", categoryHandler.Delete)
|
||||||
|
|
||||||
|
categories.GET("/:category_id/tags", categoryHandler.ListTags)
|
||||||
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
164
backend/internal/service/category_service.go
Normal file
164
backend/internal/service/category_service.go
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"tanabata/backend/internal/domain"
|
||||||
|
"tanabata/backend/internal/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
const categoryObjectType = "category"
|
||||||
|
const categoryObjectTypeID int16 = 3 // third row in 007_seed_data.sql object_types
|
||||||
|
|
||||||
|
// CategoryParams holds the fields for creating or patching a category.
|
||||||
|
type CategoryParams struct {
|
||||||
|
Name string
|
||||||
|
Notes *string
|
||||||
|
Color *string // nil = no change; pointer to empty string = clear
|
||||||
|
Metadata json.RawMessage
|
||||||
|
IsPublic *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryService handles category CRUD with ACL enforcement and audit logging.
|
||||||
|
type CategoryService struct {
|
||||||
|
categories port.CategoryRepo
|
||||||
|
tags port.TagRepo
|
||||||
|
acl *ACLService
|
||||||
|
audit *AuditService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCategoryService creates a CategoryService.
|
||||||
|
func NewCategoryService(
|
||||||
|
categories port.CategoryRepo,
|
||||||
|
tags port.TagRepo,
|
||||||
|
acl *ACLService,
|
||||||
|
audit *AuditService,
|
||||||
|
) *CategoryService {
|
||||||
|
return &CategoryService{
|
||||||
|
categories: categories,
|
||||||
|
tags: tags,
|
||||||
|
acl: acl,
|
||||||
|
audit: audit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// List returns a paginated, optionally filtered list of categories.
|
||||||
|
func (s *CategoryService) List(ctx context.Context, params port.OffsetParams) (*domain.CategoryOffsetPage, error) {
|
||||||
|
return s.categories.List(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a category by ID.
|
||||||
|
func (s *CategoryService) Get(ctx context.Context, id uuid.UUID) (*domain.Category, error) {
|
||||||
|
return s.categories.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new category record.
|
||||||
|
func (s *CategoryService) Create(ctx context.Context, p CategoryParams) (*domain.Category, error) {
|
||||||
|
userID, _, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
|
c := &domain.Category{
|
||||||
|
Name: p.Name,
|
||||||
|
Notes: p.Notes,
|
||||||
|
Color: p.Color,
|
||||||
|
Metadata: p.Metadata,
|
||||||
|
CreatorID: userID,
|
||||||
|
}
|
||||||
|
if p.IsPublic != nil {
|
||||||
|
c.IsPublic = *p.IsPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := s.categories.Create(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
objType := categoryObjectType
|
||||||
|
_ = s.audit.Log(ctx, "category_create", &objType, &created.ID, nil)
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update applies a partial patch to a category.
|
||||||
|
func (s *CategoryService) Update(ctx context.Context, id uuid.UUID, p CategoryParams) (*domain.Category, error) {
|
||||||
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
|
current, err := s.categories.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, current.CreatorID, categoryObjectTypeID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
patch := *current
|
||||||
|
if p.Name != "" {
|
||||||
|
patch.Name = p.Name
|
||||||
|
}
|
||||||
|
if p.Notes != nil {
|
||||||
|
patch.Notes = p.Notes
|
||||||
|
}
|
||||||
|
if p.Color != nil {
|
||||||
|
patch.Color = p.Color
|
||||||
|
}
|
||||||
|
if len(p.Metadata) > 0 {
|
||||||
|
patch.Metadata = p.Metadata
|
||||||
|
}
|
||||||
|
if p.IsPublic != nil {
|
||||||
|
patch.IsPublic = *p.IsPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.categories.Update(ctx, id, &patch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
objType := categoryObjectType
|
||||||
|
_ = s.audit.Log(ctx, "category_edit", &objType, &id, nil)
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a category by ID, enforcing edit ACL.
|
||||||
|
func (s *CategoryService) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
|
c, err := s.categories.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, c.CreatorID, categoryObjectTypeID, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.categories.Delete(ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
objType := categoryObjectType
|
||||||
|
_ = s.audit.Log(ctx, "category_delete", &objType, &id, nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tags in category
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ListTags returns a paginated list of tags belonging to this category.
|
||||||
|
func (s *CategoryService) ListTags(ctx context.Context, categoryID uuid.UUID, params port.OffsetParams) (*domain.TagOffsetPage, error) {
|
||||||
|
return s.tags.ListByCategory(ctx, categoryID, params)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user