From 21debf626df510e7024d10a73912986653b8217f Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Sat, 4 Apr 2026 21:50:57 +0300 Subject: [PATCH] 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 --- backend/cmd/server/main.go | 37 ++- backend/internal/db/postgres/category_repo.go | 297 ++++++++++++++++++ backend/internal/handler/category_handler.go | 235 ++++++++++++++ backend/internal/handler/router.go | 16 + backend/internal/service/category_service.go | 164 ++++++++++ 5 files changed, 732 insertions(+), 17 deletions(-) create mode 100644 backend/internal/db/postgres/category_repo.go create mode 100644 backend/internal/handler/category_handler.go create mode 100644 backend/internal/service/category_service.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index e4412b8..851a650 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -57,15 +57,16 @@ func main() { } // Repositories - userRepo := postgres.NewUserRepo(pool) - sessionRepo := postgres.NewSessionRepo(pool) - fileRepo := postgres.NewFileRepo(pool) - mimeRepo := postgres.NewMimeRepo(pool) - aclRepo := postgres.NewACLRepo(pool) - auditRepo := postgres.NewAuditRepo(pool) - tagRepo := postgres.NewTagRepo(pool) - tagRuleRepo := postgres.NewTagRuleRepo(pool) - transactor := postgres.NewTransactor(pool) + userRepo := postgres.NewUserRepo(pool) + sessionRepo := postgres.NewSessionRepo(pool) + fileRepo := postgres.NewFileRepo(pool) + mimeRepo := postgres.NewMimeRepo(pool) + aclRepo := postgres.NewACLRepo(pool) + auditRepo := postgres.NewAuditRepo(pool) + tagRepo := postgres.NewTagRepo(pool) + tagRuleRepo := postgres.NewTagRuleRepo(pool) + categoryRepo := postgres.NewCategoryRepo(pool) + transactor := postgres.NewTransactor(pool) // Services authSvc := service.NewAuthService( @@ -75,9 +76,10 @@ func main() { cfg.JWTAccessTTL, cfg.JWTRefreshTTL, ) - aclSvc := service.NewACLService(aclRepo) - auditSvc := service.NewAuditService(auditRepo) - tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor) + aclSvc := service.NewACLService(aclRepo) + auditSvc := service.NewAuditService(auditRepo) + tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor) + categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc) fileSvc := service.NewFileService( fileRepo, mimeRepo, @@ -90,12 +92,13 @@ func main() { ) // Handlers - authMiddleware := handler.NewAuthMiddleware(authSvc) - authHandler := handler.NewAuthHandler(authSvc) - fileHandler := handler.NewFileHandler(fileSvc, tagSvc) - tagHandler := handler.NewTagHandler(tagSvc, fileSvc) + authMiddleware := handler.NewAuthMiddleware(authSvc) + authHandler := handler.NewAuthHandler(authSvc) + fileHandler := handler.NewFileHandler(fileSvc, tagSvc) + 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) if err := r.Run(cfg.ListenAddr); err != nil { diff --git a/backend/internal/db/postgres/category_repo.go b/backend/internal/db/postgres/category_repo.go new file mode 100644 index 0000000..ea97bbd --- /dev/null +++ b/backend/internal/db/postgres/category_repo.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/handler/category_handler.go b/backend/internal/handler/category_handler.go new file mode 100644 index 0000000..46f0f30 --- /dev/null +++ b/backend/internal/handler/category_handler.go @@ -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, + }) +} \ No newline at end of file diff --git a/backend/internal/handler/router.go b/backend/internal/handler/router.go index 07a70b1..978ea06 100644 --- a/backend/internal/handler/router.go +++ b/backend/internal/handler/router.go @@ -12,6 +12,7 @@ func NewRouter( authHandler *AuthHandler, fileHandler *FileHandler, tagHandler *TagHandler, + categoryHandler *CategoryHandler, ) *gin.Engine { r := gin.New() r.Use(gin.Logger(), gin.Recovery()) @@ -91,5 +92,20 @@ func NewRouter( 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 } \ No newline at end of file diff --git a/backend/internal/service/category_service.go b/backend/internal/service/category_service.go new file mode 100644 index 0000000..2034245 --- /dev/null +++ b/backend/internal/service/category_service.go @@ -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) +} \ No newline at end of file