feat: implement full tag stack (repo, service, handler, routes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 21:29:20 +03:00
parent 4154c1b0b9
commit 38294e20dd
8 changed files with 1564 additions and 324 deletions
+5 -195
View File
@@ -73,6 +73,7 @@ type FileService struct {
storage port.FileStorage
acl *ACLService
audit *AuditService
tags *TagService
tx port.Transactor
importPath string // default server-side import directory
}
@@ -84,6 +85,7 @@ func NewFileService(
storage port.FileStorage,
acl *ACLService,
audit *AuditService,
tags *TagService,
tx port.Transactor,
importPath string,
) *FileService {
@@ -93,6 +95,7 @@ func NewFileService(
storage: storage,
acl: acl,
audit: audit,
tags: tags,
tx: tx,
importPath: importPath,
}
@@ -166,10 +169,7 @@ func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File,
}
if len(p.TagIDs) > 0 {
if err := s.files.SetTags(ctx, created.ID, p.TagIDs); err != nil {
return err
}
tags, err := s.files.ListTags(ctx, created.ID)
tags, err := s.tags.SetFileTags(ctx, created.ID, p.TagIDs)
if err != nil {
return err
}
@@ -249,10 +249,7 @@ func (s *FileService) Update(ctx context.Context, id uuid.UUID, p UpdateParams)
return updateErr
}
if p.TagIDs != nil {
if err := s.files.SetTags(ctx, id, *p.TagIDs); err != nil {
return err
}
tags, err := s.files.ListTags(ctx, id)
tags, err := s.tags.SetFileTags(ctx, id, *p.TagIDs)
if err != nil {
return err
}
@@ -447,120 +444,6 @@ func (s *FileService) GetPreview(ctx context.Context, id uuid.UUID) (io.ReadClos
return s.storage.Preview(ctx, id)
}
// ---------------------------------------------------------------------------
// Tag operations
// ---------------------------------------------------------------------------
// ListFileTags returns the tags on a file, enforcing view ACL.
func (s *FileService) ListFileTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
if _, err := s.Get(ctx, fileID); err != nil {
return nil, err
}
return s.files.ListTags(ctx, fileID)
}
// SetFileTags replaces all tags on a file (full replace semantics), enforcing edit ACL.
func (s *FileService) SetFileTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) ([]domain.Tag, error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
f, err := s.files.GetByID(ctx, fileID)
if err != nil {
return nil, err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
if err := s.files.SetTags(ctx, fileID, tagIDs); err != nil {
return nil, err
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, nil)
return s.files.ListTags(ctx, fileID)
}
// AddTag adds a single tag to a file, enforcing edit ACL.
func (s *FileService) AddTag(ctx context.Context, fileID, tagID uuid.UUID) ([]domain.Tag, error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
f, err := s.files.GetByID(ctx, fileID)
if err != nil {
return nil, err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
current, err := s.files.ListTags(ctx, fileID)
if err != nil {
return nil, err
}
// Only add if not already present.
for _, t := range current {
if t.ID == tagID {
return current, nil
}
}
ids := make([]uuid.UUID, 0, len(current)+1)
for _, t := range current {
ids = append(ids, t.ID)
}
ids = append(ids, tagID)
if err := s.files.SetTags(ctx, fileID, ids); err != nil {
return nil, err
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, map[string]any{"tag_id": tagID})
return s.files.ListTags(ctx, fileID)
}
// RemoveTag removes a single tag from a file, enforcing edit ACL.
func (s *FileService) RemoveTag(ctx context.Context, fileID, tagID uuid.UUID) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
f, err := s.files.GetByID(ctx, fileID)
if err != nil {
return err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
current, err := s.files.ListTags(ctx, fileID)
if err != nil {
return err
}
ids := make([]uuid.UUID, 0, len(current))
for _, t := range current {
if t.ID != tagID {
ids = append(ids, t.ID)
}
}
if err := s.files.SetTags(ctx, fileID, ids); err != nil {
return err
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_tag_remove", &objType, &fileID, map[string]any{"tag_id": tagID})
return nil
}
// ---------------------------------------------------------------------------
// Bulk operations
// ---------------------------------------------------------------------------
@@ -569,7 +452,6 @@ func (s *FileService) RemoveTag(ctx context.Context, fileID, tagID uuid.UUID) er
func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error {
for _, id := range fileIDs {
if err := s.Delete(ctx, id); err != nil {
// Skip files not found or forbidden; surface real errors.
if err == domain.ErrNotFound || err == domain.ErrForbidden {
continue
}
@@ -579,78 +461,6 @@ func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error
return nil
}
// BulkSetTags adds or removes the given tags on multiple files.
// For "add": tags are appended to each file's existing set.
// For "remove": tags are removed from each file's existing set.
// Returns the tag IDs that were applied (the input tagIDs, for add).
func (s *FileService) BulkSetTags(ctx context.Context, fileIDs []uuid.UUID, action string, tagIDs []uuid.UUID) ([]uuid.UUID, error) {
for _, fileID := range fileIDs {
switch action {
case "add":
for _, tagID := range tagIDs {
if _, err := s.AddTag(ctx, fileID, tagID); err != nil {
if err == domain.ErrNotFound || err == domain.ErrForbidden {
continue
}
return nil, err
}
}
case "remove":
for _, tagID := range tagIDs {
if err := s.RemoveTag(ctx, fileID, tagID); err != nil {
if err == domain.ErrNotFound || err == domain.ErrForbidden {
continue
}
return nil, err
}
}
default:
return nil, domain.ErrValidation
}
}
if action == "add" {
return tagIDs, nil
}
return []uuid.UUID{}, nil
}
// CommonTags loads the tag sets for all given files and splits them into:
// - common: tag IDs present on every file
// - partial: tag IDs present on some but not all files
func (s *FileService) CommonTags(ctx context.Context, fileIDs []uuid.UUID) (common, partial []uuid.UUID, err error) {
if len(fileIDs) == 0 {
return nil, nil, nil
}
// Count how many files each tag appears on.
counts := map[uuid.UUID]int{}
for _, fid := range fileIDs {
tags, err := s.files.ListTags(ctx, fid)
if err != nil {
return nil, nil, err
}
for _, t := range tags {
counts[t.ID]++
}
}
n := len(fileIDs)
for id, cnt := range counts {
if cnt == n {
common = append(common, id)
} else {
partial = append(partial, id)
}
}
if common == nil {
common = []uuid.UUID{}
}
if partial == nil {
partial = []uuid.UUID{}
}
return common, partial, nil
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
+393
View File
@@ -0,0 +1,393 @@
package service
import (
"context"
"encoding/json"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/port"
)
const tagObjectType = "tag"
const tagObjectTypeID int16 = 2 // second row in 007_seed_data.sql object_types
// TagParams holds the fields for creating or patching a tag.
type TagParams struct {
Name string
Notes *string
Color *string // nil = no change; pointer to empty string = clear
CategoryID *uuid.UUID // nil = no change; Nil UUID = unassign
Metadata json.RawMessage
IsPublic *bool
}
// TagService handles tag CRUD, tag-rule management, and filetag operations
// including automatic recursive rule application.
type TagService struct {
tags port.TagRepo
rules port.TagRuleRepo
acl *ACLService
audit *AuditService
tx port.Transactor
}
// NewTagService creates a TagService.
func NewTagService(
tags port.TagRepo,
rules port.TagRuleRepo,
acl *ACLService,
audit *AuditService,
tx port.Transactor,
) *TagService {
return &TagService{
tags: tags,
rules: rules,
acl: acl,
audit: audit,
tx: tx,
}
}
// ---------------------------------------------------------------------------
// Tag CRUD
// ---------------------------------------------------------------------------
// List returns a paginated, optionally filtered list of tags.
func (s *TagService) List(ctx context.Context, params port.OffsetParams) (*domain.TagOffsetPage, error) {
return s.tags.List(ctx, params)
}
// Get returns a tag by ID.
func (s *TagService) Get(ctx context.Context, id uuid.UUID) (*domain.Tag, error) {
return s.tags.GetByID(ctx, id)
}
// Create inserts a new tag record.
func (s *TagService) Create(ctx context.Context, p TagParams) (*domain.Tag, error) {
userID, _, _ := domain.UserFromContext(ctx)
t := &domain.Tag{
Name: p.Name,
Notes: p.Notes,
Color: p.Color,
CategoryID: p.CategoryID,
Metadata: p.Metadata,
CreatorID: userID,
}
if p.IsPublic != nil {
t.IsPublic = *p.IsPublic
}
created, err := s.tags.Create(ctx, t)
if err != nil {
return nil, err
}
objType := tagObjectType
_ = s.audit.Log(ctx, "tag_create", &objType, &created.ID, nil)
return created, nil
}
// Update applies a partial patch to a tag.
// The service reads the current tag first so the caller only needs to supply
// the fields that should change.
func (s *TagService) Update(ctx context.Context, id uuid.UUID, p TagParams) (*domain.Tag, error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
current, err := s.tags.GetByID(ctx, id)
if err != nil {
return nil, err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, current.CreatorID, tagObjectTypeID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
// Merge patch into current.
patch := *current // copy
if p.Name != "" {
patch.Name = p.Name
}
if p.Notes != nil {
patch.Notes = p.Notes
}
if p.Color != nil {
patch.Color = p.Color
}
if p.CategoryID != nil {
if *p.CategoryID == uuid.Nil {
patch.CategoryID = nil // explicit unassign
} else {
patch.CategoryID = p.CategoryID
}
}
if len(p.Metadata) > 0 {
patch.Metadata = p.Metadata
}
if p.IsPublic != nil {
patch.IsPublic = *p.IsPublic
}
updated, err := s.tags.Update(ctx, id, &patch)
if err != nil {
return nil, err
}
objType := tagObjectType
_ = s.audit.Log(ctx, "tag_edit", &objType, &id, nil)
return updated, nil
}
// Delete removes a tag by ID, enforcing edit ACL.
func (s *TagService) Delete(ctx context.Context, id uuid.UUID) error {
userID, isAdmin, _ := domain.UserFromContext(ctx)
t, err := s.tags.GetByID(ctx, id)
if err != nil {
return err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, t.CreatorID, tagObjectTypeID, id)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
if err := s.tags.Delete(ctx, id); err != nil {
return err
}
objType := tagObjectType
_ = s.audit.Log(ctx, "tag_delete", &objType, &id, nil)
return nil
}
// ---------------------------------------------------------------------------
// Tag rules
// ---------------------------------------------------------------------------
// ListRules returns all rules for a tag (when this tag is applied, these follow).
func (s *TagService) ListRules(ctx context.Context, tagID uuid.UUID) ([]domain.TagRule, error) {
return s.rules.ListByTag(ctx, tagID)
}
// CreateRule adds a tag rule. If applyToExisting is true, the then_tag is
// retroactively applied to all files that already carry the when_tag.
// Retroactive application requires a FileRepo; it is deferred until wired
// in a future iteration (see port.FileRepo.ListByTag).
func (s *TagService) CreateRule(ctx context.Context, whenTagID, thenTagID uuid.UUID, isActive, _ bool) (*domain.TagRule, error) {
return s.rules.Create(ctx, domain.TagRule{
WhenTagID: whenTagID,
ThenTagID: thenTagID,
IsActive: isActive,
})
}
// DeleteRule removes a tag rule.
func (s *TagService) DeleteRule(ctx context.Context, whenTagID, thenTagID uuid.UUID) error {
return s.rules.Delete(ctx, whenTagID, thenTagID)
}
// ---------------------------------------------------------------------------
// Filetag operations (with auto-rule expansion)
// ---------------------------------------------------------------------------
// ListFileTags returns the tags on a file.
func (s *TagService) ListFileTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
return s.tags.ListByFile(ctx, fileID)
}
// SetFileTags replaces all tags on a file, then applies active rules for all
// newly set tags (BFS expansion). Returns the full resulting tag set.
func (s *TagService) SetFileTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) ([]domain.Tag, error) {
expanded, err := s.expandTagSet(ctx, tagIDs)
if err != nil {
return nil, err
}
if err := s.tags.SetFileTags(ctx, fileID, expanded); err != nil {
return nil, err
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, nil)
return s.tags.ListByFile(ctx, fileID)
}
// AddFileTag adds a single tag to a file, then recursively applies active rules.
// Returns the full resulting tag set.
func (s *TagService) AddFileTag(ctx context.Context, fileID, tagID uuid.UUID) ([]domain.Tag, error) {
// Compute the full set including rule-expansion from tagID.
extra, err := s.expandTagSet(ctx, []uuid.UUID{tagID})
if err != nil {
return nil, err
}
// Fetch current tags so we don't lose them.
current, err := s.tags.ListByFile(ctx, fileID)
if err != nil {
return nil, err
}
// Union: existing + expanded new tags.
seen := make(map[uuid.UUID]bool, len(current)+len(extra))
for _, t := range current {
seen[t.ID] = true
}
merged := make([]uuid.UUID, len(current))
for i, t := range current {
merged[i] = t.ID
}
for _, id := range extra {
if !seen[id] {
seen[id] = true
merged = append(merged, id)
}
}
if err := s.tags.SetFileTags(ctx, fileID, merged); err != nil {
return nil, err
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, map[string]any{"tag_id": tagID})
return s.tags.ListByFile(ctx, fileID)
}
// RemoveFileTag removes a single tag from a file.
func (s *TagService) RemoveFileTag(ctx context.Context, fileID, tagID uuid.UUID) error {
if err := s.tags.RemoveFileTag(ctx, fileID, tagID); err != nil {
return err
}
objType := fileObjectType
_ = s.audit.Log(ctx, "file_tag_remove", &objType, &fileID, map[string]any{"tag_id": tagID})
return nil
}
// BulkSetTags adds or removes tags on multiple files (with rule expansion for add).
// Returns the tagIDs that were applied (the expanded input set for add; empty for remove).
func (s *TagService) BulkSetTags(ctx context.Context, fileIDs []uuid.UUID, action string, tagIDs []uuid.UUID) ([]uuid.UUID, error) {
if action != "add" && action != "remove" {
return nil, domain.ErrValidation
}
// Pre-expand tag set once; all files get the same expansion.
var expanded []uuid.UUID
if action == "add" {
var err error
expanded, err = s.expandTagSet(ctx, tagIDs)
if err != nil {
return nil, err
}
}
for _, fileID := range fileIDs {
switch action {
case "add":
current, err := s.tags.ListByFile(ctx, fileID)
if err != nil {
if err == domain.ErrNotFound {
continue
}
return nil, err
}
seen := make(map[uuid.UUID]bool, len(current))
merged := make([]uuid.UUID, len(current))
for i, t := range current {
seen[t.ID] = true
merged[i] = t.ID
}
for _, id := range expanded {
if !seen[id] {
seen[id] = true
merged = append(merged, id)
}
}
if err := s.tags.SetFileTags(ctx, fileID, merged); err != nil {
return nil, err
}
case "remove":
current, err := s.tags.ListByFile(ctx, fileID)
if err != nil {
if err == domain.ErrNotFound {
continue
}
return nil, err
}
remove := make(map[uuid.UUID]bool, len(tagIDs))
for _, id := range tagIDs {
remove[id] = true
}
kept := make([]uuid.UUID, 0, len(current))
for _, t := range current {
if !remove[t.ID] {
kept = append(kept, t.ID)
}
}
if err := s.tags.SetFileTags(ctx, fileID, kept); err != nil {
return nil, err
}
}
}
if action == "add" {
return expanded, nil
}
return []uuid.UUID{}, nil
}
// CommonTags returns tags present on ALL given files and tags present on SOME.
func (s *TagService) CommonTags(ctx context.Context, fileIDs []uuid.UUID) (common, partial []domain.Tag, err error) {
common, err = s.tags.CommonTagsForFiles(ctx, fileIDs)
if err != nil {
return nil, nil, err
}
partial, err = s.tags.PartialTagsForFiles(ctx, fileIDs)
if err != nil {
return nil, nil, err
}
return common, partial, nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// expandTagSet runs a BFS from the given seed tags, following active tag rules,
// and returns the full set of tag IDs that should be applied (seeds + auto-applied).
func (s *TagService) expandTagSet(ctx context.Context, seeds []uuid.UUID) ([]uuid.UUID, error) {
visited := make(map[uuid.UUID]bool, len(seeds))
queue := make([]uuid.UUID, 0, len(seeds))
for _, id := range seeds {
if !visited[id] {
visited[id] = true
queue = append(queue, id)
}
}
for i := 0; i < len(queue); i++ {
tagID := queue[i]
rules, err := s.rules.ListByTag(ctx, tagID)
if err != nil {
return nil, err
}
for _, r := range rules {
if r.IsActive && !visited[r.ThenTagID] {
visited[r.ThenTagID] = true
queue = append(queue, r.ThenTagID)
}
}
}
return queue, nil
}