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

+274 -17
View File
@@ -16162,16 +16162,19 @@ 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)."
description: "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:
@@ -33206,8 +33209,8 @@ components:
type: array
items:
$ref: "#/components/schemas/JsonImportMoodEntry"
description: JSON import payload, matching the structure `GET /api/export/json` emits. Both sections optional; up to 10
000 rows each.
description: "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."
JsonImportMeasurement:
type: object
properties:
@@ -33217,6 +33220,12 @@ components:
type: number
description: Checked against the type's server-side plausibility range; an out-of-range value fails the whole request
with 422.
valueMin:
description: Lower bound of a range reading (an hourly heart-rate statistic). Not above `valueMax`.
type: number
valueMax:
description: Upper bound of a range reading.
type: number
unit:
type: string
measuredAt:
@@ -33232,9 +33241,31 @@ components:
- POSTPRANDIAL
- RANDOM
- BEDTIME
source:
description: "Accepted for export-format compatibility and ignored: every imported row stores `source = IMPORT`."
sleepStage:
description: A sleep row's stage.
type: string
enum:
- IN_BED
- ASLEEP
- AWAKE
- CORE
- DEEP
- REM
rhythmClassification:
description: The device's own verdict on an ECG, a rhythm alert or a steadiness reading.
type: string
enum:
- IRREGULAR
- NOT_DETECTED
- INCONCLUSIVE
- LOW
- VERY_LOW
- FIRED
deviceType:
description: The writer of the reading (watch, phone).
type: string
minLength: 1
maxLength: 32
notes:
description: Encrypted at rest.
type: string
@@ -33271,17 +33302,231 @@ components:
type: integer
minimum: 1
maximum: 5
description: Stored as sent — the export format's own field. Pleasantness (`a1`) is re-derived from `mood` on every
imported row, never taken from this.
description: Stored as sent — the export format's own field. Pleasantness (`a1`) is never taken from this.
a1:
description: Pleasantness, 0–10. Absent → derived from `mood`.
type: integer
minimum: 0
maximum: 10
a2:
description: Stress, 0–10.
type: integer
minimum: 0
maximum: 10
a3:
description: Energy, 0–10.
type: integer
minimum: 0
maximum: 10
a4:
description: Connection, 0–10.
type: integer
minimum: 0
maximum: 10
a5:
description: Stability, 0–10. `a2` to `a5` absent leave a row the import updates as it was.
type: integer
minimum: 0
maximum: 10
tags:
description: JSON-encoded array of tag strings, stored verbatim.
type: string
note:
description: The entry's note. Encrypted at rest.
type: string
maxLength: 500
loggedAt:
description: 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.
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
tz:
description: IANA zone the `date` is anchored to. Absent → the legacy reading.
type: string
maxLength: 64
factors:
description: 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.
maxItems: 30
type: array
items:
type: object
properties:
key:
type: string
maxLength: 60
rating:
type: integer
minimum: 1
maximum: 5
required:
- key
- rating
structuredTags:
description: 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`.
maxItems: 30
type: array
items:
type: string
maxLength: 60
context:
description: The day context; replaces the entry's context. `contactCircles` and `leisureCategories` are also read as
the backup's JSON-encoded string.
type: object
properties:
workStatus:
description: Shape of the working day.
anyOf:
- type: string
enum:
- off
- partial
- regular
- overtime
- type: "null"
workMinutes:
anyOf:
- type: integer
minimum: 0
maximum: 1440
- type: "null"
overtimeMinutes:
anyOf:
- type: integer
minimum: 0
maximum: 1440
- type: "null"
workLoad:
description: "0-10, INVERSE-oriented: a higher value is a heavier day, not a better one."
anyOf:
- type: integer
minimum: 0
maximum: 10
- type: "null"
workSatisfaction:
anyOf:
- type: integer
minimum: 0
maximum: 10
- type: "null"
contactCircles:
description: "Who the day's contact was with. Multi-select. Never scored: the count of people is not a good or a bad
number."
anyOf:
- type: array
items:
type: string
enum:
- partner
- child
- family
- friends
- colleagues
- acquaintances
- professional
- other
- type: "null"
contactForm:
anyOf:
- type: string
enum:
- inPerson
- phone
- video
- written
- type: "null"
contactExtent:
anyOf:
- type: string
enum:
- brief
- medium
- extended
- type: "null"
contactQuality:
anyOf:
- type: integer
minimum: 0
maximum: 10
- type: "null"
contactSupport:
anyOf:
- type: integer
minimum: 0
maximum: 10
- type: "null"
leisureCategories:
anyOf:
- type: array
items:
type: string
enum:
- film
- reading
- gaming
- music
- creative
- outing
- garden
- cooking
- timeWithChild
- relaxation
- other
- type: "null"
leisureMinutes:
anyOf:
- type: integer
minimum: 0
maximum: 1440
- type: "null"
leisureJoy:
anyOf:
- type: integer
minimum: 0
maximum: 10
- type: "null"
leisureRecovery:
anyOf:
- type: integer
minimum: 0
maximum: 10
- type: "null"
eventType:
description: One notable event. One per entry, not a list.
anyOf:
- type: string
enum:
- goodNews
- badNews
- success
- disappointment
- conflict
- appointment
- familyEvent
- change
- unexpected
- type: "null"
eventValence:
description: -5 (clearly bad) to +5 (clearly good).
anyOf:
- type: integer
minimum: -5
maximum: 5
- type: "null"
eventAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
note:
description: One free-text note for the whole context. AES-256-GCM at rest; the ciphertext never rides the wire.
anyOf:
- type: string
maxLength: 500
- type: "null"
externalId:
description: "Source-stable id (e.g. a Daylio row id) making re-import idempotent (upsert on `(userId, source=IMPORT,
externalId)`). Same stability rule as the measurement `externalId`: an unstable id skips the row."
@@ -73827,7 +74072,19 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
description: "Rows not written: duplicates of existing rows, and rows carrying an unstable `externalId`."
description: "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:
description: 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.
type: array
items:
type: string
droppedFactorKeys:
description: Rated-factor keys this account cannot resolve, as `droppedTagKeys`.
type: array
items:
type: string
required:
- measurements
- moodEntries
+48 -22
View File
@@ -57,23 +57,43 @@ arrays:
}
```
Each array holds at most **10 000** entries. The endpoint is rate-limited
to **5 imports per hour per account**. Existing rows (matched on the
type + timestamp uniqueness, or on `externalId` for mood entries) are
skipped and counted under `skipped` in the response.
Each array holds at most **10 000** entries and the file at most 16 MB.
The endpoint is rate-limited to **5 imports per hour per account**.
Existing rows (matched on the type + timestamp uniqueness, or on
`externalId` for mood entries) are skipped and counted under `skipped` in
the response.
The app's own files go straight in: the JSON export (**Export → JSON**,
read inside its `data` envelope) and the portable full backup. Every field
either file writes for a reading or a mood entry comes back, apart from
the row ids, the source (every imported row is stored as `IMPORT`) and the
sync bookkeeping of the account the file came from. An empty field, `null`
in the backup and `""` in the export, reads as absent, a backup's deletion
records are skipped and counted, and a reading in a display unit (lb, °F,
mmol/L) is converted to the stored one.
A mood entry's tags and rated factors are linked by key. A key the
importing account has no tag for, typically a tag another account made
for itself, is dropped from that entry, and the response names it in
`droppedTagKeys` or `droppedFactorKeys`. The rest of the entry lands.
### Measurement entry
| Field | Required | Type | Notes |
| ---------------- | -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
| `unit` | yes | string | The canonical unit for the type (see the table). |
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, type, source, externalId)` so an edit upstream is reflected rather than skipped. |
| Field | Required | Type | Notes |
| ---------------------- | -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
| `unit` | yes | string | The canonical unit for the type (see the table). |
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label, not stored. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
| `valueMin` | no | number | Lower bound of a range reading, such as an hourly heart-rate statistic. Not above `valueMax`. |
| `valueMax` | no | number | Upper bound of a range reading. |
| `sleepStage` | no | enum | A sleep row's stage: `IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP` or `REM`. |
| `rhythmClassification` | no | enum | The device's verdict on an ECG, a rhythm alert or a steadiness reading. |
| `deviceType` | no | string | The writer of the reading (watch, phone), 1–32 chars. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, type, source, externalId)` so an edit upstream is reflected rather than skipped. |
### Glucose context
@@ -98,14 +118,20 @@ moment you know it.
### Mood entry
| Field | Required | Type | Notes |
| ------------ | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `date` | yes | string | `YYYY-MM-DD`. |
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to the start of the given `date` in the default timezone. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
| Field | Required | Type | Notes |
| ---------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `date` | yes | string | `YYYY-MM-DD`. |
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `note` | no | string | The entry's note, up to 500 chars. |
| `a1`–`a5` | no | integer | The five level-A values, `0`–`10`. Without `a1`, pleasantness is derived from `mood`. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to the start of the given `date` in the default timezone. |
| `tz` | no | string | The IANA zone `date` is anchored to. |
| `factors` | no | array | Rated factors, `[{ "key": "…", "rating": 1–5 }]`; replace the entry's rated links. |
| `structuredTags` | no | array | Ticked tags by key; replace the entry's tag links. |
| `context` | no | object | The day context (work, contacts, leisure, an event, a note), as the backup writes it; replaces the entry's context. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
### `MeasurementType` values and canonical units
+1
View File
@@ -5794,6 +5794,7 @@
"readFailed": "Diese Datei konnte nicht gelesen werden.",
"resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen.",
"resultSuccess": "{measurements} Messwerte und {moods} Stimmungseinträge importiert",
"droppedLinks": "Ohne diese Tags importiert, weil dieses Konto keine mit dem Namen hat: {keys}",
"resultNothing": "Nichts importiert. Alle {skipped} Einträge wurden übersprungen.",
"resultEmpty": "Diese Datei enthielt weder Messwerte noch Stimmungseinträge."
},
+1
View File
@@ -5794,6 +5794,7 @@
"readFailed": "Couldn't read that file.",
"resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped.",
"resultSuccess": "Imported {measurements} measurements and {moods} mood entries",
"droppedLinks": "Imported without these tags, because this account has none by that name: {keys}",
"resultNothing": "Nothing was imported. All {skipped} entries were skipped.",
"resultEmpty": "That file carried no measurements and no mood entries."
},
+1
View File
@@ -5794,6 +5794,7 @@
"readFailed": "No se pudo leer ese archivo.",
"resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas.",
"resultSuccess": "{measurements} mediciones y {moods} entradas de ánimo importadas",
"droppedLinks": "Importado sin estas etiquetas, porque esta cuenta no tiene ninguna con ese nombre: {keys}",
"resultNothing": "No se importó nada. Se omitieron las {skipped} entradas.",
"resultEmpty": "Ese archivo no contenía mediciones ni entradas de ánimo."
},
+1
View File
@@ -5794,6 +5794,7 @@
"readFailed": "Impossible de lire ce fichier.",
"resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées.",
"resultSuccess": "{measurements} mesures et {moods} entrées d'humeur importées",
"droppedLinks": "Importé sans ces étiquettes, car ce compte n’en a aucune de ce nom : {keys}",
"resultNothing": "Rien n'a été importé. Les {skipped} entrées ont toutes été ignorées.",
"resultEmpty": "Ce fichier ne contenait ni mesures ni entrées d'humeur."
},
+1
View File
@@ -5794,6 +5794,7 @@
"readFailed": "Impossibile leggere il file.",
"resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate.",
"resultSuccess": "{measurements} misurazioni e {moods} voci dell'umore importate",
"droppedLinks": "Importato senza questi tag, perché questo account non ne ha nessuno con quel nome: {keys}",
"resultNothing": "Non è stato importato nulla. Tutte le {skipped} voci sono state saltate.",
"resultEmpty": "Questo file non conteneva né misurazioni né voci dell'umore."
},
+1
View File
@@ -5794,6 +5794,7 @@
"readFailed": "파일을 읽지 못했어요.",
"resultSummary": "측정 {measurements}개와 기분 기록 {moods}개를 불러왔고, {skipped}개는 건너뛰었어요.",
"resultSuccess": "측정 {measurements}개와 기분 기록 {moods}개를 불러왔어요",
"droppedLinks": "이 계정에 같은 이름의 태그가 없어 다음 태그 없이 가져왔습니다: {keys}",
"resultNothing": "불러온 게 없어요. {skipped}개 기록을 모두 건너뛰었어요.",
"resultEmpty": "그 파일에는 측정도 기분 기록도 없었어요."
},
+1
View File
@@ -5794,6 +5794,7 @@
"readFailed": "Dat bestand kon niet worden gelezen.",
"resultSummary": "{measurements} metingen en {moods} stemmingsregistraties geïmporteerd, {skipped} overgeslagen.",
"resultSuccess": "{measurements} metingen en {moods} stemmingsregistraties geïmporteerd",
"droppedLinks": "Geïmporteerd zonder deze tags, omdat dit account er geen met die naam heeft: {keys}",
"resultNothing": "Er is niets geïmporteerd. Alle {skipped} registraties zijn overgeslagen.",
"resultEmpty": "Dat bestand bevatte geen metingen en geen stemmingsregistraties."
},
+1
View File
@@ -5794,6 +5794,7 @@
"readFailed": "Nie można odczytać tego pliku.",
"resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}.",
"resultSuccess": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju",
"droppedLinks": "Zaimportowano bez tych tagów, bo to konto nie ma żadnego o tej nazwie: {keys}",
"resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wpisów.",
"resultEmpty": "Ten plik nie zawierał pomiarów ani wpisów nastroju."
},
+1
View File
@@ -5794,6 +5794,7 @@
"readFailed": "Não foi possível ler esse arquivo.",
"resultSummary": "Importadas {measurements} medições e {moods} registros de humor, {skipped} pulados.",
"resultSuccess": "Importadas {measurements} medições e {moods} registros de humor",
"droppedLinks": "Importado sem estas tags, porque esta conta não tem nenhuma com esse nome: {keys}",
"resultNothing": "Nada foi importado. Todas as {skipped} entradas foram puladas.",
"resultEmpty": "Esse arquivo não continha medições nem registros de humor."
},
@@ -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 } },
@@ -0,0 +1,262 @@
/**
* Every field the app's exporters write for a reading or a mood entry is
* either read by the JSON import or named here with the reason it is not.
*
* The same kind of guard as `measurement-backup-completeness.test.ts` and
* `mood-backup-completeness.test.ts`, one step further down the pipe: those
* prove a column reaches the file, this one proves a field of the file reaches
* the import. The JSON import used to read six measurement fields and six
* mood fields while the files carried twice that, so a sleep night came back
* as stageless rows, a range as a single value, a mood entry without its note,
* its sliders, its links or its day.
*
* The field sets are read from what the exporters actually write, for an
* account with every column filled in (`json-import-seed.ts`), so a field an
* exporter learns to write appears here the release it does. Each file is
* walked in both of its forms: the JSON export, the portable full backup and
* the disaster-recovery backup.
*
* The reasons are checked the other way too: a reason for a field no exporter
* writes any more is stale and fails, so the list cannot grow into a list of
* things that were once true.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { GET as exportJson } from "@/app/api/export/route";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import {
jsonImportMeasurementSchema,
jsonImportMoodContextSchema,
jsonImportMoodEntrySchema,
} from "@/lib/import/json/schema";
import { callRoute } from "../kit/call-route";
import { makeSession } from "../kit/factories";
import {
seedJsonImportCatalogue,
seedJsonImportOwner,
} from "./json-import-seed";
import { getPrismaClient, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
/** Why a field of an exported row is not a field of the imported one. */
const ROW_ID =
"The import writes new rows into the importing account; the file's row id names a row of the account it came from.";
const SOURCE =
"Every imported row is stamped IMPORT. Source priority and the sync paths key on the source, and a row claiming an integration would be taken for that integration's own.";
const DELETED_AT =
"A deletion record, not a reading: the import skips the row and counts it (`normalize.ts`).";
const CIPHERTEXT =
"Ciphertext under the exporting instance's key (disaster-recovery file only). The portable file carries the same note as text, and that is what the import reads.";
const BOOKKEEPING =
"Sync bookkeeping of the account the file came from (disaster-recovery file only). It describes that account's history with its sources, not the reading.";
const ROW_STAMP =
"The row's own write stamp (disaster-recovery file only); an imported row is written now.";
const MEASUREMENT_NOT_IMPORTED: Record<string, string> = {
id: ROW_ID,
source: SOURCE,
deletedAt: DELETED_AT,
notesEncrypted: CIPHERTEXT,
externalSourceVersion: BOOKKEEPING,
aggregationProvenance: BOOKKEEPING,
ingestTokenId:
"The API token that wrote the reading on the exporting account; no token of that account exists in the importing one.",
syncVersion: BOOKKEEPING,
createdAt: ROW_STAMP,
updatedAt: ROW_STAMP,
};
const MOOD_NOT_IMPORTED: Record<string, string> = {
id: ROW_ID,
source: SOURCE,
deletedAt: DELETED_AT,
noteEncrypted: CIPHERTEXT,
syncedAt: BOOKKEEPING,
syncVersion: BOOKKEEPING,
createdAt: ROW_STAMP,
updatedAt: ROW_STAMP,
linkCreatedAt: ROW_STAMP,
};
const CONTEXT_NOT_IMPORTED: Record<string, string> = {
notesEncrypted: CIPHERTEXT,
createdAt: ROW_STAMP,
updatedAt: ROW_STAMP,
};
/**
* The files' other sections. The JSON import carries readings and mood
* entries into an account that has its own record already; an account's
* whole record (medications, labs, documents, settings, …) comes back through
* the backup restore, which replaces it. The mood vocabulary is named apart
* because the entries' links point into it: a link to a tag the importing
* account does not have is dropped and named in the response.
*/
const IMPORTED_SECTIONS = new Set(["measurements", "moodEntries"]);
const VOCABULARY_SECTIONS: Record<string, string> = {
customMoodTags:
"Account vocabulary, restored by the backup restore. An imported link to one of these tags resolves only when the importing account has a tag of that key, and is otherwise dropped and named (`droppedTagKeys`).",
customMoodTagCategories:
"Account vocabulary, restored by the backup restore with the tags it groups.",
hiddenMoodTags:
"An account setting (which tags its editor hides), restored by the backup restore.",
};
type Row = Record<string, unknown>;
function keysOf(rows: readonly Row[]): Set<string> {
const keys = new Set<string>();
for (const row of rows) for (const key of Object.keys(row)) keys.add(key);
return keys;
}
/** Fields of `written` the import neither reads nor names. */
function unaccounted(
written: Set<string>,
read: Set<string>,
named: Record<string, string>,
): string[] {
return [...written].filter((key) => !read.has(key) && !(key in named));
}
interface Files {
jsonExport: { top: Row; measurements: Row[]; moodEntries: Row[] };
portable: Row & { measurements: Row[]; moodEntries: Row[] };
disasterRecovery: Row & { measurements: Row[]; moodEntries: Row[] };
}
let files: Files;
beforeAll(async () => {
await truncateAllTables(prisma);
await seedJsonImportCatalogue(prisma);
const { ownerId } = await seedJsonImportOwner(prisma);
const owner = await prisma.user.findUniqueOrThrow({ where: { id: ownerId } });
const exported = await callRoute<unknown>(exportJson, {
method: "GET",
route: "/api/export",
query: { format: "json", type: "all" },
auth: await makeSession(owner),
});
expect(exported.status).toBe(200);
const data = (exported.body as { data: Row }).data;
const portable = await buildFullBackupPayload(prisma, ownerId);
const disasterRecovery = await buildFullBackupPayload(prisma, ownerId, {
purpose: "disaster-recovery",
});
files = {
jsonExport: {
top: data,
measurements: data.measurements as Row[],
moodEntries: data.moodEntries as Row[],
},
portable: portable.payload as Files["portable"],
disasterRecovery: disasterRecovery.payload as Files["disasterRecovery"],
};
});
afterAll(async () => {
await truncateAllTables(prisma);
});
const MEASUREMENT_READ = new Set(
Object.keys(jsonImportMeasurementSchema.shape),
);
const MOOD_READ = new Set(Object.keys(jsonImportMoodEntrySchema.shape));
const CONTEXT_READ = new Set(Object.keys(jsonImportMoodContextSchema.shape));
const FILE_NAMES = ["jsonExport", "portable", "disasterRecovery"] as const;
function measurementsOf(name: (typeof FILE_NAMES)[number]): Row[] {
return files[name].measurements;
}
function moodOf(name: (typeof FILE_NAMES)[number]): Row[] {
return files[name].moodEntries;
}
function contextsOf(name: (typeof FILE_NAMES)[number]): Row[] {
return moodOf(name)
.map((entry) => entry.context)
.filter((context): context is Row => !!context);
}
describe("the JSON import reads what the exporters write", () => {
it.each(FILE_NAMES)("every measurement field of the %s", (name) => {
const rows = measurementsOf(name);
expect(rows.length).toBeGreaterThan(3);
expect(
unaccounted(keysOf(rows), MEASUREMENT_READ, MEASUREMENT_NOT_IMPORTED),
).toEqual([]);
});
it.each(FILE_NAMES)("every mood-entry field of the %s", (name) => {
const rows = moodOf(name);
expect(rows.length).toBeGreaterThan(0);
expect(unaccounted(keysOf(rows), MOOD_READ, MOOD_NOT_IMPORTED)).toEqual([]);
});
it.each(["portable", "disasterRecovery"] as const)(
"every day-context field of the %s",
(name) => {
const rows = contextsOf(name);
expect(rows.length).toBeGreaterThan(0);
expect(
unaccounted(keysOf(rows), CONTEXT_READ, CONTEXT_NOT_IMPORTED),
).toEqual([]);
},
);
it("names the mood vocabulary the entries' links point into", () => {
for (const name of ["portable", "disasterRecovery"] as const) {
const sections = Object.keys(files[name]);
expect(sections).toEqual(expect.arrayContaining([...IMPORTED_SECTIONS]));
for (const section of Object.keys(VOCABULARY_SECTIONS)) {
expect(sections, `${name} carries ${section}`).toContain(section);
}
}
// The JSON export's other two sections are medications, restored the
// same way as every other section of an account's record.
expect(Object.keys(files.jsonExport.top).sort()).toEqual([
"intakeEvents",
"measurements",
"medications",
"moodEntries",
]);
});
it("gives no reason for a field no exporter writes", () => {
const writtenMeasurement = new Set(
FILE_NAMES.flatMap((name) => [...keysOf(measurementsOf(name))]),
);
const writtenMood = new Set(
FILE_NAMES.flatMap((name) => [...keysOf(moodOf(name))]),
);
const writtenContext = new Set(
FILE_NAMES.flatMap((name) => [...keysOf(contextsOf(name))]),
);
const stale = [
...Object.keys(MEASUREMENT_NOT_IMPORTED)
.filter((key) => !writtenMeasurement.has(key))
.map((key) => `measurement.${key}`),
...Object.keys(MOOD_NOT_IMPORTED)
.filter((key) => !writtenMood.has(key))
.map((key) => `moodEntry.${key}`),
...Object.keys(CONTEXT_NOT_IMPORTED)
.filter((key) => !writtenContext.has(key))
.map((key) => `context.${key}`),
];
expect(stale).toEqual([]);
});
it("reads no field it also names as left out", () => {
const both = [
...Object.keys(MEASUREMENT_NOT_IMPORTED).filter((k) =>
MEASUREMENT_READ.has(k),
),
...Object.keys(MOOD_NOT_IMPORTED).filter((k) => MOOD_READ.has(k)),
...Object.keys(CONTEXT_NOT_IMPORTED).filter((k) => CONTEXT_READ.has(k)),
];
expect(both).toEqual([]);
});
});
+230 -199
View File
@@ -1,18 +1,24 @@
/**
* The JSON import takes the app's own files back.
* The JSON import takes the app's own files back, whole.
*
* An account's full backup (`/api/export/full-backup`, the portable export)
* and its JSON export (`/api/export?format=json`) are written, as they are
* served, and imported into a fresh account. What the import carries (a
* measurement's type, value, unit, instant, glucose context and note; a mood
* entry's day, label, score, tags and instant) must come back as it was.
* An account with every column filled in (`json-import-seed.ts`) is written
* out by the full backup (the portable export, `/api/export/full-backup`) and
* by the JSON export (`/api/export?format=json`), each file is imported into
* a fresh account, and the fresh account is written out again by the same
* exporter. The two files must agree on everything the import carries; what
* it deliberately leaves out is named in `json-import-field-coverage.test.ts`
* and is the only thing stripped before the comparison.
*
* Reading the result back through the exporter rather than through a
* hand-written projection of the tables is the point: a field the import
* drops shows up as a difference in the file a person would get back, which
* is the loss that matters, and a field an exporter learns to write is
* compared from the release it starts.
*
* Both files spell an empty field their own way, `null` in the backup and an
* empty string in the export. Before the import read files in the exporter's
* terms, the first empty note, tag list or glucose context refused the whole
* file with a 422. The portable backup also left a reading's own attributes
* out (a glucose value's fasting context among them), so the backup half
* pins those too.
* file with a 422.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
@@ -21,163 +27,60 @@ import { POST as importCsv } from "@/app/api/import/csv/route";
import { GET as exportMeasurementsCsv } from "@/app/api/export/measurements/route";
import { POST as importJson } from "@/app/api/import/route";
import { encryptNote, readNote } from "@/lib/crypto/note-cipher";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
import { callRoute } from "../kit/call-route";
import { makeSession, makeUser } from "../kit/factories";
import {
JI_OWN_TAG_KEY,
seedJsonImportCatalogue,
seedJsonImportOwner,
} from "./json-import-seed";
import { getPrismaClient, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
const MEASUREMENTS = [
{
type: "WEIGHT",
value: 81.4,
unit: "kg",
at: "2026-09-10T06:30:00.000Z",
note: "after the run",
},
{
type: "WEIGHT",
value: 81.9,
unit: "kg",
at: "2026-09-11T06:30:00.000Z",
note: null,
},
{
type: "PULSE",
value: 62,
unit: "bpm",
at: "2026-09-11T07:00:00.000Z",
note: null,
},
{
type: "BLOOD_GLUCOSE",
value: 104,
unit: "mg/dL",
at: "2026-09-12T08:00:00.000Z",
note: null,
glucoseContext: "FASTING" as const,
},
{
type: "BLOOD_GLUCOSE",
value: 141,
unit: "mg/dL",
at: "2026-09-12T13:00:00.000Z",
note: "after lunch",
glucoseContext: "POSTPRANDIAL" as const,
},
] as const;
type Row = Record<string, unknown>;
const MOOD = [
{
date: "2026-09-10",
mood: "GUT",
score: 4,
tags: null,
at: "2026-09-10T19:00:00.000Z",
},
{
date: "2026-09-11",
mood: "OKAY",
score: 3,
tags: JSON.stringify(["work", "sleep"]),
at: "2026-09-11T20:30:00.000Z",
},
{
date: "2026-09-12",
mood: "SUPER_GUT",
score: 5,
tags: null,
at: "2026-09-12T21:15:00.000Z",
},
] as const;
interface ImportStats {
measurements: number;
moodEntries: number;
skipped: number;
droppedTagKeys?: string[];
droppedFactorKeys?: string[];
}
let ownerId: string;
async function seedOwner(): Promise<void> {
const owner = await makeUser({
timezone: "Europe/Berlin",
glucoseUnit: "mmol/L" /* the JSON export stays in the stored unit */,
async function fullBackupOf(userId: string): Promise<{
measurements: Row[];
moodEntries: Row[];
}> {
let text = "";
await streamFullBackupJson(prisma, userId, (chunk) => {
text += chunk;
});
ownerId = owner.id;
for (const m of MEASUREMENTS) {
await prisma.measurement.create({
data: {
userId: ownerId,
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: new Date(m.at),
source: "MANUAL",
notes: null,
notesEncrypted: encryptNote(m.note),
glucoseContext: "glucoseContext" in m ? m.glucoseContext : null,
},
});
}
// A reading deleted before the backup: its row is the record of a
// deletion, and the import leaves it out.
await prisma.measurement.create({
data: {
userId: ownerId,
type: "WEIGHT",
value: 90,
unit: "kg",
measuredAt: new Date("2026-09-09T06:30:00.000Z"),
source: "MANUAL",
deletedAt: new Date("2026-09-09T07:00:00.000Z"),
},
});
for (const e of MOOD) {
await prisma.moodEntry.create({
data: {
userId: ownerId,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags,
source: "MANUAL",
moodLoggedAt: new Date(e.at),
},
});
}
return JSON.parse(text);
}
/** What an import carries, read back from an account. */
async function carried(userId: string) {
const measurements = await prisma.measurement.findMany({
where: { userId, deletedAt: null },
orderBy: [{ measuredAt: "asc" }, { type: "asc" }],
async function jsonExportOf(userId: string): Promise<{
data: { measurements: Row[]; moodEntries: Row[] };
}> {
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const res = await callRoute<unknown>(exportJson, {
method: "GET",
route: "/api/export",
query: { format: "json", type: "all" },
auth: await makeSession(user),
});
const mood = await prisma.moodEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "asc" },
});
return {
measurements: measurements.map((m) => ({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
glucoseContext: m.glucoseContext,
note: readNote(m.notesEncrypted, m.notes),
})),
mood: mood.map((e) => ({
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags,
loggedAt: e.moodLoggedAt.toISOString(),
})),
};
expect(res.status).toBe(200);
return res.body as { data: { measurements: Row[]; moodEntries: Row[] } };
}
async function importInto(file: unknown) {
const user = await makeUser({ timezone: "Europe/Berlin" });
const res = await callRoute<{
data: { measurements: number; moodEntries: number; skipped: number };
}>(importJson, {
const res = await callRoute<ImportStats>(importJson, {
method: "POST",
route: "/api/import",
body: file,
@@ -186,77 +89,194 @@ async function importInto(file: unknown) {
return { userId: user.id, res };
}
/** A row without the fields the import deliberately does not carry. */
function comparable(row: Row): Row {
const { id: _id, source: _source, ...rest } = row;
return rest;
}
function byInstant(at: string) {
return (a: Row, b: Row) =>
String(a[at]).localeCompare(String(b[at])) ||
String(a.type ?? "").localeCompare(String(b.type ?? ""));
}
beforeAll(async () => {
await truncateAllTables(prisma);
await seedOwner();
await seedJsonImportCatalogue(prisma);
({ ownerId } = await seedJsonImportOwner(prisma));
});
afterAll(async () => {
await truncateAllTables(prisma);
});
describe("the JSON import reads the app's own files", () => {
it("takes a full backup back, nulls and deletion records included", async () => {
let text = "";
await streamFullBackupJson(prisma, ownerId, (chunk) => {
text += chunk;
});
const backup = JSON.parse(text) as {
measurements: Array<Record<string, unknown>>;
moodEntries: Array<Record<string, unknown>>;
};
// The file carries the shapes the import used to refuse.
expect(backup.measurements.some((m) => m.notes === null)).toBe(true);
expect(backup.moodEntries.some((e) => e.tags === null)).toBe(true);
describe("the JSON import takes the app's own files back", () => {
it("takes a full backup back field for field", async () => {
const original = await fullBackupOf(ownerId);
// The file carries the shapes the import used to refuse or drop.
expect(original.measurements.some((m) => m.notes === null)).toBe(true);
expect(original.measurements.some((m) => m.sleepStage === "DEEP")).toBe(
true,
);
expect(original.moodEntries.some((e) => e.context !== null)).toBe(true);
const { userId, res } = await importInto(backup);
const { userId, res } = await importInto(original);
expect(res.status).toBe(200);
const deletedInFile = backup.measurements.filter(
(m) => typeof m.deletedAt === "string",
).length;
expect(res.data).toEqual({
measurements: MEASUREMENTS.length,
moodEntries: MOOD.length,
skipped: deletedInFile,
measurements: original.measurements.length,
moodEntries: original.moodEntries.length,
skipped: 0,
// The account's own tag is not this account's: the entry lands without
// that one link, and the response says which.
droppedTagKeys: [JI_OWN_TAG_KEY],
});
expect(await carried(userId)).toEqual(await carried(ownerId));
const back = await fullBackupOf(userId);
const expectedMeasurements = original.measurements
.map(comparable)
.sort(byInstant("measuredAt"));
expect(
back.measurements.map(comparable).sort(byInstant("measuredAt")),
).toEqual(expectedMeasurements);
const expectedMood = original.moodEntries
.map((entry) => ({
...comparable(entry),
structuredTags: (entry.structuredTags as string[]).filter(
(key) => key !== JI_OWN_TAG_KEY,
),
}))
.sort(byInstant("loggedAt"));
expect(
back.moodEntries.map(comparable).sort(byInstant("loggedAt")),
).toEqual(expectedMood);
});
it("takes the JSON export back, empty strings and its envelope included", async () => {
const owner = await prisma.user.findUniqueOrThrow({
where: { id: ownerId },
});
const exported = await callRoute<unknown>(exportJson, {
method: "GET",
route: "/api/export",
query: { format: "json", type: "all" },
auth: await makeSession(owner),
});
expect(exported.status).toBe(200);
const file = exported.body as {
data: {
measurements: Array<Record<string, unknown>>;
moodEntries: Array<Record<string, unknown>>;
};
};
expect(file.data.measurements.some((m) => m.notes === "")).toBe(true);
expect(file.data.moodEntries.some((e) => e.tags === "")).toBe(true);
const original = await jsonExportOf(ownerId);
expect(original.data.measurements.some((m) => m.notes === "")).toBe(true);
expect(original.data.moodEntries.some((e) => e.note === "")).toBe(true);
const { userId, res } = await importInto(file);
const { userId, res } = await importInto(original);
expect(res.status).toBe(200);
expect(res.data).toMatchObject({
measurements: MEASUREMENTS.length,
moodEntries: MOOD.length,
measurements: original.data.measurements.length,
moodEntries: original.data.moodEntries.length,
skipped: 0,
});
expect(await carried(userId)).toEqual(await carried(ownerId));
// A sleep night is one summary record in this file, and its `notes` is
// the stage breakdown the exporter writes from the stored stages. The
// import stores the night and that text as its note; a re-export
// rebuilds the column from stages the summary does not have. So the
// night's note is compared against what was stored, and the rest of the
// record through the file.
const nights = original.data.measurements.filter(
(m) => m.type === "SLEEP_DURATION",
);
expect(nights).toHaveLength(1);
const storedNight = await prisma.measurement.findFirstOrThrow({
where: { userId, type: "SLEEP_DURATION" },
});
expect(readNote(storedNight.notesEncrypted, storedNight.notes)).toBe(
nights[0]!.notes,
);
const withoutNightNote = (m: Row) =>
m.type === "SLEEP_DURATION"
? { ...comparable(m), notes: null }
: comparable(m);
const back = await jsonExportOf(userId);
expect(
back.data.measurements
.map(withoutNightNote)
.sort(byInstant("measuredAt")),
).toEqual(
original.data.measurements
.map(withoutNightNote)
.sort(byInstant("measuredAt")),
);
expect(
back.data.moodEntries.map(comparable).sort(byInstant("loggedAt")),
).toEqual(
original.data.moodEntries.map(comparable).sort(byInstant("loggedAt")),
);
});
it("takes the measurement CSV back into the CSV import, in display units too", async () => {
const owner = await prisma.user.findUniqueOrThrow({
where: { id: ownerId },
it("leaves out a disaster-recovery file's deletion records, and counts them", async () => {
const { payload } = await buildFullBackupPayload(prisma, ownerId, {
purpose: "disaster-recovery",
});
const rows = (payload as { measurements: Row[] }).measurements;
const deleted = rows.filter((m) => typeof m.deletedAt === "string");
expect(deleted).toHaveLength(1);
const { userId, res } = await importInto(payload);
expect(res.status).toBe(200);
expect(res.data).toMatchObject({
measurements: rows.length - deleted.length,
skipped: deleted.length,
});
expect(
await prisma.measurement.count({ where: { userId, value: 90 } }),
).toBe(0);
});
it("takes the same file twice without changing what the first import wrote", async () => {
const original = await fullBackupOf(ownerId);
const { userId } = await importInto(original);
const first = await fullBackupOf(userId);
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const again = await callRoute<ImportStats>(importJson, {
method: "POST",
route: "/api/import",
body: original,
auth: await makeSession(user),
});
expect(again.status).toBe(200);
// The mood entries carry their instant, so the second pass finds them;
// the readings carry no source id in a portable file and are written
// again, as the import has always documented for a file without one.
expect(again.data.moodEntries).toBe(0);
const second = await fullBackupOf(userId);
expect(
second.moodEntries.map(comparable).sort(byInstant("loggedAt")),
).toEqual(first.moodEntries.map(comparable).sort(byInstant("loggedAt")));
});
});
describe("the CSV import reads the measurement CSV export", () => {
const READINGS = [
{ type: "WEIGHT", value: 81.4, unit: "kg", at: "2026-09-10T06:30:00.000Z" },
{ type: "PULSE", value: 62, unit: "bpm", at: "2026-09-11T07:00:00.000Z" },
{
type: "BLOOD_GLUCOSE",
value: 104,
unit: "mg/dL",
at: "2026-09-12T08:00:00.000Z",
glucoseContext: "FASTING" as const,
},
] as const;
it("takes it back, in display units too", async () => {
const owner = await makeUser({
timezone: "Europe/Berlin",
glucoseUnit: "mmol/L",
});
for (const m of READINGS) {
await prisma.measurement.create({
data: {
userId: owner.id,
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: new Date(m.at),
source: "MANUAL",
notesEncrypted: encryptNote(null),
glucoseContext: "glucoseContext" in m ? m.glucoseContext : null,
},
});
}
const exported = await callRoute(exportMeasurementsCsv, {
method: "GET",
route: "/api/export/measurements",
@@ -279,13 +299,24 @@ describe("the JSON import reads the app's own files", () => {
},
);
expect(res.status).toBe(200);
expect(res.data).toMatchObject({
inserted: MEASUREMENTS.length,
skipped: 0,
});
expect(res.data).toMatchObject({ inserted: READINGS.length, skipped: 0 });
const back = (await carried(user.id)).measurements;
const original = (await carried(ownerId)).measurements;
const read = async (userId: string) =>
(
await prisma.measurement.findMany({
where: { userId, deletedAt: null },
orderBy: [{ measuredAt: "asc" }, { type: "asc" }],
})
).map((m) => ({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
glucoseContext: m.glucoseContext,
note: readNote(m.notesEncrypted, m.notes),
}));
const back = await read(user.id);
const original = await read(owner.id);
expect(back).toHaveLength(original.length);
back.forEach((m, i) => {
const o = original[i]!;
+267
View File
@@ -0,0 +1,267 @@
/**
* One account with every column the exporters read filled in, for the JSON
* import's round trip and its field-coverage guard.
*
* Every reading carries what makes it that reading: a range keeps its bounds,
* a sleep night its stages and its writer, a rhythm alert its verdict, a
* glucose value its meal-time context, a note its text. The mood entry
* carries a note, all five level-A values, its zone, a rated factor, a
* catalogue tag, a tag of the account's own and a full day context. A field
* the seed leaves empty is a field neither test can see arrive, so nothing
* here is left at its default.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { encodeKeyList } from "@/lib/mood/context-vocabulary";
import { makeUser } from "../kit/factories";
export const JI_FACTOR_KEY = "ji_sleep_quality";
export const JI_TAG_KEY = "ji_headache";
/** The account's own tag: another account importing the file cannot resolve it. */
export const JI_OWN_TAG_KEY = "custom:ji_long_walk";
export const JI_MEASUREMENTS = [
{
type: "WEIGHT",
value: 81.4,
unit: "kg",
at: "2026-09-10T06:30:00.000Z",
note: "after the run",
},
{
type: "PULSE",
value: 64,
valueMin: 52,
valueMax: 91,
unit: "bpm",
at: "2026-09-11T07:00:00.000Z",
deviceType: "watch",
},
{
type: "BLOOD_GLUCOSE",
value: 104,
unit: "mg/dL",
at: "2026-09-12T08:00:00.000Z",
glucoseContext: "FASTING",
},
{
type: "BLOOD_GLUCOSE",
value: 141,
unit: "mg/dL",
at: "2026-09-12T13:00:00.000Z",
note: "after lunch",
glucoseContext: "POSTPRANDIAL",
},
{
type: "IRREGULAR_RHYTHM_NOTIFICATION",
value: 1,
unit: "count",
at: "2026-09-12T15:00:00.000Z",
rhythmClassification: "IRREGULAR",
deviceType: "watch",
},
// One night, as a watch writes it: a stage per row.
{
type: "SLEEP_DURATION",
value: 70,
unit: "minutes",
at: "2026-09-12T23:40:00.000Z",
sleepStage: "DEEP",
deviceType: "watch",
},
{
type: "SLEEP_DURATION",
value: 240,
unit: "minutes",
at: "2026-09-13T03:40:00.000Z",
sleepStage: "CORE",
deviceType: "watch",
},
{
type: "SLEEP_DURATION",
value: 95,
unit: "minutes",
at: "2026-09-13T05:15:00.000Z",
sleepStage: "REM",
deviceType: "watch",
},
] as const;
export const JI_MOOD = {
date: "2026-09-12",
mood: "GUT",
score: 4,
a1: 8,
a2: 3,
a3: 6,
a4: 7,
a5: 5,
tags: JSON.stringify(["work", "sleep"]),
note: "long day, good evening",
tz: "America/New_York",
at: "2026-09-13T01:30:00.000Z",
} as const;
/** A second entry with nothing but the label: what a one-tap check-in writes. */
export const JI_MOOD_BARE = {
date: "2026-09-11",
mood: "OKAY",
score: 3,
at: "2026-09-11T20:30:00.000Z",
} as const;
export interface SeededOwner {
ownerId: string;
}
/** The catalogue the links resolve against: seeded rows, owned by no account. */
export async function seedJsonImportCatalogue(
prisma: PrismaClient,
): Promise<void> {
const category = await prisma.moodTagCategory.create({
data: { key: "ji_category", labelKey: "mood.tagCategory.ji" },
});
await prisma.moodTag.create({
data: {
categoryId: category.id,
key: JI_TAG_KEY,
labelKey: "mood.tag.jiHeadache",
kind: "BINARY",
},
});
await prisma.moodTag.create({
data: {
categoryId: category.id,
key: JI_FACTOR_KEY,
labelKey: "mood.tag.jiSleepQuality",
kind: "RATED",
scaleMin: 1,
scaleMax: 5,
},
});
}
export async function seedJsonImportOwner(
prisma: PrismaClient,
): Promise<SeededOwner> {
const owner = await makeUser({
timezone: "Europe/Berlin",
glucoseUnit: "mmol/L" /* the JSON export stays in the stored unit */,
});
const ownerId = owner.id;
for (const m of JI_MEASUREMENTS) {
await prisma.measurement.create({
data: {
userId: ownerId,
type: m.type,
value: m.value,
valueMin: "valueMin" in m ? m.valueMin : null,
valueMax: "valueMax" in m ? m.valueMax : null,
unit: m.unit,
measuredAt: new Date(m.at),
source: "APPLE_HEALTH",
notes: null,
notesEncrypted: encryptNote("note" in m ? m.note : null),
glucoseContext: "glucoseContext" in m ? m.glucoseContext : null,
sleepStage: "sleepStage" in m ? m.sleepStage : null,
rhythmClassification:
"rhythmClassification" in m ? m.rhythmClassification : null,
deviceType: "deviceType" in m ? m.deviceType : null,
},
});
}
// A reading deleted before the backup: its row is the record of a
// deletion, and the import leaves it out.
await prisma.measurement.create({
data: {
userId: ownerId,
type: "WEIGHT",
value: 90,
unit: "kg",
measuredAt: new Date("2026-09-09T06:30:00.000Z"),
source: "MANUAL",
deletedAt: new Date("2026-09-09T07:00:00.000Z"),
},
});
const category = await prisma.moodTagCategory.findUniqueOrThrow({
where: { key: "ji_category" },
});
const ownTag = await prisma.moodTag.create({
data: {
userId: ownerId,
categoryId: category.id,
key: JI_OWN_TAG_KEY,
labelKey: "mood.tag.custom",
kind: "BINARY",
},
});
const [tag, factor] = await Promise.all([
prisma.moodTag.findUniqueOrThrow({ where: { key: JI_TAG_KEY } }),
prisma.moodTag.findUniqueOrThrow({ where: { key: JI_FACTOR_KEY } }),
]);
const entry = await prisma.moodEntry.create({
data: {
userId: ownerId,
date: JI_MOOD.date,
mood: JI_MOOD.mood,
score: JI_MOOD.score,
moodA1: JI_MOOD.a1,
stressA2: JI_MOOD.a2,
energyA3: JI_MOOD.a3,
connectionA4: JI_MOOD.a4,
stabilityA5: JI_MOOD.a5,
tags: JI_MOOD.tags,
note: null,
noteEncrypted: encryptNote(JI_MOOD.note),
tz: JI_MOOD.tz,
source: "MANUAL",
moodLoggedAt: new Date(JI_MOOD.at),
},
});
await prisma.moodEntryTagLink.createMany({
data: [
{ moodEntryId: entry.id, moodTagId: tag.id },
{ moodEntryId: entry.id, moodTagId: ownTag.id },
{ moodEntryId: entry.id, moodTagId: factor.id, rating: 4 },
],
});
await prisma.moodContext.create({
data: {
moodEntryId: entry.id,
userId: ownerId,
workStatus: "regular",
workMinutes: 540,
overtimeMinutes: 60,
workLoad: 7,
workSatisfaction: 6,
contactCircles: encodeKeyList(["partner", "family"]),
contactForm: "inPerson",
contactExtent: "extended",
contactQuality: 8,
contactSupport: 7,
leisureCategories: encodeKeyList(["reading"]),
leisureMinutes: 90,
leisureJoy: 8,
leisureRecovery: 7,
eventType: "goodNews",
eventValence: 3,
eventAt: new Date("2026-09-12T18:00:00.000Z"),
notesEncrypted: encryptNote("the call went well"),
},
});
await prisma.moodEntry.create({
data: {
userId: ownerId,
date: JI_MOOD_BARE.date,
mood: JI_MOOD_BARE.mood,
score: JI_MOOD_BARE.score,
moodA1: 5,
source: "MANUAL",
moodLoggedAt: new Date(JI_MOOD_BARE.at),
},
});
return { ownerId };
}
@@ -20,6 +20,7 @@
"moodEntries": 4,
"skipped": 2
},
"import_dropped_link_keys": 0,
"external_id_rejected": 2,
"external_id_shapes": "object_description",
"external_id_surface": "import.json"
@@ -20,6 +20,7 @@
"moodEntries": 1,
"skipped": 6
},
"import_dropped_link_keys": 0,
"external_id_rejected": 2,
"external_id_shapes": "object_description",
"external_id_surface": "import.json"