Files
HealthLog/src/lib/targets/build-response.ts
T
Marc-André Bombeck 62eb431d66 release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0

* test: stabilize water capture re-entry
2026-07-22 04:14:15 +02:00

349 lines
10 KiB
TypeScript

/**
* `GET /api/insights/targets` response builder.
*
* Owns the bounded repository fan-out and public response composition. Pure
* section builders live beside this module so timezone, classification, and
* ordering rules can be tested without the route or database shell. The route
* remains auth → `cachedSwr(buildTargetsResponse)` → `apiSuccess`.
*
* Every per-day bucket key resolves against the user's display timezone
* (`userDayKey`) so streaks, "last met goal", and the consistency strip
* are read in the user's own calendar.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import pLimit from "p-limit";
import type { MeasurementType } from "@/generated/prisma/client";
import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range";
import { resolveGlucoseUnit } from "@/lib/glucose";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
import { buildGlucoseTargets } from "./glucose-builder";
import { buildMedicationTarget } from "./medication-builder";
import { buildMoodTargets } from "./mood-builder";
import { buildSleepTarget } from "./sleep-builder";
import { buildTargetPageSummary } from "./summary-builder";
import { buildVitalTargets } from "./vitals-builder";
import type { TargetItem, TargetMoodEntry, TargetValueByType } from "./types";
import type { User } from "@/generated/prisma/client";
function getAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < dateOfBirth.getDate())
) {
age--;
}
return age;
}
/**
* The authenticated-user shape the builder reads directly. The profile
* fields (height / dob / gender / glucose unit / thresholds) are re-read
* from the DB inside the builder, so the handler only threads id + tz.
*/
export type AuthedUser = Pick<User, "id" | "timezone">;
/**
* `/api/insights/targets` body. Wrapped in `cachedSwr` by the route so the
* cold multi-query walk is paid once per 60 s window; writes hard-evict the
* bucket so user actions reflect on the next read.
*/
export async function buildTargetsResponse(user: AuthedUser) {
const userId = user.id;
const timezone = user.timezone ?? DEFAULT_TIMEZONE;
const queryNow = new Date();
const types: MeasurementType[] = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"RESTING_HEART_RATE",
"BODY_FAT",
"ACTIVITY_STEPS",
];
const thirtyDaysAgo = new Date(queryNow.getTime() - 30 * 24 * 60 * 60 * 1000);
const oneYearAgo = new Date(queryNow.getTime() - 365 * 24 * 60 * 60 * 1000);
const moodSince = new Date(
queryNow.getTime() - 5 * 365 * 24 * 60 * 60 * 1000,
);
void ensureUserMoodRollupsFresh(userId);
// All independent reads stay in one bounded fan-out. The medication event
// read is the sole dependent query and remains chained to its medication
// list inside one concurrency slot.
const limit = pLimit(4);
const [
dbUser,
recentMeasurements,
latestEverByType,
sleepStageRows,
medicationBundle,
moodRollups,
latestMoodEntry,
glucoseRows,
] = await Promise.all([
limit(() =>
prisma.user.findUnique({
where: { id: userId },
select: {
heightCm: true,
dateOfBirth: true,
gender: true,
glucoseUnit: true,
hasDiabetes: true,
thresholdsJson: true,
sourcePriorityJson: true,
},
}),
),
limit(() =>
prisma.measurement.findMany({
where: {
userId,
type: { in: types },
measuredAt: { gte: thirtyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
select: { type: true, value: true, measuredAt: true },
}),
),
limit(() => {
for (const type of types) {
if (!/^[A-Z0-9_]+$/.test(type)) {
throw new Error(`invalid measurement type: ${type}`);
}
}
const typeList = types
.map((type) => `'${type}'::"measurement_type"`)
.join(",");
return prisma.$queryRawUnsafe<
Array<{ type: MeasurementType; value: number }>
>(
`SELECT DISTINCT ON (m."type")
m."type"::text AS type,
m."value" AS value
FROM measurements m
WHERE m."user_id" = $1
AND m."type" IN (${typeList})
AND m."measured_at" >= $2
AND m."deleted_at" IS NULL
ORDER BY m."type" ASC, m."measured_at" DESC`,
userId,
oneYearAgo,
);
}),
limit(() =>
prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION",
measuredAt: { gte: thirtyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
}),
),
limit(async () => {
const activeMedications = await prisma.medication.findMany({
where: { userId, active: true, asNeeded: false },
include: {
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
scheduleRevisions: { orderBy: { validFrom: "asc" } },
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
orderBy: { name: "asc" },
});
if (activeMedications.length === 0) {
return { activeMedications, intakeEvents: [] };
}
const intakeEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId,
deletedAt: null,
medicationId: {
in: activeMedications.map((medication) => medication.id),
},
scheduledFor: { gte: thirtyDaysAgo },
},
orderBy: { scheduledFor: "desc" },
select: {
medicationId: true,
takenAt: true,
skipped: true,
scheduledFor: true,
},
});
return { activeMedications, intakeEvents };
}),
limit(() => readMoodDayRollups(userId, moodSince)),
limit(() =>
prisma.moodEntry.findFirst({
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
select: { score: true, moodLoggedAt: true },
}),
),
limit(() =>
prisma.measurement.findMany({
where: {
userId,
type: "BLOOD_GLUCOSE",
measuredAt: { gte: oneYearAgo },
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
select: { value: true, measuredAt: true, glucoseContext: true },
}),
),
]);
// Rollup coverage fallback stays an explicit repository read here rather
// than hiding persistence inside the pure mood section builder.
let recentRawMood: TargetMoodEntry[] | null = null;
if (moodRollups.length === 0 && latestMoodEntry !== null) {
const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
recentRawMood = await prisma.moodEntry.findMany({
where: {
userId,
deletedAt: null,
moodLoggedAt: { gte: thirtyDaysAgoMood },
},
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
});
}
const now = new Date();
const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null;
const gender = (dbUser?.gender as "MALE" | "FEMALE" | null) ?? null;
const heightCm = dbUser?.heightCm ?? null;
const latestByType: TargetValueByType = {};
const average30ByType: TargetValueByType = {};
for (const type of types) {
latestByType[type] =
latestEverByType.find((row) => row.type === type)?.value ?? null;
const recent = recentMeasurements.filter(
(measurement) => measurement.type === type,
);
average30ByType[type] =
recent.length > 0
? Math.round(
(recent.reduce((sum, measurement) => sum + measurement.value, 0) /
recent.length) *
10,
) / 10
: null;
}
const vitalSection = buildVitalTargets({
recentMeasurements,
latestByType,
average30ByType,
heightCm,
age,
gender,
timezone,
now,
});
const sleepTarget = buildSleepTarget({
sleepStageRows,
timezone,
sourcePriorityJson: dbUser?.sourcePriorityJson ?? null,
now,
});
const pulseIndex = vitalSection.targets.findIndex(
(target) => target.type === "PULSE",
);
const targets: TargetItem[] = [
...vitalSection.targets.slice(0, pulseIndex + 1),
sleepTarget,
...vitalSection.targets.slice(pulseIndex + 1),
];
const medicationTarget = buildMedicationTarget({
activeMedications: medicationBundle.activeMedications,
intakeEvents: medicationBundle.intakeEvents,
timezone,
now,
});
if (medicationTarget) targets.push(medicationTarget);
targets.push(
...buildMoodTargets({
moodRollups,
recentRawMood,
latestMoodEntry,
timezone,
now,
}),
);
const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null);
targets.push(
...buildGlucoseTargets({
rows: glucoseRows,
profile: {
heightCm,
dateOfBirth: dbUser?.dateOfBirth ?? null,
gender: dbUser?.gender ?? null,
glucoseUnit: dbUser?.glucoseUnit ?? null,
hasDiabetes: dbUser?.hasDiabetes ?? false,
thresholdsJson: (dbUser?.thresholdsJson ??
null) as ThresholdOverridesJson | null,
},
timezone,
now,
}),
);
const pageSummary = buildTargetPageSummary(targets);
annotate({
action: { name: "insights.targets" },
meta: {
targetCount: targets.length,
targetsMetThisWeek: pageSummary.targetsMetThisWeek,
streakHighlightMetric: pageSummary.streakHighlight?.metric ?? null,
},
});
return {
targets,
pageSummary,
bpDiastolic: {
current: latestByType.BLOOD_PRESSURE_DIA ?? null,
average30: average30ByType.BLOOD_PRESSURE_DIA ?? null,
range: vitalSection.bpRange
? {
min: vitalSection.bpRange.diaLow,
max: vitalSection.bpRange.diaHigh,
}
: null,
},
profile: {
heightCm,
age,
gender,
glucoseUnit,
},
};
}