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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 22:41:36 +02:00
1 parent d1545cf554
commit 2357807706
25 files changed
+995 -67

No files matched your search

+122 -6
View File
@@ -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
+11 -4
View File
@@ -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
+1
View File
@@ -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."
},
+1
View File
@@ -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."
},
+1
View File
@@ -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."
},
+1
View File
@@ -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."
},
+1
View File
@@ -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."
},
+1
View File
@@ -5795,6 +5795,7 @@
"resultSummary": "측정 {measurements}개와 기분 기록 {moods}개를 불러왔고, {skipped}개는 건너뛰었어요.",
"resultSuccess": "측정 {measurements}개와 기분 기록 {moods}개를 불러왔어요",
"droppedLinks": "이 계정에 같은 이름의 태그가 없어 다음 태그 없이 가져왔습니다: {keys}",
"keptLabels": "이 계정에는 이미 같은 키의 태그나 그룹이 자체 이름으로 있어서 그대로 유지했어요: {keys}",
"resultNothing": "불러온 게 없어요. {skipped}개 기록을 모두 건너뛰었어요.",
"resultEmpty": "그 파일에는 측정도 기분 기록도 없었어요."
},
+1
View File
@@ -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."
},
+1
View File
@@ -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."
},
+1
View File
@@ -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."
},
@@ -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!);
+8
View File
@@ -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) {
@@ -38,9 +38,22 @@ describe("<JsonImportResultView>", () => {
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("<JsonImportResultView>", () => {
moodEntries: 0,
skipped: 0,
droppedLinkKeys: [],
keptLabelKeys: [],
});
});
});
@@ -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 }) {
})}
</p>
)}
{result.keptLabelKeys.length > 0 && (
<p
className="text-muted-foreground text-xs"
data-testid="import-json-kept-labels"
>
{t("settings.sections.export.import.json.keptLabels", {
keys: result.keptLabelKeys.join(", "),
})}
</p>
)}
</>
);
}
+70 -20
View File
@@ -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<string>;
}
/**
* 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<string>,
): Promise<void> {
const links = new Map<string, number | null>();
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<void> {
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<void> {
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<void> {
// 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<void> {
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;
}
}
+10 -1
View File
@@ -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 };
}
+24 -2
View File
@@ -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<JsonImportTally, "stats" | "writeFailed"> & {
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(),
};
}
+51
View File
@@ -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<typeof jsonImportMeasurementSchema>;
export type JsonImportMoodEntry = z.infer<typeof jsonImportMoodEntrySchema>;
export type JsonImportMoodTagCategory = z.infer<
typeof jsonImportMoodTagCategorySchema
>;
export type JsonImportMoodTag = z.infer<typeof jsonImportMoodTagSchema>;
+9
View File
@@ -11,6 +11,13 @@ export interface JsonImportTally {
*/
droppedTagKeys: Set<string>;
droppedFactorKeys: Set<string>;
/**
* 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<string>;
keptMoodCategoryKeys: Set<string>;
/** 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,
};
}
+288
View File
@@ -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<string, ImportLinkTag>;
/** 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<string, string | null>();
private ownTags = 0;
private ownGroups = 0;
constructor(
private readonly userId: string,
private readonly categories: readonly JsonImportMoodTagCategory[],
private readonly tally: JsonImportTally,
) {}
async countOwn(): Promise<void> {
[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<string | null> {
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<string | null> {
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<ImportLinkTag | null> {
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<MoodLinkResolver> {
const { userId, entries, tally } = input;
const definitions = new Map(input.tags.map((tag) => [tag.key, tag]));
const keys = new Set<string>(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<string, ImportLinkTag>();
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;
}
+80 -3
View File
@@ -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",
@@ -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<string, string> = {
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<string, string> = {
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<string, string> = {};
/**
* 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<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.",
const IMPORTED_SECTIONS = new Set([
"measurements",
"moodEntries",
"customMoodTags",
"customMoodTagCategories",
]);
const NAMED_MOOD_SECTIONS: Record<string, string> = {
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([]);
});
+150 -12
View File
@@ -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<FullBackup> {
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<ImportStats>(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<ImportStats>(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" },
+45 -4
View File
@@ -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<void> {
// 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({