feat(cycle): user-defined custom symptoms

Mirror the v1.13 custom-mood-tag CRUD for cycle symptoms. A user can mint
their own symptoms beyond the 15 seeded ones, picked from a label + icon
popover via a dashed ghost-chip in the log-day sheet's symptom grid.

- POST/GET /api/cycle/symptoms/custom and PATCH/DELETE on /[key]: mint a
  custom:<uuid> key, encrypt the free-text label at rest (AES-256-GCM),
  per-user cap, owner-scoped, gated behind requireCycleEnabled. Soft-hide by
  default, hard-purge on ?purge=true.
- The day-log write already resolves a symptom key against the catalogue plus
  the caller's own rows, so a custom: key links by construction.
- The log-day sheet fetches the user's customs and merges them under a new
  custom category in the chip grid; the ghost-chip + popover reuse the chip
  styling and the Lucide icon set.
- Seed the global custom symptom category (migration 0134).
- The purge route (DELETE /api/cycle/all) now drops the user's custom symptom
  rows too, so no intent-revealing label survives a purge.
- The label is never logged: only the icon + counts reach annotate/auditLog.
- i18n: cycle.symptom.custom.* across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-06 16:54:43 +02:00
1 parent 81ec98fc97
commit 985e197db4
18 files changed
+1233 -59

No files matched your search

@@ -0,0 +1,108 @@
/**
* v1.15.1 — custom cycle-symptom helpers: key mint/detect, label encrypt /
* decrypt fail-soft, and the Zod create/update schemas.
*/
import { describe, expect, it, 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.replace(/^enc:/, "");
},
}));
import {
CUSTOM_SYMPTOM_KEY_PREFIX,
MAX_CUSTOM_SYMPTOMS_PER_USER,
createCustomSymptomSchema,
decryptCustomLabel,
encryptCustomLabel,
isCustomSymptomKey,
mintCustomSymptomKey,
updateCustomSymptomSchema,
} from "@/lib/cycle/custom-symptoms";
describe("custom-symptom keys", () => {
it("mints a custom:<uuid> key the detector recognises", () => {
const key = mintCustomSymptomKey();
expect(key.startsWith(CUSTOM_SYMPTOM_KEY_PREFIX)).toBe(true);
expect(isCustomSymptomKey(key)).toBe(true);
});
it("does not treat a bare catalogue slug as custom", () => {
expect(isCustomSymptomKey("cramps")).toBe(false);
expect(isCustomSymptomKey("headache")).toBe(false);
});
it("mints unique keys", () => {
expect(mintCustomSymptomKey()).not.toBe(mintCustomSymptomKey());
});
});
describe("label encryption", () => {
it("round-trips a label through encrypt/decrypt", () => {
const enc = encryptCustomLabel("Schwindel");
expect(enc).toBe("enc:Schwindel");
expect(decryptCustomLabel(enc)).toBe("Schwindel");
});
it("decrypts a corrupt/missing ciphertext fail-soft to null", () => {
expect(decryptCustomLabel(null)).toBeNull();
expect(decryptCustomLabel("not-encrypted")).toBeNull();
});
});
describe("create schema", () => {
it("accepts a label + allow-listed icon", () => {
const p = createCustomSymptomSchema.safeParse({
label: "Dizziness",
icon: "Brain",
});
expect(p.success).toBe(true);
});
it("rejects an empty label and an over-long one", () => {
expect(createCustomSymptomSchema.safeParse({ label: "" }).success).toBe(
false,
);
expect(
createCustomSymptomSchema.safeParse({ label: "x".repeat(41) }).success,
).toBe(false);
});
it("rejects an icon outside the allow-list", () => {
expect(
createCustomSymptomSchema.safeParse({ label: "X", icon: "Skull" })
.success,
).toBe(false);
});
it("rejects a categoryKey other than custom", () => {
expect(
createCustomSymptomSchema.safeParse({ label: "X", categoryKey: "physical" })
.success,
).toBe(false);
});
});
describe("update schema", () => {
it("requires at least one field", () => {
expect(updateCustomSymptomSchema.safeParse({}).success).toBe(false);
});
it("accepts a partial update", () => {
expect(
updateCustomSymptomSchema.safeParse({ isActive: false }).success,
).toBe(true);
expect(updateCustomSymptomSchema.safeParse({ label: "New" }).success).toBe(
true,
);
});
});
describe("per-user cap", () => {
it("pins a sane ceiling", () => {
expect(MAX_CUSTOM_SYMPTOMS_PER_USER).toBe(50);
});
});
@@ -0,0 +1,61 @@
/**
* v1.15.1 — `resolveSymptomIds` resolves a mix of seeded catalogue keys AND
* the caller's own `custom:` keys (the day-log write path for custom symptoms).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => ({
cycleSymptom: { findMany: 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/cycle/profile", () => ({
getOrCreateCycleProfile: vi.fn(),
}));
import { resolveSymptomIds } from "@/lib/cycle/day-log-write";
beforeEach(() => {
vi.clearAllMocks();
});
describe("resolveSymptomIds", () => {
it("queries the catalogue (userId null) AND the caller's own rows", async () => {
db.cycleSymptom.findMany.mockResolvedValue([
{ id: "cs_cramps", key: "cramps" },
{ id: "id_custom", key: "custom:abc" },
]);
const out = await resolveSymptomIds("user-1", ["cramps", "custom:abc"]);
expect(out).toEqual([
{ key: "cramps", id: "cs_cramps" },
{ key: "custom:abc", id: "id_custom" },
]);
const where = db.cycleSymptom.findMany.mock.calls[0][0].where;
expect(where.OR).toEqual([{ userId: null }, { userId: "user-1" }]);
expect(where.key.in).toEqual(["cramps", "custom:abc"]);
expect(where.isActive).toBe(true);
});
it("drops unknown keys (returns only resolved rows)", async () => {
db.cycleSymptom.findMany.mockResolvedValue([
{ id: "id_custom", key: "custom:abc" },
]);
const out = await resolveSymptomIds("user-1", [
"custom:abc",
"custom:not-mine",
]);
expect(out).toEqual([{ key: "custom:abc", id: "id_custom" }]);
});
it("short-circuits on an empty key set", async () => {
const out = await resolveSymptomIds("user-1", []);
expect(out).toEqual([]);
expect(db.cycleSymptom.findMany).not.toHaveBeenCalled();
});
});
+121
View File
@@ -0,0 +1,121 @@
import { randomUUID } from "node:crypto";
import { z } from "zod/v4";
import { encrypt, decrypt } from "@/lib/crypto";
/**
* v1.15.1 — per-user custom cycle-symptom helpers (the v1.13 custom-mood-tag
* precedent, mirrored 1:1).
*
* Custom symptoms live in the same `cycle_symptoms` table as the seeded
* 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 (`day-log-write.ts:resolveSymptomIds`) already resolves a
* key against the catalogue rows (userId NULL) PLUS the caller's own rows —
* so a `custom:` key routes to the user's symptom by construction. The label
* is held AES-256-GCM-encrypted at rest (`labelEncrypted`): a symptom name is
* intent-revealing free text, the highest-sensitivity category in the
* post-Dobbs threat model.
*/
/** Reserved key prefix for a user-minted custom symptom. */
export const CUSTOM_SYMPTOM_KEY_PREFIX = "custom:";
/** Stable category key + seeded id every custom symptom hangs under. */
export const CUSTOM_SYMPTOM_CATEGORY_KEY = "custom";
export const CUSTOM_SYMPTOM_CATEGORY_ID = "csc_custom";
/** Per-user ceiling on custom symptoms (422 over the cap). */
export const MAX_CUSTOM_SYMPTOMS_PER_USER = 50;
/**
* Allow-list of Lucide icon names a custom symptom may use. Kept to names the
* iOS client maps to an SF Symbol so a custom symptom never falls back to the
* generic glyph on one platform. An unknown name → 422. `Tag` is the default.
*/
export const CUSTOM_SYMPTOM_ICON_ALLOWLIST = [
"Tag",
"Activity",
"Heart",
"HeartPulse",
"Brain",
"Zap",
"Flame",
"Snowflake",
"Droplet",
"CircleDot",
"BatteryLow",
"MoonStar",
"PersonStanding",
"Drama",
"Frown",
"Cookie",
"Soup",
"Pill",
"Thermometer",
"Stethoscope",
] as const;
const ICON_SET = new Set<string>(CUSTOM_SYMPTOM_ICON_ALLOWLIST);
/** Is `key` a custom-symptom key (vs a bare catalogue slug)? */
export function isCustomSymptomKey(key: string): boolean {
return key.startsWith(CUSTOM_SYMPTOM_KEY_PREFIX);
}
/** Mint a fresh, collision-proof custom-symptom key. */
export function mintCustomSymptomKey(): string {
return `${CUSTOM_SYMPTOM_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 symptom. */
export const createCustomSymptomSchema = z.object({
label: labelSchema,
icon: iconSchema,
// Reserved for a future per-symptom category choice; v1 pins everything to
// the `custom` category, so a supplied value other than `custom` is rejected.
categoryKey: z.literal(CUSTOM_SYMPTOM_CATEGORY_KEY).optional(),
});
/** PATCH body — update a custom symptom (every field optional). */
export const updateCustomSymptomSchema = 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",
);
+3
View File
@@ -518,6 +518,9 @@ export const queryKeys = {
cyclePrefs: () => ["cycle", "prefs"] as const,
cycleInsights: () => ["cycle", "insights"] as const,
cycleDayLog: (date: string) => ["cycle", "day-log", date] as const,
/** The caller's own custom symptoms (decrypted labels) the log-day sheet
* merges into the seeded chip grid. */
cycleCustomSymptoms: () => ["cycle", "custom-symptoms"] as const,
};
/**