Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -6918,7 +6918,8 @@
"restoreSkippedDocumentConditionLink": "Dokumentzuordnung",
"restoreSkippedExtractedFact": "Dokumentbefund",
"restoreSkippedFactCommitment": "Befundübernahme",
"docsLink": "Dokumentation"
"docsLink": "Dokumentation",
"restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung"
},
"danger-zone": {
"title": "Gefahrenzone",
Expand Down
3 changes: 2 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 11 additions & 5 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([]) },
Expand Down
43 changes: 43 additions & 0 deletions src/app/api/admin/backups/[id]/restore/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -114,6 +116,10 @@ interface RestoreResponse {
coachReminders: number;
mentalHealthAssessments: number;
consentReceipts: number;
personalRecords: number;
userAchievements: number;
environmentContexts: number;
environmentTravelLocations: number;
};
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) };
},
Expand Down
12 changes: 12 additions & 0 deletions src/app/api/export/__tests__/per-type-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]) },
Expand Down Expand Up @@ -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(() => {
Expand Down
6 changes: 6 additions & 0 deletions src/app/api/export/encrypted/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]) },
Expand Down
3 changes: 3 additions & 0 deletions src/components/admin/backups-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/lib/export/__tests__/full-backup-payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading