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:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 20:50:05 +02:00
1 parent 09bdd49791
commit f1329b24e1
21 files changed
+934 -121

No files matched your search

@@ -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();
});
});
+125
View File
@@ -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(),
});
+26 -6
View File
@@ -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 },