diff --git a/CHANGELOG.md b/CHANGELOG.md index d663d7389..b2407e5d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [1.13.0] — 2026-06-05 — your own mood tags + +### Added + +- **Make the mood-tag list your own.** You can now create your own custom mood tags, give each a label and an icon, and hide any of the built-in tags you don't use — so the tag picker shows exactly the set that fits you. Custom tags work everywhere a built-in tag does: logging, history, and the analysis. Custom tag names are encrypted at rest like the rest of your personal content. Deleting a custom tag keeps it on your past entries by default; a separate purge removes it from history too. + ## [1.12.12] — 2026-06-05 — the period retrospective is never empty ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f52dc9819..c2a5a4fca 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.12.12 + version: 1.13.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index d87fc7340..c252373ab 100644 --- a/messages/de.json +++ b/messages/de.json @@ -531,7 +531,8 @@ "social": "Soziales", "work": "Arbeit", "hobbies": "Hobbys", - "factors": "Faktoren" + "factors": "Faktoren", + "custom": "Eigene" }, "tag": { "happy": "glücklich", @@ -4813,4 +4814,4 @@ }, "footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker." } -} +} \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 34cd6f238..de823f4ee 100644 --- a/messages/en.json +++ b/messages/en.json @@ -531,7 +531,8 @@ "social": "Social", "work": "Work", "hobbies": "Hobbies", - "factors": "Factors" + "factors": "Factors", + "custom": "Custom" }, "tag": { "happy": "happy", @@ -4813,4 +4814,4 @@ }, "footer": "Shared from HealthLog — a self-hosted personal health tracker." } -} +} \ No newline at end of file diff --git a/messages/es.json b/messages/es.json index f77e0498f..3d49d0d69 100644 --- a/messages/es.json +++ b/messages/es.json @@ -531,7 +531,8 @@ "social": "Social", "work": "Trabajo", "hobbies": "Aficiones", - "factors": "Factores" + "factors": "Factores", + "custom": "Personalizadas" }, "tag": { "happy": "feliz", @@ -4813,4 +4814,4 @@ }, "footer": "Compartido desde HealthLog — un registro de salud autoalojado." } -} +} \ No newline at end of file diff --git a/messages/fr.json b/messages/fr.json index 5cbdafd73..bb058fce4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -531,7 +531,8 @@ "social": "Social", "work": "Travail", "hobbies": "Loisirs", - "factors": "Facteurs" + "factors": "Facteurs", + "custom": "Personnalisées" }, "tag": { "happy": "heureux", @@ -4813,4 +4814,4 @@ }, "footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé." } -} +} \ No newline at end of file diff --git a/messages/it.json b/messages/it.json index 71b82ecfa..b061550b9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -531,7 +531,8 @@ "social": "Sociale", "work": "Lavoro", "hobbies": "Hobby", - "factors": "Fattori" + "factors": "Fattori", + "custom": "Personalizzate" }, "tag": { "happy": "felice", @@ -4813,4 +4814,4 @@ }, "footer": "Condiviso da HealthLog — un tracker della salute self-hosted." } -} +} \ No newline at end of file diff --git a/messages/pl.json b/messages/pl.json index 368af74df..a20339a0f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -531,7 +531,8 @@ "social": "Społeczne", "work": "Praca", "hobbies": "Hobby", - "factors": "Czynniki" + "factors": "Czynniki", + "custom": "Własne" }, "tag": { "happy": "szczęśliwy", @@ -4813,4 +4814,4 @@ }, "footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia." } -} +} \ No newline at end of file diff --git a/package.json b/package.json index f9cb44cf5..76e9523e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.12.12", + "version": "1.13.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0127_v1130_custom_mood_tags/migration.sql b/prisma/migrations/0127_v1130_custom_mood_tags/migration.sql new file mode 100644 index 000000000..0c35dbde3 --- /dev/null +++ b/prisma/migrations/0127_v1130_custom_mood_tags/migration.sql @@ -0,0 +1,52 @@ +-- v1.13.0 — per-user custom mood tags + per-user catalogue hide. +-- +-- Additive, non-destructive: +-- 1. `mood_tags` gains `user_id` (NULL = global catalogue, set = a user's +-- custom tag carrying a `custom:` key) and `label_encrypted` +-- (AES-256-GCM ciphertext of a custom tag's free-text label; NULL for +-- catalogue rows, which resolve `label_key` against the locale). +-- 2. New `mood_tag_hidden` join: a per-user hide of a CATALOGUE tag. +-- 3. Seed the global `custom` category every user's custom tags hang under. +-- +-- Idempotent: ADD COLUMN IF NOT EXISTS, CREATE TABLE IF NOT EXISTS, and the +-- category seed upserts by `key` (deterministic id `mtc_custom`). + +-- ─── 1. mood_tags: owner + encrypted custom label ──────────────────── +ALTER TABLE "mood_tags" ADD COLUMN IF NOT EXISTS "user_id" TEXT; +ALTER TABLE "mood_tags" ADD COLUMN IF NOT EXISTS "label_encrypted" TEXT; + +DO $$ BEGIN + ALTER TABLE "mood_tags" + ADD CONSTRAINT "mood_tags_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE INDEX IF NOT EXISTS "mood_tags_user_id_idx" ON "mood_tags"("user_id"); + +-- ─── 2. mood_tag_hidden: per-user catalogue hide ───────────────────── +CREATE TABLE IF NOT EXISTS "mood_tag_hidden" ( + "user_id" TEXT NOT NULL, + "mood_tag_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "mood_tag_hidden_pkey" PRIMARY KEY ("user_id", "mood_tag_id") +); +CREATE INDEX IF NOT EXISTS "mood_tag_hidden_mood_tag_id_idx" ON "mood_tag_hidden"("mood_tag_id"); + +DO $$ BEGIN + ALTER TABLE "mood_tag_hidden" + ADD CONSTRAINT "mood_tag_hidden_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN + ALTER TABLE "mood_tag_hidden" + ADD CONSTRAINT "mood_tag_hidden_mood_tag_id_fkey" + FOREIGN KEY ("mood_tag_id") REFERENCES "mood_tags"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- ─── 3. Seed the global `custom` category ──────────────────────────── +INSERT INTO "mood_tag_categories" + ("id", "key", "label_key", "icon", "sort_order", "is_active") +VALUES ('mtc_custom', 'custom', 'mood.tagCategory.custom', 'Tag', 100, true) +ON CONFLICT ("key") DO UPDATE SET + "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", + "sort_order" = EXCLUDED."sort_order", "is_active" = EXCLUDED."is_active"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0fd25f1a6..52d1e7cbe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -378,6 +378,8 @@ model User { fitbitConnection FitbitConnection? fitbitOAuthStates FitbitOAuthState[] moodEntries MoodEntry[] + moodTagsCustom MoodTag[] @relation("UserMoodTags") + moodTagsHidden MoodTagHidden[] notificationChannels NotificationChannel[] pushSubscriptions PushSubscription[] dataBackups DataBackup[] @@ -2131,13 +2133,42 @@ model MoodTag { /// analytics layer flips the sign so "up" always reads as "better". inverse Boolean @default(false) @map("inverse") + /// v1.13.0 — owner of a per-user custom tag. NULL = the global catalogue + /// (every row seeded by migration; resolves `labelKey` against the locale). + /// Set = a user's own custom tag, carrying a `custom:` key and a + /// free-text label held in `labelEncrypted`. + userId String? @map("user_id") + /// v1.13.0 — AES-256-GCM ciphertext of a custom tag's free-text label + /// (user prose, possibly health-adjacent — e.g. "Migräne", "Therapie"). + /// NULL for catalogue rows. Decrypted server-side for the effective read. + labelEncrypted String? @map("label_encrypted") + category MoodTagCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) + user User? @relation("UserMoodTags", fields: [userId], references: [id], onDelete: Cascade) links MoodEntryTagLink[] + hiddenBy MoodTagHidden[] @@index([categoryId, sortOrder]) + @@index([userId]) @@map("mood_tags") } +/// v1.13.0 — per-user hide of a CATALOGUE tag. Present = hidden from that +/// user's effective picker; absent = visible. A custom tag is hidden by +/// flipping its own `MoodTag.isActive` instead, never via this table. +model MoodTagHidden { + userId String @map("user_id") + moodTagId String @map("mood_tag_id") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + moodTag MoodTag @relation(fields: [moodTagId], references: [id], onDelete: Cascade) + + @@id([userId, moodTagId]) + @@index([moodTagId]) + @@map("mood_tag_hidden") +} + // Join: which structured tags a mood entry carries. Composite PK keeps // the pairing unique; cascade on both sides so deleting an entry (or a // catalog tag) cleans up its links. Distinct from the flat `tags` JSON diff --git a/src/app/api/mood/tags/[key]/hidden/route.ts b/src/app/api/mood/tags/[key]/hidden/route.ts new file mode 100644 index 000000000..f1929bea5 --- /dev/null +++ b/src/app/api/mood/tags/[key]/hidden/route.ts @@ -0,0 +1,63 @@ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { hideCatalogueTagSchema, isCustomTagKey } from "@/lib/mood/custom-tags"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ key: string }> }; + +/** + * v1.13.0 — hide / show a CATALOGUE tag for the calling user. + * + * `PUT /api/mood/tags/:key/hidden` — body `{ hidden: boolean }`. Inserts / + * removes a `mood_tag_hidden` row so the effective `GET /api/mood/tags` omits + * the tag for this user without touching the global catalogue. Custom tags + * are not hidden this way — flip their own `isActive` via the custom PATCH. + */ +export const PUT = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { key } = await params; + if (isCustomTagKey(key)) { + return apiError( + "Custom tags are hidden via their isActive flag, not this route", + 400, + ); + } + + const parsed = hideCatalogueTagSchema.safeParse(await request.json()); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + // Resolve against the catalogue (user_id NULL) only. + const tag = await prisma.moodTag.findFirst({ + where: { key, userId: null }, + select: { id: true }, + }); + if (!tag) return apiError("Catalogue tag not found", 404); + + if (parsed.data.hidden) { + await prisma.moodTagHidden.upsert({ + where: { + userId_moodTagId: { userId: user.id, moodTagId: tag.id }, + }, + create: { userId: user.id, moodTagId: tag.id }, + update: {}, + }); + } else { + await prisma.moodTagHidden.deleteMany({ + where: { userId: user.id, moodTagId: tag.id }, + }); + } + + annotate({ + action: { name: "mood.tag.hidden.set" }, + meta: { hidden: parsed.data.hidden }, + }); + + return apiSuccess({ key, hidden: parsed.data.hidden }); + }, +); diff --git a/src/app/api/mood/tags/__tests__/custom-crud.test.ts b/src/app/api/mood/tags/__tests__/custom-crud.test.ts new file mode 100644 index 000000000..161ff255e --- /dev/null +++ b/src/app/api/mood/tags/__tests__/custom-crud.test.ts @@ -0,0 +1,131 @@ +/** + * v1.13.0 — custom mood-tag CRUD route guards: create cap, ownership 404 on + * edit/delete, soft-delete vs purge, and the custom-key rejection on the + * catalogue-hide route. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const db = vi.hoisted(() => ({ + moodTag: { + count: vi.fn(), + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + moodTagHidden: { upsert: vi.fn(), deleteMany: vi.fn() }, +})); + +vi.mock("@/lib/db", () => ({ prisma: db })); +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `enc:${s}`, + decrypt: (s: string) => s.replace(/^enc:/, ""), +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined) })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined) })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })), +})); + +import { POST } from "../custom/route"; +import { PATCH, DELETE } from "../custom/[key]/route"; +import { PUT } from "../[key]/hidden/route"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "t", role: "USER" as const }, +}; + +function jsonReq(url: string, method: string, body: unknown) { + return new NextRequest(url, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("POST /api/mood/tags/custom", () => { + it("creates a custom tag and returns it (custom:true, decrypted label)", async () => { + db.moodTag.count.mockResolvedValue(3); + db.moodTag.create.mockResolvedValue({ + key: "custom:abc", icon: "Heart", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, + }); + const res = await POST(jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "Date night", icon: "Heart" })); + expect(res.status).toBe(201); + const body = (await res.json()) as { data: { custom: boolean; label: string; labelKey: null; key: string } }; + expect(body.data).toMatchObject({ custom: true, label: "Date night", labelKey: null, key: "custom:abc" }); + // Label is stored encrypted, never plaintext. + expect(db.moodTag.create.mock.calls[0][0].data.labelEncrypted).toBe("enc:Date night"); + }); + + it("rejects over the per-user cap with 422", async () => { + db.moodTag.count.mockResolvedValue(50); + const res = await POST(jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X" })); + expect(res.status).toBe(422); + expect(db.moodTag.create).not.toHaveBeenCalled(); + }); +}); + +describe("PATCH/DELETE /api/mood/tags/custom/:key", () => { + const params = (key: string) => ({ params: Promise.resolve({ key }) }); + + it("404s when the key is not the caller's own custom tag", async () => { + db.moodTag.findFirst.mockResolvedValue(null); + const res = await PATCH( + jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", { isActive: false }), + params("custom:x"), + ); + expect(res.status).toBe(404); + }); + + it("404s a non-custom key without touching the DB", async () => { + const res = await DELETE(new NextRequest("http://localhost/api/mood/tags/custom/happy"), params("happy")); + expect(res.status).toBe(404); + expect(db.moodTag.findFirst).not.toHaveBeenCalled(); + }); + + it("soft-deactivates by default and hard-deletes on ?purge=true", async () => { + db.moodTag.findFirst.mockResolvedValue({ id: "id1" }); + const soft = await DELETE(new NextRequest("http://localhost/api/mood/tags/custom/custom:abc"), params("custom:abc")); + expect(soft.status).toBe(200); + expect(db.moodTag.update).toHaveBeenCalledWith({ where: { id: "id1" }, data: { isActive: false } }); + expect(db.moodTag.delete).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + db.moodTag.findFirst.mockResolvedValue({ id: "id1" }); + const purge = await DELETE(new NextRequest("http://localhost/api/mood/tags/custom/custom:abc?purge=true"), params("custom:abc")); + expect(purge.status).toBe(200); + expect(db.moodTag.delete).toHaveBeenCalledWith({ where: { id: "id1" } }); + }); +}); + +describe("PUT /api/mood/tags/:key/hidden", () => { + it("rejects a custom key with 400 (custom tags hide via isActive)", async () => { + const res = await PUT( + jsonReq("http://localhost/api/mood/tags/custom:abc/hidden", "PUT", { hidden: true }), + { params: Promise.resolve({ key: "custom:abc" }) }, + ); + expect(res.status).toBe(400); + }); + + it("upserts a hide row for a catalogue tag", async () => { + db.moodTag.findFirst.mockResolvedValue({ id: "t_sad" }); + const res = await PUT( + jsonReq("http://localhost/api/mood/tags/sad/hidden", "PUT", { hidden: true }), + { params: Promise.resolve({ key: "sad" }) }, + ); + expect(res.status).toBe(200); + expect(db.moodTagHidden.upsert).toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/mood/tags/__tests__/route.test.ts b/src/app/api/mood/tags/__tests__/route.test.ts index a890bca48..6e300d3c2 100644 --- a/src/app/api/mood/tags/__tests__/route.test.ts +++ b/src/app/api/mood/tags/__tests__/route.test.ts @@ -1,18 +1,31 @@ /** - * v1.12.0 — `GET /api/mood/tags` must expose the rated-factor metadata - * (`kind` / `scaleMin` / `scaleMax` / `inverse`) so the client can branch - * between binary toggle chips and rated segmented controls. + * v1.12.0 / v1.13.0 — `GET /api/mood/tags` exposes the rated-factor metadata + * (`kind` / `scaleMin` / `scaleMax` / `inverse`) AND the v1.13.0 effective + * per-user set: own custom tags merged in (label decrypted, `custom: true`), + * hidden catalogue tags omitted by default and surfaced under + * `?include=hidden`. */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; const categoryFindMany = vi.fn(); +const tagFindMany = vi.fn(); +const hiddenFindMany = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { moodTagCategory: { findMany: (...a: unknown[]) => categoryFindMany(...a) }, + moodTag: { findMany: (...a: unknown[]) => tagFindMany(...a) }, + moodTagHidden: { findMany: (...a: unknown[]) => hiddenFindMany(...a) }, }, })); +// Identity-ish crypto so the decrypted custom label is asserted in plaintext. +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `enc:${s}`, + decrypt: (s: string) => s.replace(/^enc:/, ""), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), @@ -38,77 +51,89 @@ const SESSION_OK = { user: { id: "user-1", username: "tester", role: "USER" as const }, }; +const CATEGORIES = [ + { id: "c1", key: "feelings", labelKey: "mood.tagCategory.feelings", icon: "Smile" }, + { id: "mtc_custom", key: "custom", labelKey: "mood.tagCategory.custom", icon: "Tag" }, +]; + +const TAGS = [ + { id: "t_happy", categoryId: "c1", key: "happy", labelKey: "mood.tag.happy", icon: "Smile", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: null, labelEncrypted: null }, + { id: "t_sad", categoryId: "c1", key: "sad", labelKey: "mood.tag.sad", icon: "Frown", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: null, labelEncrypted: null }, + { id: "t_custom", categoryId: "mtc_custom", key: "custom:abc", labelKey: "custom:abc", icon: "Heart", kind: "BINARY", scaleMin: 1, scaleMax: 5, inverse: false, userId: "user-1", labelEncrypted: "enc:Migräne" }, +]; + +interface TagOut { + key: string; + labelKey: string | null; + label: string | null; + custom: boolean; + kind: string; + scaleMin: number; + scaleMax: number; + inverse: boolean; + hidden?: boolean; +} +interface Body { + data: { categories: Array<{ key: string; tags: TagOut[] }> }; +} + +function req(url = "http://localhost/api/mood/tags") { + return new NextRequest(url); +} + beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + categoryFindMany.mockResolvedValue(CATEGORIES); + tagFindMany.mockResolvedValue(TAGS); + hiddenFindMany.mockResolvedValue([{ moodTagId: "t_sad" }]); }); -describe("GET /api/mood/tags — rated-factor catalog metadata (v1.12.0)", () => { - it("selects + emits kind / scaleMin / scaleMax / inverse per tag", async () => { - categoryFindMany.mockResolvedValue([ - { - key: "factors", - labelKey: "mood.tagCategory.factors", - icon: "SlidersHorizontal", - tags: [ - { - key: "factor_work", - labelKey: "mood.tag.factorWork", - icon: "Briefcase", - kind: "RATED", - scaleMin: 1, - scaleMax: 5, - inverse: false, - }, - { - key: "factor_conflict", - labelKey: "mood.tag.factorConflict", - icon: "Swords", - kind: "RATED", - scaleMin: 1, - scaleMax: 2, - inverse: true, - }, - ], - }, - ]); +function flat(body: Body): Record { + const out: Record = {}; + for (const c of body.data.categories) for (const t of c.tags) out[t.key] = t; + return out; +} - const res = await GET(); +describe("GET /api/mood/tags — effective per-user set (v1.13.0)", () => { + it("emits rated-factor metadata + custom/label fields and omits hidden by default", async () => { + const res = await GET(req()); expect(res.status).toBe(200); - const body = (await res.json()) as { - data: { - categories: Array<{ - key: string; - tags: Array<{ - key: string; - kind: string; - scaleMin: number; - scaleMax: number; - inverse: boolean; - }>; - }>; - }; - }; + const tags = flat((await res.json()) as Body); - // The Prisma select must request the new columns. - const selectArg = categoryFindMany.mock.calls[0]?.[0] as { - select: { tags: { select: Record } }; - }; - expect(selectArg.select.tags.select).toMatchObject({ - kind: true, - scaleMin: true, - scaleMax: true, - inverse: true, + // Catalogue tag: custom=false, label=null, labelKey kept, metadata present. + expect(tags.happy).toMatchObject({ + custom: false, + label: null, + labelKey: "mood.tag.happy", + kind: "BINARY", + scaleMin: 1, + scaleMax: 5, + inverse: false, + }); + expect(tags.happy.hidden).toBeUndefined(); + + // Custom tag merged in: custom=true, decrypted label, labelKey null. + expect(tags["custom:abc"]).toMatchObject({ + custom: true, + label: "Migräne", + labelKey: null, }); - const factor = body.data.categories[0].tags[0]; - expect(factor.kind).toBe("RATED"); - expect(factor.scaleMin).toBe(1); - expect(factor.scaleMax).toBe(5); - expect(factor.inverse).toBe(false); + // Hidden catalogue tag omitted by default. + expect(tags.sad).toBeUndefined(); - const conflict = body.data.categories[0].tags[1]; - expect(conflict.scaleMax).toBe(2); - expect(conflict.inverse).toBe(true); + // Tag query scopes to catalogue OR the caller's own customs. + const tagWhere = tagFindMany.mock.calls[0]?.[0]?.where; + expect(tagWhere.OR).toEqual([{ userId: null }, { userId: "user-1" }]); + }); + + it("surfaces hidden catalogue tags with hidden:true under ?include=hidden", async () => { + const res = await GET(req("http://localhost/api/mood/tags?include=hidden")); + const tags = flat((await res.json()) as Body); + expect(tags.sad).toMatchObject({ hidden: true, custom: false }); + expect(tags.happy).toMatchObject({ hidden: false }); + // Custom tags never carry a hidden flag. + expect(tags["custom:abc"].hidden).toBeUndefined(); }); }); diff --git a/src/app/api/mood/tags/custom/[key]/route.ts b/src/app/api/mood/tags/custom/[key]/route.ts new file mode 100644 index 000000000..c8dd7b9cf --- /dev/null +++ b/src/app/api/mood/tags/custom/[key]/route.ts @@ -0,0 +1,113 @@ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + updateCustomTagSchema, + encryptCustomLabel, + decryptCustomLabel, + isCustomTagKey, +} from "@/lib/mood/custom-tags"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ key: string }> }; + +/** + * v1.13.0 — update / delete a per-user custom mood tag. + * + * Both handlers resolve the `custom:`-prefixed key against the CALLER's own + * rows only — another user's key (or a catalogue key) is a 404, so a tag can + * never be edited or removed across the ownership boundary. + */ + +/** `PATCH /api/mood/tags/custom/:key` — rename / recolour / (de)activate. */ +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { key } = await params; + if (!isCustomTagKey(key)) return apiError("Not a custom tag", 404); + + const parsed = updateCustomTagSchema.safeParse(await request.json()); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const owned = await prisma.moodTag.findFirst({ + where: { key, userId: user.id }, + select: { id: true }, + }); + if (!owned) return apiError("Custom tag not found", 404); + + const updated = await prisma.moodTag.update({ + where: { id: owned.id }, + data: { + ...(parsed.data.label !== undefined + ? { labelEncrypted: encryptCustomLabel(parsed.data.label) } + : {}), + ...(parsed.data.icon !== undefined ? { icon: parsed.data.icon } : {}), + ...(parsed.data.isActive !== undefined + ? { isActive: parsed.data.isActive } + : {}), + }, + select: { + key: true, + icon: true, + kind: true, + scaleMin: true, + scaleMax: true, + inverse: true, + isActive: true, + labelEncrypted: true, + }, + }); + + annotate({ action: { name: "mood.tag.custom.update" } }); + + return apiSuccess({ + key: updated.key, + labelKey: null, + label: decryptCustomLabel(updated.labelEncrypted), + icon: updated.icon, + kind: updated.kind, + scaleMin: updated.scaleMin, + scaleMax: updated.scaleMax, + inverse: updated.inverse, + isActive: updated.isActive, + custom: true, + }); + }, +); + +/** + * `DELETE /api/mood/tags/custom/:key` — soft-deactivate by default (history + * intact); `?purge=true` hard-deletes the row and cascades its entry links. + */ +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { key } = await params; + if (!isCustomTagKey(key)) return apiError("Not a custom tag", 404); + + const owned = await prisma.moodTag.findFirst({ + where: { key, userId: user.id }, + select: { id: true }, + }); + if (!owned) return apiError("Custom tag not found", 404); + + const purge = request.nextUrl.searchParams.get("purge") === "true"; + if (purge) { + // FK cascade removes the `mood_entry_tag_links` rows too. + await prisma.moodTag.delete({ where: { id: owned.id } }); + } else { + await prisma.moodTag.update({ + where: { id: owned.id }, + data: { isActive: false }, + }); + } + + annotate({ action: { name: "mood.tag.custom.delete" }, meta: { purge } }); + + return apiSuccess({ key, purged: purge }); + }, +); diff --git a/src/app/api/mood/tags/custom/route.ts b/src/app/api/mood/tags/custom/route.ts new file mode 100644 index 000000000..2736816c8 --- /dev/null +++ b/src/app/api/mood/tags/custom/route.ts @@ -0,0 +1,74 @@ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + createCustomTagSchema, + mintCustomTagKey, + encryptCustomLabel, + CUSTOM_CATEGORY_ID, + MAX_CUSTOM_TAGS_PER_USER, +} from "@/lib/mood/custom-tags"; + +export const dynamic = "force-dynamic"; + +/** + * v1.13.0 — create a per-user custom mood tag. + * + * `POST /api/mood/tags/custom` — body `{ label, icon?, categoryKey? }`. Mints + * a `custom:` key, encrypts the label at rest, and stores a BINARY tag + * under the global `custom` category owned by the caller. Capped per user. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const parsed = createCustomTagSchema.safeParse(await request.json()); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const activeCount = await prisma.moodTag.count({ + where: { userId: user.id, isActive: true }, + }); + if (activeCount >= MAX_CUSTOM_TAGS_PER_USER) { + return apiError( + `Custom tag limit reached (${MAX_CUSTOM_TAGS_PER_USER})`, + 422, + ); + } + + const key = mintCustomTagKey(); + const created = await prisma.moodTag.create({ + data: { + categoryId: CUSTOM_CATEGORY_ID, + key, + // Catalogue rows resolve `labelKey` against the locale; a custom tag + // renders its decrypted `label` instead, so labelKey just mirrors the + // key for a stable, non-empty value. + labelKey: key, + labelEncrypted: encryptCustomLabel(parsed.data.label), + icon: parsed.data.icon ?? null, + kind: "BINARY", + sortOrder: activeCount, + userId: user.id, + }, + select: { key: true, icon: true, kind: true, scaleMin: true, scaleMax: true, inverse: true }, + }); + + annotate({ action: { name: "mood.tag.custom.create" }, meta: { icon: created.icon } }); + + return apiSuccess( + { + key: created.key, + labelKey: null, + label: parsed.data.label, + icon: created.icon, + kind: created.kind, + scaleMin: created.scaleMin, + scaleMax: created.scaleMax, + inverse: created.inverse, + custom: true, + }, + 201, + ); +}); diff --git a/src/app/api/mood/tags/route.ts b/src/app/api/mood/tags/route.ts index ba13023ba..c4589267d 100644 --- a/src/app/api/mood/tags/route.ts +++ b/src/app/api/mood/tags/route.ts @@ -1,55 +1,116 @@ +import { NextRequest } from "next/server"; + import { prisma } from "@/lib/db"; import { apiSuccess } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { decryptCustomLabel } from "@/lib/mood/custom-tags"; export const dynamic = "force-dynamic"; /** - * v1.8.5 — structured mood-tag taxonomy catalog. + * v1.8.5 / v1.13.0 — the EFFECTIVE per-user mood-tag taxonomy. * - * Returns the active Category -> Tag tree the mood-logging form renders - * as a pick-from-catalog capture surface. Global reference data (one - * shared catalog for the deployment, seeded by migration 0101), so the - * read carries no per-user filter and no encryption. The `labelKey` - * fields resolve client-side against the active locale. + * Returns the active Category → Tag tree the mood-logging form renders as a + * pick-from-catalogue surface. The catalogue is global reference data (seeded + * by migration); v1.13.0 layers two per-user adjustments on top so the read + * is now user-scoped: + * - the user's own custom tags (`mood_tags.user_id = me`, `custom:` keys) + * appear under the seeded `custom` category, their label decrypted; + * - catalogue tags the user hid (`mood_tag_hidden`) are omitted — unless + * `?include=hidden` is set, in which case they're returned with + * `hidden: true` so the management screen can toggle them back on. + * + * Every tag carries `custom: boolean` + `label: string | null`: render + * `label` for a custom tag, resolve `labelKey` against the locale for a + * catalogue tag. Backward-compatible — a client that ignores the new fields + * just sees the same categories → tags shape. */ -export const GET = apiHandler(async () => { - // Auth-gated like every app route — the catalog is not public, but it - // is identical for every authenticated user. - await requireAuth(); +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const includeHidden = + request.nextUrl.searchParams.get("include") === "hidden"; - const categories = await prisma.moodTagCategory.findMany({ - where: { isActive: true }, - orderBy: { sortOrder: "asc" }, - select: { - key: true, - labelKey: true, - icon: true, - tags: { - where: { isActive: true }, - orderBy: { sortOrder: "asc" }, - // v1.12.0 — emit the rated-factor metadata so the client can - // branch rendering: `kind = 'BINARY'` → a toggle chip; `'RATED'` - // → a 1..scaleMax segmented control (Yes/No when scaleMax = 2). - // `inverse` flags factors where a higher score means a worse day. - select: { - key: true, - labelKey: true, - icon: true, - kind: true, - scaleMin: true, - scaleMax: true, - inverse: true, - }, + const [categories, tags, hiddenRows] = await Promise.all([ + prisma.moodTagCategory.findMany({ + where: { isActive: true }, + orderBy: { sortOrder: "asc" }, + select: { id: true, key: true, labelKey: true, icon: true }, + }), + // Catalogue tags (user_id NULL) + this user's own custom tags. Another + // user's customs are excluded by construction. + prisma.moodTag.findMany({ + where: { isActive: true, OR: [{ userId: null }, { userId: user.id }] }, + orderBy: { sortOrder: "asc" }, + select: { + id: true, + categoryId: true, + key: true, + labelKey: true, + icon: true, + kind: true, + scaleMin: true, + scaleMax: true, + inverse: true, + userId: true, + labelEncrypted: true, }, - }, - }); + }), + prisma.moodTagHidden.findMany({ + where: { userId: user.id }, + select: { moodTagId: true }, + }), + ]); + + const hiddenIds = new Set(hiddenRows.map((r) => r.moodTagId)); + + const tagsByCategory = new Map(); + for (const t of tags) { + const isCustom = t.userId !== null; + const isHidden = !isCustom && hiddenIds.has(t.id); + if (isHidden && !includeHidden) continue; + const list = tagsByCategory.get(t.categoryId) ?? []; + list.push(t); + tagsByCategory.set(t.categoryId, list); + } + + const responseCategories = categories + .map((c) => { + const catTags = (tagsByCategory.get(c.id) ?? []).map((t) => { + const isCustom = t.userId !== null; + return { + key: t.key, + labelKey: isCustom ? null : t.labelKey, + label: isCustom ? decryptCustomLabel(t.labelEncrypted) : null, + icon: t.icon, + kind: t.kind, + scaleMin: t.scaleMin, + scaleMax: t.scaleMax, + inverse: t.inverse, + custom: isCustom, + ...(includeHidden && !isCustom + ? { hidden: hiddenIds.has(t.id) } + : {}), + }; + }); + return { + key: c.key, + labelKey: c.labelKey, + icon: c.icon, + tags: catTags, + }; + }) + // Drop a category with no visible tags (e.g. `custom` for a user with + // none yet) so the picker grid stays tight. + .filter((c) => c.tags.length > 0); annotate({ action: { name: "mood.tags.catalog.read" }, - meta: { category_count: categories.length }, + meta: { + category_count: responseCategories.length, + include_hidden: includeHidden, + }, }); - return apiSuccess({ categories }); + return apiSuccess({ categories: responseCategories }); }); diff --git a/src/lib/mood/__tests__/custom-tags.test.ts b/src/lib/mood/__tests__/custom-tags.test.ts new file mode 100644 index 000000000..13697e6a9 --- /dev/null +++ b/src/lib/mood/__tests__/custom-tags.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `enc:${s}`, + decrypt: (s: string) => { + if (!s.startsWith("enc:")) throw new Error("bad ciphertext"); + return s.slice(4); + }, +})); + +import { + isCustomTagKey, + mintCustomTagKey, + encryptCustomLabel, + decryptCustomLabel, + createCustomTagSchema, + updateCustomTagSchema, + hideCatalogueTagSchema, + CUSTOM_TAG_KEY_PREFIX, +} from "@/lib/mood/custom-tags"; +import { resolveTagKeysToIds } from "@/lib/mood/tag-links"; + +describe("custom-tag helpers", () => { + it("mints prefixed, unique keys and recognises them", () => { + const a = mintCustomTagKey(); + const b = mintCustomTagKey(); + expect(a.startsWith(CUSTOM_TAG_KEY_PREFIX)).toBe(true); + expect(a).not.toBe(b); + expect(isCustomTagKey(a)).toBe(true); + expect(isCustomTagKey("happy")).toBe(false); + }); + + it("round-trips a label and tolerates a corrupt ciphertext", () => { + const enc = encryptCustomLabel("Migräne"); + expect(decryptCustomLabel(enc)).toBe("Migräne"); + expect(decryptCustomLabel(null)).toBeNull(); + expect(decryptCustomLabel("garbage")).toBeNull(); + }); +}); + +describe("custom-tag schemas", () => { + it("accepts a valid create and rejects a bad icon / empty / over-long label", () => { + expect(createCustomTagSchema.safeParse({ label: "Date night", icon: "Heart" }).success).toBe(true); + expect(createCustomTagSchema.safeParse({ label: " ok " }).success).toBe(true); + expect(createCustomTagSchema.safeParse({ label: "x", icon: "NotAnIcon" }).success).toBe(false); + expect(createCustomTagSchema.safeParse({ label: "" }).success).toBe(false); + expect(createCustomTagSchema.safeParse({ label: "a".repeat(41) }).success).toBe(false); + }); + + it("requires at least one field on update and validates hidden flag", () => { + expect(updateCustomTagSchema.safeParse({}).success).toBe(false); + expect(updateCustomTagSchema.safeParse({ isActive: false }).success).toBe(true); + expect(hideCatalogueTagSchema.safeParse({ hidden: true }).success).toBe(true); + expect(hideCatalogueTagSchema.safeParse({ hidden: "yes" }).success).toBe(false); + }); +}); + +describe("resolveTagKeysToIds — custom-tag ownership scoping (v1.13.0)", () => { + function fakeDb(rows: Array<{ id: string }>) { + return { moodTag: { findMany: vi.fn().mockResolvedValue(rows) } }; + } + + it("scopes custom keys to the caller (catalogue OR own custom) when an owner is given", async () => { + const db = fakeDb([{ id: "id1" }]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await resolveTagKeysToIds(["happy", "custom:mine"], db as any, "user-1"); + const where = db.moodTag.findMany.mock.calls[0][0].where; + expect(where.OR).toEqual([{ userId: null }, { userId: "user-1" }]); + expect(where.isActive).toBe(true); + }); + + it("falls back to catalogue-only (never matching any custom) without an owner", async () => { + const db = fakeDb([]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await resolveTagKeysToIds(["happy"], db as any); + const where = db.moodTag.findMany.mock.calls[0][0].where; + expect(where.userId).toBeNull(); + expect(where.OR).toBeUndefined(); + }); +}); diff --git a/src/lib/mood/custom-tags.ts b/src/lib/mood/custom-tags.ts new file mode 100644 index 000000000..69078d5f9 --- /dev/null +++ b/src/lib/mood/custom-tags.ts @@ -0,0 +1,125 @@ +import { randomUUID } from "node:crypto"; + +import { z } from "zod/v4"; + +import { encrypt, decrypt } from "@/lib/crypto"; + +/** + * v1.13.0 — per-user custom mood-tag helpers (iOS v1.13.0 contract). + * + * Custom tags live in the same `mood_tags` table as the global catalogue, + * distinguished by a non-null `userId`. Their key carries a reserved + * `custom:` prefix so it never collides with a bare catalogue slug, and the + * write path (`tag-links.ts`) routes a `custom:` key to the caller's own rows + * and a bare key to the catalogue. v1 customs are BINARY only; the label is + * held AES-256-GCM-encrypted at rest (it is user prose, possibly + * health-adjacent — iOS F4 push-back). + */ + +/** Reserved key prefix for a user-minted custom tag. */ +export const CUSTOM_TAG_KEY_PREFIX = "custom:"; + +/** Stable category key + seeded id every custom tag hangs under. */ +export const CUSTOM_CATEGORY_KEY = "custom"; +export const CUSTOM_CATEGORY_ID = "mtc_custom"; + +/** Per-user ceiling on custom tags (422 over the cap). */ +export const MAX_CUSTOM_TAGS_PER_USER = 50; + +/** + * Allow-list of Lucide icon names a custom tag may use. Kept to names the iOS + * client maps to an SF Symbol (`MoodTagSFSymbol`) so a custom tag never falls + * back to the generic glyph on one platform; iOS extends its map to cover the + * final list. An unknown name → 422. `Tag` is the default. + */ +export const CUSTOM_TAG_ICON_ALLOWLIST = [ + "Tag", + "Heart", + "Smile", + "Frown", + "Dumbbell", + "Moon", + "Sun", + "Wine", + "Coffee", + "House", + "Briefcase", + "Book", + "Music", + "Plane", + "Car", + "Users", + "Pill", + "Activity", + "Brain", + "Cloud", + "Star", + "Zap", +] as const; + +const ICON_SET = new Set(CUSTOM_TAG_ICON_ALLOWLIST); + +/** Is `key` a custom-tag key (vs a bare catalogue slug)? */ +export function isCustomTagKey(key: string): boolean { + return key.startsWith(CUSTOM_TAG_KEY_PREFIX); +} + +/** Mint a fresh, collision-proof custom-tag key. */ +export function mintCustomTagKey(): string { + return `${CUSTOM_TAG_KEY_PREFIX}${randomUUID()}`; +} + +/** Encrypt a custom label for storage in `label_encrypted`. */ +export function encryptCustomLabel(label: string): string { + return encrypt(label); +} + +/** + * Decrypt a stored custom label. Returns null on a missing or corrupt + * ciphertext so one bad row never throws the whole effective-set read. + */ +export function decryptCustomLabel(encoded: string | null): string | null { + if (!encoded) return null; + try { + return decrypt(encoded); + } catch { + return null; + } +} + +const labelSchema = z + .string() + .trim() + .min(1, "Label must not be empty") + .max(40, "Label must be at most 40 characters"); + +const iconSchema = z + .string() + .refine((v) => ICON_SET.has(v), "Unknown icon") + .nullish(); + +/** POST body — create a custom tag. */ +export const createCustomTagSchema = z.object({ + label: labelSchema, + icon: iconSchema, + // Reserved for a future per-tag category choice; v1 pins everything to the + // `custom` category, so a supplied value other than `custom` is rejected. + categoryKey: z.literal(CUSTOM_CATEGORY_KEY).optional(), +}); + +/** PATCH body — update a custom tag (every field optional). */ +export const updateCustomTagSchema = z + .object({ + label: labelSchema.optional(), + icon: iconSchema, + isActive: z.boolean().optional(), + }) + .refine( + (v) => v.label !== undefined || v.icon !== undefined || v.isActive !== undefined, + "At least one field is required", + ); + +/** PUT body — hide / show a catalogue tag for the user. */ +export const hideCatalogueTagSchema = z.object({ + hidden: z.boolean(), +}); diff --git a/src/lib/mood/tag-links.ts b/src/lib/mood/tag-links.ts index 3e631d938..4d268d229 100644 --- a/src/lib/mood/tag-links.ts +++ b/src/lib/mood/tag-links.ts @@ -110,7 +110,14 @@ export async function resolveRatedFactors( for (const f of factors) byKey.set(f.key, f.rating); const rows = await db.moodTag.findMany({ - where: { key: { in: [...byKey.keys()] }, isActive: true, kind: "RATED" }, + // Rated factors are catalogue-only (v1 customs are BINARY); pin to + // `user_id IS NULL` so a custom row can never be resolved as a factor. + where: { + key: { in: [...byKey.keys()] }, + isActive: true, + kind: "RATED", + userId: null, + }, select: { id: true, key: true, scaleMin: true, scaleMax: true }, }); @@ -132,17 +139,30 @@ export async function resolveRatedFactors( } /** - * Resolve catalog tag keys to ids, dropping unknown / inactive keys. - * Returns the ids in catalog order (deduped). + * Resolve tag keys to ids, dropping unknown / inactive keys. Returns the ids + * (deduped). + * + * v1.13.0 — when `ownerUserId` is given, a `custom:`-prefixed key resolves + * ONLY against that user's own custom tags; a bare catalogue key resolves + * against the global catalogue. A custom key owned by someone else simply + * does not match (dropped silently — the same posture as an unknown key), so + * a caller can never link another user's custom tag. Without `ownerUserId` + * the resolution is catalogue-only (`user_id IS NULL`), never matching any + * custom row — the safe default, since `{ userId: undefined }` in a Prisma + * filter would otherwise match every row. */ export async function resolveTagKeysToIds( keys: string[], db: TagLinkDb = prisma, + ownerUserId?: string, ): Promise { const unique = Array.from(new Set(keys)); if (unique.length === 0) return []; + const ownerClause = ownerUserId + ? { OR: [{ userId: null }, { userId: ownerUserId }] } + : { userId: null }; const rows = await db.moodTag.findMany({ - where: { key: { in: unique }, isActive: true }, + where: { key: { in: unique }, isActive: true, ...ownerClause }, select: { id: true }, }); return rows.map((row) => row.id); @@ -188,7 +208,7 @@ export async function createTagLinks( }); } - const tagIds = (await resolveTagKeysToIds(keys, db)).filter( + const tagIds = (await resolveTagKeysToIds(keys, db, userId)).filter( // A key already written as a rated link is not re-inserted as a // bare binary row (that would lose the rating to `skipDuplicates`). (id) => !ratedTagIds.has(id), @@ -215,7 +235,7 @@ export async function replaceTagLinks( db: TagLinkDb = prisma, ): Promise { await assertEntryOwnership(moodEntryId, userId, db); - const desiredIds = new Set(await resolveTagKeysToIds(keys, db)); + const desiredIds = new Set(await resolveTagKeysToIds(keys, db, userId)); const existing = await db.moodEntryTagLink.findMany({ where: { moodEntryId }, select: { moodTagId: true }, diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index 24955a0c7..c55e3ae6a 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -26,6 +26,11 @@ export function getPrismaClient(): PrismaClient { return prisma; } +/** Lazily-captured global mood-tag catalogue (NULL user_id rows). */ +let catalogueSnapshot: + | Awaited> + | null = null; + /** * Truncate every user-facing table in dependency-safe order using * `TRUNCATE … RESTART IDENTITY CASCADE`. Call from beforeEach() to make @@ -74,8 +79,29 @@ export async function truncateAllTables(client: PrismaClient): Promise { "workouts", ]; + // v1.13.0 — the mood-tag catalogue (`mood_tags` rows with NULL user_id) is + // global reference data seeded by migrations and is intentionally NOT in the + // truncate list, so tests can rely on it. But `mood_tags.user_id` now FKs to + // `users`, so `TRUNCATE users CASCADE` cascades into `mood_tags` and wipes + // the catalogue too (TRUNCATE CASCADE truncates dependent tables wholesale, + // regardless of the ON DELETE action). Snapshot the catalogue once (before + // the first truncate clears it) and restore it after each truncate so the + // migration-seeded catalogue persists across tests as it did before the FK. + if (catalogueSnapshot === null) { + catalogueSnapshot = await client.moodTag.findMany({ + where: { userId: null }, + }); + } + const quoted = tables.map((t) => `"${t}"`).join(", "); await client.$executeRawUnsafe( `TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE;`, ); + + if (catalogueSnapshot.length > 0) { + await client.moodTag.createMany({ + data: catalogueSnapshot, + skipDuplicates: true, + }); + } }