mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user