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