diff --git a/messages/de.json b/messages/de.json index 93a4690e0..1d22c97c3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6918,7 +6918,8 @@ "restoreSkippedDocumentConditionLink": "Dokumentzuordnung", "restoreSkippedExtractedFact": "Dokumentbefund", "restoreSkippedFactCommitment": "Befundübernahme", - "docsLink": "Dokumentation" + "docsLink": "Dokumentation", + "restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index 3ff9c8057..d01087845 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6918,7 +6918,8 @@ "restoreSkippedDocumentConditionLink": "Document filing", "restoreSkippedExtractedFact": "Document fact", "restoreSkippedFactCommitment": "Fact commitment", - "docsLink": "Documentation" + "docsLink": "Documentation", + "restoreSkippedPersonalRecordReference": "Personal best provenance" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index b0ef97c44..7d5de9ed8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6918,7 +6918,8 @@ "restoreSkippedDocumentConditionLink": "Clasificación de documento", "restoreSkippedExtractedFact": "Dato del documento", "restoreSkippedFactCommitment": "Registro del dato", - "docsLink": "Documentación" + "docsLink": "Documentación", + "restoreSkippedPersonalRecordReference": "Procedencia de un récord personal" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index b0477629c..c6bae9f4c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6918,7 +6918,8 @@ "restoreSkippedDocumentConditionLink": "Classement du document", "restoreSkippedExtractedFact": "Donnée du document", "restoreSkippedFactCommitment": "Enregistrement de la donnée", - "docsLink": "Documentation" + "docsLink": "Documentation", + "restoreSkippedPersonalRecordReference": "Provenance d'un record personnel" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index 85815b598..72f424c33 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6918,7 +6918,8 @@ "restoreSkippedDocumentConditionLink": "Classificazione del documento", "restoreSkippedExtractedFact": "Dato del documento", "restoreSkippedFactCommitment": "Registrazione del dato", - "docsLink": "Documentazione" + "docsLink": "Documentazione", + "restoreSkippedPersonalRecordReference": "Provenienza di un primato personale" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/pl.json b/messages/pl.json index a712d08d7..d726cc0e8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6918,7 +6918,8 @@ "restoreSkippedDocumentConditionLink": "Przypisanie dokumentu", "restoreSkippedExtractedFact": "Fakt z dokumentu", "restoreSkippedFactCommitment": "Zapis faktu", - "docsLink": "Dokumentacja" + "docsLink": "Dokumentacja", + "restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index af1b34cd0..185f7ff28 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1053,8 +1053,10 @@ model EnvironmentContext { lat Float lon Float /// @internal: human-readable label of the resolved location, stored as - /// operator/debug provenance beside the coordinates; readers select the - /// numeric and weather columns only, never this label. + /// operator/debug provenance beside the coordinates; surfaces select the + /// numeric and weather columns only, never this label. The backup selects it + /// (`src/lib/export/environment-backup.ts`) with the rest of the row: a + /// restored reading has to keep saying where it was read. locationLabel String @map("location_label") source EnvironmentLocationSource @@ -2426,9 +2428,13 @@ model PersonalRecord { /// FK to the Measurement row that achieved the record. SET NULL /// on delete so the historical fact survives even if the /// underlying measurement is later removed. - /// @internal: audit-trail provenance written by the PR detection worker; - /// no read path selects it — the record row itself carries the surfaced - /// value/unit/achievedAt. + /// @internal: audit-trail provenance written by the PR detection worker; no + /// SURFACE selects it: the record row itself carries the value, unit and + /// achievedAt a reader sees. The backup does select it + /// (`src/lib/export/awards-backup.ts`), because the column is a real foreign + /// key in the database even though no relation is declared here, so a + /// restore has to resolve it against the measurements it wrote instead of + /// writing it blind and failing the constraint. sourceMeasurementId String? @map("source_measurement_id") source MeasurementSource @default(MANUAL) externalId String? @map("external_id") diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 89bd12f65..5cd049395 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -296,6 +296,12 @@ function sourceClient() { // same reason as the visit tables above. measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, + // The bests, the badges, and the environmental history with the location + // periods that explain it. Empty for the same reason as the tables above. + personalRecord: { findMany: vi.fn().mockResolvedValue([]) }, + userAchievement: { findMany: vi.fn().mockResolvedValue([]) }, + environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, + environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) }, coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) }, moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index a6d1f28d8..3b25022d4 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -57,6 +57,8 @@ import { } from "@/lib/export/coach-backup"; import { restoreRemindersData } from "@/lib/export/reminders-backup"; import { restoreDocumentFilingData } from "@/lib/export/document-filing-backup"; +import { restoreAwardsData } from "@/lib/export/awards-backup"; +import { restoreEnvironmentData } from "@/lib/export/environment-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -114,6 +116,10 @@ interface RestoreResponse { coachReminders: number; mentalHealthAssessments: number; consentReceipts: number; + personalRecords: number; + userAchievements: number; + environmentContexts: number; + environmentTravelLocations: number; }; } @@ -1654,6 +1660,38 @@ const handler = apiHandler( payload, ); + // The bests and the badges. AFTER the measurements, and this one is + // not a preference: `PersonalRecord.sourceMeasurementId` is a real + // foreign key against `measurements` (migration 0054) even though + // `prisma/schema.prisma` declares no relation for it, so a pointer + // resolved before the measurements exist would not drop quietly. + // Postgres would refuse the insert and roll the whole restore back + // over one provenance column. The id set is the measurements this + // transaction actually wrote with a stable id, threaded in rather + // than re-queried so the function stays a pure reader of it. Both + // ends of this section live in `src/lib/export/awards-backup.ts`. + const awardsCleared = await restoreAwardsData( + tx, + ownerId, + payload, + new Set(stableRows.map((row) => row.id)), + skips, + ); + + // The per-day readings and the location periods that explain them. + // Neither references anything but the account, so this section has + // no ordering constraint against any other and sits here beside the + // rest. What it does owe is atomicity WITH ITSELF, which is why one + // function writes both: readings restored without their periods get + // re-resolved to the home location and overwritten by the next + // environment refresh. Both ends of this section live in + // `src/lib/export/environment-backup.ts`. + const environmentCleared = await restoreEnvironmentData( + tx, + ownerId, + payload, + ); + const cleared = { measurements: measurements.count, medications: meds.count, @@ -1699,6 +1737,11 @@ const handler = apiHandler( coachReminders: coachMemoryCleared.coachReminders, mentalHealthAssessments: sensitiveCleared.mentalHealthAssessments, consentReceipts: sensitiveCleared.consentReceipts, + personalRecords: awardsCleared.personalRecords, + userAchievements: awardsCleared.userAchievements, + environmentContexts: environmentCleared.environmentContexts, + environmentTravelLocations: + environmentCleared.environmentTravelLocations, }; return { cleared, skipped: summarizeRestoreSkips(skips) }; }, diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 34dc352e9..2d5ef7f7c 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -42,6 +42,12 @@ vi.mock("@/lib/db", () => ({ // same reason as the visit tables above. measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, + // The bests, the badges, and the environmental history with the location + // periods that explain it. Empty for the same reason as the tables above. + personalRecord: { findMany: vi.fn().mockResolvedValue([]) }, + userAchievement: { findMany: vi.fn().mockResolvedValue([]) }, + environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, + environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) }, coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) }, moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) }, @@ -155,6 +161,12 @@ beforeEach(() => { [] as never, ); vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.environmentContext.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.environmentTravelLocation.findMany).mockResolvedValue( + [] as never, + ); }); afterEach(() => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 7e86387a3..99b2e7139 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -40,6 +40,12 @@ vi.mock("@/lib/db", () => ({ // same reason as the visit tables above. measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, + // The bests, the badges, and the environmental history with the location + // periods that explain it. Empty for the same reason as the tables above. + personalRecord: { findMany: vi.fn().mockResolvedValue([]) }, + userAchievement: { findMany: vi.fn().mockResolvedValue([]) }, + environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, + environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) }, coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) }, moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index e63ffde07..26f29a1ce 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -353,6 +353,9 @@ function catalogueLabel( if (catalogue === "moodFactor") { return t("admin.section.backups.restoreSkippedMoodFactor"); } + if (catalogue === "personalRecordReference") { + return t("admin.section.backups.restoreSkippedPersonalRecordReference"); + } // Not a fallback: the chain above is exhaustive and this line is what makes // the compiler say so. A catalogue added without a label here now stops the // build instead of shipping under a label that belongs to something else, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 7537e3a92..42de97aa7 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -352,6 +352,12 @@ function makePrisma() { // same reason as the visit tables above. measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, + // The bests, the badges, and the environmental history with the location + // periods that explain it. Empty for the same reason as the tables above. + personalRecord: { findMany: vi.fn().mockResolvedValue([]) }, + userAchievement: { findMany: vi.fn().mockResolvedValue([]) }, + environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, + environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) }, // The Coach transcript. Deliberately NOT `?? []` at the call site, so a // future `include` that forgets the relation fails here loudly instead of // exporting an account whose Coach never spoke. diff --git a/src/lib/export/awards-backup.ts b/src/lib/export/awards-backup.ts new file mode 100644 index 000000000..ad8c665c7 --- /dev/null +++ b/src/lib/export/awards-backup.ts @@ -0,0 +1,346 @@ +/** + * What the account EARNED: its personal bests and its unlocked badges, with + * both backup ends in one file. + * + * Same arrangement as `reminders-backup.ts` and `coach-backup.ts`, for the same + * reason: a reader asking "is this carried at both ends?" answers it here, and + * a reader who greps only the restore ROUTE gets a false negative because the + * route delegates. + * + * Two models ride. The register called the bests "recomputable in principle + * from measurements and workouts, but nothing recomputes them today, so in + * practice they are lost", and the badges "milestones the account earned, with + * the date each was reached. The date is the part that cannot be recovered". + * Both entries understate it slightly, and the difference is worth writing + * down because it decides what this file has to carry. + * + * ## Neither one is really recomputable + * + * A best is a claim about a history, and the history a restored account has is + * not the one the best was found in. Workout sample series and GPS routes do + * not travel at all (disclosed in the file's own manifest), a portable export + * omits soft-deleted measurements, and a record found in a reading that has + * since been corrected can never be found again. So a "recomputation" would + * not reproduce these rows; it would produce different ones and call them the + * same. + * + * The badge is worse, because the date is load-bearing and half the dates have + * no source left. `achievements-result.ts` merges persisted unlock dates with + * dates it can still derive, and persisted wins. Several metrics it derives + * from are things this backup deliberately does not carry: passkey and + * password login counts, the doctor-PDF and locale-flip counters. Drop the + * rows and those badges do not come back with an older date; they come back + * with today's, or they relock. + * + * ## The one reference that can take the whole restore down + * + * `PersonalRecord.sourceMeasurementId` looks like a bare id column in + * `prisma/schema.prisma`, which declares no relation for it. The DATABASE + * disagrees: migration 0054 created it as + * `REFERENCES "measurements"("id") ON DELETE SET NULL`, so it is a real + * foreign key that the Prisma client does not know about. + * + * That is the dangerous combination rather than the harmless one. The client + * writes whatever value it is handed, and Postgres refuses the statement, + * which rolls back the whole transaction and returns an account with NOTHING + * in it over a single provenance pointer. The mood categories were the same + * shape of defect measured a release earlier: a 500 and an empty account. + * + * So the restore resolves the pointer against the measurements it actually + * wrote, NULLS what it cannot resolve, and reports the miss. Nulling rather + * than dropping is the point: the record itself is the historical fact, which + * is exactly why the column was declared `ON DELETE SET NULL` in the first + * place. Losing the pointer costs an audit trail nothing reads; losing the row + * costs a best that cannot be found again. + * + * The ordinary way a pointer fails to resolve is a portable file: the + * measurement behind the record was soft-deleted, so the file omits it while + * the record still names it. + * + * ## An unlocked badge is not judged against the catalogue + * + * `UserAchievement.achievementId` names a definition in + * `src/lib/gamification/achievements.ts`. That is code, not a table, so nothing in + * the database constrains it and an unknown value costs no error. It is + * carried and written back verbatim, and the restore does not check it against + * the catalogue this build ships. The catalogue is the part that drifts: a + * file can be older than the release reading it, or newer. A row is evidence + * that a person earned something on a day, and a build that no longer defines + * the badge is not evidence that they did not. The same decision the + * vaccination restore makes for an antigen slug it cannot resolve, for the + * same reason. + * + * ## One wire shape, both purposes + * + * Neither model has a tombstone column and neither holds ciphertext, so a + * portable export and a disaster-recovery payload carry byte-identical + * sections. This builder therefore takes no `purpose`: a parameter that + * changes nothing would advertise a distinction the file does not have. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import type { + MeasurementSource, + MeasurementType, + PersonalRecordDirection, +} from "@/generated/prisma/client"; + +import { + recordUnknownKeys, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; + +/** One best, as the detection worker recorded it. */ +export interface PersonalRecordBackupEntry { + metricType: MeasurementType; + /** + * The sport dimension for a workout-driven best (`running_5km_time`), NULL + * for a measurement-driven one. Carried because it is what separates two + * bests that share a metric: without it a best 5k and a best 10k are the + * same row said twice. + */ + metricSlot: string | null; + /** + * Whether higher or lower wins. Not derivable from the row: the read path + * orders by `value` in the direction this column names, so a best time + * restored as MAX becomes the account's WORST time, presented as its record. + */ + direction: PersonalRecordDirection; + value: number; + unit: string; + /** The day the best was set. The whole point of the row. */ + achievedAt: string; + /** + * The measurement the record was found in. A real foreign key in the + * database despite the schema declaring no relation; see the file header. + */ + sourceMeasurementId: string | null; + source: MeasurementSource; + externalId: string | null; + createdAt: string; +} + +/** One badge, and the day it was earned. */ +export interface UserAchievementBackupEntry { + /** A definition id in the code catalogue. Carried verbatim, never judged. */ + achievementId: string; + /** + * When the badge was earned. The irreplaceable field: the evaluator prefers + * a persisted date over a derived one, and for several metrics no derivable + * date survives a restore at all. + */ + unlockedAt: string; + createdAt: string; +} + +export interface AwardsBackupSection { + personalRecords: PersonalRecordBackupEntry[]; + userAchievements: UserAchievementBackupEntry[]; +} + +export interface AwardsBackupCounts { + personalRecords: number; + userAchievements: number; +} + +/** + * Named select constants, one per model, because a structural matcher binds a model + * to the literal beside its delegate call, matching the other section files. + * + * No `id` on either. Nothing in the file addresses a best or a badge, so a + * stable id would be carried for its own sake; the one id that IS carried is + * the measurement a best points at, because that one is a reference the + * restore has to resolve. + */ +const PERSONAL_RECORD_BACKUP_SELECT = { + metricType: true, + metricSlot: true, + direction: true, + value: true, + unit: true, + achievedAt: true, + sourceMeasurementId: true, + source: true, + externalId: true, + createdAt: true, +} as const satisfies Prisma.PersonalRecordSelect; + +const USER_ACHIEVEMENT_BACKUP_SELECT = { + achievementId: true, + unlockedAt: true, + createdAt: true, +} as const satisfies Prisma.UserAchievementSelect; + +/** + * Build the awards slice of a user's full backup. + * + * Takes the delegates it uses rather than a whole client, matching the other + * section builders. + */ +export async function buildAwardsBackupSection( + prisma: Pick, + userId: string, +): Promise { + const [recordRows, achievementRows] = await Promise.all([ + prisma.personalRecord.findMany({ + where: { userId }, + orderBy: { achievedAt: "asc" }, + select: PERSONAL_RECORD_BACKUP_SELECT, + }), + prisma.userAchievement.findMany({ + where: { userId }, + orderBy: { unlockedAt: "asc" }, + select: USER_ACHIEVEMENT_BACKUP_SELECT, + }), + ]); + + return { + personalRecords: recordRows.map((row) => ({ + metricType: row.metricType, + metricSlot: row.metricSlot, + direction: row.direction, + value: row.value, + unit: row.unit, + achievedAt: row.achievedAt.toISOString(), + sourceMeasurementId: row.sourceMeasurementId, + source: row.source, + externalId: row.externalId, + createdAt: row.createdAt.toISOString(), + })), + userAchievements: achievementRows.map((row) => ({ + achievementId: row.achievementId, + unlockedAt: row.unlockedAt.toISOString(), + createdAt: row.createdAt.toISOString(), + })), + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countAwardsBackupSection( + section: AwardsBackupSection, +): AwardsBackupCounts { + return { + personalRecords: section.personalRecords.length, + userAchievements: section.userAchievements.length, + }; +} + +/** Counts the awards restore wiped, for the audit trail. */ +export interface AwardsRestoreCleared { + personalRecords: number; + userAchievements: number; +} + +/** + * What the restore reads, as the parsed file actually presents it. Wider than + * what this release writes, because every optional column arrives as + * `undefined` rather than `null` from a file written before it existed. + * + * `unlockedAt` stays strictly required. A file that does not say when a badge + * was earned must not be silently restored to restore-day, and making the + * field optional here would let exactly that compile. + */ +type OptionalNullable = { [K in keyof T]?: T[K] | undefined }; + +export type RestoredPersonalRecord = Pick< + PersonalRecordBackupEntry, + "metricType" | "direction" | "value" | "unit" | "achievedAt" +> & + OptionalNullable< + Pick< + PersonalRecordBackupEntry, + "metricSlot" | "sourceMeasurementId" | "source" | "externalId" + > + > & { createdAt?: string }; + +export type RestoredUserAchievement = Pick< + UserAchievementBackupEntry, + "achievementId" | "unlockedAt" +> & { createdAt?: string }; + +export interface AwardsRestoreInput { + personalRecords: RestoredPersonalRecord[]; + userAchievements: RestoredUserAchievement[]; +} + +/** + * Re-create the account's bests and badges. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. + * + * MUST be called AFTER the measurements are restored. `sourceMeasurementId` is + * a foreign key the Prisma schema does not declare (see the file header), so + * running earlier would not quietly drop the provenance. It would fail the + * constraint and roll the entire restore back. + * + * `measurementIds` is the set of measurements the restore actually wrote with + * a stable id, passed in rather than re-queried so this stays a pure function + * of the transaction it was handed. Legacy v1 files carry measurements with no + * id at all; those rows are minted fresh and are unaddressable by definition, + * so a best that names one lands with a NULL pointer and a report. + */ +export async function restoreAwardsData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: AwardsRestoreInput, + measurementIds: ReadonlySet, + skips: RestoreSkipLog, +): Promise { + const [clearedRecords, clearedAchievements] = await Promise.all([ + tx.personalRecord.deleteMany({ where: { userId: ownerId } }), + tx.userAchievement.deleteMany({ where: { userId: ownerId } }), + ]); + + const danglingMeasurementRefs: string[] = []; + if (payload.personalRecords.length > 0) { + await tx.personalRecord.createMany({ + data: payload.personalRecords.map((entry) => { + let sourceMeasurementId = entry.sourceMeasurementId ?? null; + if (sourceMeasurementId && !measurementIds.has(sourceMeasurementId)) { + danglingMeasurementRefs.push(sourceMeasurementId); + sourceMeasurementId = null; + } + return { + userId: ownerId, + metricType: entry.metricType, + metricSlot: entry.metricSlot ?? null, + direction: entry.direction, + value: entry.value, + unit: entry.unit, + achievedAt: new Date(entry.achievedAt), + sourceMeasurementId, + // A file written before the column existed says nothing about it, + // and the schema default is what those rows were living as. + source: entry.source ?? "MANUAL", + externalId: entry.externalId ?? null, + ...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}), + }; + }), + }); + } + recordUnknownKeys( + skips, + "personalRecordReference", + [...new Set(danglingMeasurementRefs)], + danglingMeasurementRefs, + ); + + if (payload.userAchievements.length > 0) { + await tx.userAchievement.createMany({ + data: payload.userAchievements.map((entry) => ({ + userId: ownerId, + // Verbatim, and not checked against this build's catalogue. See the + // file header: the catalogue is code and drifts, the row is evidence. + achievementId: entry.achievementId, + // Verbatim, and the reason this section exists at all. + unlockedAt: new Date(entry.unlockedAt), + ...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}), + })), + }); + } + + return { + personalRecords: clearedRecords.count, + userAchievements: clearedAchievements.count, + }; +} diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 797c3a630..5afdce432 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -230,6 +230,12 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ // documents themselves are read by `records-backup.ts`; these two read // through their OWN delegates here, for the reason the visits comment gives. "src/lib/export/document-filing-backup.ts", + // The bests and the badges, and the environmental history with the location + // periods that explain it. Two files rather than one because they are two + // unrelated parts of the record, both with their ends beside each other for + // the reason the visits comment gives. + "src/lib/export/awards-backup.ts", + "src/lib/export/environment-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -244,6 +250,8 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/coach-backup.ts", "src/lib/export/sensitive-backup.ts", "src/lib/export/document-filing-backup.ts", + "src/lib/export/awards-backup.ts", + "src/lib/export/environment-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -412,6 +420,34 @@ export const TWO_ENDED_MODELS = [ // nulled together with its type when it resolves to nothing. "DocumentConditionLink", "ExtractedFact", + // What the account earned. The register called the bests "recomputable in + // principle", which is true and does not help: nothing recomputes them, and + // a recomputation over a restored history would not find the same rows: + // sample series and GPS routes never travel, a portable file omits deleted + // readings, and a best found in a reading that has since been corrected is + // not findable twice. The badge's unlock date is worse than that. The + // evaluator prefers a persisted date over a derived one, and several of the + // dates it can derive come from counters this backup deliberately excludes, + // so a dropped row does not relock the badge, it re-earns it today. + // + // `PersonalRecord.sourceMeasurementId` is the reference that needs care, and + // it is the dangerous kind: the schema declares no relation, but migration + // 0054 created the column as a real foreign key against `measurements`. The + // restore therefore runs after the measurements and nulls what it cannot + // resolve, because the alternative is not a dangling pointer, it is Postgres + // refusing the file and handing back an empty account. + "PersonalRecord", + "UserAchievement", + // The per-day readings and the location periods, which travel as a pair + // because the register's own two lines say why: the readings are "joined to + // the record", and the periods are "what makes the environmental readings + // mean anything". Measured, the pairing is stronger than context. Carrying + // the readings alone would let the nightly seven-day refresh re-resolve every + // trip day to the home location and UPSERT over it, so an account would come + // back with a fortnight abroad and then quietly have it rewritten as a + // fortnight at home, days after a restore that reported success. + "EnvironmentContext", + "EnvironmentTravelLocation", ] as const; /** One model claimed to travel both ways. */ @@ -458,14 +494,6 @@ export const COVERAGE_PENDING: Readonly> = { "GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.", WorkoutSamples: "Per-sample heart-rate and pace series behind a workout summary. Same disclosed-exclusion status as the routes above, same cost.", - PersonalRecord: - "Bests the account accumulated. Recomputable in principle from measurements and workouts, but nothing recomputes them today, so in practice they are lost.", - UserAchievement: - "Milestones the account earned, with the date each was reached. The date is the part that cannot be recovered.", - EnvironmentContext: - "Per-day environmental readings joined to the record. Re-fetchable for recent days only; older history is gone once the provider window closes.", - EnvironmentTravelLocation: - "Where the person was on a given day, which is what makes the environmental readings mean anything. Never re-derivable.", }; /** diff --git a/src/lib/export/environment-backup.ts b/src/lib/export/environment-backup.ts new file mode 100644 index 000000000..c02042875 --- /dev/null +++ b/src/lib/export/environment-backup.ts @@ -0,0 +1,358 @@ +/** + * The per-day environmental readings and the location periods that explain + * them, with both backup ends in one file. + * + * Same arrangement as `reminders-backup.ts` and `coach-backup.ts`, for the same + * reason: a reader asking "is this carried at both ends?" answers it here, and + * a reader who greps only the restore ROUTE gets a false negative because the + * route delegates. + * + * ## The two travel as a pair, and the pairing is not decoration + * + * The register listed them separately, as "per-day environmental readings joined + * to the record", "where the person was on a given day, which is what makes + * the environmental readings mean anything", and the second line is the one + * that decides the design. + * + * A reading carries the coarse location it was fetched for (`lat`, `lon`, + * `locationLabel`) and the precedence rule that chose it (`source`). A + * `TRAVEL` reading exists because an explicit dated location period covered + * that day. `resolveLocationForDay` in `src/lib/environment/service.ts` reads + * those periods live, every time; nothing else can produce a `TRAVEL` + * verdict. + * + * So a restore that carried the readings and not the periods would not merely + * hand back readings with less context. The nightly refresh runs a seven-day + * lookback and an operator backfill re-resolves whatever range it is given; + * both UPSERT. With the periods gone, every trip day inside the range + * re-resolves to the home location and the upsert overwrites the row: the + * coordinates, the label, the source and every weather field. The account + * would end up with a fortnight abroad recorded as a fortnight of weather at + * home, written by the app itself, some days after a restore that reported + * success. That is why these two land together or not at all. + * + * The other direction is a plain loss with no rewrite behind it: periods + * without readings leave the correlation surfaces empty for the history, and + * the archive feed only reaches back so far before the older days are simply + * unfetchable. + * + * ## Day keys stay strings + * + * `date`, `startDate` and `endDate` are `YYYY-MM-DD`, and they cross the wire + * as the strings they are stored as. The resolver compares them + * lexicographically against other day keys, so nothing here needs a `Date`, + * and parsing one into a `Date` and formatting it back is exactly how a day + * key loses a day under a negative UTC offset. The row stamps + * (`fetchedAt`, `createdAt`, `updatedAt`) are real instants and ride as + * ISO-8601. + * + * `fetchedAt` is carried verbatim rather than stamped on the way in. It says + * when the upstream feed was last read for that day, and the archive feed + * settles over a few days after the fact, so a restore that wrote "now" would + * claim a provisional reading from two years ago had just been confirmed. + * + * ## One wire shape, both purposes + * + * Neither model has a tombstone column and neither holds ciphertext, so a + * portable export and a disaster-recovery payload carry byte-identical + * sections. This builder therefore takes no `purpose`: a parameter that + * changes nothing would advertise a distinction the file does not have. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import type { EnvironmentLocationSource } from "@/generated/prisma/client"; + +/** One day's environmental observation, at the location resolved for that day. */ +export interface EnvironmentContextBackupEntry { + /** `YYYY-MM-DD`, anchored to the resolved location's timezone. */ + date: string; + lat: number; + lon: number; + locationLabel: string; + /** + * Which precedence rule chose the location. Carried because it is the only + * record that this day was NOT the home city, and because a re-resolve + * cannot recover it once the period behind it is gone. + */ + source: EnvironmentLocationSource; + tempMin: number | null; + tempMax: number | null; + tempMean: number | null; + apparentMean: number | null; + sunshineSec: number | null; + daylightSec: number | null; + precipSum: number | null; + pressureMean: number | null; + pressureDelta: number | null; + humidityMean: number | null; + cloudMean: number | null; + weatherCode: number | null; + /** When the feed was last read for this day. Verbatim; see the file header. */ + fetchedAt: string; + createdAt: string; + updatedAt: string; +} + +/** One declared stretch spent somewhere other than home. */ +export interface EnvironmentTravelLocationBackupEntry { + /** Inclusive `YYYY-MM-DD` bounds. Strings end to end; see the file header. */ + startDate: string; + endDate: string; + lat: number; + lon: number; + label: string; + createdAt: string; + updatedAt: string; +} + +export interface EnvironmentBackupSection { + environmentContexts: EnvironmentContextBackupEntry[]; + environmentTravelLocations: EnvironmentTravelLocationBackupEntry[]; +} + +export interface EnvironmentBackupCounts { + environmentContexts: number; + environmentTravelLocations: number; +} + +/** + * Named select constants, one per model, because a structural matcher binds a model + * to the literal beside its delegate call, matching the other section files. + * + * No `id` on either: nothing in the file or in the database addresses one of + * these rows, and a reading is identified by its day. + */ +const ENVIRONMENT_CONTEXT_BACKUP_SELECT = { + date: true, + lat: true, + lon: true, + locationLabel: true, + source: true, + tempMin: true, + tempMax: true, + tempMean: true, + apparentMean: true, + sunshineSec: true, + daylightSec: true, + precipSum: true, + pressureMean: true, + pressureDelta: true, + humidityMean: true, + cloudMean: true, + weatherCode: true, + fetchedAt: true, + createdAt: true, + updatedAt: true, +} as const satisfies Prisma.EnvironmentContextSelect; + +const ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT = { + startDate: true, + endDate: true, + lat: true, + lon: true, + label: true, + createdAt: true, + updatedAt: true, +} as const satisfies Prisma.EnvironmentTravelLocationSelect; + +/** + * Build the environment slice of a user's full backup. + * + * Takes the delegates it uses rather than a whole client, matching the other + * section builders. + */ +export async function buildEnvironmentBackupSection( + prisma: Pick< + PrismaClient, + "environmentContext" | "environmentTravelLocation" + >, + userId: string, +): Promise { + const [contextRows, travelRows] = await Promise.all([ + prisma.environmentContext.findMany({ + where: { userId }, + orderBy: { date: "asc" }, + select: ENVIRONMENT_CONTEXT_BACKUP_SELECT, + }), + prisma.environmentTravelLocation.findMany({ + where: { userId }, + orderBy: { startDate: "asc" }, + select: ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT, + }), + ]); + + return { + environmentContexts: contextRows.map((row) => ({ + date: row.date, + lat: row.lat, + lon: row.lon, + locationLabel: row.locationLabel, + source: row.source, + tempMin: row.tempMin, + tempMax: row.tempMax, + tempMean: row.tempMean, + apparentMean: row.apparentMean, + sunshineSec: row.sunshineSec, + daylightSec: row.daylightSec, + precipSum: row.precipSum, + pressureMean: row.pressureMean, + pressureDelta: row.pressureDelta, + humidityMean: row.humidityMean, + cloudMean: row.cloudMean, + weatherCode: row.weatherCode, + fetchedAt: row.fetchedAt.toISOString(), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })), + environmentTravelLocations: travelRows.map((row) => ({ + startDate: row.startDate, + endDate: row.endDate, + lat: row.lat, + lon: row.lon, + label: row.label, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })), + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countEnvironmentBackupSection( + section: EnvironmentBackupSection, +): EnvironmentBackupCounts { + return { + environmentContexts: section.environmentContexts.length, + environmentTravelLocations: section.environmentTravelLocations.length, + }; +} + +/** Counts the environment restore wiped, for the audit trail. */ +export interface EnvironmentRestoreCleared { + environmentContexts: number; + environmentTravelLocations: number; +} + +/** + * What the restore reads, as the parsed file actually presents it. Wider than + * what this release writes, because every weather column arrives as + * `undefined` rather than `null` from a file written before it existed. + * + * `source` stays strictly required. It is the field that says this day was not + * the home city, and defaulting it would quietly re-attribute a trip. + */ +type OptionalNullable = { [K in keyof T]?: T[K] | undefined }; + +export type RestoredEnvironmentContext = Pick< + EnvironmentContextBackupEntry, + "date" | "lat" | "lon" | "locationLabel" | "source" +> & + OptionalNullable< + Pick< + EnvironmentContextBackupEntry, + | "tempMin" + | "tempMax" + | "tempMean" + | "apparentMean" + | "sunshineSec" + | "daylightSec" + | "precipSum" + | "pressureMean" + | "pressureDelta" + | "humidityMean" + | "cloudMean" + | "weatherCode" + | "fetchedAt" + | "createdAt" + | "updatedAt" + > + >; + +export type RestoredEnvironmentTravelLocation = Pick< + EnvironmentTravelLocationBackupEntry, + "startDate" | "endDate" | "lat" | "lon" | "label" +> & + OptionalNullable< + Pick + >; + +export interface EnvironmentRestoreInput { + environmentContexts: RestoredEnvironmentContext[]; + environmentTravelLocations: RestoredEnvironmentTravelLocation[]; +} + +/** + * Re-create the account's environmental history and its location periods. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. + * + * Neither model references anything but the account, so this section has no + * ordering constraint against any other one and can run anywhere in the + * restore. The ordering that does matter is INSIDE it, and it is the reason + * the two live in one function: the periods and the readings must land in the + * same transaction, because a set of readings whose periods are missing is a + * history the next refresh will silently rewrite to the home location. The + * periods go first so a reader of this function meets the explanation before + * the thing it explains. + */ +export async function restoreEnvironmentData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: EnvironmentRestoreInput, +): Promise { + const [clearedTravel, clearedContexts] = await Promise.all([ + tx.environmentTravelLocation.deleteMany({ where: { userId: ownerId } }), + tx.environmentContext.deleteMany({ where: { userId: ownerId } }), + ]); + + if (payload.environmentTravelLocations.length > 0) { + await tx.environmentTravelLocation.createMany({ + data: payload.environmentTravelLocations.map((entry) => ({ + userId: ownerId, + // Day keys, written as they were read. See the file header for why + // neither bound goes near a `Date`. + startDate: entry.startDate, + endDate: entry.endDate, + lat: entry.lat, + lon: entry.lon, + label: entry.label, + ...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}), + ...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}), + })), + }); + } + + if (payload.environmentContexts.length > 0) { + await tx.environmentContext.createMany({ + data: payload.environmentContexts.map((entry) => ({ + userId: ownerId, + date: entry.date, + lat: entry.lat, + lon: entry.lon, + locationLabel: entry.locationLabel, + source: entry.source, + tempMin: entry.tempMin ?? null, + tempMax: entry.tempMax ?? null, + tempMean: entry.tempMean ?? null, + apparentMean: entry.apparentMean ?? null, + sunshineSec: entry.sunshineSec ?? null, + daylightSec: entry.daylightSec ?? null, + precipSum: entry.precipSum ?? null, + pressureMean: entry.pressureMean ?? null, + pressureDelta: entry.pressureDelta ?? null, + humidityMean: entry.humidityMean ?? null, + cloudMean: entry.cloudMean ?? null, + weatherCode: entry.weatherCode ?? null, + // Verbatim, not stamped: this says when the feed was read, not when + // the row was written back. + ...(entry.fetchedAt ? { fetchedAt: new Date(entry.fetchedAt) } : {}), + ...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}), + ...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}), + })), + }); + } + + return { + environmentContexts: clearedContexts.count, + environmentTravelLocations: clearedTravel.count, + }; +} diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 8f64432a9..a12cc5a9c 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -78,6 +78,18 @@ import { type DocumentFilingBackupCounts, type DocumentFilingBackupSection, } from "@/lib/export/document-filing-backup"; +import { + buildAwardsBackupSection, + countAwardsBackupSection, + type AwardsBackupCounts, + type AwardsBackupSection, +} from "@/lib/export/awards-backup"; +import { + buildEnvironmentBackupSection, + countEnvironmentBackupSection, + type EnvironmentBackupCounts, + type EnvironmentBackupSection, +} from "@/lib/export/environment-backup"; import { buildIntradayProfileBackupSection, countIntradayProfileBackupSection, @@ -97,7 +109,9 @@ export interface FullBackupCounts CoachBackupCounts, CoachMemoryBackupCounts, SensitiveBackupCounts, - DocumentFilingBackupCounts { + DocumentFilingBackupCounts, + AwardsBackupCounts, + EnvironmentBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -325,6 +339,8 @@ export async function buildFullBackupPayload( coachMemory, sensitive, documentFiling, + awards, + environment, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -523,6 +539,16 @@ export async function buildFullBackupPayload( buildDocumentFilingBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // The bests and the badges. Both ends live in + // `src/lib/export/awards-backup.ts`, the same arrangement as the sections + // above. No `purpose`: neither model tombstones and neither holds + // ciphertext, so both purposes carry the same bytes. + buildAwardsBackupSection(prisma, userId), + // The per-day readings and the location periods that explain them, which + // is why one section carries both. Both ends live in + // `src/lib/export/environment-backup.ts`, and the purpose is absent for + // the same reason as the awards above. + buildEnvironmentBackupSection(prisma, userId), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -560,6 +586,8 @@ export async function buildFullBackupPayload( }; const sensitiveManifest: SensitiveBackupManifest = sensitive.manifest; const documentFilingSection: DocumentFilingBackupSection = documentFiling; + const awardsSection: AwardsBackupSection = awards; + const environmentSection: EnvironmentBackupSection = environment; const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -916,6 +944,8 @@ export async function buildFullBackupPayload( // depending on spread order. manifest: { ...recordsSection.manifest, ...sensitiveManifest }, ...documentFilingSection, + ...awardsSection, + ...environmentSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -969,6 +999,8 @@ export async function buildFullBackupPayload( ...countCoachMemoryBackupSection(coachMemory), ...countSensitiveBackupSection(sensitive), ...countDocumentFilingBackupSection(documentFiling), + ...countAwardsBackupSection(awards), + ...countEnvironmentBackupSection(environment), }, }; } diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 8e4bdeb81..a0731bf2f 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -92,6 +92,20 @@ * cannot name. * * The thirteenth, `checkupClosure`, is not about a restore at all, and it borrows + * The tenth, `personalRecordReference`, names the measurement a personal best + * was found in. It reads like `coachReference` and is the opposite case in the + * one way that matters: `PersonalRecord.sourceMeasurementId` LOOKS like a bare + * id column in `prisma/schema.prisma`, which declares no relation for it, but + * migration 0054 created it as a real foreign key against `measurements`. So a + * dangling value here does not quietly stop meaning something. Postgres + * refuses the statement and the whole restore rolls back over one provenance + * pointer. What is dropped is therefore the POINTER and not the row: the best + * itself is the historical fact, which is why the column was declared + * `ON DELETE SET NULL` in the first place. The ordinary way it fails to + * resolve is a portable export whose source measurement was soft-deleted, and + * a legacy file whose measurements carry no ids at all. + * + * The eleventh, `checkupClosure`, is not about a restore at all, and it borrows * this shape deliberately rather than growing a second reporting mechanism * beside it. The situation is the same one: something a write was asked to do * could not be done, the record itself survives, and the person is told which @@ -112,6 +126,7 @@ export type SkippedCatalogue = | "documentConditionLink" | "extractedFact" | "factCommitment" + | "personalRecordReference" | "checkupClosure"; /** One key this instance does not know, and the links it cost. */ diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 2c9488e6e..8fcfd7a97 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -36,6 +36,7 @@ import { EncounterStatus, ExtractedFactStatus, ExtractedFactType, + EnvironmentLocationSource, FamilyRelationship, FlowLevel, GlucoseContext, @@ -61,6 +62,7 @@ import { MedicationSideEffectCategory, MedicationSideEffectEntry, OvulationTest, + PersonalRecordDirection, ReminderOrigin, RhythmClassification, SecondarySymptom, @@ -1105,6 +1107,106 @@ const measurementReminderEventBackupSchema = z }) .passthrough(); +/** + * One personal best. + * + * `direction` is required rather than defaulted: it says whether higher or + * lower wins, the read path orders by it, and a best time defaulted to MAX + * would present the account's worst time as its record. `sourceMeasurementId` + * is the provenance pointer the restore resolves, and a real foreign key in the + * database, whatever `prisma/schema.prisma` says, so the restore nulls what it + * cannot resolve rather than letting Postgres refuse the whole file. + */ +const personalRecordBackupSchema = z + .object({ + metricType: z.enum(MeasurementType), + metricSlot: z.string().nullable().optional(), + direction: z.enum(PersonalRecordDirection), + value: z.number(), + unit: z.string().min(1), + achievedAt: isoDateTime, + sourceMeasurementId: z.string().nullable().optional(), + source: z.enum(MeasurementSource).optional(), + externalId: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); + +/** + * One unlocked badge. + * + * `achievementId` is a free string on purpose: it names a definition in the + * code catalogue, which drifts between the release that wrote the file and the + * one that reads it, and a row is evidence that a person earned something on a + * day rather than a claim about what this build ships. + * + * `unlockedAt` is REQUIRED, unlike almost every other stamp in this file. It + * is the field that cannot be recovered from anywhere else, and a file that + * does not state it must fail rather than restore every badge to today. + */ +const userAchievementBackupSchema = z + .object({ + achievementId: z.string().min(1), + unlockedAt: isoDateTime, + createdAt: isoDateTime.optional(), + }) + .passthrough(); + +/** `YYYY-MM-DD`, the day key both environment tables are addressed by. */ +const environmentDayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { + message: "Expected a YYYY-MM-DD day key", +}); + +/** + * One day's environmental reading. + * + * `source` is required: it is the only record that the day resolved somewhere + * other than the home city, and a defaulted value would re-attribute a trip to + * home. Every weather column is optional, because the feed is allowed to be partial + * and a file written before a column existed says nothing about it. + */ +const environmentContextBackupSchema = z + .object({ + date: environmentDayKey, + lat: z.number(), + lon: z.number(), + locationLabel: z.string(), + source: z.enum(EnvironmentLocationSource), + tempMin: z.number().nullable().optional(), + tempMax: z.number().nullable().optional(), + tempMean: z.number().nullable().optional(), + apparentMean: z.number().nullable().optional(), + sunshineSec: z.number().int().nullable().optional(), + daylightSec: z.number().int().nullable().optional(), + precipSum: z.number().nullable().optional(), + pressureMean: z.number().nullable().optional(), + pressureDelta: z.number().nullable().optional(), + humidityMean: z.number().nullable().optional(), + cloudMean: z.number().nullable().optional(), + weatherCode: z.number().int().nullable().optional(), + fetchedAt: isoDateTime.optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); + +/** + * One declared stretch spent away from home. Both bounds are day keys rather + * than instants: the resolver compares them lexicographically against other + * day keys, and routing one through a `Date` is how a day key loses a day. + */ +const environmentTravelLocationBackupSchema = z + .object({ + startDate: environmentDayKey, + endDate: environmentDayKey, + lat: z.number(), + lon: z.number(), + label: z.string(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); + /** * One Coach turn. * @@ -1512,6 +1614,19 @@ export const backupPayloadSchema = z measurementReminderEvents: z .array(measurementReminderEventBackupSchema) .default([]), + // The bests and the badges. Defaulted for the same reason as the sections + // above: a file written before they travelled carries no key, and an + // account that has earned neither writes []. + personalRecords: z.array(personalRecordBackupSchema).default([]), + userAchievements: z.array(userAchievementBackupSchema).default([]), + // The per-day readings and the location periods that explain them. Two + // keys rather than one because they are two tables, but they are written + // and restored as a pair: readings whose periods are missing get rewritten + // to the home location by the next refresh. + environmentContexts: z.array(environmentContextBackupSchema).default([]), + environmentTravelLocations: z + .array(environmentTravelLocationBackupSchema) + .default([]), manifest: backupManifestSchema.nullable().default(null), // v1.37.19 (A6-9) — field paths a PORTABLE export could not decrypt // (fail-soft nulls). Disclosed in the file so a nulled field is @@ -1614,6 +1729,14 @@ export interface BackupSummary { mentalHealthAssessments: number; /** Consent records. Disaster-recovery payloads only. */ consentReceipts: number; + /** Personal bests across every metric and sport slot. */ + personalRecords: number; + /** Badges the account has unlocked, each with the day it was earned. */ + userAchievements: number; + /** Local days with an environmental reading. */ + environmentContexts: number; + /** Declared stretches spent away from home. */ + environmentTravelLocations: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -1685,6 +1808,13 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { coachReminders: payload.coachReminders.length, mentalHealthAssessments: payload.mentalHealthAssessments.length, consentReceipts: payload.consentReceipts.length, + // Counted from the release that carries them, so the admin's "what did I + // just restore" answer never under-counts a file with bests, badges or an + // environmental history the way it once did for visits. + personalRecords: payload.personalRecords.length, + userAchievements: payload.userAchievements.length, + environmentContexts: payload.environmentContexts.length, + environmentTravelLocations: payload.environmentTravelLocations.length, }; } diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 4c5b8d607..6964b8c34 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -215,6 +215,13 @@ const COUNT_BACK: Record< DocumentConditionLink: (p, userId) => p.documentConditionLink.count({ where: { userId } }), ExtractedFact: (p, userId) => p.extractedFact.count({ where: { userId } }), + PersonalRecord: (p, userId) => p.personalRecord.count({ where: { userId } }), + UserAchievement: (p, userId) => + p.userAchievement.count({ where: { userId } }), + EnvironmentContext: (p, userId) => + p.environmentContext.count({ where: { userId } }), + EnvironmentTravelLocation: (p, userId) => + p.environmentTravelLocation.count({ where: { userId } }), }; /** @@ -227,7 +234,10 @@ const COUNT_BACK: Record< * make the link tables look restored when nothing restored them. */ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { - await prisma.measurement.create({ + // Held rather than discarded: the personal best below points at this row + // through a column that is a real foreign key in the database, so the + // fixture needs the id to build the reference the restore has to resolve. + const weightMeasurement = await prisma.measurement.create({ data: { userId: OWNER_ID, type: "WEIGHT", @@ -945,6 +955,140 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { documentId: document.id, }, }); + + // Three bests, chosen for what a count cannot see. + // + // The first points at the measurement it was found in. That column is a + // foreign key in the database while the Prisma schema declares no relation + // for it, so this row is what proves the pointer resolves against the + // measurements the restore wrote, and would prove the opposite loudly, by + // failing the constraint and taking the whole restore with it. + // + // The other two share a metric type AND an instant and differ only in the + // sport slot: the same 5 km and 10 km bests a runner has. A restore that + // dropped the slot would return two rows describing one record twice, and + // the faster time would be presented as the 10 km best. + await prisma.personalRecord.create({ + data: { + userId: OWNER_ID, + metricType: "WEIGHT", + direction: "MIN", + value: 74.2, + unit: "kg", + achievedAt: AT("2026-07-01T07:00:00.000Z"), + sourceMeasurementId: weightMeasurement.id, + source: "MANUAL", + }, + }); + await prisma.personalRecord.createMany({ + data: [ + { + userId: OWNER_ID, + metricType: "WALKING_RUNNING_DISTANCE", + metricSlot: "running_5km_time", + direction: "MIN", + value: 1512, + unit: "s", + achievedAt: AT("2026-05-18T09:20:00.000Z"), + source: "APPLE_HEALTH", + externalId: "workout-pr-5km", + }, + { + userId: OWNER_ID, + metricType: "WALKING_RUNNING_DISTANCE", + metricSlot: "running_10km_time", + direction: "MIN", + value: 3184, + unit: "s", + achievedAt: AT("2026-05-18T09:20:00.000Z"), + source: "APPLE_HEALTH", + externalId: "workout-pr-10km", + }, + ], + }); + + // Two badges, both earned long before this fixture runs. The second names a + // definition no catalogue in this build ships: a file can be older than the + // release reading it, and the restore must carry the row rather than judge + // it, exactly as it carries a retired antigen slug. + await prisma.userAchievement.createMany({ + data: [ + { + userId: OWNER_ID, + achievementId: "intake-total-10", + unlockedAt: AT("2026-02-21T10:15:00.000Z"), + }, + { + userId: OWNER_ID, + achievementId: "retired-badge-from-an-older-release", + unlockedAt: AT("2025-11-05T08:00:00.000Z"), + }, + ], + }); + + // A fortnight in Barcelona, and one day of it recorded as a reading. + // + // The pair is the fixture. The home day and the trip day carry different + // coordinates and different weather, and the trip day's reading only makes + // sense next to the period that explains it: with the period gone, the next + // refresh re-resolves 2026-06-15 to Berlin and upserts Berlin's weather over + // it. The assertion after the restore reads the two back TOGETHER. + await prisma.environmentTravelLocation.create({ + data: { + userId: OWNER_ID, + startDate: "2026-06-10", + endDate: "2026-06-20", + lat: 41.3874, + lon: 2.1686, + label: "Barcelona", + }, + }); + await prisma.environmentContext.createMany({ + data: [ + { + userId: OWNER_ID, + date: "2026-06-15", + lat: 41.3874, + lon: 2.1686, + locationLabel: "Barcelona", + source: "TRAVEL", + tempMin: 19.4, + tempMax: 28.1, + tempMean: 23.6, + apparentMean: 25.2, + sunshineSec: 39_600, + daylightSec: 52_800, + precipSum: 0, + pressureMean: 1016.4, + pressureDelta: 3.2, + humidityMean: 63, + cloudMean: 12, + weatherCode: 1, + fetchedAt: AT("2026-06-16T03:15:00.000Z"), + }, + { + userId: OWNER_ID, + date: "2026-07-01", + lat: 52.52, + lon: 13.405, + locationLabel: "Berlin", + source: "HOME", + tempMin: 13.1, + tempMax: 22.7, + tempMean: 17.9, + apparentMean: 17.1, + sunshineSec: 28_800, + daylightSec: 59_400, + precipSum: 4.6, + pressureMean: 1008.9, + pressureDelta: 7.8, + humidityMean: 74, + cloudMean: 68, + weatherCode: 61, + fetchedAt: AT("2026-07-02T03:15:00.000Z"), + }, + ], + }); } async function createOwner(prisma: PrismaClient) { @@ -1632,6 +1776,194 @@ describe("every model the plan claims two-ended survives a real restore", () => restoredEncounter.reminderId, "the encounter's reminder reference must survive now that the reminder travels", ).toBe(restoredReminder.id); + + // The bests, read back column by column. + // + // Three rows came back, which the count above already said. What it could + // not say is that they still describe three different records: the two + // running bests share a metric type and an instant, so the slot is the + // only thing separating "best 5 km" from "best 10 km", and `direction` is + // the only thing that stops a best time being read as a worst one. The + // measurement pointer is asserted against the row the RESTORE wrote rather + // than against the id the fixture used, because resolving to something + // that exists is the property the reference owes. + const restoredMeasurement = await prisma.measurement.findFirstOrThrow({ + where: { userId: OWNER_ID, type: "WEIGHT" }, + }); + const bests = await prisma.personalRecord.findMany({ + where: { userId: OWNER_ID }, + orderBy: [{ achievedAt: "asc" }, { metricSlot: "asc" }], + }); + expect( + bests.map((best) => ({ + metricType: best.metricType, + metricSlot: best.metricSlot, + direction: best.direction, + value: best.value, + unit: best.unit, + achievedAt: best.achievedAt.toISOString(), + sourceMeasurementId: best.sourceMeasurementId, + source: best.source, + externalId: best.externalId, + })), + "each best must come back as its own record, with the direction and the sport slot that make it one", + ).toEqual([ + { + metricType: "WALKING_RUNNING_DISTANCE", + metricSlot: "running_10km_time", + direction: "MIN", + value: 3184, + unit: "s", + achievedAt: "2026-05-18T09:20:00.000Z", + sourceMeasurementId: null, + source: "APPLE_HEALTH", + externalId: "workout-pr-10km", + }, + { + metricType: "WALKING_RUNNING_DISTANCE", + metricSlot: "running_5km_time", + direction: "MIN", + value: 1512, + unit: "s", + achievedAt: "2026-05-18T09:20:00.000Z", + sourceMeasurementId: null, + source: "APPLE_HEALTH", + externalId: "workout-pr-5km", + }, + { + metricType: "WEIGHT", + metricSlot: null, + direction: "MIN", + value: 74.2, + unit: "kg", + achievedAt: "2026-07-01T07:00:00.000Z", + // Not `null`: the pointer resolved against a measurement this restore + // actually wrote. A restore that nulled it would report the drop, and + // one that wrote it before the measurements existed would fail the + // foreign key rather than reach this line. + sourceMeasurementId: restoredMeasurement.id, + source: "MANUAL", + externalId: null, + }, + ]); + + // The badges, and the only field on them that matters. + // + // Both rows would come back from a restore that stamped `unlockedAt` with + // the moment of the restore, and every count in this file would still be + // green, while the account was handed a wall of badges it had apparently + // earned all at once, today. Several of these dates cannot be re-derived + // at all: the evaluator prefers a persisted date, and the counters behind + // the login and Easter-egg badges are not in any backup. + const badges = await prisma.userAchievement.findMany({ + where: { userId: OWNER_ID }, + orderBy: { unlockedAt: "asc" }, + }); + expect( + badges.map((badge) => ({ + achievementId: badge.achievementId, + unlockedAt: badge.unlockedAt.toISOString(), + })), + "a badge comes back with the day it was earned, and with the id it was earned under", + ).toEqual([ + { + // A definition this build does not ship, kept verbatim rather than + // judged against the catalogue, the same answer the restore gives a + // retired antigen slug. + achievementId: "retired-badge-from-an-older-release", + unlockedAt: "2025-11-05T08:00:00.000Z", + }, + { + achievementId: "intake-total-10", + unlockedAt: "2026-02-21T10:15:00.000Z", + }, + ]); + + // The environmental history, read back as a PAIR. + // + // Counting says two readings and one location period returned. It cannot + // say that the trip day still knows it was a trip day, which is the whole + // content of these rows: the coordinates, the label and `source` are what + // separate a fortnight in Barcelona from a fortnight at home, and the + // period is what keeps the next refresh from re-resolving the day and + // upserting Berlin's weather over it. + const readings = await prisma.environmentContext.findMany({ + where: { userId: OWNER_ID }, + orderBy: { date: "asc" }, + }); + expect( + readings.map((reading) => ({ + date: reading.date, + lat: reading.lat, + lon: reading.lon, + locationLabel: reading.locationLabel, + source: reading.source, + tempMean: reading.tempMean, + pressureDelta: reading.pressureDelta, + daylightSec: reading.daylightSec, + weatherCode: reading.weatherCode, + // Verbatim, not re-stamped: this says when the feed was read, and a + // restore that wrote "now" would claim a two-year-old provisional + // reading had just been confirmed. + fetchedAt: reading.fetchedAt.toISOString(), + })), + "each day comes back at the place it was actually read for", + ).toEqual([ + { + date: "2026-06-15", + lat: 41.3874, + lon: 2.1686, + locationLabel: "Barcelona", + source: "TRAVEL", + tempMean: 23.6, + pressureDelta: 3.2, + daylightSec: 52_800, + weatherCode: 1, + fetchedAt: "2026-06-16T03:15:00.000Z", + }, + { + date: "2026-07-01", + lat: 52.52, + lon: 13.405, + locationLabel: "Berlin", + source: "HOME", + tempMean: 17.9, + pressureDelta: 7.8, + daylightSec: 59_400, + weatherCode: 61, + fetchedAt: "2026-07-02T03:15:00.000Z", + }, + ]); + + // And the join between them, computed from what the restore wrote rather + // than from the fixture: the trip day has to fall inside the restored + // period and carry its coordinates. Both halves can be individually + // non-empty and still disagree: a period whose bounds shifted by a day no + // longer covers the reading it explains, and the reading goes back to + // being weather from nowhere. + const trip = await prisma.environmentTravelLocation.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + const travelDay = readings.find((reading) => reading.source === "TRAVEL")!; + expect( + { + startDate: trip.startDate, + endDate: trip.endDate, + label: trip.label, + covers: + travelDay.date >= trip.startDate && travelDay.date <= trip.endDate, + sameLat: trip.lat === travelDay.lat, + sameLon: trip.lon === travelDay.lon, + }, + "the period must still explain the reading it was exported beside", + ).toEqual({ + startDate: "2026-06-10", + endDate: "2026-06-20", + label: "Barcelona", + covers: true, + sameLat: true, + sameLon: true, + }); }); /**