mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 } },
|
||||
|
||||
Reference in new issue
Block a user