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>
164 lines
4.1 KiB
Go
164 lines
4.1 KiB
Go
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)
|
|
} |