mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
feat(anamnesis): serve the life circumstances, record AUDIT-C, and keep both off the closed legacy lists
This commit is contained in:
1 parent
1e7fc08f57
commit
f2be772eaf
28 files changed
+2131
-220
No files matched your search
@@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
HEALTH_PROFILE_AI_SECTIONS,
|
||||
removedHealthProfileFactSchema,
|
||||
} from "../health-profile-facts";
|
||||
import { removedFactSchema as removedHealthProfileFactSchema } from "../life-circumstances";
|
||||
|
||||
describe("health profile AI inclusion defaults", () => {
|
||||
it("keeps the legacy opt-in list independent from accepted section values", () => {
|
||||
|
||||
@@ -77,10 +77,12 @@ import {
|
||||
} from "@/generated/prisma/enums";
|
||||
import {
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
healthProfileAiSectionSchema,
|
||||
healthProfileFactKindSchema,
|
||||
isHealthProfileFactValue,
|
||||
storedHealthProfileAiSectionSchema,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
import {
|
||||
STORED_FACT_KINDS,
|
||||
isStoredFactPlaintext,
|
||||
} from "@/lib/validations/life-circumstances";
|
||||
import {
|
||||
advanceDirectiveStatusSchema,
|
||||
emergencyBloodTypeSchema,
|
||||
@@ -830,8 +832,10 @@ const healthProfileBackupSchema = z
|
||||
conditions: z.string().nullable().default(null),
|
||||
allergies: z.string().nullable().default(null),
|
||||
coachFocus: z.string().nullable().default(null),
|
||||
// Every stored section, the four life-circumstance ones included (v1.43):
|
||||
// a file written by an account that opted one in must restore it opted in.
|
||||
aiIncludedSections: z
|
||||
.array(healthProfileAiSectionSchema)
|
||||
.array(storedHealthProfileAiSectionSchema)
|
||||
.default([...DEFAULT_HEALTH_PROFILE_AI_SECTIONS]),
|
||||
// v1.43 (#614) — section confirmations, section key to ISO instant.
|
||||
// Carried as stored; the read route judges each stamp, so an unreadable
|
||||
@@ -867,7 +871,7 @@ const healthProfileBackupSchema = z
|
||||
const healthProfileFactBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
kind: healthProfileFactKindSchema,
|
||||
kind: z.enum(STORED_FACT_KINDS),
|
||||
value: z.string().nullable().default(null),
|
||||
valueEncrypted: base64BytesSchema.optional(),
|
||||
validFrom: isoDateTime,
|
||||
@@ -881,7 +885,7 @@ const healthProfileFactBackupSchema = z
|
||||
if (fact.valueEncrypted === undefined) {
|
||||
if (
|
||||
fact.value === null ||
|
||||
!isHealthProfileFactValue(fact.kind, fact.value)
|
||||
!isStoredFactPlaintext(fact.kind, fact.value)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
|
||||
@@ -27,6 +27,36 @@ export const DEFAULT_HEALTH_PROFILE_AI_SECTIONS = [
|
||||
|
||||
export const healthProfileAiSectionSchema = z.enum(HEALTH_PROFILE_AI_SECTIONS);
|
||||
|
||||
/**
|
||||
* Every value the `ai_included_sections` column can hold: the eight above and
|
||||
* the four life-circumstance sections (v1.43, #614). The eight-value schema is
|
||||
* the wire form of `/api/coach/about-me`, which older app builds decode
|
||||
* closed; this one is for the places that carry the column as stored, the
|
||||
* backup file first among them, where dropping a value would silently take an
|
||||
* opt-in back on restore.
|
||||
*/
|
||||
export const STORED_HEALTH_PROFILE_AI_SECTIONS = [
|
||||
...HEALTH_PROFILE_AI_SECTIONS,
|
||||
"ACTIVITY",
|
||||
"WORK",
|
||||
"HABITS",
|
||||
"GOALS",
|
||||
] as const;
|
||||
export type StoredHealthProfileAiSection =
|
||||
(typeof STORED_HEALTH_PROFILE_AI_SECTIONS)[number];
|
||||
export const storedHealthProfileAiSectionSchema = z.enum(
|
||||
STORED_HEALTH_PROFILE_AI_SECTIONS,
|
||||
);
|
||||
|
||||
/** Narrow a stored section list to the eight the about-me wire carries. */
|
||||
export function aboutMeAiSections(
|
||||
stored: readonly string[],
|
||||
): HealthProfileAiSection[] {
|
||||
return stored.filter((section): section is HealthProfileAiSection =>
|
||||
(HEALTH_PROFILE_AI_SECTIONS as readonly string[]).includes(section),
|
||||
);
|
||||
}
|
||||
|
||||
export const SMOKING_STATUS_VALUES = ["NEVER", "FORMER", "CURRENT"] as const;
|
||||
export const ALCOHOL_PATTERN_VALUES = [
|
||||
"NONE",
|
||||
@@ -74,25 +104,6 @@ export function isHealthProfileFactValue(
|
||||
return HEALTH_PROFILE_FACT_VALUES[kind][value] === true;
|
||||
}
|
||||
|
||||
export const healthProfileFactWriteSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("SMOKING_STATUS"),
|
||||
value: z.enum(SMOKING_STATUS_VALUES),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("ALCOHOL_PATTERN"),
|
||||
value: z.enum(ALCOHOL_PATTERN_VALUES),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("SHIFT_SCHEDULE"),
|
||||
value: z.enum(SHIFT_SCHEDULE_VALUES),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const healthProfileFactCorrectionSchema = z.object({
|
||||
value: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export function validateCorrectedFactValue(
|
||||
kind: HealthProfileFactKind,
|
||||
value: string,
|
||||
@@ -120,9 +131,3 @@ export const healthProfileFactsResponseSchema = z.object({
|
||||
}),
|
||||
history: z.array(healthProfileFactDtoSchema),
|
||||
});
|
||||
|
||||
export const removedHealthProfileFactSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: healthProfileFactKindSchema,
|
||||
removedAt: z.iso.datetime({ offset: true }),
|
||||
});
|
||||
@@ -11,6 +11,13 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { assertStableExternalId } from "@/lib/validations/external-id";
|
||||
import {
|
||||
ALCOHOL_PATTERN_VALUES,
|
||||
HEALTH_PROFILE_FACT_KINDS,
|
||||
SHIFT_SCHEDULE_VALUES,
|
||||
SMOKING_STATUS_VALUES,
|
||||
isHealthProfileFactValue,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
|
||||
// ── Fact kinds and their values ──────────────────────────────────────────────
|
||||
|
||||
@@ -219,3 +226,162 @@ export const AUDIT_C_CUTOFF_SOURCES = [
|
||||
"KNHANES_KR",
|
||||
"BRADLEY_2007",
|
||||
] as const;
|
||||
|
||||
// ── Writing facts through `/api/anamnesis/facts` ─────────────────────────────
|
||||
|
||||
/** Every kind a fact revision row can carry: the three closed ones and the six above. */
|
||||
export const STORED_FACT_KINDS = [
|
||||
...HEALTH_PROFILE_FACT_KINDS,
|
||||
...LIFE_FACT_KINDS,
|
||||
] as const;
|
||||
export type StoredFactKind = (typeof STORED_FACT_KINDS)[number];
|
||||
|
||||
export function isLifeFactKind(kind: string): kind is LifeFactKind {
|
||||
return (LIFE_FACT_KINDS as readonly string[]).includes(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Valid since", a calendar day. Optional: without it the answer holds from
|
||||
* the moment it is saved. The web form sends the first of a month, because a
|
||||
* month is as precise as anyone remembers when they stopped smoking.
|
||||
*/
|
||||
const validFromDay = z.iso
|
||||
.date()
|
||||
.optional()
|
||||
.describe(
|
||||
"The calendar day the answer holds from (UTC midnight). Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead.",
|
||||
);
|
||||
|
||||
/**
|
||||
* `POST /api/anamnesis/facts`. The three closed kinds keep their string value
|
||||
* (older app builds send exactly that); the six life-circumstance kinds send
|
||||
* their object value with `schema: 1`.
|
||||
*/
|
||||
export const factWriteSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("SMOKING_STATUS"),
|
||||
value: z.enum(SMOKING_STATUS_VALUES),
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("ALCOHOL_PATTERN"),
|
||||
value: z.enum(ALCOHOL_PATTERN_VALUES),
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("SHIFT_SCHEDULE"),
|
||||
value: z.enum(SHIFT_SCHEDULE_VALUES),
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("TOBACCO_DETAIL"),
|
||||
value: tobaccoDetailValueSchema,
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("ACTIVITY_HABIT"),
|
||||
value: activityHabitValueSchema,
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("WORK_CONTEXT"),
|
||||
value: workContextValueSchema,
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("FREE_WEEKDAYS"),
|
||||
value: freeWeekdaysValueSchema,
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("HABITS"),
|
||||
value: habitsValueSchema,
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("HEALTH_GOALS"),
|
||||
value: healthGoalsValueSchema,
|
||||
validFrom: validFromDay,
|
||||
}),
|
||||
]);
|
||||
export type FactWriteInput = z.infer<typeof factWriteSchema>;
|
||||
|
||||
/**
|
||||
* `PATCH /api/anamnesis/facts/{id}`: the closed kinds take a string, the life
|
||||
* kinds an object; which one the row needs is checked against the stored
|
||||
* kind after the row is found.
|
||||
*/
|
||||
export const factCorrectionSchema = z.object({
|
||||
value: z.union([z.string().trim().min(1), z.record(z.string(), z.unknown())]),
|
||||
validFrom: validFromDay,
|
||||
});
|
||||
|
||||
/** Parse a life-circumstance value against its kind's schema; null when it does not fit. */
|
||||
export function parseLifeFactValue(
|
||||
kind: LifeFactKind,
|
||||
value: unknown,
|
||||
): LifeFactValue | null {
|
||||
const parsed = LIFE_FACT_VALUE_SCHEMAS[kind].safeParse(value);
|
||||
return parsed.success ? (parsed.data as LifeFactValue) : null;
|
||||
}
|
||||
|
||||
export type TobaccoDetailValue = z.infer<typeof tobaccoDetailValueSchema>;
|
||||
export type ActivityHabitValue = z.infer<typeof activityHabitValueSchema>;
|
||||
export type WorkContextValue = z.infer<typeof workContextValueSchema>;
|
||||
export type FreeWeekdaysValue = z.infer<typeof freeWeekdaysValueSchema>;
|
||||
export type HabitsValue = z.infer<typeof habitsValueSchema>;
|
||||
export type HealthGoalsValue = z.infer<typeof healthGoalsValueSchema>;
|
||||
export type LifeFactValue =
|
||||
| TobaccoDetailValue
|
||||
| ActivityHabitValue
|
||||
| WorkContextValue
|
||||
| FreeWeekdaysValue
|
||||
| HabitsValue
|
||||
| HealthGoalsValue;
|
||||
|
||||
/** `DELETE /api/anamnesis/facts/{id}`: the revision whose interval was closed. */
|
||||
export const removedFactSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(STORED_FACT_KINDS),
|
||||
removedAt: z.iso.datetime({ offset: true }),
|
||||
});
|
||||
|
||||
/**
|
||||
* A life-circumstance revision as `/api/anamnesis/life-circumstances` and the
|
||||
* fact writes answer it. `SMOKING_STATUS` and `SHIFT_SCHEDULE` ride along in
|
||||
* their sections with their string value.
|
||||
*/
|
||||
export const lifeFactRevisionSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(["SMOKING_STATUS", "SHIFT_SCHEDULE", ...LIFE_FACT_KINDS]),
|
||||
value: z
|
||||
.unknown()
|
||||
.describe(
|
||||
"`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema: 1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`.",
|
||||
),
|
||||
unreadable: z
|
||||
.boolean()
|
||||
.describe("The sealed value could not be opened on this host."),
|
||||
validFrom: z.iso.datetime({ offset: true }),
|
||||
validUntil: z.null(),
|
||||
provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether `raw` is a valid plaintext for a stored fact of `kind`: one of the
|
||||
* closed values for the three original kinds, the JSON of a value that fits
|
||||
* its schema for the life kinds. The portable backup carries exactly this
|
||||
* plaintext, so its writer and its restore share this one check.
|
||||
*/
|
||||
export function isStoredFactPlaintext(
|
||||
kind: StoredFactKind,
|
||||
raw: string,
|
||||
): boolean {
|
||||
if (!isLifeFactKind(kind)) return isHealthProfileFactValue(kind, raw);
|
||||
try {
|
||||
return parseLifeFactValue(kind, JSON.parse(raw)) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user