Skip to content

Commit e435812

Browse files
authored
feat(tags): tags page (#114)
2 parents e2a900b + 456430a commit e435812

27 files changed

Lines changed: 2660 additions & 23 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
# ShadowBrain
77

8-
[![Version](https://img.shields.io/badge/version-0.13.0-yellow)](CHANGELOG.md)
8+
[![Version](https://img.shields.io/badge/version-0.16.0-yellow)](CHANGELOG.md)
99

1010
</div>
1111

docs/design-tokens.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ accent on detail view. See the design system spec for the full rule.
9494

9595
| Family | Role | Weights | Source |
9696
| -------------- | -------------------- | ------------- | ----------------------------------- |
97-
| Inter | Sans, primary UI | 400, 500, 700 | Google Fonts via `next/font/google` |
97+
| Geist | Sans, primary UI | 400, 500, 700 | Google Fonts via `next/font/google` |
9898
| Newsreader | Serif, brand moments | 400, 600 | Google Fonts via `next/font/google` |
9999
| JetBrains Mono | Mono, code/data | 400, 500 | Google Fonts via `next/font/google` |
100100

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "shadowbrain",
3-
"version": "0.13.0",
3+
"version": "0.16.0",
44
"private": true,
55
"scripts": {
66
"dev": "next dev",

src/app/__tests__/layout.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ vi.mock("next/headers", () => ({
3434
}));
3535

3636
vi.mock("next/font/google", () => ({
37-
Inter: () => ({ variable: "--font-sans", className: null }),
37+
Geist: () => ({ variable: "--font-sans", className: null }),
3838
Newsreader: () => ({ variable: "--font-serif", className: null }),
3939
JetBrains_Mono: () => ({ variable: "--font-mono", className: null }),
4040
}));

src/app/api/__tests__/tags.test.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
import { getDb, contentItems, contentTags, tags } from "@/db/index";
99
import { GET, POST } from "@/app/api/tags/route";
1010
import { PATCH, DELETE } from "@/app/api/tags/[id]/route";
11+
import { POST as POST_MERGE } from "@/app/api/tags/[id]/merge/route";
12+
import { POST as POST_DELETE_UNUSED } from "@/app/api/tags/delete-unused/route";
1113

1214
const NOW = () => new Date().toISOString();
1315

@@ -377,3 +379,230 @@ describe("/api/tags/[id]", () => {
377379
expect(res.status).toBe(400);
378380
});
379381
});
382+
383+
describe("POST /api/tags/[id]/merge", () => {
384+
beforeEach(() => {
385+
cleanupTestDb();
386+
createTestDb().close();
387+
});
388+
389+
afterEach(() => {
390+
cleanupTestDb();
391+
});
392+
393+
it("re-points content_tags from source to target and deletes the source", async () => {
394+
const source = await createTag("alpha");
395+
const target = await createTag("beta");
396+
const itemA = makeContentItem();
397+
const itemB = makeContentItem();
398+
const db = getDb();
399+
const now = NOW();
400+
contentTags.addTag(db, itemA, source.id, now);
401+
contentTags.addTag(db, itemB, source.id, now);
402+
403+
const res = await POST_MERGE(
404+
await authedRequest(`http://localhost/api/tags/${source.id}/merge`, {
405+
method: "POST",
406+
headers: { "Content-Type": "application/json" },
407+
body: JSON.stringify({ targetId: target.id }),
408+
}),
409+
{ params: Promise.resolve({ id: source.id }) }
410+
);
411+
expect(res.status).toBe(200);
412+
const json = await res.json();
413+
expect(json.id).toBe(target.id);
414+
expect(json.name).toBe("beta");
415+
expect(json.count).toBe(2);
416+
417+
expect(tags.findById(db, source.id)).toBeUndefined();
418+
const itemATags = contentTags.findByContent(db, itemA) as Array<{
419+
id: string;
420+
}>;
421+
const itemBTags = contentTags.findByContent(db, itemB) as Array<{
422+
id: string;
423+
}>;
424+
expect(itemATags).toHaveLength(1);
425+
expect(itemATags[0].id).toBe(target.id);
426+
expect(itemBTags).toHaveLength(1);
427+
expect(itemBTags[0].id).toBe(target.id);
428+
});
429+
430+
it("dedupes when the target already tags an item", async () => {
431+
const source = await createTag("alpha");
432+
const target = await createTag("beta");
433+
const itemId = makeContentItem();
434+
const db = getDb();
435+
const now = NOW();
436+
contentTags.addTag(db, itemId, source.id, now);
437+
contentTags.addTag(db, itemId, target.id, now);
438+
439+
const res = await POST_MERGE(
440+
await authedRequest(`http://localhost/api/tags/${source.id}/merge`, {
441+
method: "POST",
442+
headers: { "Content-Type": "application/json" },
443+
body: JSON.stringify({ targetId: target.id }),
444+
}),
445+
{ params: Promise.resolve({ id: source.id }) }
446+
);
447+
expect(res.status).toBe(200);
448+
const json = await res.json();
449+
expect(json.count).toBe(1);
450+
expect(contentTags.findByContent(db, itemId)).toHaveLength(1);
451+
});
452+
453+
it("returns 400 when merging a tag into itself", async () => {
454+
const source = await createTag("alpha");
455+
const res = await POST_MERGE(
456+
await authedRequest(`http://localhost/api/tags/${source.id}/merge`, {
457+
method: "POST",
458+
headers: { "Content-Type": "application/json" },
459+
body: JSON.stringify({ targetId: source.id }),
460+
}),
461+
{ params: Promise.resolve({ id: source.id }) }
462+
);
463+
expect(res.status).toBe(400);
464+
const json = await res.json();
465+
expect(json.error.code).toBe("VALIDATION_ERROR");
466+
});
467+
468+
it("returns 404 when the source tag is missing", async () => {
469+
const target = await createTag("beta");
470+
const missingId = makeId();
471+
const res = await POST_MERGE(
472+
await authedRequest(`http://localhost/api/tags/${missingId}/merge`, {
473+
method: "POST",
474+
headers: { "Content-Type": "application/json" },
475+
body: JSON.stringify({ targetId: target.id }),
476+
}),
477+
{ params: Promise.resolve({ id: missingId }) }
478+
);
479+
expect(res.status).toBe(404);
480+
});
481+
482+
it("returns 404 when the target tag is missing", async () => {
483+
const source = await createTag("alpha");
484+
const missingId = makeId();
485+
const res = await POST_MERGE(
486+
await authedRequest(`http://localhost/api/tags/${source.id}/merge`, {
487+
method: "POST",
488+
headers: { "Content-Type": "application/json" },
489+
body: JSON.stringify({ targetId: missingId }),
490+
}),
491+
{ params: Promise.resolve({ id: source.id }) }
492+
);
493+
expect(res.status).toBe(404);
494+
const json = await res.json();
495+
expect(json.error.code).toBe("NOT_FOUND");
496+
});
497+
498+
it("writes a tag.merge audit log row", async () => {
499+
const source = await createTag("alpha");
500+
const target = await createTag("beta");
501+
const itemId = makeContentItem();
502+
const db = getDb();
503+
contentTags.addTag(db, itemId, source.id, NOW());
504+
505+
const res = await POST_MERGE(
506+
await authedRequest(`http://localhost/api/tags/${source.id}/merge`, {
507+
method: "POST",
508+
headers: { "Content-Type": "application/json" },
509+
body: JSON.stringify({ targetId: target.id }),
510+
}),
511+
{ params: Promise.resolve({ id: source.id }) }
512+
);
513+
expect(res.status).toBe(200);
514+
515+
const audit = db
516+
.prepare(
517+
"SELECT action, metadata FROM audit_logs WHERE action = 'tag.merge' ORDER BY created_at DESC LIMIT 1"
518+
)
519+
.get() as { action: string; metadata: string };
520+
expect(audit.action).toBe("tag.merge");
521+
const metadata = JSON.parse(audit.metadata) as {
522+
source: string;
523+
target: string;
524+
affected: number;
525+
};
526+
expect(metadata.source).toBe("alpha");
527+
expect(metadata.target).toBe("beta");
528+
expect(metadata.affected).toBe(1);
529+
});
530+
});
531+
532+
describe("POST /api/tags/delete-unused", () => {
533+
beforeEach(() => {
534+
cleanupTestDb();
535+
createTestDb().close();
536+
});
537+
538+
afterEach(() => {
539+
cleanupTestDb();
540+
});
541+
542+
it("deletes every tag with zero usages", async () => {
543+
const used = await createTag("alpha");
544+
await createTag("unused-a");
545+
await createTag("unused-b");
546+
const db = getDb();
547+
contentTags.addTag(db, makeContentItem(), used.id, NOW());
548+
549+
const res = await POST_DELETE_UNUSED(
550+
await authedRequest("http://localhost/api/tags/delete-unused", {
551+
method: "POST",
552+
})
553+
);
554+
expect(res.status).toBe(200);
555+
const json = await res.json();
556+
expect(json.deleted).toBe(2);
557+
558+
const remaining = tags.findAll(db).map((row) => row.name);
559+
expect(remaining).toEqual(["alpha"]);
560+
});
561+
562+
it("writes a tag.delete audit row per removed tag", async () => {
563+
await createTag("unused");
564+
const db = getDb();
565+
const before = (
566+
db.prepare("SELECT COUNT(*) as c FROM audit_logs").get() as { c: number }
567+
).c;
568+
569+
const res = await POST_DELETE_UNUSED(
570+
await authedRequest("http://localhost/api/tags/delete-unused", {
571+
method: "POST",
572+
})
573+
);
574+
expect(res.status).toBe(200);
575+
576+
const after = (
577+
db.prepare("SELECT COUNT(*) as c FROM audit_logs").get() as { c: number }
578+
).c;
579+
expect(after - before).toBe(1);
580+
581+
const audit = db
582+
.prepare(
583+
"SELECT action, metadata FROM audit_logs WHERE action = 'tag.delete' ORDER BY created_at DESC LIMIT 1"
584+
)
585+
.get() as { action: string; metadata: string };
586+
expect(audit.action).toBe("tag.delete");
587+
expect(JSON.parse(audit.metadata)).toMatchObject({
588+
name: "unused",
589+
bulk: true,
590+
});
591+
});
592+
593+
it("returns deleted: 0 when every tag is in use", async () => {
594+
const used = await createTag("alpha");
595+
const db = getDb();
596+
contentTags.addTag(db, makeContentItem(), used.id, NOW());
597+
598+
const res = await POST_DELETE_UNUSED(
599+
await authedRequest("http://localhost/api/tags/delete-unused", {
600+
method: "POST",
601+
})
602+
);
603+
expect(res.status).toBe(200);
604+
const json = await res.json();
605+
expect(json.deleted).toBe(0);
606+
expect(tags.findAll(db)).toHaveLength(1);
607+
});
608+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { z } from "zod";
2+
import { getDb, tags, contentTags, auditLogs } from "@/db/index";
3+
import { errorResponse, parseJson, logServerError } from "@/lib/api";
4+
import { log } from "@/lib/logger";
5+
import { requireAuthenticated } from "@/lib/auth/guard";
6+
7+
const mergeSchema = z.object({
8+
targetId: z.string().min(1, "Target tag is required"),
9+
});
10+
11+
export async function POST(
12+
request: Request,
13+
{ params }: { params: Promise<{ id: string }> }
14+
) {
15+
const auth = await requireAuthenticated(request);
16+
if (!auth.ok) return auth.response;
17+
const { id: sourceId } = await params;
18+
try {
19+
let body: unknown;
20+
try {
21+
body = await request.json();
22+
} catch {
23+
return errorResponse("VALIDATION_ERROR", "Invalid JSON", 400);
24+
}
25+
const parsed = parseJson(mergeSchema, body);
26+
if (!parsed.success) {
27+
return errorResponse("VALIDATION_ERROR", "Invalid input", 400, {
28+
issues: parsed.details,
29+
});
30+
}
31+
32+
const { targetId } = parsed.data;
33+
if (targetId === sourceId) {
34+
return errorResponse(
35+
"VALIDATION_ERROR",
36+
"Cannot merge a tag into itself",
37+
400
38+
);
39+
}
40+
41+
const db = getDb();
42+
const source = tags.findById(db, sourceId);
43+
if (!source) {
44+
return errorResponse("NOT_FOUND", "Tag not found", 404);
45+
}
46+
const target = tags.findById(db, targetId);
47+
if (!target) {
48+
return errorResponse("NOT_FOUND", "Target tag not found", 404);
49+
}
50+
51+
const sourceCount =
52+
(
53+
db
54+
.prepare("SELECT COUNT(*) as c FROM content_tags WHERE tag_id = ?")
55+
.get(sourceId) as { c: number } | undefined
56+
)?.c ?? 0;
57+
const now = new Date().toISOString();
58+
const auditLogId = crypto.randomUUID();
59+
60+
const tx = db.transaction(() => {
61+
contentTags.repointTag(db, sourceId, targetId);
62+
const result = tags.delete(db, sourceId);
63+
if (result.changes === 0) {
64+
throw new TagNotFoundError();
65+
}
66+
auditLogs.create(db, {
67+
id: auditLogId,
68+
actor_type: "system",
69+
action: "tag.merge",
70+
entity_type: "tag",
71+
entity_id: sourceId,
72+
success: 1,
73+
metadata: JSON.stringify({
74+
source: source.name,
75+
target: target.name,
76+
affected: sourceCount,
77+
}),
78+
created_at: now,
79+
});
80+
});
81+
82+
try {
83+
tx();
84+
} catch (error) {
85+
if (error instanceof TagNotFoundError) {
86+
return errorResponse("NOT_FOUND", "Tag not found", 404);
87+
}
88+
throw error;
89+
}
90+
91+
const targetCount =
92+
(
93+
db
94+
.prepare("SELECT COUNT(*) as c FROM content_tags WHERE tag_id = ?")
95+
.get(targetId) as { c: number } | undefined
96+
)?.c ?? 0;
97+
const merged = { ...target, count: targetCount };
98+
99+
log("info", "tags merged", {
100+
event: "tag.merge",
101+
sourceId,
102+
targetId,
103+
affected: sourceCount,
104+
});
105+
return Response.json(merged);
106+
} catch (error) {
107+
logServerError(error, {
108+
route: "/api/tags/[id]/merge",
109+
method: "POST",
110+
});
111+
return errorResponse("INTERNAL_ERROR", "Something went wrong", 500);
112+
}
113+
}
114+
115+
class TagNotFoundError extends Error {
116+
constructor() {
117+
super("tag_not_found");
118+
}
119+
}

0 commit comments

Comments
 (0)