mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
chore(release): v1.13.0 — per-user custom mood tags + catalogue hide
Let a user shape their own mood-tag set on top of the global catalogue: create custom tags (label encrypted at rest, icon from a fixed allow-list), and hide built-in tags per user. Custom tags live in `mood_tags` with a non-null `user_id` and a `custom:<uuid>` key; `GET /api/mood/tags` returns the effective per-user set (catalogue − hidden + own customs) with new `custom` + `label` fields, and `?include=hidden` surfaces hidden catalogue tags for the management screen. The write path resolves a `custom:` key only against the caller's own rows — a foreign custom key is dropped, never linked. Delete soft-deactivates by default (history intact); `?purge=true` removes the row and its links. BINARY-only for v1. New: POST/PATCH/DELETE /api/mood/tags/custom[/:key], PUT /api/mood/tags/:key/hidden, migration 0127 (user_id + label_encrypted columns, mood_tag_hidden table, `custom` category). Integration setup snapshots + restores the seeded catalogue because the new mood_tags→users FK makes TRUNCATE users CASCADE wipe it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
09bdd49791
commit
f1329b24e1
21 files changed
+934
-121
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+3
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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é."
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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:<cuid>` 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";
|
||||
@@ -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:<cuid>` 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
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, TagOut> {
|
||||
const out: Record<string, TagOut> = {};
|
||||
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<string, boolean> } };
|
||||
};
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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:<uuid>` 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,
|
||||
);
|
||||
});
|
||||
@@ -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<string, typeof tags>();
|
||||
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 });
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string>(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(),
|
||||
});
|
||||
@@ -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<string[]> {
|
||||
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<void> {
|
||||
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 },
|
||||
|
||||
@@ -26,6 +26,11 @@ export function getPrismaClient(): PrismaClient {
|
||||
return prisma;
|
||||
}
|
||||
|
||||
/** Lazily-captured global mood-tag catalogue (NULL user_id rows). */
|
||||
let catalogueSnapshot:
|
||||
| Awaited<ReturnType<PrismaClient["moodTag"]["findMany"]>>
|
||||
| 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<void> {
|
||||
"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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user