mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
724 lines
22 KiB
TypeScript
724 lines
22 KiB
TypeScript
/**
|
|
* v1.43 (#614) — the life circumstances of a record: the read model behind
|
|
* `GET /api/anamnesis/life-circumstances`, the AI switches, the "still true"
|
|
* confirmations and the AUDIT-C entries.
|
|
*
|
|
* The answers are fact revisions (`HealthProfileFactRevision`, sealed values,
|
|
* dated, corrected by a successor) and AUDIT-C entries
|
|
* (`MentalHealthAssessment` with `instrument = AUDIT_C`, sealed item answers,
|
|
* soft delete). Everything a client shows is resolved here: pack-years,
|
|
* minutes per week, free weekdays, screening hints, the AUDIT-C cut-off, and
|
|
* the instrument wording in the account language.
|
|
*
|
|
* Server-only.
|
|
*/
|
|
import { Prisma } from "@/generated/prisma/client";
|
|
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
|
import { deriveAgeYears } from "@/lib/ai/coach/about-me";
|
|
import {
|
|
AUDIT_C_HISTORY_LIMIT,
|
|
loadNewestReadableAuditC,
|
|
newestReadableAuditC,
|
|
openAuditCResponses,
|
|
type AuditCResponses,
|
|
} from "@/lib/profile/audit-c-entries";
|
|
import { prisma } from "@/lib/db";
|
|
import { getEvent } from "@/lib/logging/context";
|
|
import {
|
|
auditCCutoff,
|
|
auditCInstrument,
|
|
auditCTotal,
|
|
isValidatedAuditCLocale,
|
|
normaliseAuditCLocale,
|
|
type AuditCCutoff,
|
|
type AuditCInstrument,
|
|
} from "@/lib/mental-health/audit-c";
|
|
import { isModuleEnabled } from "@/lib/modules/gate";
|
|
import {
|
|
decryptHealthProfileFactValue,
|
|
decryptLifeFactValue,
|
|
invalidateHealthProfileFactConsumers,
|
|
toLifeFactRevisionDto,
|
|
type LifeFactRevisionDto,
|
|
} from "@/lib/profile/health-facts";
|
|
import {
|
|
LIFE_SECTIONS,
|
|
activityMinutesPerWeek,
|
|
computePackYears,
|
|
DEFAULT_FREE_WEEKDAYS,
|
|
isReviewDue,
|
|
LIFE_KIND_AI_SECTION,
|
|
screeningHints,
|
|
type FreeWeekdaysSpan,
|
|
type PackYears,
|
|
type LifeClinicalSummary,
|
|
type ScreeningHint,
|
|
} from "@/lib/profile/life-derived";
|
|
import {
|
|
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
|
type StoredHealthProfileAiSection,
|
|
} from "@/lib/validations/health-profile-facts";
|
|
import {
|
|
LIFE_AI_SECTIONS,
|
|
LIFE_SECTION_KEYS,
|
|
type ActivityHabitValue,
|
|
type FreeWeekdaysValue,
|
|
type LifeAiSection,
|
|
type LifeFactKind,
|
|
type LifeSectionKey,
|
|
type TobaccoDetailValue,
|
|
type WorkContextValue,
|
|
} from "@/lib/validations/life-circumstances";
|
|
|
|
const MS_PER_DAY = 86_400_000;
|
|
/** AUDIT-C is offered again once the newest entry is this old. */
|
|
const AUDIT_C_OFFER_AFTER_DAYS = 365;
|
|
/** A step count this recent means activity is measured, not only reported. */
|
|
const MEASURED_STEPS_DAYS = 30;
|
|
|
|
export interface AuditCResult {
|
|
id: string;
|
|
takenAt: string;
|
|
items: [number, number, number];
|
|
totalScore: number;
|
|
locale: string;
|
|
cutoff: AuditCCutoff | null;
|
|
aboveCutoff: boolean | null;
|
|
validatedLocale: boolean;
|
|
}
|
|
|
|
export interface LifeCircumstancesDto {
|
|
sections: Array<{
|
|
key: LifeSectionKey;
|
|
aiSection: Exclude<LifeAiSection, "SHIFT_SCHEDULE">;
|
|
aiIncluded: boolean;
|
|
lastConfirmedAt: string | null;
|
|
reviewDue: boolean;
|
|
facts: LifeFactRevisionDto[];
|
|
}>;
|
|
extraAiSections: { SHIFT_SCHEDULE: boolean };
|
|
derived: {
|
|
packYears: PackYears;
|
|
activityMinutesPerWeek: {
|
|
value: number | null;
|
|
meetsWho150: boolean | null;
|
|
measuredStepsAvailable: boolean;
|
|
};
|
|
freeWeekdays: { days: number[]; source: "fact" | "default_weekend" };
|
|
screeningHints: ScreeningHint[];
|
|
};
|
|
auditC: {
|
|
latest: AuditCResult | null;
|
|
history: Array<{ id: string; takenAt: string; totalScore: number }>;
|
|
offerDue: boolean;
|
|
instrument: AuditCInstrument;
|
|
};
|
|
takeOver: Array<{
|
|
field: "conditions" | "allergies";
|
|
freeTextPresent: boolean;
|
|
structuredCount: number;
|
|
available: boolean;
|
|
}>;
|
|
}
|
|
|
|
/** The kinds the read route shows: the six new ones and the two closed ones living in a section. */
|
|
const SECTION_KINDS = [
|
|
...new Set(LIFE_SECTIONS.flatMap((section) => section.kinds)),
|
|
];
|
|
|
|
function storedSections(
|
|
stored: readonly string[] | null | undefined,
|
|
): Set<StoredHealthProfileAiSection> {
|
|
return new Set(
|
|
(stored ??
|
|
DEFAULT_HEALTH_PROFILE_AI_SECTIONS) as StoredHealthProfileAiSection[],
|
|
);
|
|
}
|
|
|
|
/** The stored confirmations, read leniently: a stamp that does not parse counts as none. */
|
|
function confirmations(
|
|
value: Prisma.JsonValue | null | undefined,
|
|
): Map<string, Date> {
|
|
const out = new Map<string, Date>();
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return out;
|
|
for (const [key, stamp] of Object.entries(value)) {
|
|
if (typeof stamp !== "string") continue;
|
|
const at = new Date(stamp);
|
|
if (!Number.isNaN(at.getTime())) out.set(key, at);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function latest(...dates: Array<Date | null | undefined>): Date | null {
|
|
let best: Date | null = null;
|
|
for (const date of dates) {
|
|
if (date && (!best || date > best)) best = date;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** One stored entry scored for a profile sex; the source line follows `labelLocale`. */
|
|
export function toAuditCResult(
|
|
row: { id: string; takenAt: Date; totalScore: number; locale: string },
|
|
items: [number, number, number],
|
|
sex: string | null,
|
|
labelLocale: string,
|
|
): AuditCResult {
|
|
const cutoff = auditCCutoff(row.locale, sex, labelLocale);
|
|
return {
|
|
id: row.id,
|
|
takenAt: row.takenAt.toISOString(),
|
|
items,
|
|
totalScore: row.totalScore,
|
|
locale: row.locale,
|
|
cutoff,
|
|
aboveCutoff: cutoff ? row.totalScore >= cutoff.value : null,
|
|
validatedLocale: isValidatedAuditCLocale(row.locale),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The dated free-weekday answers of a record, oldest first, for the
|
|
* night-by-night day type. Fail-soft: the answers refine a chronotype the
|
|
* weekend default already produces, so a failed read falls back to that
|
|
* default with a warning instead of taking the sleep surfaces down with it.
|
|
*/
|
|
export async function loadFreeWeekdaySpans(
|
|
userId: string,
|
|
): Promise<FreeWeekdaysSpan[]> {
|
|
try {
|
|
const rows = await prisma.healthProfileFactRevision.findMany({
|
|
where: { userId, kind: "FREE_WEEKDAYS" },
|
|
orderBy: { validFrom: "asc" },
|
|
select: { validFrom: true, validUntil: true, valueEncrypted: true },
|
|
});
|
|
return rows.flatMap((row) => {
|
|
const opened = decryptLifeFactValue("FREE_WEEKDAYS", row.valueEncrypted);
|
|
const value = opened.value as FreeWeekdaysValue | null;
|
|
return value
|
|
? [
|
|
{
|
|
validFrom: row.validFrom,
|
|
validUntil: row.validUntil,
|
|
days: value.days,
|
|
},
|
|
]
|
|
: [];
|
|
});
|
|
} catch (error) {
|
|
getEvent()?.addWarning(
|
|
`free weekdays read failed: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The free-weekday answers for a prompt: only when the section they belong
|
|
* to (`WORK`) is released to the AI. Otherwise the answers are never read or
|
|
* decrypted for a prompt, and the night-by-night day type falls back to the
|
|
* weekend default, exactly as for a record that never answered. The data
|
|
* surfaces (the sleep page, the dashboard) read `loadFreeWeekdaySpans`
|
|
* directly: they show the person their own answer, no model sees it.
|
|
*/
|
|
export async function loadFreeWeekdaySpansForPrompt(
|
|
userId: string,
|
|
): Promise<FreeWeekdaysSpan[]> {
|
|
// Fail-soft and fail-closed, like the read below: a failed switch read
|
|
// gives the weekend default and never the answers.
|
|
try {
|
|
const profile = await prisma.userHealthProfile.findUnique({
|
|
where: { userId },
|
|
select: { aiIncludedSections: true },
|
|
});
|
|
if (
|
|
!storedSections(profile?.aiIncludedSections).has(
|
|
LIFE_KIND_AI_SECTION.FREE_WEEKDAYS,
|
|
)
|
|
) {
|
|
return [];
|
|
}
|
|
} catch (error) {
|
|
getEvent()?.addWarning(
|
|
`free weekdays switch read failed: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
return [];
|
|
}
|
|
return loadFreeWeekdaySpans(userId);
|
|
}
|
|
|
|
/** Assemble the whole read model for one record. */
|
|
export async function buildLifeCircumstances(
|
|
userId: string,
|
|
now: Date = new Date(),
|
|
): Promise<LifeCircumstancesDto> {
|
|
const [
|
|
user,
|
|
profile,
|
|
factRows,
|
|
auditRows,
|
|
steps,
|
|
illnessCount,
|
|
allergyCount,
|
|
illnessOn,
|
|
] = await Promise.all([
|
|
prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { dateOfBirth: true, gender: true, locale: true },
|
|
}),
|
|
prisma.userHealthProfile.findUnique({
|
|
where: { userId },
|
|
select: {
|
|
aiIncludedSections: true,
|
|
lifeConfirmedJson: true,
|
|
conditionsEncrypted: true,
|
|
allergiesEncrypted: true,
|
|
},
|
|
}),
|
|
prisma.healthProfileFactRevision.findMany({
|
|
where: {
|
|
userId,
|
|
kind: { in: SECTION_KINDS },
|
|
validUntil: null,
|
|
supersededByRevisionId: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
kind: true,
|
|
valueEncrypted: true,
|
|
validFrom: true,
|
|
provenance: true,
|
|
createdAt: true,
|
|
},
|
|
}),
|
|
prisma.mentalHealthAssessment.findMany({
|
|
where: { userId, instrument: "AUDIT_C", deletedAt: null },
|
|
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
|
|
take: AUDIT_C_HISTORY_LIMIT,
|
|
select: {
|
|
id: true,
|
|
takenAt: true,
|
|
totalScore: true,
|
|
locale: true,
|
|
responsesEncrypted: true,
|
|
},
|
|
}),
|
|
prisma.measurement.findFirst({
|
|
where: {
|
|
userId,
|
|
type: "ACTIVITY_STEPS",
|
|
deletedAt: null,
|
|
measuredAt: {
|
|
gte: new Date(now.getTime() - MEASURED_STEPS_DAYS * MS_PER_DAY),
|
|
},
|
|
},
|
|
select: { id: true },
|
|
}),
|
|
prisma.illnessEpisode.count({
|
|
where: { userId, lifecycle: "CHRONIC_ONGOING", deletedAt: null },
|
|
}),
|
|
prisma.allergy.count({
|
|
where: { userId, status: "ACTIVE", deletedAt: null },
|
|
}),
|
|
isModuleEnabled(userId, "illness"),
|
|
]);
|
|
|
|
const locale = user?.locale ?? "en";
|
|
const sex = user?.gender ?? null;
|
|
const included = storedSections(profile?.aiIncludedSections);
|
|
const confirmed = confirmations(profile?.lifeConfirmedJson);
|
|
const facts = factRows.map(toLifeFactRevisionDto);
|
|
const factOf = <T>(kind: string): T | null =>
|
|
(facts.find((fact) => fact.kind === kind)?.value as T | undefined) ?? null;
|
|
|
|
// AUDIT-C: the newest entry whose answers this host can open is the
|
|
// result, and the offer is timed from it, as every other reader times it
|
|
// (`audit-c-entries.ts`). The history lists totals, which need no
|
|
// decryption, so an unreadable entry can still be removed.
|
|
const readable = newestReadableAuditC(auditRows);
|
|
const latestAuditC: AuditCResult | null = readable
|
|
? toAuditCResult(readable.row, readable.items, sex, locale)
|
|
: null;
|
|
const newestAuditC = auditRows[0]?.takenAt ?? null;
|
|
const newestReadableAt = readable?.row.takenAt ?? null;
|
|
|
|
const sections = LIFE_SECTIONS.map((section) => {
|
|
const sectionFacts = facts.filter((fact) =>
|
|
(section.kinds as readonly string[]).includes(fact.kind),
|
|
);
|
|
const lastAnswer = latest(
|
|
...sectionFacts.map((fact) => new Date(fact.createdAt)),
|
|
section.key === "alcohol" ? newestAuditC : null,
|
|
);
|
|
const lastConfirmed = latest(lastAnswer, confirmed.get(section.key));
|
|
return {
|
|
key: section.key,
|
|
aiSection: section.aiSection,
|
|
aiIncluded: included.has(section.aiSection),
|
|
lastConfirmedAt: lastConfirmed?.toISOString() ?? null,
|
|
reviewDue: isReviewDue(lastConfirmed, now),
|
|
facts: sectionFacts,
|
|
};
|
|
});
|
|
|
|
const smokingStatus = factOf<string>("SMOKING_STATUS");
|
|
const tobacco = factOf<TobaccoDetailValue>("TOBACCO_DETAIL");
|
|
const activity = factOf<ActivityHabitValue>("ACTIVITY_HABIT");
|
|
const freeDays = factOf<FreeWeekdaysValue>("FREE_WEEKDAYS");
|
|
const packYears = computePackYears(smokingStatus, tobacco, now);
|
|
const minutes = activityMinutesPerWeek(
|
|
activity?.moderateDaysPerWeek ?? null,
|
|
activity?.moderateMinutesPerDay ?? null,
|
|
);
|
|
|
|
return {
|
|
sections,
|
|
extraAiSections: { SHIFT_SCHEDULE: included.has("SHIFT_SCHEDULE") },
|
|
derived: {
|
|
packYears,
|
|
activityMinutesPerWeek: {
|
|
...minutes,
|
|
measuredStepsAvailable: steps !== null,
|
|
},
|
|
freeWeekdays: freeDays
|
|
? { days: [...freeDays.days].sort((a, b) => a - b), source: "fact" }
|
|
: { days: [...DEFAULT_FREE_WEEKDAYS], source: "default_weekend" },
|
|
screeningHints: screeningHints({
|
|
ageYears: deriveAgeYears(user?.dateOfBirth ?? null, now),
|
|
sex,
|
|
smokingStatus,
|
|
detail: tobacco,
|
|
packYears,
|
|
locale,
|
|
now,
|
|
}),
|
|
},
|
|
auditC: {
|
|
latest: latestAuditC,
|
|
history: auditRows.map((row) => ({
|
|
id: row.id,
|
|
takenAt: row.takenAt.toISOString(),
|
|
totalScore: row.totalScore,
|
|
})),
|
|
offerDue:
|
|
newestReadableAt === null ||
|
|
now.getTime() - newestReadableAt.getTime() >=
|
|
AUDIT_C_OFFER_AFTER_DAYS * MS_PER_DAY,
|
|
instrument: auditCInstrument(locale),
|
|
},
|
|
takeOver: [
|
|
{
|
|
field: "conditions",
|
|
freeTextPresent: profile?.conditionsEncrypted != null,
|
|
structuredCount: illnessCount,
|
|
available: illnessOn,
|
|
},
|
|
{
|
|
field: "allergies",
|
|
freeTextPresent: profile?.allergiesEncrypted != null,
|
|
structuredCount: allergyCount,
|
|
available: true,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// ── AI switches ──────────────────────────────────────────────────────────────
|
|
|
|
export type LifeAiSectionsOutcome =
|
|
| { conflict: true }
|
|
| {
|
|
conflict: false;
|
|
aiSections: Record<LifeAiSection, boolean>;
|
|
updatedAt: string;
|
|
};
|
|
|
|
function lifeSwitches(
|
|
stored: Set<StoredHealthProfileAiSection>,
|
|
): Record<LifeAiSection, boolean> {
|
|
return Object.fromEntries(
|
|
LIFE_AI_SECTIONS.map((section) => [section, stored.has(section)]),
|
|
) as Record<LifeAiSection, boolean>;
|
|
}
|
|
|
|
/**
|
|
* Serialise every read-modify-write of a profile's AI switches. Two routes
|
|
* write the one array: this module's switch route and the about-me PUT, which
|
|
* replaces its eight sections and carries the life switches over as stored.
|
|
* Without a lock, a PUT that read the array before a concurrent switch-off
|
|
* committed wrote the old value back and turned the section on again. A
|
|
* transaction-scoped advisory lock covers the first write too, when there is
|
|
* no row yet to lock.
|
|
*/
|
|
export async function lockAiSections(
|
|
tx: Prisma.TransactionClient,
|
|
userId: string,
|
|
): Promise<void> {
|
|
await tx.$queryRaw`
|
|
SELECT 1 AS locked
|
|
FROM pg_advisory_xact_lock(hashtextextended(${`ai-sections:${userId}`}, 0))
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Switch life-circumstance sections in or out of the AI inclusion list. Only
|
|
* the named sections change; the other stored values, the five about-me
|
|
* sections among them, stay as they are. `base` is the about-me token
|
|
* (`UserHealthProfile.updatedAt`): a stale one is a conflict, as on about-me.
|
|
* Any change clears the cached briefing in the same transaction, because it
|
|
* may quote a section that just left.
|
|
*/
|
|
export async function updateLifeAiSections(
|
|
userId: string,
|
|
changes: Partial<Record<LifeAiSection, boolean>>,
|
|
base?: Date,
|
|
): Promise<LifeAiSectionsOutcome> {
|
|
return prisma.$transaction(async (tx) => {
|
|
await lockAiSections(tx, userId);
|
|
const row = await tx.userHealthProfile.findUnique({
|
|
where: { userId },
|
|
select: { aiIncludedSections: true, updatedAt: true },
|
|
});
|
|
if (row && base && row.updatedAt.getTime() !== base.getTime()) {
|
|
return { conflict: true as const };
|
|
}
|
|
const next = storedSections(row?.aiIncludedSections);
|
|
for (const [section, on] of Object.entries(changes)) {
|
|
if (on) next.add(section as StoredHealthProfileAiSection);
|
|
else next.delete(section as StoredHealthProfileAiSection);
|
|
}
|
|
const aiIncludedSections = [...next];
|
|
const saved = row
|
|
? await tx.userHealthProfile.update({
|
|
where: { userId },
|
|
data: { aiIncludedSections },
|
|
select: { updatedAt: true },
|
|
})
|
|
: await tx.userHealthProfile.create({
|
|
data: { userId, aiIncludedSections },
|
|
select: { updatedAt: true },
|
|
});
|
|
await invalidateHealthProfileFactConsumers(userId, tx);
|
|
return {
|
|
conflict: false as const,
|
|
aiSections: lifeSwitches(next),
|
|
updatedAt: saved.updatedAt.toISOString(),
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Store "still true" for one section: the instant only, never a revision. */
|
|
export async function confirmLifeSection(
|
|
userId: string,
|
|
section: LifeSectionKey,
|
|
now: Date = new Date(),
|
|
): Promise<string> {
|
|
const stamp = now.toISOString();
|
|
await prisma.$transaction(async (tx) => {
|
|
const row = await tx.userHealthProfile.findUnique({
|
|
where: { userId },
|
|
select: { lifeConfirmedJson: true },
|
|
});
|
|
const confirmedAt: Record<string, string> = {};
|
|
for (const [key, at] of confirmations(row?.lifeConfirmedJson)) {
|
|
if ((LIFE_SECTION_KEYS as readonly string[]).includes(key)) {
|
|
confirmedAt[key] = at.toISOString();
|
|
}
|
|
}
|
|
confirmedAt[section] = stamp;
|
|
await tx.userHealthProfile.upsert({
|
|
where: { userId },
|
|
create: { userId, lifeConfirmedJson: confirmedAt },
|
|
update: { lifeConfirmedJson: confirmedAt },
|
|
});
|
|
});
|
|
return stamp;
|
|
}
|
|
|
|
// ── AUDIT-C entries ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* The client's `externalId` is stored with this prefix. The screeners share
|
|
* the table and a per-user unique index on `external_id`, so a sync client
|
|
* that happened to reuse an id across the two surfaces would otherwise get
|
|
* the wrong record back as its "existing" entry.
|
|
*/
|
|
const AUDIT_C_EXTERNAL_ID_PREFIX = "audit-c:";
|
|
|
|
export interface AuditCCreateInput {
|
|
items: [number, number, number];
|
|
takenAt?: Date;
|
|
tz?: string;
|
|
locale: string;
|
|
source: "WEB" | "IOS";
|
|
externalId?: string;
|
|
}
|
|
|
|
const AUDIT_C_SELECT = {
|
|
id: true,
|
|
takenAt: true,
|
|
totalScore: true,
|
|
locale: true,
|
|
responsesEncrypted: true,
|
|
} as const;
|
|
|
|
/**
|
|
* Store and score one entry. A repeat `externalId` returns the stored entry
|
|
* (`replayed: true`) and writes nothing.
|
|
*/
|
|
export async function createAuditC(
|
|
userId: string,
|
|
input: AuditCCreateInput,
|
|
now: Date = new Date(),
|
|
): Promise<{ result: AuditCResult; replayed: boolean }> {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { gender: true, locale: true },
|
|
});
|
|
const sex = user?.gender ?? null;
|
|
const labelLocale = user?.locale ?? "en";
|
|
const externalId = input.externalId
|
|
? `${AUDIT_C_EXTERNAL_ID_PREFIX}${input.externalId}`
|
|
: null;
|
|
|
|
const replay = async () => {
|
|
if (!externalId) return null;
|
|
const existing = await prisma.mentalHealthAssessment.findFirst({
|
|
where: { userId, instrument: "AUDIT_C", externalId },
|
|
select: AUDIT_C_SELECT,
|
|
});
|
|
if (!existing) return null;
|
|
const opened = openAuditCResponses(existing.responsesEncrypted);
|
|
return {
|
|
result: toAuditCResult(
|
|
existing,
|
|
opened?.items ?? input.items,
|
|
sex,
|
|
labelLocale,
|
|
),
|
|
replayed: true,
|
|
};
|
|
};
|
|
|
|
const prior = await replay();
|
|
if (prior) return prior;
|
|
|
|
const locale = normaliseAuditCLocale(input.locale);
|
|
const responses: AuditCResponses = { schema: 1, items: input.items };
|
|
try {
|
|
const row = await prisma.mentalHealthAssessment.create({
|
|
data: {
|
|
userId,
|
|
instrument: "AUDIT_C",
|
|
locale,
|
|
responsesEncrypted: encryptToBytes(JSON.stringify(responses)),
|
|
totalScore: auditCTotal(input.items),
|
|
// Not banded: the cut-off depends on the profile's sex at read time,
|
|
// so nothing derived from it is frozen into the row.
|
|
severityBand: "unbanded",
|
|
takenAt: input.takenAt ?? now,
|
|
tz: input.tz ?? null,
|
|
source: input.source,
|
|
externalId,
|
|
},
|
|
select: AUDIT_C_SELECT,
|
|
});
|
|
await invalidateHealthProfileFactConsumers(userId, prisma);
|
|
return {
|
|
result: toAuditCResult(row, input.items, sex, labelLocale),
|
|
replayed: false,
|
|
};
|
|
} catch (error) {
|
|
// A concurrent replay of the same externalId lost the unique race.
|
|
if (
|
|
externalId &&
|
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
error.code === "P2002"
|
|
) {
|
|
const winner = await replay();
|
|
if (winner) return winner;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ── Clinical summary (doctor report, FHIR) ───────────────────────────────────
|
|
|
|
export async function loadLifeClinicalSummary(
|
|
userId: string,
|
|
now: Date = new Date(),
|
|
): Promise<LifeClinicalSummary> {
|
|
const [user, rows, audit] = await Promise.all([
|
|
prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { gender: true, locale: true },
|
|
}),
|
|
prisma.healthProfileFactRevision.findMany({
|
|
where: {
|
|
userId,
|
|
kind: {
|
|
in: [
|
|
"SMOKING_STATUS",
|
|
"TOBACCO_DETAIL",
|
|
"ACTIVITY_HABIT",
|
|
"WORK_CONTEXT",
|
|
],
|
|
},
|
|
validUntil: null,
|
|
supersededByRevisionId: null,
|
|
},
|
|
select: { kind: true, valueEncrypted: true },
|
|
}),
|
|
loadNewestReadableAuditC(userId),
|
|
]);
|
|
const value = <T>(kind: string): T | null => {
|
|
const row = rows.find((candidate) => candidate.kind === kind);
|
|
if (!row) return null;
|
|
const opened =
|
|
kind === "SMOKING_STATUS"
|
|
? decryptHealthProfileFactValue("SMOKING_STATUS", row.valueEncrypted)
|
|
: decryptLifeFactValue(kind as LifeFactKind, row.valueEncrypted);
|
|
return (opened.value as T | null) ?? null;
|
|
};
|
|
const packYears = computePackYears(
|
|
value<string>("SMOKING_STATUS"),
|
|
value<TobaccoDetailValue>("TOBACCO_DETAIL"),
|
|
now,
|
|
);
|
|
const activity = value<ActivityHabitValue>("ACTIVITY_HABIT");
|
|
const work = value<WorkContextValue>("WORK_CONTEXT");
|
|
const cutoff = audit
|
|
? auditCCutoff(audit.locale, user?.gender ?? null, user?.locale ?? "en")
|
|
: null;
|
|
return {
|
|
packYears: packYears.value,
|
|
auditC: audit
|
|
? {
|
|
id: audit.id,
|
|
takenAt: audit.takenAt.toISOString(),
|
|
totalScore: audit.totalScore,
|
|
aboveCutoff: cutoff ? audit.totalScore >= cutoff.value : null,
|
|
cutoffValue: cutoff?.value ?? null,
|
|
cutoffSourceLabel: cutoff?.sourceLabel ?? null,
|
|
}
|
|
: null,
|
|
activityMinutesPerWeek: activityMinutesPerWeek(
|
|
activity?.moderateDaysPerWeek ?? null,
|
|
activity?.moderateMinutesPerDay ?? null,
|
|
).value,
|
|
sittingHoursWorkday: activity?.sittingHoursWorkday ?? null,
|
|
work: work
|
|
? {
|
|
situation: work.situation,
|
|
physicalNature: work.physicalNature,
|
|
nightWork: work.nightWork,
|
|
exposures: work.exposures,
|
|
}
|
|
: null,
|
|
};
|
|
}
|