mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(backup): restore the mood note, zone, counter and binary tags
`moodEntrySchema` gains `note`, `noteEncrypted`, `tz`, `syncedAt`, `syncVersion`, `createdAt`, `updatedAt` and `structuredTags`; every one of them optional or defaulted, so a backup file written before this parses unchanged and restores exactly as it did. The restore writes them. The note follows the measurement pair: ciphertext verbatim when the file carries it, a portable file's plaintext re-encrypted on the way in, legacy column null either way. `tz` absent still means the legacy Europe/Berlin reading, which is what those rows meant. `syncVersion` comes back at the value the file recorded — restoring at the default handed the next round to any paired device holding a higher number, and the device would then overwrite the recovered entry with what it had. The link rebuild re-creates both arms: rated factors from `factors`, ticked tags from `structuredTags` with a NULL rating. An unresolvable tag key is reported under its own `moodTag` family, beside the rated one, because an operator needs to know which of the two an entry lost.
This commit is contained in:
1 parent
222047e0d4
commit
18edd34c33
10 files changed
+107
-5
No files matched your search
@@ -6701,6 +6701,7 @@
|
||||
"restoreSkippedCycleSymptom": "Zyklussymptom",
|
||||
"restoreSkippedIllnessSymptom": "Krankheitssymptom",
|
||||
"restoreSkippedMoodFactor": "Stimmungsfaktor",
|
||||
"restoreSkippedMoodTag": "Stimmungs-Tag",
|
||||
"docsLink": "Dokumentation"
|
||||
},
|
||||
"danger-zone": {
|
||||
|
||||
@@ -6701,6 +6701,7 @@
|
||||
"restoreSkippedCycleSymptom": "Cycle symptom",
|
||||
"restoreSkippedIllnessSymptom": "Illness symptom",
|
||||
"restoreSkippedMoodFactor": "Mood factor",
|
||||
"restoreSkippedMoodTag": "Mood tag",
|
||||
"docsLink": "Documentation"
|
||||
},
|
||||
"danger-zone": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -6701,6 +6701,7 @@
|
||||
"restoreSkippedCycleSymptom": "Sintomo del ciclo",
|
||||
"restoreSkippedIllnessSymptom": "Sintomo di malattia",
|
||||
"restoreSkippedMoodFactor": "Fattore dell'umore",
|
||||
"restoreSkippedMoodTag": "Etichetta dell'umore",
|
||||
"docsLink": "Documentazione"
|
||||
},
|
||||
"danger-zone": {
|
||||
|
||||
@@ -6701,6 +6701,7 @@
|
||||
"restoreSkippedCycleSymptom": "Objaw cyklu",
|
||||
"restoreSkippedIllnessSymptom": "Objaw choroby",
|
||||
"restoreSkippedMoodFactor": "Czynnik nastroju",
|
||||
"restoreSkippedMoodTag": "Tag nastroju",
|
||||
"docsLink": "Dokumentacja"
|
||||
},
|
||||
"danger-zone": {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user