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

@@ -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({