feat(import): carry every field the app's own files write

The JSON import read six measurement fields and six mood fields while
the full backup and the JSON export carry twice that. A sleep night came
back as stageless rows, a range reading as a single value, an ECG alert
without its verdict, and a mood entry without its note, its level-A
values, its zone, its tag and factor links or its day context.

The import now reads valueMin/valueMax, sleepStage,
rhythmClassification and deviceType on a reading, and note, a1 to a5,
tz, factors, structuredTags and context on a mood entry. Each mood entry
is written with its links and context in one transaction, the way the
bulk mood ingest writes one. A link to a tag the importing account does
not have is dropped and named in droppedTagKeys / droppedFactorKeys, and
the import card says which. The schema moves to
src/lib/import/json/schema.ts so the guards can read it.

json-import-field-coverage.test.ts runs the JSON export and both backup
variants on an account with every column filled in: every field they
write is either read by the import or named with the reason it is not
(row ids, source, deletion records, ciphertext, sync bookkeeping), and a
reason for a field no exporter writes fails as stale. The round trip
imports each file into a fresh account and compares the re-export field
for field. The published contract is held to the runtime schema.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 21:33:06 +02:00
1 parent 43002d37e8
commit a840e30fac
31 files changed
+1814 -508

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 355,
"total": 114640,
"count": 354,
"total": 114417,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -208,7 +208,6 @@
"components/settings/import-panel/csv-import-card.tsx :: CsvImportCard": 212,
"components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 225,
"components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293,
"components/settings/import-panel/json-import-card.tsx :: JsonImportCard": 223,
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 460,
"components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255,
"components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 230,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2898,
"total": 9294,
"count": 2897,
"total": 9290,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -284,7 +284,6 @@
"app/api/illness/episodes/route.ts": 1,
"app/api/import/__tests__/route.test.ts": 3,
"app/api/import/csv/__tests__/route.test.ts": 2,
"app/api/import/route.ts": 4,
"app/api/ingest/medication/__tests__/route.test.ts": 1,
"app/api/ingest/medication/route.ts": 7,
"app/api/insights/biomarker-assessment/route.ts": 1,
@@ -0,0 +1,75 @@
/**
* The published JSON import contract names the fields the import reads.
*
* `src/lib/openapi/routes/import.ts` restates `jsonImportSchema` by hand,
* because the runtime schema carries transforms the document describes in
* prose. A restatement drifts: a field the import learns to read stays
* undocumented, and one it stops reading stays promised. So the two field
* sets are compared here, per row kind, both ways.
*/
import { describe, expect, it } from "vitest";
import {
jsonImportMeasurementSchema,
jsonImportMoodContextSchema,
jsonImportMoodEntrySchema,
} from "@/lib/import/json/schema";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
type SchemaObject = {
properties?: Record<string, SchemaObject>;
$ref?: string;
allOf?: SchemaObject[];
};
const doc = buildOpenApiDocument() as {
components: { schemas: Record<string, SchemaObject> };
};
function resolve(schema: SchemaObject): SchemaObject {
if (schema.$ref) {
const name = schema.$ref.split("/").pop()!;
return resolve(doc.components.schemas[name]!);
}
if (schema.allOf) {
return {
properties: Object.assign(
{},
...schema.allOf.map((part) => resolve(part).properties ?? {}),
),
};
}
return schema;
}
function documented(name: string): string[] {
const schema = doc.components.schemas[name];
expect(schema, `${name} is published`).toBeDefined();
return Object.keys(resolve(schema!).properties ?? {}).sort();
}
describe("the published JSON import contract", () => {
it("names every measurement field the import reads, and no other", () => {
const fields = documented("JsonImportMeasurement");
expect(fields.length).toBeGreaterThan(5);
expect(fields).toEqual(
Object.keys(jsonImportMeasurementSchema.shape).sort(),
);
});
it("names every mood-entry field the import reads, and no other", () => {
const fields = documented("JsonImportMoodEntry");
expect(fields.length).toBeGreaterThan(5);
expect(fields).toEqual(Object.keys(jsonImportMoodEntrySchema.shape).sort());
});
it("names every day-context field the import reads, and no other", () => {
const entry = resolve(doc.components.schemas.JsonImportMoodEntry!);
const context = resolve(entry.properties!.context!);
const fields = Object.keys(context.properties ?? {}).sort();
expect(fields.length).toBeGreaterThan(10);
expect(fields).toEqual(
Object.keys(jsonImportMoodContextSchema.shape).sort(),
);
});
});
@@ -75,7 +75,7 @@ const WRITERS: Record<string, Disposition> = {
},
"lib/import/json/measurements.ts": {
kind: "upstream",
validatedIn: "app/api/import/route.ts",
validatedIn: "lib/import/json/schema.ts",
},
// The batch ingest's set-based insert writes rows that already passed
// `validateMeasurementRange` per entry in its first pass; everything else
+9 -4
View File
@@ -8,8 +8,8 @@ vi.mock("@/lib/api-handler", () => ({
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
vi.mock("@/lib/db", () => {
const prisma = {
measurement: {
create: vi.fn(),
createManyAndReturn: vi.fn(),
@@ -18,8 +18,13 @@ vi.mock("@/lib/db", () => ({
upsert: vi.fn(),
},
moodEntry: { create: vi.fn(), findUnique: vi.fn(), upsert: vi.fn() },
},
}));
// A mood entry is written in a transaction with its links and context;
// the callback runs against this same client. Given as the mock's own
// implementation, so `resetAllMocks` keeps it.
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(prisma)),
};
return { prisma };
});
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
+20 -90
View File
@@ -1,7 +1,7 @@
/**
* `POST /api/import` — import user data (JSON format matching the export
* structure). The route authenticates, meters and parses the file; the
* writers live in `src/lib/import/json/`. The app's own full backup and JSON
* schema and the writers live in `src/lib/import/json/`. The app's own full backup and JSON
* export are read in their own terms first (`normalizeJsonImportFile`).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -16,94 +16,14 @@ import {
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import {
validateMeasurementRange,
measurementTypeEnum,
glucoseContextEnum,
} from "@/lib/validations/measurement";
import {
isPlausibleEntryInstant,
validateEntryInstant,
} from "@/lib/validations/entry-instant";
import { isoDateTime } from "@/lib/validations/datetime";
import { fallbackMoodDateStart } from "@/lib/import/json/mood";
import { droppedLinkKeysForWire } from "@/lib/mood/tag-links";
import { normalizeJsonImportFile } from "@/lib/import/json/normalize";
import { runJsonImport } from "@/lib/import/json/run";
// Derived from canonical enum so round-trip export → import covers every
// type. Previous hardcoded subset silently dropped 4 of 11 types
// (V3 audit: enum drift cousins).
const measurementSchema = z
.object({
type: measurementTypeEnum,
value: z.number(),
unit: z.string(),
// v1.17.1 — share the canonical entry-instant bound used by the single
// POST + edit paths (v1.17 W1b). Previously a bare datetime check
// with no ceiling, so a crafted JSON import could forward-date a reading.
// `validateEntryInstant` rejects any future instant beyond a 5-min skew
// tolerance and any instant before 1900; the field transforms to a `Date`.
measuredAt: validateEntryInstant(
isoDateTime().transform((s) => new Date(s)),
),
glucoseContext: glucoseContextEnum.optional(),
source: z.string().optional(),
notes: z.string().optional(),
// v1.17.1 — optional source-stable id. When present, the import upserts on
// `(userId, type, source=IMPORT, externalId)` so a re-import of the same
// export is idempotent rather than minting a duplicate set — mirrors the
// mood path. Absent → first-write-wins create with a NULL externalId
// (distinct in the unique key), which DOES duplicate on re-import.
// Stability is enforced per entry in the import loop, not at the
// field: a schema-level refusal would fail the whole file on one bad
// row.
// @external-id-checked-per-entry: src/lib/import/json/measurements.ts
externalId: z.string().min(1).max(120).optional(),
})
.refine(
(data) => validateMeasurementRange(data.type, data.value) === null,
"Wert ausserhalb des plausiblen Bereichs",
);
const moodEntrySchema = z
.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]),
score: z.number().int().min(1).max(5),
tags: z.string().optional(),
// v1.17.1 — same entry-instant bound as measurements; an imported mood
// log cannot be forward-dated. Optional + transforms to a `Date` when set.
loggedAt: validateEntryInstant(
isoDateTime().transform((s) => new Date(s)),
).optional(),
// v1.12.1 — optional source-stable id (e.g. a Daylio row id). When
// present, the import upserts on `(userId, source, externalId)` so a
// re-import of the same export is idempotent rather than minting a
// duplicate; absent → the legacy first-write-wins create path.
// Stability is enforced per entry in the import loop, not at the
// field: a schema-level refusal would fail the whole file on one bad
// row.
// @external-id-checked-per-entry: src/lib/import/json/mood.ts
externalId: z.string().min(1).max(120).optional(),
})
.superRefine((entry, ctx) => {
if (entry.loggedAt) return;
const dateStart = fallbackMoodDateStart(entry.date);
if (!dateStart || !isPlausibleEntryInstant(dateStart)) {
ctx.addIssue({
code: "custom",
path: ["date"],
message: "Mood date must be between 1900 and today",
});
}
});
const importSchema = z.object({
measurements: z.array(measurementSchema).max(10000).optional(),
moodEntries: z.array(moodEntrySchema).max(10000).optional(),
});
import {
JSON_IMPORT_MAX_BYTES,
jsonImportSchema,
} from "@/lib/import/json/schema";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -118,12 +38,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024 * 1024,
maxBytes: JSON_IMPORT_MAX_BYTES,
});
if (jsonError) return jsonError;
const normalized = normalizeJsonImportFile(body);
const parsed = importSchema.safeParse(normalized.file);
const parsed = jsonImportSchema.safeParse(normalized.file);
if (!parsed.success) {
return apiValidationError(
parsed.error.issues[0]?.message ?? "Invalid import payload",
@@ -132,13 +52,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
const { stats: written, writeFailed } = await runJsonImport({
const {
stats: written,
writeFailed,
droppedLinkKeys,
} = await runJsonImport({
userId: user.id,
measurements: parsed.data.measurements,
moodEntries: parsed.data.moodEntries,
ipAddress: getClientIp(request),
});
const stats = { ...written, skipped: written.skipped + normalized.skipped };
const stats = {
...written,
skipped: written.skipped + normalized.skipped,
// The mood links the account could not resolve, by key, only when there
// are any: "imported" alone told the person their tags had landed.
...droppedLinkKeysForWire(droppedLinkKeys),
};
if (writeFailed) {
return apiError("Import write failed", 503, {
@@ -0,0 +1,54 @@
/**
* What the JSON import card says once a file is in.
*
* SSR-only, like the sibling card tests: the markup each result renders. The
* claim worth pinning is the one the counts cannot make. A mood entry whose
* tag the account does not have lands without that link, and the card names
* the tag rather than reporting a clean import.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
JsonImportResultView,
readJsonImportResult,
} from "../json-import-card";
function render(data: Parameters<typeof readJsonImportResult>[0]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<JsonImportResultView result={readJsonImportResult(data)} />
</I18nProvider>,
);
}
describe("<JsonImportResultView>", () => {
it("names the tags an import could not link", () => {
const html = render({
measurements: 3,
moodEntries: 2,
skipped: 0,
droppedTagKeys: ["custom:long_walk"],
droppedFactorKeys: ["retired_factor"],
});
expect(html).toContain('data-testid="import-json-dropped-links"');
expect(html).toContain("custom:long_walk, retired_factor");
expect(html).toContain("Imported 3 measurements and 2 mood entries");
});
it("says nothing about links when every one landed", () => {
const html = render({ measurements: 3, moodEntries: 2, skipped: 0 });
expect(html).not.toContain("import-json-dropped-links");
});
it("reads a missing count as none", () => {
expect(readJsonImportResult(null)).toEqual({
measurements: 0,
moodEntries: 0,
skipped: 0,
droppedLinkKeys: [],
});
});
});
@@ -18,45 +18,90 @@ import {
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome";
interface JsonImportResult {
export interface JsonImportResult {
measurements: number;
moodEntries: number;
skipped: number;
/**
* Tag and factor keys of imported mood entries this account has no tag
* for (another account's own tags, mostly). The entries landed without
* those links, which the count alone would not say.
*/
droppedLinkKeys: string[];
}
export function JsonImportCard() {
/** The route's `data`, read field by field; a missing count reads as none. */
export function readJsonImportResult(
data: {
measurements?: number;
moodEntries?: number;
skipped?: number;
droppedTagKeys?: string[];
droppedFactorKeys?: string[];
} | null,
): JsonImportResult {
return {
measurements: data?.measurements ?? 0,
moodEntries: data?.moodEntries ?? 0,
skipped: data?.skipped ?? 0,
droppedLinkKeys: [
...(data?.droppedTagKeys ?? []),
...(data?.droppedFactorKeys ?? []),
],
};
}
/** What an import wrote, and the links it could not make. */
export function JsonImportResultView({ result }: { result: JsonImportResult }) {
const { t } = useTranslations();
// Same reasoning as the CSV card: the JSON route answers 200 with a per-
// concept count envelope, so a payload where every entry was refused used
// to render the success tick. The counts decide the tone.
const jsonOutcome = (r: JsonImportResult) =>
classifyWrittenOutcome({
written: r.measurements + r.moodEntries,
skipped: r.skipped,
});
const jsonMessage = (r: JsonImportResult) => {
const outcome = jsonOutcome(r);
if (outcome === "empty") {
return t("settings.sections.export.import.json.resultEmpty");
}
if (outcome === "failed") {
return t("settings.sections.export.import.json.resultNothing", {
skipped: r.skipped,
});
}
if (outcome === "partial") {
return t("settings.sections.export.import.json.resultSummary", {
measurements: r.measurements,
moods: r.moodEntries,
skipped: r.skipped,
});
}
return t("settings.sections.export.import.json.resultSuccess", {
measurements: r.measurements,
moods: r.moodEntries,
});
};
const outcome = classifyWrittenOutcome({
written: result.measurements + result.moodEntries,
skipped: result.skipped,
});
const message =
outcome === "empty"
? t("settings.sections.export.import.json.resultEmpty")
: outcome === "failed"
? t("settings.sections.export.import.json.resultNothing", {
skipped: result.skipped,
})
: outcome === "partial"
? t("settings.sections.export.import.json.resultSummary", {
measurements: result.measurements,
moods: result.moodEntries,
skipped: result.skipped,
})
: t("settings.sections.export.import.json.resultSuccess", {
measurements: result.measurements,
moods: result.moodEntries,
});
return (
<>
<WrittenOutcomeLine
outcome={outcome}
message={message}
testId="import-json-result"
/>
{result.droppedLinkKeys.length > 0 && (
<p
className="text-muted-foreground text-xs"
data-testid="import-json-dropped-links"
>
{t("settings.sections.export.import.json.droppedLinks", {
keys: result.droppedLinkKeys.join(", "),
})}
</p>
)}
</>
);
}
export function JsonImportCard() {
const { t } = useTranslations();
const fileInputRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState("");
@@ -112,12 +157,7 @@ export function JsonImportCard() {
setError(t("settings.sections.export.import.json.failed"));
return;
}
const data = (await res.json()).data as JsonImportResult;
setResult({
measurements: data?.measurements ?? 0,
moodEntries: data?.moodEntries ?? 0,
skipped: data?.skipped ?? 0,
});
setResult(readJsonImportResult((await res.json()).data ?? null));
} catch {
setError(t("settings.sections.export.import.json.failed"));
} finally {
@@ -180,13 +220,7 @@ export function JsonImportCard() {
</p>
<div aria-live="polite" className="space-y-2">
{result && (
<WrittenOutcomeLine
outcome={jsonOutcome(result)}
message={jsonMessage(result)}
testId="import-json-result"
/>
)}
{result && <JsonImportResultView result={result} />}
{error && (
<p
role="alert"
+27 -36
View File
@@ -7,29 +7,37 @@
* one it is a first-write-wins create with a NULL external id, which does
* duplicate on re-import.
*/
import type { z } from "zod/v4";
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import type {
glucoseContextEnum,
measurementTypeEnum,
} from "@/lib/validations/measurement";
import { classifyExternalId } from "@/lib/validations/external-id";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { isP2002, isP2025 } from "@/lib/prisma-errors";
import type { JsonImportMeasurement } from "./schema";
import type { JsonImportTally } from "./tally";
export interface JsonImportMeasurement {
type: z.infer<typeof measurementTypeEnum>;
value: number;
unit: string;
measuredAt: Date;
glucoseContext?: z.infer<typeof glucoseContextEnum>;
source?: string;
notes?: string;
externalId?: string;
export type { JsonImportMeasurement } from "./schema";
/**
* A reading's own columns beyond its value: what the reading says, carried
* whole so a range keeps its bounds, a sleep row its stage and an ECG its
* verdict. Every arm of the write states them, so a re-import of the same
* file puts back what an edit in between changed.
*/
function readingColumns(m: JsonImportMeasurement) {
return {
value: m.value,
valueMin: m.valueMin ?? null,
valueMax: m.valueMax ?? null,
unit: m.unit,
measuredAt: m.measuredAt,
glucoseContext: m.glucoseContext ?? null,
sleepStage: m.sleepStage ?? null,
rhythmClassification: m.rhythmClassification ?? null,
deviceType: m.deviceType ?? null,
notes: null,
notesEncrypted: encryptNote(m.notes || null),
};
}
export interface ImportedMeasurements {
@@ -72,8 +80,8 @@ export async function importJsonMeasurements(
try {
// `measuredAt` is already a `Date` (validateEntryInstant transform).
const measuredAt = m.measuredAt;
const columns = readingColumns(m);
if (m.externalId) {
const notesEncrypted = encryptNote(m.notes || null);
// INSERT ... RETURNING is the only insertion proof. A concurrent
// importer can win this key after any existence probe, so attempt
// the insert first and update only when the statement returns no row.
@@ -82,14 +90,9 @@ export async function importJsonMeasurements(
{
userId,
type: m.type,
value: m.value,
unit: m.unit,
source: "IMPORT",
externalId: m.externalId,
measuredAt,
notes: null,
notesEncrypted,
glucoseContext: m.glucoseContext ?? null,
...columns,
},
],
skipDuplicates: true,
@@ -112,14 +115,7 @@ export async function importJsonMeasurements(
externalId: m.externalId,
},
},
data: {
value: m.value,
unit: m.unit,
measuredAt,
notes: null,
notesEncrypted,
glucoseContext: m.glucoseContext ?? null,
},
data: columns,
});
} catch (err) {
if (isP2025(err)) {
@@ -134,13 +130,8 @@ export async function importJsonMeasurements(
data: {
userId,
type: m.type,
value: m.value,
unit: m.unit,
source: "IMPORT",
measuredAt,
notes: null,
notesEncrypted: encryptNote(m.notes || null),
glucoseContext: m.glucoseContext ?? null,
...columns,
},
select: { id: true, type: true, measuredAt: true },
});
+23
View File
@@ -0,0 +1,23 @@
/**
* The day bound of a JSON-imported mood entry without a logged instant, on
* its own so the import schema can bound such a row without reaching the
* writer and the database client behind it.
*/
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
/**
* The start of a `YYYY-MM-DD` day in the default zone, or null when the
* string does not name a real day. The route's schema uses it to bound a row
* without a logged instant.
*/
export function fallbackMoodDateStart(date: string): Date | null {
const [year, month, day] = date.split("-").map(Number);
if (!year || !month || !day) return null;
const instant = zonedWallClockToUtc(
{ year, month, day, hour: 0, minute: 0 },
DEFAULT_TIMEZONE,
);
return moodDateKey(instant, DEFAULT_TIMEZONE) === date ? instant : null;
}
+119 -78
View File
@@ -6,43 +6,34 @@
* derived from the row's content and its occurrence in the file, so the same
* file lands on the same instants every time and twin rows land on distinct
* ones.
*
* Each entry is written with its note, its level-A values, its zone, its day
* context and its tag and factor links, in one transaction, the way the bulk
* mood ingest writes one: a link that throws (a rating outside its factor's
* own scale) takes the entry back with it, so a skipped row left nothing
* behind.
*/
import { createHash } from "node:crypto";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { ENTRY_INSTANT_CLOCK_SKEW_MS } from "@/lib/validations/entry-instant";
import { classifyExternalId } from "@/lib/validations/external-id";
import { isP2002 } from "@/lib/prisma-errors";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { deriveA1 } from "@/lib/mood/level-a";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import { levelAForWrite } from "@/lib/mood/level-a";
import { persistMoodContext } from "@/lib/mood/context";
import {
RatedFactorOutOfRangeError,
replaceRatedFactorLinks,
replaceTagLinks,
} from "@/lib/mood/tag-links";
import { fallbackMoodDateStart } from "./mood-instant";
import type { JsonImportMoodEntry } from "./schema";
import type { JsonImportTally } from "./tally";
export interface JsonImportMoodEntry {
date: string;
mood: "SUPER_GUT" | "GUT" | "OKAY" | "SCHLECHT" | "LAUSIG";
score: number;
tags?: string;
loggedAt?: Date;
externalId?: string;
}
/**
* The start of a `YYYY-MM-DD` day in the default zone, or null when the
* string does not name a real day. The route's schema uses it to bound a row
* without a logged instant.
*/
export function fallbackMoodDateStart(date: string): Date | null {
const [year, month, day] = date.split("-").map(Number);
if (!year || !month || !day) return null;
const instant = zonedWallClockToUtc(
{ year, month, day, hour: 0, minute: 0 },
DEFAULT_TIMEZONE,
);
return moodDateKey(instant, DEFAULT_TIMEZONE) === date ? instant : null;
}
export type { JsonImportMoodEntry } from "./schema";
function fallbackMoodIdentity(entry: JsonImportMoodEntry): string {
return JSON.stringify([
@@ -75,65 +66,109 @@ interface FallbackState {
reserved: Set<number>;
}
/**
* The entry's own columns, as both arms of the write state them. The level-A
* values follow `levelAForWrite`: the file's `a1` wins over the one derived
* from the label, and an `a2` to `a5` the file leaves out is left alone on a
* row the import updates rather than blanked.
*/
function moodEntryColumns(e: JsonImportMoodEntry, loggedAt: Date) {
return {
date: e.date,
mood: e.mood,
score: e.score,
...levelAForWrite(e.mood, {
a1: e.a1,
a2: e.a2,
a3: e.a3,
a4: e.a4,
a5: e.a5,
}),
tags: e.tags || null,
note: null,
noteEncrypted: encryptNote(e.note ?? null),
...(e.tz === undefined ? {} : { tz: e.tz }),
moodLoggedAt: loggedAt,
};
}
/** Link keys an entry's write could not resolve. */
interface DroppedKeys {
tags: Set<string>;
factors: Set<string>;
}
/** The entry's links and day context, when the file states them. */
async function writeMoodEntryParts(
tx: Prisma.TransactionClient,
userId: string,
moodEntryId: string,
e: JsonImportMoodEntry,
dropped: DroppedKeys,
): Promise<void> {
await persistMoodContext(tx, moodEntryId, userId, e.context);
if (e.factors !== undefined) {
const keys = await replaceRatedFactorLinks(
moodEntryId,
userId,
e.factors,
tx,
);
for (const key of keys) dropped.factors.add(key);
}
if (e.structuredTags !== undefined) {
const keys = await replaceTagLinks(
moodEntryId,
userId,
e.structuredTags,
tx,
);
for (const key of keys) dropped.tags.add(key);
}
}
async function writeMoodEntry(
userId: string,
e: JsonImportMoodEntry,
loggedAt: Date,
tally: JsonImportTally,
): Promise<void> {
if (e.externalId) {
const columns = moodEntryColumns(e, loggedAt);
// Links resolve against what the importing account can see. Collected
// here and handed to the tally only once the transaction committed, so a
// rolled-back entry reports nothing.
const dropped: DroppedKeys = { tags: new Set(), factors: new Set() };
await prisma.$transaction(async (tx) => {
// Idempotent re-import keyed on the source-stable id. A second import
// of the same export updates the row in place rather than skipping it as
// a duplicate, so an upstream edit (re-scored mood, added tag) is
// reflected.
await prisma.moodEntry.upsert({
where: {
userId_source_externalId: {
userId,
source: "IMPORT",
externalId: e.externalId,
},
},
update: {
date: e.date,
mood: e.mood,
score: e.score,
// Pleasantness derived from the row's own label, not from the file's
// `score`. This is the one writer whose score comes out of the file
// rather than out of the label, so the two can disagree; the column
// that records a self-assessment has to agree with the label the
// user actually picked.
moodA1: deriveA1(e.mood),
tags: e.tags || null,
moodLoggedAt: loggedAt,
},
create: {
userId,
date: e.date,
mood: e.mood,
score: e.score,
moodA1: deriveA1(e.mood),
tags: e.tags || null,
source: "IMPORT",
externalId: e.externalId,
moodLoggedAt: loggedAt,
},
});
} else {
await prisma.moodEntry.create({
data: {
userId,
date: e.date,
mood: e.mood,
score: e.score,
// See the upsert arm above: derived from the label, because an
// imported file's `score` is its own field.
moodA1: deriveA1(e.mood),
tags: e.tags || null,
source: "IMPORT",
moodLoggedAt: loggedAt,
},
});
}
const row = e.externalId
? await tx.moodEntry.upsert({
where: {
userId_source_externalId: {
userId,
source: "IMPORT",
externalId: e.externalId,
},
},
update: columns,
create: {
userId,
...columns,
source: "IMPORT",
externalId: e.externalId,
},
select: { id: true },
})
: await tx.moodEntry.create({
data: { userId, ...columns, source: "IMPORT" },
select: { id: true },
});
await writeMoodEntryParts(tx, userId, row.id, e, dropped);
});
for (const key of dropped.tags) tally.droppedTagKeys.add(key);
for (const key of dropped.factors) tally.droppedFactorKeys.add(key);
}
/**
@@ -199,13 +234,19 @@ async function importMoodEntry(
continue;
}
try {
await writeMoodEntry(userId, e, loggedAt);
await writeMoodEntry(userId, e, loggedAt, tally);
if (!e.loggedAt) {
fallback.reserved.add(loggedAt.getTime());
}
tally.stats.moodEntries++;
return;
} catch (err) {
// A rating outside its factor's own scale refuses that one entry, the
// way the bulk ingest refuses it; the transaction took the row back.
if (err instanceof RatedFactorOutOfRangeError) {
tally.stats.skipped++;
return;
}
if (!isP2002(err)) {
tally.writeFailed = true;
return;
+16 -2
View File
@@ -11,6 +11,7 @@ import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import type { DroppedLinkKeys } from "@/lib/mood/tag-links";
import {
importJsonMeasurements,
@@ -24,7 +25,11 @@ export async function runJsonImport(input: {
measurements: JsonImportMeasurement[] | undefined;
moodEntries: JsonImportMoodEntry[] | undefined;
ipAddress: string | null;
}): Promise<Pick<JsonImportTally, "stats" | "writeFailed">> {
}): Promise<
Pick<JsonImportTally, "stats" | "writeFailed"> & {
droppedLinkKeys: DroppedLinkKeys;
}
> {
const { userId } = input;
const tally = newJsonImportTally();
@@ -80,6 +85,8 @@ export async function runJsonImport(input: {
annotate({
meta: {
import_stats: tally.stats,
import_dropped_link_keys:
tally.droppedTagKeys.size + tally.droppedFactorKeys.size,
...(tally.unstableShapes.length > 0
? unstableExternalIdMeta("import.json", tally.unstableShapes)
: {}),
@@ -97,5 +104,12 @@ export async function runJsonImport(input: {
annotate({ meta: { import_audit_failed: true } });
}
return { stats: tally.stats, writeFailed: tally.writeFailed };
return {
stats: tally.stats,
writeFailed: tally.writeFailed,
droppedLinkKeys: {
droppedTagKeys: [...tally.droppedTagKeys].sort(),
droppedFactorKeys: [...tally.droppedFactorKeys].sort(),
},
};
}
+184
View File
@@ -0,0 +1,184 @@
/**
* The schema of `POST /api/import`, one file of the app's own JSON back in.
*
* Two of the app's files feed it: the full backup's portable half and the
* JSON export (both read in their own terms first, `normalize.ts`). What a
* reading or a mood entry says in either file has a field here, and
* `json-import-field-coverage.test.ts` holds the two exporters to it: a field
* either exporter writes is either read below or named there with the reason
* the import leaves it out.
*
* Lives here rather than in the route because the route module may export
* nothing but its handlers, and the guard needs the field set.
*/
import { z } from "zod/v4";
import { RhythmClassification } from "@/generated/prisma/enums";
import { decodeKeyList } from "@/lib/mood/context-vocabulary";
import { isValidTimezone } from "@/lib/tz/format";
import { isoDateTime } from "@/lib/validations/datetime";
import {
isPlausibleEntryInstant,
validateEntryInstant,
} from "@/lib/validations/entry-instant";
import {
glucoseContextEnum,
measurementTypeEnum,
validateMeasurementRange,
} from "@/lib/validations/measurement";
import { moodContextSchema, moodLevelEnum } from "@/lib/validations/mood";
import { SLEEP_STAGE_NAMES } from "@/lib/validations/sleep-stage-entry";
import { fallbackMoodDateStart } from "./mood-instant";
/** Rows per section, and the file as a whole, as the route has always capped them. */
export const JSON_IMPORT_MAX_ROWS = 10_000;
export const JSON_IMPORT_MAX_BYTES = 16 * 1024 * 1024;
// The type is the canonical enum, so every type the exporters write comes
// back; a hand-kept subset once dropped four of eleven.
export const jsonImportMeasurementSchema = z
.object({
type: measurementTypeEnum,
value: z.number(),
// A range reading's bounds (an hourly heart-rate statistic's min and
// max). Without them the reading comes back as a single value.
valueMin: z.number().finite().optional(),
valueMax: z.number().finite().optional(),
unit: z.string(),
// The canonical entry-instant bound the single POST and edit paths use,
// so a crafted file cannot forward-date a reading. `validateEntryInstant`
// rejects any future instant beyond a 5-min skew tolerance and any
// instant before 1900; the field transforms to a `Date`.
measuredAt: validateEntryInstant(
isoDateTime().transform((s) => new Date(s)),
),
glucoseContext: glucoseContextEnum.optional(),
// A sleep row's stage. Without it a night of stages comes back as that
// many undifferentiated sleep readings.
sleepStage: z.enum(SLEEP_STAGE_NAMES).optional(),
// A device's own verdict on an ECG, a rhythm alert or a steadiness
// score: the reading's meaning, not a detail of it.
rhythmClassification: z.enum(RhythmClassification).optional(),
// Which device wrote the reading (watch, phone). The per-night sleep
// collapse picks between writers by it.
deviceType: z.string().min(1).max(32).optional(),
notes: z.string().optional(),
// Optional source-stable id. When present, the import upserts on
// `(userId, type, source=IMPORT, externalId)` so a re-import of the same
// export is idempotent rather than minting a duplicate set — mirrors the
// mood path. Absent → first-write-wins create with a NULL externalId
// (distinct in the unique key), which DOES duplicate on re-import.
// Stability is enforced per entry in the import loop, not at the
// field: a schema-level refusal would fail the whole file on one bad
// row.
// @external-id-checked-per-entry: src/lib/import/json/measurements.ts
externalId: z.string().min(1).max(120).optional(),
})
.refine(
(data) => validateMeasurementRange(data.type, data.value) === null,
"Wert ausserhalb des plausiblen Bereichs",
)
.refine(
(data) =>
data.valueMin === undefined ||
data.valueMax === undefined ||
data.valueMin <= data.valueMax,
{ message: "valueMin must be <= valueMax", path: ["valueMin"] },
);
/**
* A multi-select of the day context. The backup writes it as the stored JSON
* string, unparsed; every other writer sends the list. Both read as the list.
*/
function keyList<T extends z.ZodType>(list: T) {
return z.preprocess(
(value) => (typeof value === "string" ? decodeKeyList(value) : value),
list,
);
}
export const jsonImportMoodContextSchema = moodContextSchema.extend({
contactCircles: keyList(moodContextSchema.shape.contactCircles),
leisureCategories: keyList(moodContextSchema.shape.leisureCategories),
});
const levelADimension = z.number().int().min(0).max(10);
export const jsonImportMoodEntrySchema = z
.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
mood: moodLevelEnum,
score: z.number().int().min(1).max(5),
// The five level-A values. `a1` is derived from `mood` when the file
// leaves it out; the other four are only ever what the person answered.
a1: levelADimension.optional(),
a2: levelADimension.optional(),
a3: levelADimension.optional(),
a4: levelADimension.optional(),
a5: levelADimension.optional(),
tags: z.string().optional(),
// Capped like every other mood note.
note: z.string().max(500).optional(),
// The same entry-instant bound as measurements; an imported mood log
// cannot be forward-dated. Optional, and a `Date` when set.
loggedAt: validateEntryInstant(
isoDateTime().transform((s) => new Date(s)),
).optional(),
// The zone the `date` string is anchored to. Absent, the entry keeps the
// legacy reading, which is what an entry without one always meant.
tz: z
.string()
.max(64)
.refine(isValidTimezone, { message: "Unknown time zone" })
.optional(),
// The entry's rated factors and ticked tags, by catalogue key. A key the
// importing account cannot resolve costs that one link, and is counted.
factors: z
.array(
z.object({
key: z.string().max(60),
rating: z.number().int().min(1).max(5),
}),
)
.max(30)
.optional(),
structuredTags: z.array(z.string().max(60)).max(30).optional(),
// The day context. Absent says nothing about it; a file that carries one
// replaces whatever the entry held.
context: jsonImportMoodContextSchema.optional(),
// Optional source-stable id (e.g. a Daylio row id). When
// present, the import upserts on `(userId, source, externalId)` so a
// re-import of the same export is idempotent rather than minting a
// duplicate; absent → the legacy first-write-wins create path.
// Stability is enforced per entry in the import loop, not at the
// field: a schema-level refusal would fail the whole file on one bad
// row.
// @external-id-checked-per-entry: src/lib/import/json/mood.ts
externalId: z.string().min(1).max(120).optional(),
})
.superRefine((entry, ctx) => {
if (entry.loggedAt) return;
const dateStart = fallbackMoodDateStart(entry.date);
if (!dateStart || !isPlausibleEntryInstant(dateStart)) {
ctx.addIssue({
code: "custom",
path: ["date"],
message: "Mood date must be between 1900 and today",
});
}
});
export const jsonImportSchema = z.object({
measurements: z
.array(jsonImportMeasurementSchema)
.max(JSON_IMPORT_MAX_ROWS)
.optional(),
moodEntries: z
.array(jsonImportMoodEntrySchema)
.max(JSON_IMPORT_MAX_ROWS)
.optional(),
});
export type JsonImportMeasurement = z.infer<typeof jsonImportMeasurementSchema>;
export type JsonImportMoodEntry = z.infer<typeof jsonImportMoodEntrySchema>;
+9
View File
@@ -4,6 +4,13 @@ import type { UnstableExternalIdShape } from "@/lib/validations/external-id";
export interface JsonImportTally {
stats: { measurements: number; moodEntries: number; skipped: number };
unstableShapes: UnstableExternalIdShape[];
/**
* Link keys of imported mood entries that the importing account cannot
* resolve (a custom tag of the account the file came from, a retired
* catalogue key). The entry lands without that link; the key is named.
*/
droppedTagKeys: Set<string>;
droppedFactorKeys: Set<string>;
/** A write failed for a reason other than a duplicate; the import stops. */
writeFailed: boolean;
}
@@ -12,6 +19,8 @@ export function newJsonImportTally(): JsonImportTally {
return {
stats: { measurements: 0, moodEntries: 0, skipped: 0 },
unstableShapes: [],
droppedTagKeys: new Set(),
droppedFactorKeys: new Set(),
writeFailed: false,
};
}
+105 -11
View File
@@ -16,6 +16,9 @@ import { z } from "zod/v4";
import { glucoseContextEnum } from "@/lib/validations/measurement";
import { moodLevelEnum } from "@/lib/validations/mood";
import { SLEEP_STAGE_NAMES } from "@/lib/validations/sleep-stage-entry";
import { RhythmClassification } from "@/generated/prisma/enums";
import { moodContextWire } from "./mood";
import {
dataEnvelope,
errorEnvelope,
@@ -94,10 +97,10 @@ const csvImportResponse = z
"Per-row import outcome. Mirrors the batch-route per-entry envelope. In `dryRun` mode no write runs and every valid row reports its projected `inserted` status.",
});
// ── JSON import — mirrors the route-local `importSchema`
// (`src/app/api/import/route.ts`), which is deliberately not exported: the
// runtime side carries transforms + plausibility refinements the document
// restates in prose.
// ── JSON import — mirrors `jsonImportSchema`
// (`src/lib/import/json/schema.ts`). The runtime side carries transforms and
// plausibility refinements the document restates in prose; its field set is
// held to this one by `json-import-field-coverage.test.ts`.
const jsonImportMeasurement = z
.object({
@@ -107,6 +110,13 @@ const jsonImportMeasurement = z
.describe(
"Checked against the type's server-side plausibility range; an out-of-range value fails the whole request with 422.",
),
valueMin: z
.number()
.optional()
.describe(
"Lower bound of a range reading (an hourly heart-rate statistic). Not above `valueMax`.",
),
valueMax: z.number().optional().describe("Upper bound of a range reading."),
unit: z.string(),
measuredAt: isoDateTime().describe(
"Bounded: no future instants beyond a 5-min clock-skew tolerance, nothing before 1900.",
@@ -116,12 +126,22 @@ const jsonImportMeasurement = z
.describe(
"Optional even on a BLOOD_GLUCOSE row — a sensor export classifies nothing per sample.",
),
source: z
.string()
sleepStage: z
.enum(SLEEP_STAGE_NAMES)
.optional()
.describe("A sleep row's stage."),
rhythmClassification: z
.enum(RhythmClassification)
.optional()
.describe(
"Accepted for export-format compatibility and ignored: every imported row stores `source = IMPORT`.",
"The device's own verdict on an ECG, a rhythm alert or a steadiness reading.",
),
deviceType: z
.string()
.min(1)
.max(32)
.optional()
.describe("The writer of the reading (watch, phone)."),
notes: z.string().optional().describe("Encrypted at rest."),
externalId: z
.string()
@@ -150,17 +170,79 @@ const jsonImportMoodEntry = z
.min(1)
.max(5)
.describe(
"Stored as sent — the export format's own field. Pleasantness (`a1`) is re-derived from `mood` on every imported row, never taken from this.",
"Stored as sent — the export format's own field. Pleasantness (`a1`) is never taken from this.",
),
a1: z
.number()
.int()
.min(0)
.max(10)
.optional()
.describe("Pleasantness, 0–10. Absent → derived from `mood`."),
a2: z.number().int().min(0).max(10).optional().describe("Stress, 0–10."),
a3: z.number().int().min(0).max(10).optional().describe("Energy, 0–10."),
a4: z
.number()
.int()
.min(0)
.max(10)
.optional()
.describe("Connection, 0–10."),
a5: z
.number()
.int()
.min(0)
.max(10)
.optional()
.describe(
"Stability, 0–10. `a2` to `a5` absent leave a row the import updates as it was.",
),
tags: z
.string()
.optional()
.describe("JSON-encoded array of tag strings, stored verbatim."),
note: z
.string()
.max(500)
.optional()
.describe("The entry's note. Encrypted at rest."),
loggedAt: isoDateTime()
.optional()
.describe(
"Bounded like `measuredAt`. Absent → the server derives a stable per-row instant within the `date` day, so a re-import of the same file dedups instead of duplicating.",
),
tz: z
.string()
.max(64)
.optional()
.describe(
"IANA zone the `date` is anchored to. Absent → the legacy reading.",
),
factors: z
.array(
z.object({
key: z.string().max(60),
rating: z.number().int().min(1).max(5),
}),
)
.max(30)
.optional()
.describe(
"Rated factors by catalogue key; replaces the entry's rated links. A key the account cannot resolve is dropped and named in `droppedFactorKeys`; a rating outside the factor's own scale skips the entry.",
),
structuredTags: z
.array(z.string().max(60))
.max(30)
.optional()
.describe(
"Ticked tags by key (the catalogue or the account's own); replaces the entry's tag links. An unresolvable key is dropped and named in `droppedTagKeys`.",
),
context: moodContextWire
.partial()
.optional()
.describe(
"The day context; replaces the entry's context. `contactCircles` and `leisureCategories` are also read as the backup's JSON-encoded string.",
),
externalId: z
.string()
.min(1)
@@ -183,7 +265,7 @@ const jsonImportRequest = z
.meta({
id: "JsonImportRequest",
description:
"JSON import payload, matching the structure `GET /api/export/json` emits. Both sections optional; up to 10 000 rows each.",
"JSON import payload: the measurements and mood entries of the JSON export or the portable full backup. Both sections optional; up to 10 000 rows each.",
});
const jsonImportStats = z
@@ -203,7 +285,19 @@ const jsonImportStats = z
.int()
.nonnegative()
.describe(
"Rows not written: duplicates of existing rows, and rows carrying an unstable `externalId`.",
"Rows not written: duplicates of existing rows, rows carrying an unstable `externalId`, mood entries with a factor rating outside its own scale, and a backup's deletion records.",
),
droppedTagKeys: z
.array(z.string())
.optional()
.describe(
"Tag keys of imported mood entries this account cannot resolve (another account's custom tag, a retired key). The entries landed without them. Present only when non-empty.",
),
droppedFactorKeys: z
.array(z.string())
.optional()
.describe(
"Rated-factor keys this account cannot resolve, as `droppedTagKeys`.",
),
})
.meta({
@@ -425,7 +519,7 @@ export const importPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Import"],
summary: "Import measurements and mood entries from JSON",
description:
"Restores a JSON export (the structure `GET /api/export/json` emits): up to 10 000 measurements plus 10 000 mood entries per call, body ≤ 16 MB. Rows carrying an `externalId` upsert idempotently (re-import updates in place); rows without one are first-write-wins and dedup against the existing unique keys, so an exact duplicate is `skipped` rather than doubled. A row with an unstable `externalId` is skipped while the rest of the file still lands. Every imported measurement stores `source = IMPORT`; every imported mood entry re-derives pleasantness from its label. Writes are per-row, not transactional: on a mid-file write failure the response is 503 (`meta.errorCode` = `import.write_failed`, `meta.retryable` = true) with `meta.stats` carrying what landed — rows already written stay written, and a retry is only safe against duplication for rows that carry an `externalId`. Validation is fail-fast (the 422 carries the first issue's message, not the multi-issue envelope). Rate-limited 5/hour per user (shared `import:` bucket with the CSV importer).",
"Imports the app's own files back: the JSON export (`GET /api/export?format=json`, read inside its `data` envelope) or the portable full backup (`GET /api/export/full-backup`). Every field either file writes for a reading or a mood entry is carried, apart from the row ids, the source and the sync bookkeeping; a backup's deletion records are skipped and counted. Up to 10 000 measurements plus 10 000 mood entries per call, body ≤ 16 MB. Rows carrying an `externalId` upsert idempotently (re-import updates in place); rows without one are first-write-wins and dedup against the existing unique keys, so an exact duplicate is `skipped` rather than doubled. A row with an unstable `externalId` is skipped while the rest of the file still lands. Every imported row stores `source = IMPORT`; a mood entry without `a1` derives pleasantness from its label. Writes are per-row, not transactional: on a mid-file write failure the response is 503 (`meta.errorCode` = `import.write_failed`, `meta.retryable` = true) with `meta.stats` carrying what landed — rows already written stay written, and a retry is only safe against duplication for rows that carry an `externalId`. Validation is fail-fast (the 422 carries the first issue's message, not the multi-issue envelope). Rate-limited 5/hour per user (shared `import:` bucket with the CSV importer).",
requestBody: {
required: true,
content: { "application/json": { schema: jsonImportRequest } },