mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Add pt-BR to the locale list and everything that hangs off it: the client loader, Intl and date-fns mapping, the reply-language footers of the Coach and insights prompts (English body, pt-BR footer, the same path Korean and Dutch take), the citation-coverage normative words, the no-key fallback copy, the AUDIT-C standard-drink line, country labels, period search, CSV yes/no cells and the Telegram bare-word aliases. The locale enums in the extract, managed-profile and record-settings schemas now derive from the locale list instead of repeating it, and the five notification and login-alert routes share one isLocale guard instead of hand-rolled chains, so a later locale is one edit there. A language tag is matched exactly and case-insensitively before its primary subtag, so pt-BR and pt-PT browsers land on the Portuguese bundle rather than English, and AUDIT-C no longer cuts a region tag to two letters. PHQ-9, GAD-7 and WHO-5 are not marked validated for pt-BR: the wording has not been read against the published Brazilian forms, so readers get the English items plus the existing note.
247 lines
8.1 KiB
TypeScript
247 lines
8.1 KiB
TypeScript
import { z } from "zod/v4";
|
|
|
|
import {
|
|
ACCEPTED_INSIGHTS_TILE_IDS,
|
|
INSIGHTS_SECTION_IDS,
|
|
} from "@/lib/insights-layout";
|
|
import { MODULE_REGISTRY } from "@/lib/modules/registry";
|
|
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
|
|
import {
|
|
modulePrefsPatchSchema,
|
|
WRITABLE_MODULE_KEYS,
|
|
} from "@/lib/validations/modules";
|
|
import { thresholdsUpdateSchema } from "@/lib/validations/thresholds";
|
|
import { locales } from "@/lib/i18n/config";
|
|
|
|
/**
|
|
* The target-record configuration boundary. These literals are both the DTO
|
|
* contract and the reviewable answer to which fields a Guardian may change.
|
|
* Identity, credentials, delivery channels, memories, provider connections,
|
|
* and every future field fail closed because no family accepts them.
|
|
*/
|
|
export const MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST = {
|
|
profile: [
|
|
"displayName",
|
|
"heightCm",
|
|
"dateOfBirth",
|
|
"gender",
|
|
"locale",
|
|
"timezone",
|
|
"unitPreference",
|
|
"timeFormat",
|
|
"dateFormat",
|
|
],
|
|
modules: ["modulePreferences", "cycleTrackingEnabled"],
|
|
notifications: ["moodReminderEnabled", "notificationPreferences"],
|
|
thresholds: ["overrides"],
|
|
coach: ["disableCoach", "preferences"],
|
|
insights: ["layout"],
|
|
} as const;
|
|
|
|
export type ManagedRecordSettingsFamily =
|
|
keyof typeof MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST;
|
|
|
|
const timezoneSchema = z
|
|
.string()
|
|
.min(1)
|
|
.max(64)
|
|
.refine((timezone) => {
|
|
try {
|
|
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- validates a zone name and formats nothing
|
|
Intl.DateTimeFormat(undefined, { timeZone: timezone });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, "Expected an IANA timezone");
|
|
|
|
const profilePatchSchema = z
|
|
.object({
|
|
displayName: z.string().trim().min(1).max(100).nullable().optional(),
|
|
heightCm: z.number().finite().min(30).max(300).nullable().optional(),
|
|
dateOfBirth: z.string().date().nullable().optional(),
|
|
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable().optional(),
|
|
locale: z.enum(locales).nullable().optional(),
|
|
timezone: timezoneSchema.optional(),
|
|
unitPreference: z.enum(["metric", "imperial"]).optional(),
|
|
timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(),
|
|
dateFormat: z.enum(["AUTO", "DMY", "MDY", "YMD"]).optional(),
|
|
})
|
|
.strict();
|
|
|
|
const insightsLayoutSchema = z
|
|
.object({
|
|
version: z.union([z.literal(1), z.literal(2)]),
|
|
sections: z
|
|
.array(
|
|
z
|
|
.object({
|
|
id: z.enum(INSIGHTS_SECTION_IDS),
|
|
visible: z.boolean(),
|
|
order: z.number().int().min(0).max(99),
|
|
})
|
|
.strict(),
|
|
)
|
|
.max(50)
|
|
.optional(),
|
|
tiles: z
|
|
.array(
|
|
z
|
|
.object({
|
|
id: z.enum(ACCEPTED_INSIGHTS_TILE_IDS),
|
|
visible: z.boolean(),
|
|
order: z.number().int().min(0).max(99),
|
|
})
|
|
.strict(),
|
|
)
|
|
.min(1)
|
|
.max(ACCEPTED_INSIGHTS_TILE_IDS.length)
|
|
.optional(),
|
|
})
|
|
.strict();
|
|
|
|
// Delivery ownership is the actor's personal channel choice. A Guardian may
|
|
// set only the record's reminder timing and low-stock thresholds, never hand
|
|
// a managed record to an actor's phone or provider channel.
|
|
const managedNotificationPreferencesSchema = z
|
|
.object({
|
|
medication: z
|
|
.object({
|
|
lowStockRunwayDays: z.number().int().min(1).max(60).nullable(),
|
|
reorderLeadDays: z.number().int().min(0).max(60),
|
|
})
|
|
.strict()
|
|
.partial()
|
|
.optional(),
|
|
mood: z
|
|
.object({ reminderHour: z.number().int().min(0).max(23) })
|
|
.strict()
|
|
.partial()
|
|
.optional(),
|
|
})
|
|
.strict();
|
|
|
|
const managedCoachPreferencesSchema = coachPrefsSchema
|
|
.pick({
|
|
tone: true,
|
|
verbosity: true,
|
|
excludeMetrics: true,
|
|
defaultWindow: true,
|
|
dataClusters: true,
|
|
})
|
|
.strict();
|
|
|
|
/**
|
|
* One strict patch schema per family. Exported so the OpenAPI registry can
|
|
* publish the same objects the handler parses rather than a restatement of
|
|
* them — every family is `strict()`, so a published paraphrase that drifted
|
|
* would promise a field the route answers 422 for.
|
|
*/
|
|
export const MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS = {
|
|
profile: profilePatchSchema,
|
|
/**
|
|
* Two fields because the module foundation has two kinds of key, and only
|
|
* one of them lives in `modulePreferencesJson`.
|
|
*
|
|
* `cycle` is a DELEGATED module: its user-layer state is owned by
|
|
* `CycleProfile.cycleTrackingEnabled` (NULL derives from `User.gender`), and
|
|
* `modulePreferencesJson.cycle` is ignored on purpose — which is why
|
|
* `modulePrefsPatchSchema` refuses the key. A guardian therefore had no way
|
|
* to turn Cycle off for a record whose sex says it should be on, and no row
|
|
* for it in the list either: it is simply not a writable key (#939). Naming
|
|
* the real column here writes the real source of truth rather than minting a
|
|
* second one that the gate would ignore.
|
|
*
|
|
* Both optional so a client may send either alone; at least one required, so
|
|
* an empty body is a refusal rather than a save that changed nothing.
|
|
*/
|
|
modules: z
|
|
.object({
|
|
modulePreferences: modulePrefsPatchSchema.optional(),
|
|
cycleTrackingEnabled: z.boolean().optional(),
|
|
})
|
|
.strict()
|
|
.refine((patch) => Object.values(patch).some((v) => v !== undefined), {
|
|
message: "Name at least one field to change",
|
|
}),
|
|
notifications: z
|
|
.object({
|
|
moodReminderEnabled: z.boolean().optional(),
|
|
notificationPreferences: managedNotificationPreferencesSchema.optional(),
|
|
})
|
|
.strict(),
|
|
thresholds: z.object({ overrides: thresholdsUpdateSchema }).strict(),
|
|
coach: z
|
|
.object({
|
|
disableCoach: z.boolean().optional(),
|
|
preferences: managedCoachPreferencesSchema.optional(),
|
|
})
|
|
.strict(),
|
|
insights: z.object({ layout: insightsLayoutSchema }).strict(),
|
|
} as const;
|
|
|
|
export type ManagedRecordSettingsPatch = {
|
|
profile: z.infer<typeof profilePatchSchema>;
|
|
modules: z.infer<(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["modules"]>;
|
|
notifications: z.infer<
|
|
(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["notifications"]
|
|
>;
|
|
thresholds: z.infer<
|
|
(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["thresholds"]
|
|
>;
|
|
coach: z.infer<(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["coach"]>;
|
|
insights: z.infer<(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["insights"]>;
|
|
};
|
|
|
|
export function isManagedRecordSettingsFamily(
|
|
value: string,
|
|
): value is ManagedRecordSettingsFamily {
|
|
return Object.hasOwn(MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS, value);
|
|
}
|
|
|
|
/** Parse one named DTO family. Strict schemas make an actor field a 422. */
|
|
export function parseManagedRecordSettingsPatch<
|
|
Family extends ManagedRecordSettingsFamily,
|
|
>(family: Family, value: unknown): ManagedRecordSettingsPatch[Family] {
|
|
return MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS[family].parse(
|
|
value,
|
|
) as ManagedRecordSettingsPatch[Family];
|
|
}
|
|
|
|
export function safeParseManagedRecordSettingsPatch<
|
|
Family extends ManagedRecordSettingsFamily,
|
|
>(family: Family, value: unknown) {
|
|
return MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS[family].safeParse(value);
|
|
}
|
|
|
|
/**
|
|
* The same direct module inventory and default posture as `/api/auth/me/modules`.
|
|
* Every entry is present for a fresh record so a Guardian can administer the
|
|
* whole supported surface instead of seeing only previously persisted keys.
|
|
*/
|
|
export const MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS = Object.freeze(
|
|
Object.fromEntries(
|
|
WRITABLE_MODULE_KEYS.map((key) => [key, !MODULE_REGISTRY[key].optIn]),
|
|
),
|
|
) as Readonly<Record<(typeof WRITABLE_MODULE_KEYS)[number], boolean>>;
|
|
|
|
/** Preserve only directly-owned module keys from a legacy persisted blob. */
|
|
export function managedModulePreferencesFrom(
|
|
raw: unknown,
|
|
): ManagedRecordSettingsPatch["modules"]["modulePreferences"] {
|
|
const allowed: Record<string, boolean> = {
|
|
...MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
|
};
|
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
return allowed;
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
const parsed = modulePrefsPatchSchema.safeParse({ [key]: value });
|
|
if (parsed.success && value !== undefined) {
|
|
allowed[key] = value as boolean;
|
|
}
|
|
}
|
|
return allowed;
|
|
}
|