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:
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user