Files
HealthLog/src/lib/export.ts
T
Marc-André Bombeck d0b45e36b3 feat(integrations): remove the moodLog integration
Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
a56e6fe61 on the promise of a dedicated removal. This delivers it.

Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.

Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.

Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.

`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
2026-07-25 11:36:18 +02:00

352 lines
12 KiB
TypeScript

/**
* Export utilities — CSV, JSON, and ZIP generation.
*
* v1.4.25 W7 — per-user timezone. Timestamps were emitted with
* `.toISOString()` (UTC `Z` suffix), which Excel / LibreOffice
* silently render in the viewer's local zone — issue #167. The
* helpers now accept a `userTz` argument and write
* `2026-05-11T11:05:00+02:00` instead, so the offset survives the
* round-trip and the on-screen value is unambiguous regardless of
* the spreadsheet's locale defaults.
*
* Backward compatibility: `userTz` is optional; when omitted the
* helpers fall back to `.toISOString()` so callers that don't yet
* thread a user context (admin tools, the backup-on-disk shape) keep
* the old contract. Production routes always pass `userTz`.
*/
import { formatInUserTz } from "./tz/format";
import {
reconstructSleepSessions,
pickMainNightAndNaps,
type SleepStageRow,
} from "./analytics/sleep-night";
import { convertGlucose, type GlucoseUnit } from "./glucose";
import type { SleepStage } from "@/generated/prisma/client";
export interface ExportableRecord {
[key: string]: unknown;
}
/**
* Convert records to CSV string.
*
* The first record's keys define the column order (callers should pass
* a stable shape — see `buildAuditLogCsvRecords`). When `headerLabels`
* is provided, those strings replace the object-key header row so the
* exported CSV can carry translated column titles without changing
* the record-key contract that drives the columns.
*
* Escapes RFC 4180 special characters: commas, double quotes, line
* breaks (`\n` and `\r`). Values containing any of these are wrapped
* in double quotes with embedded quotes doubled (`"` → `""`).
*
* Spreadsheet formula injection (CSV injection): free-TEXT cells whose
* value starts with `=`, `+`, `-`, `@`, TAB, or CR get a leading `'`
* (the OWASP single-quote neutralisation — Excel / LibreOffice /
* Google Sheets treat the cell as text and hide the quote). The guard
* is scoped to STRING-typed record values only: numeric columns
* (measurement values, mood scores) stay typed `number` all the way to
* this function, so a negative reading exports as `-5.2`, never
* `'-5.2`. The text columns are where third-party content can land —
* mood `note` originates from user input, measurement `notes`
* from arbitrary clients — and a crafted `=HYPERLINK(...)` /
* `=cmd|...` there must not execute when an operator opens the export.
*/
export function toCSV(
records: ExportableRecord[],
headerLabels?: Record<string, string>,
): string {
if (records.length === 0) return "";
const headers = Object.keys(records[0]);
const headerLine = headerLabels
? headers
.map((h) =>
escapeCsvCell(neutraliseFormulaPrefix(headerLabels[h] ?? h)),
)
.join(",")
: headers.map(escapeCsvCell).join(",");
const lines = [headerLine];
for (const record of records) {
const values = headers.map((h) => {
const val = record[h];
if (val === null || val === undefined) return "";
if (val instanceof Date) return escapeCsvCell(val.toISOString());
// Only genuine TEXT cells get the formula-prefix guard; numbers /
// booleans stringify verbatim so numeric columns are never mangled.
const str = String(val);
return escapeCsvCell(
typeof val === "string" ? neutraliseFormulaPrefix(str) : str,
);
});
lines.push(values.join(","));
}
return lines.join("\n");
}
/**
* OWASP CSV-injection neutralisation: prefix a text cell that starts
* with a formula trigger (`=`, `+`, `-`, `@`) or a control character
* Excel interprets as a field continuation (TAB, CR) with a single
* quote so the spreadsheet renders it as literal text. Applied to
* string cells only — see `toCSV`.
*/
function neutraliseFormulaPrefix(str: string): string {
return /^[=+\-@\t\r]/.test(str) ? `'${str}` : str;
}
/**
* RFC 4180 cell escaping. `\r` is treated the same as `\n` so a
* Windows-newline value (`\r\n`) inside a single field doesn't break
* row alignment when Excel re-imports the CSV.
*/
function escapeCsvCell(str: string): string {
if (
str.includes(",") ||
str.includes('"') ||
str.includes("\n") ||
str.includes("\r")
) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function formatTimestamp(date: Date, userTz?: string): string {
if (userTz) return formatInUserTz(date, userTz, "iso-with-offset");
return date.toISOString();
}
interface ExportMeasurement {
type: string;
value: number;
unit: string;
measuredAt: Date;
source: string;
notes: string | null;
glucoseContext?: string | null;
sleepStage?: SleepStage | null;
/** Writer tag for the per-night sleep collapse (watch vs phone). */
deviceType?: string | null;
}
/**
* Format measurements for export.
*
* `userTz` (optional) — when provided, `measuredAt` is emitted as
* ISO-8601 with the user's UTC offset (e.g. `+02:00` in Berlin in
* May) instead of the bare `Z` suffix. See module-level docstring
* for the rationale (issue #167).
*
* v1.11.5 — SLEEP_DURATION is stored one row per STAGE per night. A flat
* export emitted a wall of per-stage minutes with no night attribution.
* By default sleep rows are now COLLAPSED to one record per night (the
* main session's TIME ASLEEP per the nap convention) carrying the stage
* breakdown in `notes` so the single uniform CSV reads as one night, not
* many stages. Pass `granularity: "raw"` to keep the per-stage rows for
* power users. Storage is untouched either way.
*/
export function formatMeasurementsForExport(
measurements: ExportMeasurement[],
userTz?: string,
opts: {
granularity?: "night" | "raw";
sleepTz?: string;
/**
* The user's persisted `sourcePriorityJson` (or null for the defaults).
* Threaded into `reconstructSleepSessions` so the CSV's per-night sleep
* dedup picks the SAME canonical source the UI shows on a multi-source
* night — without it the export silently fell back to the default ladder.
*/
sourcePriorityJson?: unknown;
/**
* v1.16.16 — the user's resolved glucose display unit. BLOOD_GLUCOSE is
* stored canonical mg/dL; when this is `"mmol/L"` the export converts the
* value (1 decimal) and emits `mmol/L` in the `unit` column, matching the
* FHIR export (`src/lib/fhir/resources.ts`) so the CSV reads in ONE unit.
* Defaults to `"mg/dL"` (the canonical-stored unit) → byte-identical for
* mg/dL-preference users.
*/
glucoseUnit?: GlucoseUnit;
} = {},
): ExportableRecord[] {
const granularity = opts.granularity ?? "night";
const glucoseUnit: GlucoseUnit = opts.glucoseUnit ?? "mg/dL";
const toRecord = (m: ExportMeasurement): ExportableRecord => {
// BLOOD_GLUCOSE is stored mg/dL; convert to the user's display unit at
// serialization so the value + unit column agree and match FHIR.
const isGlucose = m.type === "BLOOD_GLUCOSE";
return {
type: m.type,
value: isGlucose ? convertGlucose(m.value, glucoseUnit) : m.value,
unit: isGlucose ? glucoseUnit : m.unit,
measuredAt: formatTimestamp(m.measuredAt, userTz),
source: m.source,
notes: m.notes ?? "",
glucoseContext: m.glucoseContext ?? "",
};
};
if (granularity === "raw") {
return measurements.map(toRecord);
}
// Split sleep stage rows out, collapse them per night, and merge back
// in chronological order with the non-sleep rows.
const sleepRows = measurements.filter((m) => m.type === "SLEEP_DURATION");
if (sleepRows.length === 0) {
return measurements.map(toRecord);
}
const other = measurements.filter((m) => m.type !== "SLEEP_DURATION");
// `reconstructSleepSessions` clusters by wake-day using the user's tz;
// fall back to UTC when no tz is threaded (admin / on-disk shape).
const tz = opts.sleepTz ?? userTz ?? "UTC";
const stageRows: SleepStageRow[] = sleepRows.map((m) => ({
value: m.value,
measuredAt: m.measuredAt,
sleepStage: m.sleepStage ?? null,
source: m.source as SleepStageRow["source"],
deviceType: m.deviceType ?? null,
}));
const sessions = reconstructSleepSessions(
stageRows,
tz,
opts.sourcePriorityJson ?? null,
);
const byDay = new Map<string, typeof sessions>();
for (const s of sessions) {
const list = byDay.get(s.night) ?? [];
list.push(s);
byDay.set(s.night, list);
}
const nightRecords: Array<{ at: Date; rec: ExportableRecord }> = [];
for (const daySessions of byDay.values()) {
const { main, naps } = pickMainNightAndNaps(daySessions);
if (!main) continue;
const stageParts = (Object.entries(main.stages) as Array<[string, number]>)
.filter(([, mins]) => mins > 0)
.map(([stage, mins]) => `${stage}=${Math.round(mins)}m`)
.join(" ");
const napNote =
naps.length > 0
? ` naps=${naps.length}(${Math.round(
naps.reduce((sum, n) => sum + n.asleepMinutes, 0),
)}m)`
: "";
const awakeningNote =
main.awakenings > 0 ? ` awakenings=${main.awakenings}` : "";
nightRecords.push({
at: main.end,
rec: {
type: "SLEEP_DURATION",
// Headline value = the main night's TIME ASLEEP in minutes.
value: Math.round(main.asleepMinutes),
unit: "minutes",
measuredAt: formatTimestamp(main.end, userTz),
source: main.source ?? "",
notes: `${stageParts}${napNote}${awakeningNote}`.trim(),
glucoseContext: "",
},
});
}
// Merge: keep the same descending-by-time order the route reads in.
const merged: Array<{ at: Date; rec: ExportableRecord }> = [
...other.map((m) => ({ at: m.measuredAt, rec: toRecord(m) })),
...nightRecords,
];
merged.sort((a, b) => b.at.getTime() - a.at.getTime());
return merged.map((x) => x.rec);
}
/**
* Format medications for export.
*/
export function formatMedicationsForExport(
medications: Array<{
name: string;
dose: string;
active: boolean;
schedules: Array<{
windowStart: string;
windowEnd: string;
label: string | null;
dose: string | null;
}>;
}>,
): ExportableRecord[] {
return medications.map((m) => ({
name: m.name,
dose: m.dose,
active: m.active,
schedules: m.schedules
.map(
(s) =>
`${s.label ? s.label + ": " : ""}${s.windowStart}-${s.windowEnd}${s.dose ? " (" + s.dose + ")" : ""}`,
)
.join("; "),
}));
}
/**
* Format intake events for export.
*
* `userTz` (optional) — see `formatMeasurementsForExport`.
*/
export function formatIntakeEventsForExport(
events: Array<{
medication: { name: string };
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
source: string;
}>,
userTz?: string,
): ExportableRecord[] {
return events.map((e) => ({
medication: e.medication.name,
scheduledFor: formatTimestamp(e.scheduledFor, userTz),
takenAt: e.takenAt ? formatTimestamp(e.takenAt, userTz) : "",
skipped: e.skipped,
source: e.source,
}));
}
/**
* Format mood entries for export.
*
* `userTz` (optional) — see `formatMeasurementsForExport`. The
* `date` column is a Berlin-anchored `YYYY-MM-DD` string in the
* stored data (`MoodEntry.date`); the migration risk discussed in
* §6.3 of the timezone proposal applies to that column specifically.
* We do NOT rewrite the `date` column here — the read-side
* interpretation lives where the field is consumed, not in the
* export. Only `loggedAt` (the timestamptz) carries the user-tz
* offset.
*/
export function formatMoodEntriesForExport(
entries: Array<{
date: string;
mood: string;
score: number;
tags: string | null;
/** Free-text note — exported in full so the texts are readable offline. */
note?: string | null;
source: string;
moodLoggedAt: Date;
}>,
userTz?: string,
): ExportableRecord[] {
return entries.map((e) => ({
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags ?? "",
note: e.note ?? "",
source: e.source,
loggedAt: formatTimestamp(e.moodLoggedAt, userTz),
}));
}