mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
`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).
182 lines
5.8 KiB
TypeScript
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;
|
|
}
|