Files
HealthLog/src/lib/validations/backup.ts
T
Marc-André Bombeck 7f1a4ded25 fix(validation): moodEntry.tags must parse as JSON string-array (v1.4.15 H2)
`MoodEntry.tags` is stored as a JSON-array-as-string in
`mood_entries.tags`. The backup schema accepted any string here, so a
malformed blob (or a tampered backup) would persist into the DB and
crash downstream readers that blindly `JSON.parse` it. Restore-time
validation now refines the field to one of:

  - null (or omitted) — no tags on this entry
  - "" — legacy moodLog wire format (treated as null)
  - a JSON string that parses to an array of strings

Anything else fails Zod with a 422 from the restore endpoint instead
of bubbling out of `prisma.createMany` with an opaque message. New
unit-test file covers the three accept cases plus three reject cases
(non-JSON, JSON object, JSON array of non-strings).
2026-05-10 00:27:20 +02:00

182 lines
5.8 KiB
TypeScript

/**
* Zod schemas + helpers for the per-user `DataBackup` JSON payload.
*
* Single source of truth shared by:
* - the pg-boss `data-backup` worker that writes a backup,
* - `GET /api/admin/backups/[id]/download` that streams it back as JSON,
* - `POST /api/admin/backups/upload` that ingests an admin-supplied file,
* - `POST /api/admin/backups/[id]/restore` that re-creates DB rows from it.
*
* The schema is intentionally permissive about *extra* fields (`.passthrough`)
* so older snapshots written before a column was added still parse and so
* future minor additions don't break old admins. Required fields are
* deliberately tight — any drift surfaces as a validation error rather than
* a silent data-loss restore.
*
* `schemaVersion` is the migration handle. Bump when the on-disk shape
* changes incompatibly. The current writer (worker) historically did NOT
* include this field; `parseBackupPayload()` defaults it to "1" so legacy
* blobs continue to round-trip and the upload validator can still produce
* a useful summary for them.
*/
import { z } from "zod/v4";
export const BACKUP_SCHEMA_VERSION = "1" as const;
const isoDateTime = z
.string()
.min(1)
.refine((s) => !Number.isNaN(Date.parse(s)), {
message: "Expected ISO-8601 date-time string",
});
const measurementSchema = z
.object({
type: z.string().min(1),
value: z.number(),
unit: z.string().min(1),
measuredAt: isoDateTime,
source: z.string().min(1).optional(),
notes: z.string().nullable().optional(),
})
.passthrough();
const medicationScheduleSchema = z
.object({
windowStart: z.string().min(1),
windowEnd: z.string().min(1),
label: z.string().nullable().optional(),
dose: z.string().nullable().optional(),
})
.passthrough();
const medicationSchema = z
.object({
name: z.string().min(1),
dose: z.string(),
active: z.boolean().optional(),
schedules: z.array(medicationScheduleSchema).default([]),
})
.passthrough();
const intakeEventSchema = z
.object({
medication: z.string().min(1),
scheduledFor: isoDateTime,
takenAt: isoDateTime.nullable().optional(),
skipped: z.boolean().optional(),
source: z.string().min(1).optional(),
})
.passthrough();
/**
* `MoodEntry.tags` is stored as a JSON-array-as-string in the
* `mood_entries.tags` column ("[\"work\",\"sleep\"]"). The previous
* schema accepted any `string` here, so a malformed blob in a backup
* (or one tampered with mid-restore) would land in the DB and crash
* downstream readers that `JSON.parse` it. We now refine to one of
* `null` / empty-string (legacy null wire format) / a JSON string
* that parses to a `string[]`. v1.4.15 H2.
*/
const moodEntryTagsSchema = z
.union([z.null(), z.string()])
.nullable()
.optional()
.refine(
(v) => {
if (v == null || v === "") return true;
try {
const parsed = JSON.parse(v) as unknown;
return (
Array.isArray(parsed) && parsed.every((x) => typeof x === "string")
);
} catch {
return false;
}
},
{ message: "tags must be null, empty, or a JSON array of strings" },
);
const moodEntrySchema = z
.object({
date: z.string().min(1),
mood: z.string().min(1),
score: z.number().int().min(0).max(10),
tags: moodEntryTagsSchema,
source: z.string().min(1).optional(),
loggedAt: isoDateTime,
})
.passthrough();
/**
* Wire shape — exactly what the pg-boss worker writes today, plus a
* `schemaVersion` field that newer writers stamp explicitly. Older blobs
* without the field default to v1 in `parseBackupPayload`.
*/
export const backupPayloadSchema = z
.object({
schemaVersion: z.string().min(1).default(BACKUP_SCHEMA_VERSION),
exportedAt: isoDateTime,
userId: z.string().min(1),
measurements: z.array(measurementSchema).default([]),
medications: z.array(medicationSchema).default([]),
intakeEvents: z.array(intakeEventSchema).default([]),
moodEntries: z.array(moodEntrySchema).default([]),
})
.passthrough();
export type BackupPayload = z.infer<typeof backupPayloadSchema>;
/**
* Numeric counts of each backed-up record kind. Returned in the
* upload + restore API responses so the admin sees what they
* uploaded/restored without having to download the file again.
*/
export interface BackupSummary {
schemaVersion: string;
userId: string;
exportedAt: string;
measurements: number;
medications: number;
intakeEvents: number;
moodEntries: number;
}
export function summarizeBackup(payload: BackupPayload): BackupSummary {
return {
schemaVersion: payload.schemaVersion,
userId: payload.userId,
exportedAt: payload.exportedAt,
measurements: payload.measurements.length,
medications: payload.medications.length,
intakeEvents: payload.intakeEvents.length,
moodEntries: payload.moodEntries.length,
};
}
/**
* Parse a JSON blob (string or already-parsed object) against
* `backupPayloadSchema`, returning a typed payload. Throws ZodError on
* mismatch — the admin route catches it and turns it into a 422 with a
* field-level error list.
*
* Accepts both forms because:
* - the upload route hands us the parsed object after `await req.text()`
* - the restore route reads `DataBackup.data` (decrypted) which is
* always a JSON string straight from the worker.
*/
export function parseBackupPayload(input: string | unknown): BackupPayload {
const raw = typeof input === "string" ? JSON.parse(input) : input;
return backupPayloadSchema.parse(raw);
}
/**
* The schemaVersion the system understands today. Used by the upload
* route to reject inbound files written by a *future* HealthLog instance
* — restoring them under the current code might silently drop data the
* new shape carried.
*/
export function isCompatibleSchemaVersion(version: string): boolean {
return version === BACKUP_SCHEMA_VERSION;
}