diff --git a/messages/de.json b/messages/de.json index 7468d4f41..455bad74f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Zyklussymptom", "restoreSkippedIllnessSymptom": "Krankheitssymptom", "restoreSkippedMoodFactor": "Stimmungsfaktor", + "restoreSkippedMoodTag": "Stimmungs-Tag", "docsLink": "Dokumentation" }, "danger-zone": { diff --git a/messages/en.json b/messages/en.json index 1b9ce1268..2041766c0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Cycle symptom", "restoreSkippedIllnessSymptom": "Illness symptom", "restoreSkippedMoodFactor": "Mood factor", + "restoreSkippedMoodTag": "Mood tag", "docsLink": "Documentation" }, "danger-zone": { diff --git a/messages/es.json b/messages/es.json index 35193f614..bafa72e6e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Síntoma del ciclo", "restoreSkippedIllnessSymptom": "Síntoma de enfermedad", "restoreSkippedMoodFactor": "Factor de ánimo", + "restoreSkippedMoodTag": "Etiqueta de ánimo", "docsLink": "Documentación" }, "danger-zone": { diff --git a/messages/fr.json b/messages/fr.json index b3df467d1..5324f563a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Symptôme de cycle", "restoreSkippedIllnessSymptom": "Symptôme de maladie", "restoreSkippedMoodFactor": "Facteur d'humeur", + "restoreSkippedMoodTag": "Étiquette d'humeur", "docsLink": "Documentation" }, "danger-zone": { diff --git a/messages/it.json b/messages/it.json index 7f96ce248..38af4fc16 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Sintomo del ciclo", "restoreSkippedIllnessSymptom": "Sintomo di malattia", "restoreSkippedMoodFactor": "Fattore dell'umore", + "restoreSkippedMoodTag": "Etichetta dell'umore", "docsLink": "Documentazione" }, "danger-zone": { diff --git a/messages/pl.json b/messages/pl.json index 2da87ee8b..702ad81c5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6701,6 +6701,7 @@ "restoreSkippedCycleSymptom": "Objaw cyklu", "restoreSkippedIllnessSymptom": "Objaw choroby", "restoreSkippedMoodFactor": "Czynnik nastroju", + "restoreSkippedMoodTag": "Tag nastroju", "docsLink": "Dokumentacja" }, "danger-zone": { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3c22417f3..8ede74cac 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -684,6 +684,10 @@ const handler = apiHandler( entry.factors.map((factor) => factor.key), ); const factorKeys = [...new Set(referencedFactorKeys)]; + const referencedTagKeys = payload.moodEntries.flatMap( + (entry) => entry.structuredTags, + ); + const tagKeys = [...new Set(referencedTagKeys)]; // Re-create the account's own tag definitions first. The lookup // below used to ask only for the seeded catalogue (`userId: null`), // so a single custom rated tag made the whole restore throw @@ -740,6 +744,30 @@ const handler = apiHandler( referencedFactorKeys, ); + // The BINARY half of the same taxonomy, looked up the same way and + // reported the same way. It gets its own family because an + // operator reading the report needs to know whether a rated + // factor's score or a ticked tag went missing; they are different + // losses on the same entry. + const tagRows = + tagKeys.length === 0 + ? [] + : await tx.moodTag.findMany({ + where: { + key: { in: tagKeys }, + kind: "BINARY", + OR: [{ userId: null }, { userId: ownerId }], + }, + select: { id: true, key: true }, + }); + const tagByKey = new Map(tagRows.map((tag) => [tag.key, tag.id])); + recordUnknownKeys( + skips, + "moodTag", + tagKeys.filter((key) => !tagByKey.has(key)), + referencedTagKeys, + ); + for (const entry of payload.moodEntries) { const moodLoggedAt = new Date(entry.loggedAt); const restoredData = { @@ -747,10 +775,44 @@ const handler = apiHandler( mood: entry.mood, score: entry.score, tags: entry.tags ?? null, + // The note comes back the way every other dual-column note + // does: ciphertext verbatim when the file carries it, and a + // portable file's plaintext re-encrypted on the way in, with + // the legacy column left null either way. + note: null, + noteEncrypted: + entry.noteEncrypted == null + ? encryptNote(entry.note ?? null) + : decodeEncryptedBytes(entry.noteEncrypted), source: entry.source ?? "MOODLOG", externalId: entry.externalId ?? null, moodLoggedAt, + // The zone the `date` string is anchored to. A file written + // before this rode the payload carries nothing, and NULL is + // what such a row meant anyway: the legacy Europe/Berlin + // reading. So an old file keeps its old day boundaries and a + // new one keeps the zone the person actually logged in. + tz: entry.tz ?? null, + // Mood reconciles last-writer-wins by `syncVersion`. Restoring + // at the schema default would hand the next sync round to any + // paired device still holding a higher number, and the device + // would overwrite the recovered row with what it had — a + // restore that loses to the client is worse than one that + // fails. An old file carries nothing here and keeps the + // default, which is the value those rows were written with. + ...(entry.syncVersion === undefined + ? {} + : { syncVersion: entry.syncVersion }), deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + ...(entry.syncedAt + ? { syncedAt: new Date(entry.syncedAt) } + : {}), + ...(entry.createdAt + ? { createdAt: new Date(entry.createdAt) } + : {}), + ...(entry.updatedAt + ? { updatedAt: new Date(entry.updatedAt) } + : {}), }; const createData = { userId: ownerId, ...restoredData }; const restored = entry.id @@ -798,8 +860,19 @@ const handler = apiHandler( ] : []; }); - if (factorLinks.length > 0) { - await tx.moodEntryTagLink.createMany({ data: factorLinks }); + // The other arm. A BINARY link carries no score — the presence + // of the row is the signal — so `rating` is NULL by definition, + // which is exactly what the backup's old tag-link filter took to + // mean "nothing here". + const tagLinks = entry.structuredTags.flatMap((key) => { + const moodTagId = tagByKey.get(key); + return moodTagId + ? [{ moodEntryId: restored.id, moodTagId, rating: null }] + : []; + }); + const links = [...factorLinks, ...tagLinks]; + if (links.length > 0) { + await tx.moodEntryTagLink.createMany({ data: links }); } } } diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 2df37779f..76c3acede 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -194,6 +194,9 @@ function catalogueLabel( if (catalogue === "illnessSymptom") { return t("admin.section.backups.restoreSkippedIllnessSymptom"); } + if (catalogue === "moodTag") { + return t("admin.section.backups.restoreSkippedMoodTag"); + } return t("admin.section.backups.restoreSkippedMoodFactor"); } diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index e57756edd..326ea093b 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -1,8 +1,9 @@ /** * What a restore could not put back, and how much of it there was. * - * Three of the restore's lookups resolve a key against a SEEDED CATALOGUE — - * cycle symptoms, illness symptoms, mood factors. The catalogue is reference + * Four of the restore's lookups resolve a key against a SEEDED CATALOGUE — + * cycle symptoms, illness symptoms, rated mood factors and the mood tags a + * person ticks present or absent. The catalogue is reference * data the instance owns, not data the file carries, so it legitimately drifts * between the day a backup is written and the day it is read: a key gets * renamed, a symptom is retired, the file is a year old, the instance is three @@ -28,7 +29,8 @@ */ /** Which seeded catalogue a key failed to resolve against. */ -export type SkippedCatalogue = "cycleSymptom" | "illnessSymptom" | "moodFactor"; +export type SkippedCatalogue = + "cycleSymptom" | "illnessSymptom" | "moodFactor" | "moodTag"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index b4afaf650..3f1a981e1 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -255,11 +255,29 @@ const moodEntrySchema = z mood: z.string().min(1), score: z.number().int().min(0).max(10), tags: moodEntryTagsSchema, + // The free text, in both storage shapes. A portable export carries the + // decrypted `note`; a disaster-recovery file carries `noteEncrypted` plus + // whatever legacy plaintext the row still holds. Same pair, and same + // handling on restore, as `Measurement.notes` / `notesEncrypted`. + note: z.string().nullable().optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), source: z.string().min(1).optional(), externalId: z.string().nullable().optional(), loggedAt: isoDateTime, + // The IANA zone the `date` string is anchored to. Absent means the legacy + // Europe/Berlin reading, which is what a row written before v1.4.25 means + // by a NULL here — so an old file keeps its old meaning exactly. + tz: z.string().nullable().optional(), + syncedAt: isoDateTime.optional(), + syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), factors: z.array(moodFactorSchema).default([]), + // BINARY structured-tag keys. Separate from `factors`, which keeps its + // RATED-only meaning, so every file written before this one parses + // unchanged and restores identically: absent reads as none. + structuredTags: z.array(z.string().min(1)).default([]), }) .passthrough();