feat(phase 3d auto-tag): backfill area tags, bulk-edit UI, soft-delete cleanup

- migration 0012: one-shot populate empty tags from each item's area-roots
  (so chips on /?tag=work etc. actually filter the 40+ mai-backfilled rows)
- migration 0013: cleanup 12 orphan item_links + BEFORE-UPDATE trigger that
  cascades soft-delete to item_links going forward — closes the data drift
  that made TestItemsUnifiedSurfacesMaiPointer fail since 3c
- /admin/bulk page: flat filter+checkbox list with one-tx Apply for add/
  remove tag, set management, set status. Per-row inline chip add/remove
  via /admin/bulk/chip. Reuses tree_filter URL params 1:1.
- design.md §3.2 + §4.1 updated; tag+management section notes 0012
- bulk + tag-backfill + soft-delete-cascade tests cover the new surface
This commit is contained in:
mAi
2026-05-15 18:49:58 +02:00
parent cd565f4aee
commit 0e490bb600
12 changed files with 1044 additions and 1 deletions

View File

@@ -298,6 +298,97 @@ func TestMultiParentResolvesBothPaths(t *testing.T) {
}
}
// TestBackfillTagsFromArea verifies migration 0012's logic both directly (the
// query as written, applied to a transaction-local fixture) and in the
// applied state (every live child item now carries its area slug). We
// re-execute the migration body inside a transaction so we can assert on a
// freshly inserted untagged row without polluting the live DB.
func TestBackfillTagsFromArea(t *testing.T) {
pool := connect(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// First: every existing live child item already carries area tags after
// 0012 was applied on boot. This catches regressions where a future
// migration clears the column or the backfill stops covering some path.
var untagged int
if err := pool.QueryRow(ctx,
`select count(*) from projax.items
where deleted_at is null
and cardinality(parent_ids) > 0
and tags = '{}'`).Scan(&untagged); err != nil {
t.Fatalf("count untagged children: %v", err)
}
if untagged != 0 {
t.Fatalf("expected every live child to have area tags after 0012, got %d still empty", untagged)
}
// Second: idempotency + multi-parent. Insert a fresh multi-parent row
// with empty tags, re-run the backfill query, observe both area slugs in
// tags. Re-run a second time and check nothing changes.
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback(ctx)
var dev, work string
if err := tx.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
if err := tx.QueryRow(ctx, `select id from projax.items where slug='work' and cardinality(parent_ids)=0`).Scan(&work); err != nil {
t.Fatalf("work: %v", err)
}
slug := fmt.Sprintf("tag-backfill-%d", time.Now().UnixNano())
if _, err := tx.Exec(ctx,
`insert into projax.items (kind, title, slug, parent_ids, tags)
values (array['project']::text[], 'TagBackfill', $1, ARRAY[$2,$3]::uuid[], '{}')`,
slug, dev, work,
); err != nil {
t.Fatalf("insert multi-parent: %v", err)
}
backfill := `UPDATE projax.items i
SET tags = sub.area_tags
FROM (
SELECT id, ARRAY(SELECT DISTINCT split_part(p, '.', 1)
FROM unnest(paths) p ORDER BY 1) AS area_tags
FROM projax.items
WHERE deleted_at IS NULL AND tags = '{}'
AND cardinality(parent_ids) > 0
) sub
WHERE i.id = sub.id AND i.tags = '{}'
AND cardinality(sub.area_tags) > 0`
if _, err := tx.Exec(ctx, backfill); err != nil {
t.Fatalf("first backfill apply: %v", err)
}
var tags []string
if err := tx.QueryRow(ctx, `select tags from projax.items where slug=$1`, slug).Scan(&tags); err != nil {
t.Fatalf("read tags after backfill: %v", err)
}
got := map[string]bool{}
for _, t := range tags {
got[t] = true
}
if !got["dev"] || !got["work"] || len(tags) != 2 {
t.Fatalf("expected tags = {dev, work}, got %v", tags)
}
// Idempotent: second run is a no-op on this row (tags no longer = '{}').
if _, err := tx.Exec(ctx, backfill); err != nil {
t.Fatalf("second backfill apply: %v", err)
}
var tags2 []string
if err := tx.QueryRow(ctx, `select tags from projax.items where slug=$1`, slug).Scan(&tags2); err != nil {
t.Fatalf("read tags after second backfill: %v", err)
}
if len(tags2) != 2 {
t.Fatalf("expected idempotent backfill, got %v after second run", tags2)
}
}
func TestSlugCollisionUnderCommonParent(t *testing.T) {
pool := connect(t)
defer pool.Close()