Files
Marc-André Bombeck 2357807706 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.
2026-10-23 22:41:36 +02:00

309 lines
8.5 KiB
TypeScript

/**
* One account with every column the exporters read filled in, for the JSON
* import's round trip and its field-coverage guard.
*
* Every reading carries what makes it that reading: a range keeps its bounds,
* a sleep night its stages and its writer, a rhythm alert its verdict, a
* glucose value its meal-time context, a note its text. The mood entry
* carries a note, all five level-A values, its zone, a 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, 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 = [
{
type: "WEIGHT",
value: 81.4,
unit: "kg",
at: "2026-09-10T06:30:00.000Z",
note: "after the run",
},
{
type: "PULSE",
value: 64,
valueMin: 52,
valueMax: 91,
unit: "bpm",
at: "2026-09-11T07:00:00.000Z",
deviceType: "watch",
},
{
type: "BLOOD_GLUCOSE",
value: 104,
unit: "mg/dL",
at: "2026-09-12T08:00:00.000Z",
glucoseContext: "FASTING",
},
{
type: "BLOOD_GLUCOSE",
value: 141,
unit: "mg/dL",
at: "2026-09-12T13:00:00.000Z",
note: "after lunch",
glucoseContext: "POSTPRANDIAL",
},
{
type: "IRREGULAR_RHYTHM_NOTIFICATION",
value: 1,
unit: "count",
at: "2026-09-12T15:00:00.000Z",
rhythmClassification: "IRREGULAR",
deviceType: "watch",
},
// One night, as a watch writes it: a stage per row.
{
type: "SLEEP_DURATION",
value: 70,
unit: "minutes",
at: "2026-09-12T23:40:00.000Z",
sleepStage: "DEEP",
deviceType: "watch",
},
{
type: "SLEEP_DURATION",
value: 240,
unit: "minutes",
at: "2026-09-13T03:40:00.000Z",
sleepStage: "CORE",
deviceType: "watch",
},
{
type: "SLEEP_DURATION",
value: 95,
unit: "minutes",
at: "2026-09-13T05:15:00.000Z",
sleepStage: "REM",
deviceType: "watch",
},
] as const;
export const JI_MOOD = {
date: "2026-09-12",
mood: "GUT",
score: 4,
a1: 8,
a2: 3,
a3: 6,
a4: 7,
a5: 5,
tags: JSON.stringify(["work", "sleep"]),
note: "long day, good evening",
tz: "America/New_York",
at: "2026-09-13T01:30:00.000Z",
} as const;
/** A second entry with nothing but the label: what a one-tap check-in writes. */
export const JI_MOOD_BARE = {
date: "2026-09-11",
mood: "OKAY",
score: 3,
at: "2026-09-11T20:30:00.000Z",
} as const;
export interface SeededOwner {
ownerId: string;
}
/** The catalogue the links resolve against: seeded rows, owned by no account. */
export async function seedJsonImportCatalogue(
prisma: PrismaClient,
): Promise<void> {
// 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: {
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",
kind: "BINARY",
},
});
await prisma.moodTag.create({
data: {
id: "mt_ji_sleep_quality",
categoryId: category.id,
key: JI_FACTOR_KEY,
labelKey: "mood.tag.jiSleepQuality",
kind: "RATED",
scaleMin: 1,
scaleMax: 5,
},
});
}
export async function seedJsonImportOwner(
prisma: PrismaClient,
): Promise<SeededOwner> {
const owner = await makeUser({
timezone: "Europe/Berlin",
glucoseUnit: "mmol/L" /* the JSON export stays in the stored unit */,
});
const ownerId = owner.id;
for (const m of JI_MEASUREMENTS) {
await prisma.measurement.create({
data: {
userId: ownerId,
type: m.type,
value: m.value,
valueMin: "valueMin" in m ? m.valueMin : null,
valueMax: "valueMax" in m ? m.valueMax : null,
unit: m.unit,
measuredAt: new Date(m.at),
source: "APPLE_HEALTH",
notes: null,
notesEncrypted: encryptNote("note" in m ? m.note : null),
glucoseContext: "glucoseContext" in m ? m.glucoseContext : null,
sleepStage: "sleepStage" in m ? m.sleepStage : null,
rhythmClassification:
"rhythmClassification" in m ? m.rhythmClassification : null,
deviceType: "deviceType" in m ? m.deviceType : null,
},
});
}
// A reading deleted before the backup: its row is the record of a
// deletion, and the import leaves it out.
await prisma.measurement.create({
data: {
userId: ownerId,
type: "WEIGHT",
value: 90,
unit: "kg",
measuredAt: new Date("2026-09-09T06:30:00.000Z"),
source: "MANUAL",
deletedAt: new Date("2026-09-09T07:00:00.000Z"),
},
});
const category = await prisma.moodTagCategory.findUniqueOrThrow({
where: { key: "ji_category" },
});
const ownTag = await prisma.moodTag.create({
data: {
userId: ownerId,
categoryId: category.id,
key: JI_OWN_TAG_KEY,
labelKey: "mood.tag.custom",
kind: "BINARY",
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([
prisma.moodTag.findUniqueOrThrow({ where: { key: JI_TAG_KEY } }),
prisma.moodTag.findUniqueOrThrow({ where: { key: JI_FACTOR_KEY } }),
]);
const entry = await prisma.moodEntry.create({
data: {
userId: ownerId,
date: JI_MOOD.date,
mood: JI_MOOD.mood,
score: JI_MOOD.score,
moodA1: JI_MOOD.a1,
stressA2: JI_MOOD.a2,
energyA3: JI_MOOD.a3,
connectionA4: JI_MOOD.a4,
stabilityA5: JI_MOOD.a5,
tags: JI_MOOD.tags,
note: null,
noteEncrypted: encryptNote(JI_MOOD.note),
tz: JI_MOOD.tz,
source: "MANUAL",
moodLoggedAt: new Date(JI_MOOD.at),
},
});
await prisma.moodEntryTagLink.createMany({
data: [
{ moodEntryId: entry.id, moodTagId: tag.id },
{ moodEntryId: entry.id, moodTagId: ownTag.id },
{ moodEntryId: entry.id, moodTagId: factor.id, rating: 4 },
{ moodEntryId: entry.id, moodTagId: ownFactor.id, rating: 2 },
],
});
await prisma.moodContext.create({
data: {
moodEntryId: entry.id,
userId: ownerId,
workStatus: "regular",
workMinutes: 540,
overtimeMinutes: 60,
workLoad: 7,
workSatisfaction: 6,
contactCircles: encodeKeyList(["partner", "family"]),
contactForm: "inPerson",
contactExtent: "extended",
contactQuality: 8,
contactSupport: 7,
leisureCategories: encodeKeyList(["reading"]),
leisureMinutes: 90,
leisureJoy: 8,
leisureRecovery: 7,
eventType: "goodNews",
eventValence: 3,
eventAt: new Date("2026-09-12T18:00:00.000Z"),
notesEncrypted: encryptNote("the call went well"),
},
});
await prisma.moodEntry.create({
data: {
userId: ownerId,
date: JI_MOOD_BARE.date,
mood: JI_MOOD_BARE.mood,
score: JI_MOOD_BARE.score,
moodA1: 5,
source: "MANUAL",
moodLoggedAt: new Date(JI_MOOD_BARE.at),
},
});
return { ownerId };
}