fix(cycle): keep custom-symptom client constants out of the server crypto module

The log sheet's chip grid imported the icon allow-list from custom-symptoms.ts,
which pulls node:crypto + the AES helpers into the client bundle and broke the
Turbopack build for /cycle. Split the client-safe constants and Zod schemas into
custom-symptoms-shared.ts; the server module re-exports them so the API routes
are unchanged, and the client imports from the shared module.
This commit is contained in:
Marc-André Bombeck committed 2026-06-06 17:06:51 +02:00
1 parent 32552f02ec
commit 2f44654dbe
3 files changed
+106 -80

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ import {
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useTranslations } from "@/lib/i18n/context";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
import { FLOW_HUE } from "./phase-tokens";
import {
+89
View File
@@ -0,0 +1,89 @@
import { z } from "zod/v4";
/**
* v1.15.1 — client-safe constants + Zod schemas for custom cycle symptoms.
*
* Split out of `custom-symptoms.ts` so a `"use client"` component (the log
* sheet's chip grid) can read the icon allow-list and key helpers WITHOUT
* pulling the server-only crypto / `node:crypto` the mint + label-encryption
* helpers depend on into the browser bundle. The server module re-exports
* everything here, so existing server import sites are unaffected.
*/
/** 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);
}
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",
);
+16 -79
View File
@@ -1,9 +1,9 @@
import { randomUUID } from "node:crypto";
import { z } from "zod/v4";
import { encrypt, decrypt } from "@/lib/crypto";
import { CUSTOM_SYMPTOM_KEY_PREFIX } from "@/lib/cycle/custom-symptoms-shared";
/**
* v1.15.1 — per-user custom cycle-symptom helpers (the v1.13 custom-mood-tag
* precedent, mirrored 1:1).
@@ -17,52 +17,22 @@ import { encrypt, decrypt } from "@/lib/crypto";
* 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.
*
* This module carries the SERVER-ONLY helpers (uuid mint + label crypto). The
* client-safe constants + Zod schemas live in `custom-symptoms-shared.ts` and
* are re-exported here so existing server import sites need no change.
*/
/** 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);
}
export {
CUSTOM_SYMPTOM_KEY_PREFIX,
CUSTOM_SYMPTOM_CATEGORY_KEY,
CUSTOM_SYMPTOM_CATEGORY_ID,
MAX_CUSTOM_SYMPTOMS_PER_USER,
CUSTOM_SYMPTOM_ICON_ALLOWLIST,
isCustomSymptomKey,
createCustomSymptomSchema,
updateCustomSymptomSchema,
} from "@/lib/cycle/custom-symptoms-shared";
/** Mint a fresh, collision-proof custom-symptom key. */
export function mintCustomSymptomKey(): string {
@@ -86,36 +56,3 @@ export function decryptCustomLabel(encoded: string | null): string | null {
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",
);