mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +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
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user