From 2357807706b1cf0d64bd345b05438e2c4294e239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 22:41:36 +0200 Subject: [PATCH] feat(import): bring the account's own mood vocabulary along A full backup carries the account's own mood tags, rated factors and tag groups, and the JSON import left them out: a mood entry imported into another account landed without every link to a tag the person had made. Before the entries are written, every key they or the file name is resolved once. A catalogue tag or one the account owns is used as it is; an own tag whose label the file states differently is kept, never overwritten, and named in keptMoodTagKeys (keptMoodCategoryKeys for a group). A tag the file defines and the account lacks is created in its group, the group created the same way, within the account's caps of 50 tags and 12 groups. A key another account of the server holds comes over under a key derived from the account and the file's key, so a second import of the same file finds the copy instead of minting another. The links are written through that resolution, own rated factors included, inside the entry's transaction; anything unresolved is still dropped and named. The import card names kept keys in all nine locales. The field-coverage guard now holds the backup's customMoodTags and customMoodTagCategories to the import schema. The round trip moves a backup to another server (the same seeded catalogue, none of the account's rows), imports it into a fresh account and compares the re-export, own tags, group and rated factor included; a second case imports into another account of the same server, and a third keeps a renamed own tag. --- docs/api/openapi.yaml | 128 +++++++- docs/integrations/data-import.md | 15 +- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + messages/pt-BR.json | 1 + .../json-import-openapi-parity.test.ts | 15 + src/app/api/import/route.ts | 8 + .../__tests__/json-import-result.test.tsx | 14 + .../import-panel/json-import-card.tsx | 21 ++ src/lib/import/json/mood.ts | 90 ++++-- src/lib/import/json/normalize.ts | 11 +- src/lib/import/json/run.ts | 26 +- src/lib/import/json/schema.ts | 51 ++++ src/lib/import/json/tally.ts | 9 + src/lib/import/json/vocabulary.ts | 288 ++++++++++++++++++ src/lib/openapi/routes/import.ts | 83 ++++- .../json-import-field-coverage.test.ts | 83 ++++- .../json-import-own-exports.test.ts | 162 +++++++++- tests/integration/json-import-seed.ts | 49 ++- 25 files changed, 995 insertions(+), 67 deletions(-) create mode 100644 src/lib/import/json/vocabulary.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 138854254..2ac1e174b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -33209,8 +33209,18 @@ components: type: array items: $ref: "#/components/schemas/JsonImportMoodEntry" - 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." + customMoodTagCategories: + maxItems: 10000 + type: array + items: + $ref: "#/components/schemas/JsonImportMoodTagCategory" + customMoodTags: + maxItems: 10000 + type: array + items: + $ref: "#/components/schemas/JsonImportMoodTag" + description: "JSON import payload: the measurements and mood entries of the JSON export or the portable full backup, and + the backup's own mood vocabulary the entries link to. Every section optional; up to 10 000 rows each." JsonImportMeasurement: type: object properties: @@ -33346,8 +33356,9 @@ components: 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. + description: Rated factors by key (the catalogue's, or the account's own as for `structuredTags`); 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: @@ -33364,8 +33375,8 @@ components: - 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`. + description: Ticked tags by key (the catalogue, the account's own, or one the file's `customMoodTags` brings); replaces + the entry's tag links. An unresolvable key is dropped and named in `droppedTagKeys`. maxItems: 30 type: array items: @@ -33538,6 +33549,100 @@ components: - mood - score description: One mood-entry row of a JSON import file. + JsonImportMoodTagCategory: + type: object + properties: + id: + description: The file's own id, read only to find the group a tag names in `categoryId`. + type: string + maxLength: 120 + key: + type: string + minLength: 1 + maxLength: 80 + labelKey: + type: string + minLength: 1 + maxLength: 120 + icon: + type: string + maxLength: 60 + sortOrder: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + isActive: + type: boolean + label: + description: The group's own name. + type: string + maxLength: 200 + labelEncrypted: + description: A disaster-recovery file's sealed name, used only when this server's keys open it. + type: string + maxLength: 2000 + required: + - key + - labelKey + description: "A mood-tag group the account made itself. Matched by `key`: a group the account has is used as it is, a + missing one is created." + JsonImportMoodTag: + type: object + properties: + key: + type: string + minLength: 1 + maxLength: 80 + labelKey: + type: string + minLength: 1 + maxLength: 120 + categoryId: + type: string + minLength: 1 + maxLength: 120 + description: The group's id in the file; a group the file does not carry falls back to the seeded `custom` group. + kind: + type: string + enum: + - BINARY + - RATED + isActive: + type: boolean + icon: + type: string + maxLength: 60 + sortOrder: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + label: + description: The tag's own name. + type: string + maxLength: 200 + labelEncrypted: + description: A disaster-recovery file's sealed name, used only when this server's keys open it. + type: string + maxLength: 2000 + scaleMin: + type: integer + minimum: 0 + maximum: 10 + scaleMax: + type: integer + minimum: 0 + maximum: 10 + inverse: + type: boolean + required: + - key + - labelKey + - categoryId + - kind + description: "A mood tag the account made itself, a rated factor included. Matched by `key`: the account's own tag of + that key is used and never overwritten (a different label is named in `keptMoodTagKeys`); a missing one is + created, within the account's custom-tag cap; a key another account of this server holds comes over under a key + derived from the account and the file's key, so a second import of the same file finds it." MeasurementReminderCreate: type: object properties: @@ -74085,6 +74190,17 @@ components: type: array items: type: string + keptMoodTagKeys: + description: Keys of the account's own tags the file names differently. The account's tag is kept as it is. Present only + when non-empty. + type: array + items: + type: string + keptMoodCategoryKeys: + description: The same, for the account's own tag groups. + type: array + items: + type: string required: - measurements - moodEntries diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md index f53fc3e33..af378a9d3 100644 --- a/docs/integrations/data-import.md +++ b/docs/integrations/data-import.md @@ -72,10 +72,17 @@ 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. +A mood entry's tags and rated factors are linked by key. A full backup +also carries the account's own tags, rated factors and tag groups, and the +import brings them along: a tag the importing account already has is used +as it is, and one it lacks is created (within the account's limit of 50 +custom tags and 12 groups). The account's own tag is never overwritten: when +the file names it differently, the account's name stays and the response +lists the key in `keptMoodTagKeys` (`keptMoodCategoryKeys` for a group). +Moving a file between two accounts of one server works too; the tags come +over under keys of their own. A key the account cannot resolve at all is +dropped from that entry and named in `droppedTagKeys` or +`droppedFactorKeys`, and the rest of the entry lands. ### Measurement entry diff --git a/messages/de.json b/messages/de.json index 69916f438..102a530a2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5795,6 +5795,7 @@ "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}", + "keptLabels": "Dieses Konto hat Tags oder Gruppen mit diesen Schlüsseln schon unter eigenem Namen, die wurden behalten: {keys}", "resultNothing": "Nichts importiert. Alle {skipped} Einträge wurden übersprungen.", "resultEmpty": "Diese Datei enthielt weder Messwerte noch Stimmungseinträge." }, diff --git a/messages/en.json b/messages/en.json index 4d70f02d0..da5a32d20 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5795,6 +5795,7 @@ "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}", + "keptLabels": "This account already has tags or groups with these keys under its own names, so those were kept: {keys}", "resultNothing": "Nothing was imported. All {skipped} entries were skipped.", "resultEmpty": "That file carried no measurements and no mood entries." }, diff --git a/messages/es.json b/messages/es.json index 879ccea71..4e55bf123 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5795,6 +5795,7 @@ "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}", + "keptLabels": "Esta cuenta ya tiene etiquetas o grupos con estas claves y con su propio nombre, así que se han conservado: {keys}", "resultNothing": "No se importó nada. Se omitieron las {skipped} entradas.", "resultEmpty": "Ese archivo no contenía mediciones ni entradas de ánimo." }, diff --git a/messages/fr.json b/messages/fr.json index 5105b4b79..0d1b503e9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5795,6 +5795,7 @@ "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}", + "keptLabels": "Ce compte a déjà des étiquettes ou des groupes avec ces clés sous son propre nom, ils ont été conservés : {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." }, diff --git a/messages/it.json b/messages/it.json index ffcbd71a8..8067e9aa9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5795,6 +5795,7 @@ "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}", + "keptLabels": "Questo account ha già tag o gruppi con queste chiavi e con il proprio nome, quindi sono stati mantenuti: {keys}", "resultNothing": "Non è stato importato nulla. Tutte le {skipped} voci sono state saltate.", "resultEmpty": "Questo file non conteneva né misurazioni né voci dell'umore." }, diff --git a/messages/ko.json b/messages/ko.json index 7a90ee3e6..842d0ab87 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5795,6 +5795,7 @@ "resultSummary": "측정 {measurements}개와 기분 기록 {moods}개를 불러왔고, {skipped}개는 건너뛰었어요.", "resultSuccess": "측정 {measurements}개와 기분 기록 {moods}개를 불러왔어요", "droppedLinks": "이 계정에 같은 이름의 태그가 없어 다음 태그 없이 가져왔습니다: {keys}", + "keptLabels": "이 계정에는 이미 같은 키의 태그나 그룹이 자체 이름으로 있어서 그대로 유지했어요: {keys}", "resultNothing": "불러온 게 없어요. {skipped}개 기록을 모두 건너뛰었어요.", "resultEmpty": "그 파일에는 측정도 기분 기록도 없었어요." }, diff --git a/messages/nl.json b/messages/nl.json index 1d791cffe..44163c4a2 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -5795,6 +5795,7 @@ "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}", + "keptLabels": "Dit account heeft al tags of groepen met deze sleutels onder een eigen naam, die zijn behouden: {keys}", "resultNothing": "Er is niets geïmporteerd. Alle {skipped} registraties zijn overgeslagen.", "resultEmpty": "Dat bestand bevatte geen metingen en geen stemmingsregistraties." }, diff --git a/messages/pl.json b/messages/pl.json index 85ddb378c..5b215e72f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5795,6 +5795,7 @@ "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}", + "keptLabels": "To konto ma już tagi lub grupy z tymi kluczami pod własną nazwą, więc zostały zachowane: {keys}", "resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wpisów.", "resultEmpty": "Ten plik nie zawierał pomiarów ani wpisów nastroju." }, diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 191340d81..60977cca7 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -5795,6 +5795,7 @@ "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}", + "keptLabels": "Esta conta já tem tags ou grupos com essas chaves e com nome próprio, então eles foram mantidos: {keys}", "resultNothing": "Nada foi importado. Todas as {skipped} entradas foram puladas.", "resultEmpty": "Esse arquivo não continha medições nem registros de humor." }, diff --git a/src/__tests__/json-import-openapi-parity.test.ts b/src/__tests__/json-import-openapi-parity.test.ts index 7fe77d326..70e749d98 100644 --- a/src/__tests__/json-import-openapi-parity.test.ts +++ b/src/__tests__/json-import-openapi-parity.test.ts @@ -13,6 +13,9 @@ import { jsonImportMeasurementSchema, jsonImportMoodContextSchema, jsonImportMoodEntrySchema, + jsonImportMoodTagCategorySchema, + jsonImportMoodTagSchema, + jsonImportSchema, } from "@/lib/import/json/schema"; import { buildOpenApiDocument } from "@/lib/openapi/registry"; @@ -63,6 +66,18 @@ describe("the published JSON import contract", () => { expect(fields).toEqual(Object.keys(jsonImportMoodEntrySchema.shape).sort()); }); + it("names every section and every vocabulary field the import reads", () => { + expect(documented("JsonImportRequest")).toEqual( + Object.keys(jsonImportSchema.shape).sort(), + ); + expect(documented("JsonImportMoodTag")).toEqual( + Object.keys(jsonImportMoodTagSchema.shape).sort(), + ); + expect(documented("JsonImportMoodTagCategory")).toEqual( + Object.keys(jsonImportMoodTagCategorySchema.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!); diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index ccf6fe068..7c183775f 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -56,10 +56,14 @@ export const POST = apiHandler(async (request: NextRequest) => { stats: written, writeFailed, droppedLinkKeys, + keptMoodTagKeys, + keptMoodCategoryKeys, } = await runJsonImport({ userId: user.id, measurements: parsed.data.measurements, moodEntries: parsed.data.moodEntries, + customMoodTagCategories: parsed.data.customMoodTagCategories, + customMoodTags: parsed.data.customMoodTags, ipAddress: getClientIp(request), }); const stats = { @@ -68,6 +72,10 @@ export const POST = apiHandler(async (request: NextRequest) => { // 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), + // The account's own tags and groups the file names differently: kept as + // they are, and said so, only when there are any. + ...(keptMoodTagKeys.length > 0 ? { keptMoodTagKeys } : {}), + ...(keptMoodCategoryKeys.length > 0 ? { keptMoodCategoryKeys } : {}), }; if (writeFailed) { diff --git a/src/components/settings/import-panel/__tests__/json-import-result.test.tsx b/src/components/settings/import-panel/__tests__/json-import-result.test.tsx index 64825e985..fe6fbfd03 100644 --- a/src/components/settings/import-panel/__tests__/json-import-result.test.tsx +++ b/src/components/settings/import-panel/__tests__/json-import-result.test.tsx @@ -38,9 +38,22 @@ describe("", () => { expect(html).toContain("Imported 3 measurements and 2 mood entries"); }); + it("names the account's own tags it kept rather than renamed", () => { + const html = render({ + measurements: 0, + moodEntries: 2, + skipped: 0, + keptMoodTagKeys: ["custom:walk"], + keptMoodCategoryKeys: ["customcat:outdoors"], + }); + expect(html).toContain('data-testid="import-json-kept-labels"'); + expect(html).toContain("custom:walk, customcat:outdoors"); + }); + 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"); + expect(html).not.toContain("import-json-kept-labels"); }); it("reads a missing count as none", () => { @@ -49,6 +62,7 @@ describe("", () => { moodEntries: 0, skipped: 0, droppedLinkKeys: [], + keptLabelKeys: [], }); }); }); diff --git a/src/components/settings/import-panel/json-import-card.tsx b/src/components/settings/import-panel/json-import-card.tsx index f64e6b7bc..e38c9b284 100644 --- a/src/components/settings/import-panel/json-import-card.tsx +++ b/src/components/settings/import-panel/json-import-card.tsx @@ -28,6 +28,11 @@ export interface JsonImportResult { * those links, which the count alone would not say. */ droppedLinkKeys: string[]; + /** + * Keys of the account's own tags and groups the file names differently. + * The account's names were kept, never overwritten. + */ + keptLabelKeys: string[]; } /** The route's `data`, read field by field; a missing count reads as none. */ @@ -38,6 +43,8 @@ export function readJsonImportResult( skipped?: number; droppedTagKeys?: string[]; droppedFactorKeys?: string[]; + keptMoodTagKeys?: string[]; + keptMoodCategoryKeys?: string[]; } | null, ): JsonImportResult { return { @@ -48,6 +55,10 @@ export function readJsonImportResult( ...(data?.droppedTagKeys ?? []), ...(data?.droppedFactorKeys ?? []), ], + keptLabelKeys: [ + ...(data?.keptMoodTagKeys ?? []), + ...(data?.keptMoodCategoryKeys ?? []), + ], }; } @@ -96,6 +107,16 @@ export function JsonImportResultView({ result }: { result: JsonImportResult }) { })}

)} + {result.keptLabelKeys.length > 0 && ( +

+ {t("settings.sections.export.import.json.keptLabels", { + keys: result.keptLabelKeys.join(", "), + })} +

+ )} ); } diff --git a/src/lib/import/json/mood.ts b/src/lib/import/json/mood.ts index e706b6b62..76cd9fbae 100644 --- a/src/lib/import/json/mood.ts +++ b/src/lib/import/json/mood.ts @@ -8,8 +8,9 @@ * 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 + * context and its tag and factor links (resolved through the account's + * vocabulary, `vocabulary.ts`), 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. */ @@ -23,15 +24,12 @@ import { classifyExternalId } from "@/lib/validations/external-id"; import { isP2002 } from "@/lib/prisma-errors"; import { levelAForWrite } from "@/lib/mood/level-a"; import { persistMoodContext } from "@/lib/mood/context"; -import { - RatedFactorOutOfRangeError, - replaceRatedFactorLinks, - replaceTagLinks, -} from "@/lib/mood/tag-links"; +import { RatedFactorOutOfRangeError } from "@/lib/mood/tag-links"; import { fallbackMoodDateStart } from "./mood-instant"; import type { JsonImportMoodEntry } from "./schema"; import type { JsonImportTally } from "./tally"; +import type { MoodLinkResolver } from "./vocabulary"; export type { JsonImportMoodEntry } from "./schema"; @@ -98,32 +96,81 @@ interface DroppedKeys { factors: Set; } +/** + * Replace one kind of an entry's links with the ones the file states, + * resolved through the import's vocabulary. A link to an archived tag stays, + * as the edit path keeps it: it is history, not editable state. A rating + * outside its tag's own scale throws, and the transaction takes the entry + * back with it. + */ +async function replaceImportedLinks( + tx: Prisma.TransactionClient, + moodEntryId: string, + kind: "RATED" | "BINARY", + wanted: ReadonlyArray<{ key: string; rating: number | null }>, + resolver: MoodLinkResolver, + dropped: Set, +): Promise { + const links = new Map(); + for (const { key, rating } of wanted) { + const tag = resolver.get(key); + if (!tag || tag.kind !== kind) { + dropped.add(key); + continue; + } + if (rating !== null && (rating < tag.scaleMin || rating > tag.scaleMax)) { + throw new RatedFactorOutOfRangeError( + key, + rating, + tag.scaleMin, + tag.scaleMax, + ); + } + links.set(tag.id, rating); + } + await tx.moodEntryTagLink.deleteMany({ + where: { moodEntryId, moodTag: { isActive: true, kind } }, + }); + if (links.size === 0) return; + await tx.moodEntryTagLink.createMany({ + data: [...links].map(([moodTagId, rating]) => ({ + moodEntryId, + moodTagId, + rating, + })), + skipDuplicates: true, + }); +} + /** The entry's links and day context, when the file states them. */ async function writeMoodEntryParts( tx: Prisma.TransactionClient, userId: string, moodEntryId: string, e: JsonImportMoodEntry, + resolver: MoodLinkResolver, dropped: DroppedKeys, ): Promise { await persistMoodContext(tx, moodEntryId, userId, e.context); if (e.factors !== undefined) { - const keys = await replaceRatedFactorLinks( - moodEntryId, - userId, - e.factors, + await replaceImportedLinks( tx, + moodEntryId, + "RATED", + e.factors, + resolver, + dropped.factors, ); - for (const key of keys) dropped.factors.add(key); } if (e.structuredTags !== undefined) { - const keys = await replaceTagLinks( - moodEntryId, - userId, - e.structuredTags, + await replaceImportedLinks( tx, + moodEntryId, + "BINARY", + e.structuredTags.map((key) => ({ key, rating: null })), + resolver, + dropped.tags, ); - for (const key of keys) dropped.tags.add(key); } } @@ -131,6 +178,7 @@ async function writeMoodEntry( userId: string, e: JsonImportMoodEntry, loggedAt: Date, + resolver: MoodLinkResolver, tally: JsonImportTally, ): Promise { const columns = moodEntryColumns(e, loggedAt); @@ -165,7 +213,7 @@ async function writeMoodEntry( data: { userId, ...columns, source: "IMPORT" }, select: { id: true }, }); - await writeMoodEntryParts(tx, userId, row.id, e, dropped); + await writeMoodEntryParts(tx, userId, row.id, e, resolver, dropped); }); for (const key of dropped.tags) tally.droppedTagKeys.add(key); for (const key of dropped.factors) tally.droppedFactorKeys.add(key); @@ -209,6 +257,7 @@ async function importMoodEntry( userId: string, e: JsonImportMoodEntry, fallback: FallbackState, + resolver: MoodLinkResolver, tally: JsonImportTally, ): Promise { // Same stability floor as the measurement rows: the mood upsert keys on @@ -234,7 +283,7 @@ async function importMoodEntry( continue; } try { - await writeMoodEntry(userId, e, loggedAt, tally); + await writeMoodEntry(userId, e, loggedAt, resolver, tally); if (!e.loggedAt) { fallback.reserved.add(loggedAt.getTime()); } @@ -281,6 +330,7 @@ async function importMoodEntry( export async function importJsonMoodEntries( userId: string, rows: JsonImportMoodEntry[], + resolver: MoodLinkResolver, tally: JsonImportTally, ): Promise { const fallback: FallbackState = { @@ -288,7 +338,7 @@ export async function importJsonMoodEntries( reserved: new Set(), }; for (const e of rows) { - await importMoodEntry(userId, e, fallback, tally); + await importMoodEntry(userId, e, fallback, resolver, tally); if (tally.writeFailed) break; } } diff --git a/src/lib/import/json/normalize.ts b/src/lib/import/json/normalize.ts index a4ee9403b..ec309675d 100644 --- a/src/lib/import/json/normalize.ts +++ b/src/lib/import/json/normalize.ts @@ -1,7 +1,7 @@ /** * The JSON import reads what the app itself writes. * - * Two of the app's files carry the import's two arrays: the full backup + * Two of the app's files carry the import's arrays: the full backup * (`/api/export/full-backup`), at the top level, and the JSON export * (`/api/export?format=json`), inside the response envelope's `data`. Neither * was written for the import schema, and both spell an absent value the way @@ -104,5 +104,14 @@ export function normalizeJsonImportFile( file.moodEntries = read.entries; skipped += read.skipped; } + // The mood vocabulary the entries' links point into, read the same way. + for (const section of [ + "customMoodTagCategories", + "customMoodTags", + ] as const) { + if (section in source) { + file[section] = readEntries(source[section], (entry) => entry).entries; + } + } return { file, skipped }; } diff --git a/src/lib/import/json/run.ts b/src/lib/import/json/run.ts index 572b21cca..206b5b6de 100644 --- a/src/lib/import/json/run.ts +++ b/src/lib/import/json/run.ts @@ -18,16 +18,22 @@ import { type JsonImportMeasurement, } from "./measurements"; import { importJsonMoodEntries, type JsonImportMoodEntry } from "./mood"; +import type { JsonImportMoodTag, JsonImportMoodTagCategory } from "./schema"; import { newJsonImportTally, type JsonImportTally } from "./tally"; +import { resolveMoodVocabulary } from "./vocabulary"; export async function runJsonImport(input: { userId: string; measurements: JsonImportMeasurement[] | undefined; moodEntries: JsonImportMoodEntry[] | undefined; + customMoodTagCategories: JsonImportMoodTagCategory[] | undefined; + customMoodTags: JsonImportMoodTag[] | undefined; ipAddress: string | null; }): Promise< Pick & { droppedLinkKeys: DroppedLinkKeys; + keptMoodTagKeys: string[]; + keptMoodCategoryKeys: string[]; } > { const { userId } = input; @@ -49,8 +55,22 @@ export async function runJsonImport(input: { .map((row) => row.measuredAt), ).catch(() => {}); - if (!tally.writeFailed && input.moodEntries?.length) { - await importJsonMoodEntries(userId, input.moodEntries, tally); + // The account's own vocabulary goes in before the entries that link to + // it, and every link key is resolved once. + const entries = input.moodEntries ?? []; + const resolver = + !tally.writeFailed && + (entries.length > 0 || (input.customMoodTags?.length ?? 0) > 0) + ? await resolveMoodVocabulary({ + userId, + entries, + categories: input.customMoodTagCategories ?? [], + tags: input.customMoodTags ?? [], + tally, + }) + : new Map(); + if (!tally.writeFailed && entries.length > 0) { + await importJsonMoodEntries(userId, entries, resolver, tally); } // One bounded re-fold per import is cheaper than firing N per-row hooks. @@ -111,5 +131,7 @@ export async function runJsonImport(input: { droppedTagKeys: [...tally.droppedTagKeys].sort(), droppedFactorKeys: [...tally.droppedFactorKeys].sort(), }, + keptMoodTagKeys: [...tally.keptMoodTagKeys].sort(), + keptMoodCategoryKeys: [...tally.keptMoodCategoryKeys].sort(), }; } diff --git a/src/lib/import/json/schema.ts b/src/lib/import/json/schema.ts index 7b33bdd1a..89c3d728f 100644 --- a/src/lib/import/json/schema.ts +++ b/src/lib/import/json/schema.ts @@ -174,6 +174,44 @@ export const jsonImportMoodEntrySchema = z } }); +/** + * A mood-tag group the account made itself, as the full backup writes it. + * `id` is the file's own id, read only to find the group a tag of the file + * names; the group is matched and created by `key`. + */ +export const jsonImportMoodTagCategorySchema = z.object({ + id: z.string().max(120).optional(), + key: z.string().min(1).max(80), + labelKey: z.string().min(1).max(120), + icon: z.string().max(60).optional(), + sortOrder: z.number().int().optional(), + isActive: z.boolean().optional(), + // The person's own name for the group: readable in a portable file, + // ciphertext in a disaster-recovery one (opened only by the host's keys). + label: z.string().max(200).optional(), + labelEncrypted: z.string().max(2000).optional(), +}); + +/** + * A mood tag the account made itself, a rated factor included. Matched and + * created by `key`; its group is the file's `categoryId`, resolved through + * the groups the file carries. + */ +export const jsonImportMoodTagSchema = z.object({ + key: z.string().min(1).max(80), + labelKey: z.string().min(1).max(120), + categoryId: z.string().min(1).max(120), + kind: z.enum(["BINARY", "RATED"]), + isActive: z.boolean().optional(), + icon: z.string().max(60).optional(), + sortOrder: z.number().int().optional(), + label: z.string().max(200).optional(), + labelEncrypted: z.string().max(2000).optional(), + scaleMin: z.number().int().min(0).max(10).optional(), + scaleMax: z.number().int().min(0).max(10).optional(), + inverse: z.boolean().optional(), +}); + export const jsonImportSchema = z.object({ measurements: z .array(jsonImportMeasurementSchema) @@ -183,7 +221,20 @@ export const jsonImportSchema = z.object({ .array(jsonImportMoodEntrySchema) .max(JSON_IMPORT_MAX_ROWS) .optional(), + // The account's own mood vocabulary, which the entries' links point into. + customMoodTagCategories: z + .array(jsonImportMoodTagCategorySchema) + .max(JSON_IMPORT_MAX_ROWS) + .optional(), + customMoodTags: z + .array(jsonImportMoodTagSchema) + .max(JSON_IMPORT_MAX_ROWS) + .optional(), }); export type JsonImportMeasurement = z.infer; export type JsonImportMoodEntry = z.infer; +export type JsonImportMoodTagCategory = z.infer< + typeof jsonImportMoodTagCategorySchema +>; +export type JsonImportMoodTag = z.infer; diff --git a/src/lib/import/json/tally.ts b/src/lib/import/json/tally.ts index 9110417a7..7627ad57b 100644 --- a/src/lib/import/json/tally.ts +++ b/src/lib/import/json/tally.ts @@ -11,6 +11,13 @@ export interface JsonImportTally { */ droppedTagKeys: Set; droppedFactorKeys: Set; + /** + * The account's own tags and groups whose key the file shares and whose + * label it states differently. The account's version is kept, never + * overwritten; the key is named. + */ + keptMoodTagKeys: Set; + keptMoodCategoryKeys: Set; /** A write failed for a reason other than a duplicate; the import stops. */ writeFailed: boolean; } @@ -21,6 +28,8 @@ export function newJsonImportTally(): JsonImportTally { unstableShapes: [], droppedTagKeys: new Set(), droppedFactorKeys: new Set(), + keptMoodTagKeys: new Set(), + keptMoodCategoryKeys: new Set(), writeFailed: false, }; } diff --git a/src/lib/import/json/vocabulary.ts b/src/lib/import/json/vocabulary.ts new file mode 100644 index 000000000..761c1ef69 --- /dev/null +++ b/src/lib/import/json/vocabulary.ts @@ -0,0 +1,288 @@ +/** + * The account's own mood vocabulary, brought along by a JSON import. + * + * A mood entry links to tags by key: the seeded catalogue's, and the ones the + * person made (custom tags, their own rated factors, the groups they sit in). + * A full backup carries the account's own as `customMoodTags` and + * `customMoodTagCategories`. Without them an entry imported into another + * account landed without every link to a tag the person had made. + * + * So before the entries are written, each key an entry or the file names is + * resolved once, to the tag the importing account links to: + * + * - a catalogue tag, or a tag the account already owns, is used as it is. + * An own tag whose label differs from the file's is kept, never + * overwritten, and named (`keptMoodTagKeys`): the account's name for it + * is the one it uses now; + * - a tag the file defines and the account lacks is created, under its own + * key, in its group (created the same way when missing); + * - a key another account on this instance holds (keys are unique per + * instance, so a file moved between two accounts of one server collides) + * comes over under a key derived from the account and the file's key, so + * importing the same file twice finds the copy rather than making a + * second one; + * - anything else (a key neither the catalogue nor the file knows, the + * wrong kind for its link, a tag past the account's custom-tag cap) is + * dropped from the entries and named. + */ +import { createHash } from "node:crypto"; + +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { sealForRestore } from "@/lib/export/sealed-text"; +import { + CUSTOM_CATEGORY_KEY_PREFIX, + CUSTOM_TAG_KEY_PREFIX, + decryptCustomLabel, + MAX_CUSTOM_GROUPS_PER_USER, + MAX_CUSTOM_TAGS_PER_USER, +} from "@/lib/mood/custom-tags"; + +import type { + JsonImportMoodEntry, + JsonImportMoodTag, + JsonImportMoodTagCategory, +} from "./schema"; +import type { JsonImportTally } from "./tally"; + +/** A tag an imported link may point at. */ +export interface ImportLinkTag { + id: string; + kind: string; + scaleMin: number; + scaleMax: number; +} + +/** The file's keys, resolved to the importing account's tags. */ +export type MoodLinkResolver = ReadonlyMap; + +/** The seeded group a tag lands in when the file does not carry its own. */ +const FALLBACK_CATEGORY_KEY = "custom"; + +const LINK_TAG_SELECT = { + id: true, + key: true, + kind: true, + scaleMin: true, + scaleMax: true, + userId: true, + labelEncrypted: true, +} as const satisfies Prisma.MoodTagSelect; + +/** + * The key a copy of `fileKey` carries in `userId`'s account when another + * account holds `fileKey`: a fresh-looking custom key, the same on every + * import of the same file into the same account. + */ +export function derivedVocabularyKey( + prefix: string, + userId: string, + fileKey: string, +): string { + const hex = createHash("sha256") + .update(`${userId}\u0000${fileKey}`) + .digest("hex"); + return `${prefix}${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +/** The file's readable label, or the one its ciphertext opens to here. */ +function fileLabel(item: { + label?: string; + labelEncrypted?: string; +}): string | null { + if (item.label !== undefined) return item.label; + return item.labelEncrypted ? decryptCustomLabel(item.labelEncrypted) : null; +} + +/** The label column for a row written from `item`, sealed under this host's key. */ +function sealedLabel( + item: { label?: string; labelEncrypted?: string }, + path: string, +): string | null { + return sealForRestore(item.labelEncrypted, item.label, path, []); +} + +class Vocabulary { + private readonly categoryIds = new Map(); + private ownTags = 0; + private ownGroups = 0; + + constructor( + private readonly userId: string, + private readonly categories: readonly JsonImportMoodTagCategory[], + private readonly tally: JsonImportTally, + ) {} + + async countOwn(): Promise { + [this.ownTags, this.ownGroups] = await Promise.all([ + prisma.moodTag.count({ where: { userId: this.userId, isActive: true } }), + prisma.moodTagCategory.count({ where: { userId: this.userId } }), + ]); + } + + /** The importing account's group for a tag of the file, or null. */ + async category(fileCategoryId: string): Promise { + const cached = this.categoryIds.get(fileCategoryId); + if (cached !== undefined) return cached; + const resolved = await this.resolveCategory(fileCategoryId); + this.categoryIds.set(fileCategoryId, resolved); + return resolved; + } + + private async resolveCategory( + fileCategoryId: string, + ): Promise { + const own = this.categories.find((c) => c.id === fileCategoryId); + if (!own) { + // A seeded group of the instance the file came from. Its id is this + // instance's only when the file came from here; otherwise the tag goes + // to the seeded group made for custom tags. + const seeded = + (await prisma.moodTagCategory.findFirst({ + where: { userId: null, id: fileCategoryId }, + select: { id: true }, + })) ?? + (await prisma.moodTagCategory.findFirst({ + where: { userId: null, key: FALLBACK_CATEGORY_KEY }, + select: { id: true }, + })); + return seeded?.id ?? null; + } + const existing = await prisma.moodTagCategory.findUnique({ + where: { key: own.key }, + select: { id: true, userId: true, labelEncrypted: true }, + }); + if ( + existing && + (existing.userId === null || existing.userId === this.userId) + ) { + const label = fileLabel(own); + if ( + existing.userId === this.userId && + label !== null && + decryptCustomLabel(existing.labelEncrypted) !== label + ) { + this.tally.keptMoodCategoryKeys.add(own.key); + } + return existing.id; + } + const key = existing + ? derivedVocabularyKey(CUSTOM_CATEGORY_KEY_PREFIX, this.userId, own.key) + : own.key; + const copy = await prisma.moodTagCategory.findUnique({ + where: { key }, + select: { id: true, userId: true }, + }); + if (copy) return copy.userId === this.userId ? copy.id : null; + if (this.ownGroups >= MAX_CUSTOM_GROUPS_PER_USER) return null; + const created = await prisma.moodTagCategory.create({ + data: { + userId: this.userId, + key, + labelKey: own.labelKey, + icon: own.icon ?? null, + sortOrder: own.sortOrder ?? 0, + isActive: own.isActive ?? true, + labelEncrypted: sealedLabel(own, `customMoodTagCategories.${own.key}`), + }, + select: { id: true }, + }); + this.ownGroups += 1; + return created.id; + } + + /** Create `tag` in the importing account under `key`; null when it cannot be. */ + async create( + tag: JsonImportMoodTag, + key: string, + ): Promise { + const active = tag.isActive ?? true; + if (active && this.ownTags >= MAX_CUSTOM_TAGS_PER_USER) return null; + const categoryId = await this.category(tag.categoryId); + if (categoryId === null) return null; + const created = await prisma.moodTag.create({ + data: { + userId: this.userId, + key, + labelKey: tag.labelKey, + categoryId, + kind: tag.kind, + isActive: active, + icon: tag.icon ?? null, + sortOrder: tag.sortOrder ?? 0, + labelEncrypted: sealedLabel(tag, `customMoodTags.${tag.key}`), + scaleMin: tag.scaleMin ?? 1, + scaleMax: tag.scaleMax ?? 5, + inverse: tag.inverse ?? false, + }, + select: LINK_TAG_SELECT, + }); + if (active) this.ownTags += 1; + return created; + } +} + +/** + * Resolve every key the file's entries link to, creating the account's own + * tags and groups the file brings and the account lacks. Keys that resolve + * to nothing are named on the tally by the link writer, which knows which + * kind of link asked for them. + */ +export async function resolveMoodVocabulary(input: { + userId: string; + entries: readonly JsonImportMoodEntry[]; + categories: readonly JsonImportMoodTagCategory[]; + tags: readonly JsonImportMoodTag[]; + tally: JsonImportTally; +}): Promise { + const { userId, entries, tally } = input; + const definitions = new Map(input.tags.map((tag) => [tag.key, tag])); + const keys = new Set(definitions.keys()); + for (const entry of entries) { + for (const factor of entry.factors ?? []) keys.add(factor.key); + for (const key of entry.structuredTags ?? []) keys.add(key); + } + const resolved = new Map(); + if (keys.size === 0) return resolved; + + const derived = new Map( + [...definitions.keys()].map((key) => [ + key, + derivedVocabularyKey(CUSTOM_TAG_KEY_PREFIX, userId, key), + ]), + ); + const rows = await prisma.moodTag.findMany({ + where: { key: { in: [...keys, ...derived.values()] } }, + select: LINK_TAG_SELECT, + }); + const byKey = new Map(rows.map((row) => [row.key, row])); + const vocabulary = new Vocabulary(userId, input.categories, tally); + await vocabulary.countOwn(); + + for (const key of keys) { + const definition = definitions.get(key); + const row = byKey.get(key); + if (row && (row.userId === null || row.userId === userId)) { + const label = definition ? fileLabel(definition) : null; + if ( + row.userId === userId && + label !== null && + decryptCustomLabel(row.labelEncrypted) !== label + ) { + tally.keptMoodTagKeys.add(key); + } + resolved.set(key, row); + continue; + } + if (!definition) continue; + const target = row ? derived.get(key)! : key; + const copy = row ? byKey.get(target) : undefined; + if (copy) { + if (copy.userId === userId) resolved.set(key, copy); + continue; + } + const created = await vocabulary.create(definition, target); + if (created) resolved.set(key, created); + } + return resolved; +} diff --git a/src/lib/openapi/routes/import.ts b/src/lib/openapi/routes/import.ts index 871fa2d04..c22baff64 100644 --- a/src/lib/openapi/routes/import.ts +++ b/src/lib/openapi/routes/import.ts @@ -228,14 +228,14 @@ const jsonImportMoodEntry = z .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.", + "Rated factors by key (the catalogue's, or the account's own as for `structuredTags`); 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`.", + "Ticked tags by key (the catalogue, the account's own, or one the file's `customMoodTags` brings); replaces the entry's tag links. An unresolvable key is dropped and named in `droppedTagKeys`.", ), context: moodContextWire .partial() @@ -257,15 +257,82 @@ const jsonImportMoodEntry = z description: "One mood-entry row of a JSON import file.", }); +const jsonImportMoodTagCategory = z + .object({ + id: z + .string() + .max(120) + .optional() + .describe( + "The file's own id, read only to find the group a tag names in `categoryId`.", + ), + key: z.string().min(1).max(80), + labelKey: z.string().min(1).max(120), + icon: z.string().max(60).optional(), + sortOrder: z.number().int().optional(), + isActive: z.boolean().optional(), + label: z.string().max(200).optional().describe("The group's own name."), + labelEncrypted: z + .string() + .max(2000) + .optional() + .describe( + "A disaster-recovery file's sealed name, used only when this server's keys open it.", + ), + }) + .meta({ + id: "JsonImportMoodTagCategory", + description: + "A mood-tag group the account made itself. Matched by `key`: a group the account has is used as it is, a missing one is created.", + }); + +const jsonImportMoodTag = z + .object({ + key: z.string().min(1).max(80), + labelKey: z.string().min(1).max(120), + categoryId: z + .string() + .min(1) + .max(120) + .describe( + "The group's id in the file; a group the file does not carry falls back to the seeded `custom` group.", + ), + kind: z.enum(["BINARY", "RATED"]), + isActive: z.boolean().optional(), + icon: z.string().max(60).optional(), + sortOrder: z.number().int().optional(), + label: z.string().max(200).optional().describe("The tag's own name."), + labelEncrypted: z + .string() + .max(2000) + .optional() + .describe( + "A disaster-recovery file's sealed name, used only when this server's keys open it.", + ), + scaleMin: z.number().int().min(0).max(10).optional(), + scaleMax: z.number().int().min(0).max(10).optional(), + inverse: z.boolean().optional(), + }) + .meta({ + id: "JsonImportMoodTag", + description: + "A mood tag the account made itself, a rated factor included. Matched by `key`: the account's own tag of that key is used and never overwritten (a different label is named in `keptMoodTagKeys`); a missing one is created, within the account's custom-tag cap; a key another account of this server holds comes over under a key derived from the account and the file's key, so a second import of the same file finds it.", + }); + const jsonImportRequest = z .object({ measurements: z.array(jsonImportMeasurement).max(10000).optional(), moodEntries: z.array(jsonImportMoodEntry).max(10000).optional(), + customMoodTagCategories: z + .array(jsonImportMoodTagCategory) + .max(10000) + .optional(), + customMoodTags: z.array(jsonImportMoodTag).max(10000).optional(), }) .meta({ id: "JsonImportRequest", 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.", + "JSON import payload: the measurements and mood entries of the JSON export or the portable full backup, and the backup's own mood vocabulary the entries link to. Every section optional; up to 10 000 rows each.", }); const jsonImportStats = z @@ -299,6 +366,16 @@ const jsonImportStats = z .describe( "Rated-factor keys this account cannot resolve, as `droppedTagKeys`.", ), + keptMoodTagKeys: z + .array(z.string()) + .optional() + .describe( + "Keys of the account's own tags the file names differently. The account's tag is kept as it is. Present only when non-empty.", + ), + keptMoodCategoryKeys: z + .array(z.string()) + .optional() + .describe("The same, for the account's own tag groups."), }) .meta({ id: "JsonImportStats", diff --git a/tests/integration/json-import-field-coverage.test.ts b/tests/integration/json-import-field-coverage.test.ts index 69bcf01fb..c0aa14789 100644 --- a/tests/integration/json-import-field-coverage.test.ts +++ b/tests/integration/json-import-field-coverage.test.ts @@ -28,6 +28,8 @@ import { jsonImportMeasurementSchema, jsonImportMoodContextSchema, jsonImportMoodEntrySchema, + jsonImportMoodTagCategorySchema, + jsonImportMoodTagSchema, } from "@/lib/import/json/schema"; import { callRoute } from "../kit/call-route"; @@ -86,20 +88,26 @@ const CONTEXT_NOT_IMPORTED: Record = { updatedAt: ROW_STAMP, }; +/** A tag or group of the file is matched and created by key, not by id. */ +const MOOD_TAG_NOT_IMPORTED: Record = { + id: "A tag is matched and created by its key; its id in the file names a row of the account it came from (disaster-recovery file only).", +}; +const MOOD_CATEGORY_NOT_IMPORTED: Record = {}; + /** - * 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. + * The files' other sections. The JSON import carries readings, mood entries + * and the account's own mood vocabulary (which the entries' links point into) + * 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. */ -const IMPORTED_SECTIONS = new Set(["measurements", "moodEntries"]); -const VOCABULARY_SECTIONS: Record = { - 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.", +const IMPORTED_SECTIONS = new Set([ + "measurements", + "moodEntries", + "customMoodTags", + "customMoodTagCategories", +]); +const NAMED_MOOD_SECTIONS: Record = { hiddenMoodTags: "An account setting (which tags its editor hides), restored by the backup restore.", }; @@ -166,6 +174,18 @@ const MEASUREMENT_READ = new Set( ); const MOOD_READ = new Set(Object.keys(jsonImportMoodEntrySchema.shape)); const CONTEXT_READ = new Set(Object.keys(jsonImportMoodContextSchema.shape)); +const MOOD_TAG_READ = new Set(Object.keys(jsonImportMoodTagSchema.shape)); +const MOOD_CATEGORY_READ = new Set( + Object.keys(jsonImportMoodTagCategorySchema.shape), +); +const BACKUP_NAMES = ["portable", "disasterRecovery"] as const; + +function sectionOf( + name: (typeof BACKUP_NAMES)[number], + section: "customMoodTags" | "customMoodTagCategories", +): Row[] { + return files[name][section] as Row[]; +} const FILE_NAMES = ["jsonExport", "portable", "disasterRecovery"] as const; @@ -207,13 +227,37 @@ describe("the JSON import reads what the exporters write", () => { }, ); - it("names the mood vocabulary the entries' links point into", () => { - for (const name of ["portable", "disasterRecovery"] as const) { + it.each(BACKUP_NAMES)("every field of the %s's own mood tags", (name) => { + const rows = sectionOf(name, "customMoodTags"); + // The seed's own tag and its own rated factor. + expect(rows.length).toBeGreaterThanOrEqual(2); + expect( + unaccounted(keysOf(rows), MOOD_TAG_READ, MOOD_TAG_NOT_IMPORTED), + ).toEqual([]); + }); + + it.each(BACKUP_NAMES)("every field of the %s's own mood groups", (name) => { + const rows = sectionOf(name, "customMoodTagCategories"); + expect(rows.length).toBeGreaterThan(0); + expect( + unaccounted(keysOf(rows), MOOD_CATEGORY_READ, MOOD_CATEGORY_NOT_IMPORTED), + ).toEqual([]); + }); + + it("reads or names every mood section the backups carry", () => { + for (const name of BACKUP_NAMES) { const sections = Object.keys(files[name]); expect(sections).toEqual(expect.arrayContaining([...IMPORTED_SECTIONS])); - for (const section of Object.keys(VOCABULARY_SECTIONS)) { + for (const section of Object.keys(NAMED_MOOD_SECTIONS)) { expect(sections, `${name} carries ${section}`).toContain(section); } + // Every mood section of the file is one of the two lists. + const mood = sections.filter((s) => /mood/i.test(s)); + expect( + mood.filter( + (s) => !IMPORTED_SECTIONS.has(s) && !(s in NAMED_MOOD_SECTIONS), + ), + ).toEqual([]); } // The JSON export's other two sections are medications, restored the // same way as every other section of an account's record. @@ -245,6 +289,14 @@ describe("the JSON import reads what the exporters write", () => { ...Object.keys(CONTEXT_NOT_IMPORTED) .filter((key) => !writtenContext.has(key)) .map((key) => `context.${key}`), + ...Object.keys(MOOD_TAG_NOT_IMPORTED) + .filter( + (key) => + !BACKUP_NAMES.some((name) => + keysOf(sectionOf(name, "customMoodTags")).has(key), + ), + ) + .map((key) => `customMoodTag.${key}`), ]; expect(stale).toEqual([]); }); @@ -256,6 +308,7 @@ describe("the JSON import reads what the exporters write", () => { ), ...Object.keys(MOOD_NOT_IMPORTED).filter((k) => MOOD_READ.has(k)), ...Object.keys(CONTEXT_NOT_IMPORTED).filter((k) => CONTEXT_READ.has(k)), + ...Object.keys(MOOD_TAG_NOT_IMPORTED).filter((k) => MOOD_TAG_READ.has(k)), ]; expect(both).toEqual([]); }); diff --git a/tests/integration/json-import-own-exports.test.ts b/tests/integration/json-import-own-exports.test.ts index 50487557f..29540ce28 100644 --- a/tests/integration/json-import-own-exports.test.ts +++ b/tests/integration/json-import-own-exports.test.ts @@ -29,11 +29,14 @@ 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 { derivedVocabularyKey } from "@/lib/import/json/vocabulary"; +import { decryptCustomLabel, encryptCustomLabel } from "@/lib/mood/custom-tags"; import { callRoute } from "../kit/call-route"; import { makeSession, makeUser } from "../kit/factories"; import { JI_OWN_TAG_KEY, + JI_OWN_TAG_LABEL, seedJsonImportCatalogue, seedJsonImportOwner, } from "./json-import-seed"; @@ -49,14 +52,19 @@ interface ImportStats { skipped: number; droppedTagKeys?: string[]; droppedFactorKeys?: string[]; + keptMoodTagKeys?: string[]; } let ownerId: string; -async function fullBackupOf(userId: string): Promise<{ +interface FullBackup { measurements: Row[]; moodEntries: Row[]; -}> { + customMoodTags: Row[]; + customMoodTagCategories: Row[]; +} + +async function fullBackupOf(userId: string): Promise { let text = ""; await streamFullBackupJson(prisma, userId, (chunk) => { text += chunk; @@ -89,6 +97,35 @@ async function importInto(file: unknown) { return { userId: user.id, res }; } +/** + * A file's own vocabulary, comparable across accounts and servers: each tag + * with its group named by key rather than by a row id. + */ +function vocabularyOf(file: FullBackup) { + const groupKey = new Map( + file.customMoodTagCategories.map((c) => [c.id as string, c.key as string]), + ); + const groups = new Map( + file.customMoodTagCategories.map((c) => { + const { id: _id, ...rest } = c; + return [c.key as string, rest]; + }), + ); + return file.customMoodTags + .map((tag) => { + const { categoryId, ...rest } = tag; + const categoryKey = + groupKey.get(categoryId as string) ?? (categoryId as string); + return { + ...rest, + key: tag.key as string, + categoryKey, + group: groups.get(categoryKey) ?? null, + }; + }) + .sort((a, b) => String(a.key).localeCompare(String(b.key))); +} + /** A row without the fields the import deliberately does not carry. */ function comparable(row: Row): Row { const { id: _id, source: _source, ...rest } = row; @@ -112,7 +149,7 @@ afterAll(async () => { }); describe("the JSON import takes the app's own files back", () => { - it("takes a full backup back field for field", async () => { + it("takes a full backup into another account of the same server, every link included", 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); @@ -123,33 +160,100 @@ describe("the JSON import takes the app's own files back", () => { const { userId, res } = await importInto(original); expect(res.status).toBe(200); + // Every link lands: the owner's own tags come over as copies, under keys + // of the importing account, because this server's keys are the owner's. expect(res.data).toEqual({ 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], }); + const copyOf = (key: string) => + key.startsWith("custom:") + ? derivedVocabularyKey("custom:", userId, key) + : key.startsWith("customcat:") + ? derivedVocabularyKey("customcat:", userId, key) + : key; const back = await fullBackupOf(userId); - const expectedMeasurements = original.measurements - .map(comparable) - .sort(byInstant("measuredAt")); expect( back.measurements.map(comparable).sort(byInstant("measuredAt")), - ).toEqual(expectedMeasurements); + ).toEqual( + original.measurements.map(comparable).sort(byInstant("measuredAt")), + ); const expectedMood = original.moodEntries .map((entry) => ({ ...comparable(entry), - structuredTags: (entry.structuredTags as string[]).filter( - (key) => key !== JI_OWN_TAG_KEY, + structuredTags: (entry.structuredTags as string[]).map(copyOf), + factors: (entry.factors as Array<{ key: string; rating: number }>).map( + (factor) => ({ ...factor, key: copyOf(factor.key) }), ), })) .sort(byInstant("loggedAt")); expect( back.moodEntries.map(comparable).sort(byInstant("loggedAt")), ).toEqual(expectedMood); + expect(vocabularyOf(back)).toEqual( + vocabularyOf(original) + .map((tag) => ({ + ...tag, + key: copyOf(tag.key), + categoryKey: copyOf(tag.categoryKey), + group: tag.group && { + ...tag.group, + key: copyOf(String(tag.group.key)), + }, + })) + .sort((a, b) => a.key.localeCompare(b.key)), + ); + + // A second import of the same file finds the copies. + const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); + const again = await callRoute(importJson, { + method: "POST", + route: "/api/import", + body: original, + auth: await makeSession(user), + }); + expect(again.status).toBe(200); + expect(await prisma.moodTag.count({ where: { userId } })).toBe( + original.customMoodTags.length, + ); + expect(await prisma.moodTagCategory.count({ where: { userId } })).toBe( + original.customMoodTagCategories.length, + ); + }); + + it("keeps the account's own name for a tag the file names differently, and says so", async () => { + const original = await fullBackupOf(ownerId); + // The owner renames the tag after the backup, then imports the backup. + await prisma.moodTag.update({ + where: { key: JI_OWN_TAG_KEY }, + data: { labelEncrypted: encryptCustomLabel("Evening walk") }, + }); + const owner = await prisma.user.findUniqueOrThrow({ + where: { id: ownerId }, + }); + // The vocabulary alone: the readings of a portable file carry no source + // id, and would be written a second time into the account they came from. + const res = await callRoute(importJson, { + method: "POST", + route: "/api/import", + body: { + customMoodTags: original.customMoodTags, + customMoodTagCategories: original.customMoodTagCategories, + }, + auth: await makeSession(owner), + }); + expect(res.status).toBe(200); + expect(res.data.keptMoodTagKeys).toEqual([JI_OWN_TAG_KEY]); + const tag = await prisma.moodTag.findUniqueOrThrow({ + where: { key: JI_OWN_TAG_KEY }, + }); + expect(decryptCustomLabel(tag.labelEncrypted)).toBe("Evening walk"); + await prisma.moodTag.update({ + where: { key: JI_OWN_TAG_KEY }, + data: { labelEncrypted: encryptCustomLabel(JI_OWN_TAG_LABEL) }, + }); }); it("takes the JSON export back, empty strings and its envelope included", async () => { @@ -245,6 +349,40 @@ describe("the JSON import takes the app's own files back", () => { }); }); +describe("a full backup moved to another server", () => { + it("comes back equal, the account's own tags, groups and rated factor included", async () => { + const original = await fullBackupOf(ownerId); + expect(original.customMoodTags.length).toBeGreaterThanOrEqual(2); + expect(original.customMoodTags.some((tag) => tag.kind === "RATED")).toBe( + true, + ); + expect(original.customMoodTagCategories).toHaveLength(1); + + // Another server: the same seeded catalogue, none of this account's rows. + await truncateAllTables(prisma); + await seedJsonImportCatalogue(prisma); + + const { userId, res } = await importInto(original); + expect(res.status).toBe(200); + expect(res.data).toEqual({ + measurements: original.measurements.length, + moodEntries: original.moodEntries.length, + skipped: 0, + }); + + const back = await fullBackupOf(userId); + expect( + back.measurements.map(comparable).sort(byInstant("measuredAt")), + ).toEqual( + original.measurements.map(comparable).sort(byInstant("measuredAt")), + ); + expect( + back.moodEntries.map(comparable).sort(byInstant("loggedAt")), + ).toEqual(original.moodEntries.map(comparable).sort(byInstant("loggedAt"))); + expect(vocabularyOf(back)).toEqual(vocabularyOf(original)); + }); +}); + 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" }, diff --git a/tests/integration/json-import-seed.ts b/tests/integration/json-import-seed.ts index 5191d1b0c..c613c3114 100644 --- a/tests/integration/json-import-seed.ts +++ b/tests/integration/json-import-seed.ts @@ -5,21 +5,27 @@ * 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 + * carries a note, all five level-A values, its zone, a catalogue factor and + * tag, the account's own tag and its own rated factor (in a group of its 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 { encryptCustomLabel } from "@/lib/mood/custom-tags"; 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. */ +/** The account's own tag, in a seeded group. */ export const JI_OWN_TAG_KEY = "custom:ji_long_walk"; +/** The account's own group, and its own rated factor in it. */ +export const JI_OWN_CATEGORY_KEY = "customcat:ji_outdoors"; +export const JI_OWN_FACTOR_KEY = "custom:ji_trail_feel"; +export const JI_OWN_TAG_LABEL = "Long walk"; export const JI_MEASUREMENTS = [ { @@ -119,11 +125,19 @@ export interface SeededOwner { export async function seedJsonImportCatalogue( prisma: PrismaClient, ): Promise { + // Fixed ids, as the migrations seed the real catalogue (whose `custom` + // group the test database holds too): a group's id is the same on every + // server, which is what lets an imported tag find it. const category = await prisma.moodTagCategory.create({ - data: { key: "ji_category", labelKey: "mood.tagCategory.ji" }, + data: { + id: "mtc_ji_category", + key: "ji_category", + labelKey: "mood.tagCategory.ji", + }, }); await prisma.moodTag.create({ data: { + id: "mt_ji_headache", categoryId: category.id, key: JI_TAG_KEY, labelKey: "mood.tag.jiHeadache", @@ -132,6 +146,7 @@ export async function seedJsonImportCatalogue( }); await prisma.moodTag.create({ data: { + id: "mt_ji_sleep_quality", categoryId: category.id, key: JI_FACTOR_KEY, labelKey: "mood.tag.jiSleepQuality", @@ -195,6 +210,31 @@ export async function seedJsonImportOwner( key: JI_OWN_TAG_KEY, labelKey: "mood.tag.custom", kind: "BINARY", + labelEncrypted: encryptCustomLabel(JI_OWN_TAG_LABEL), + }, + }); + const ownCategory = await prisma.moodTagCategory.create({ + data: { + userId: ownerId, + key: JI_OWN_CATEGORY_KEY, + labelKey: "mood.tagCategory.custom", + icon: null, + sortOrder: 7, + labelEncrypted: encryptCustomLabel("Outdoors"), + }, + }); + const ownFactor = await prisma.moodTag.create({ + data: { + userId: ownerId, + categoryId: ownCategory.id, + key: JI_OWN_FACTOR_KEY, + labelKey: "mood.tag.custom", + kind: "RATED", + scaleMin: 1, + scaleMax: 5, + inverse: true, + sortOrder: 2, + labelEncrypted: encryptCustomLabel("How the trail felt"), }, }); const [tag, factor] = await Promise.all([ @@ -226,6 +266,7 @@ export async function seedJsonImportOwner( { moodEntryId: entry.id, moodTagId: tag.id }, { moodEntryId: entry.id, moodTagId: ownTag.id }, { moodEntryId: entry.id, moodTagId: factor.id, rating: 4 }, + { moodEntryId: entry.id, moodTagId: ownFactor.id, rating: 2 }, ], }); await prisma.moodContext.create({