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
29 changes: 23 additions & 6 deletions e2e/v137-sharing-managed-profiles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,15 +291,32 @@ test.describe.serial("scoped sharing browser journeys", () => {

let corruptAuthPayload = true;
await page.route("**/api/auth/me", async (route) => {
const response = await route.fetch();
// The switch this test drives ends in a full reload, and the reload
// cancels whatever `/api/auth/me` call is in flight. Playwright then
// considers the route handled, and the `fulfill` below lands on it with
// "Route is already handled!" — a failure of the teardown, not of the
// thing under test, and one that turns the whole shard red under
// `failOnFlakyTests`.
//
// Same guard as `v137-record-session-fence.spec.ts` and the a11y sibling
// of this file. The fetch is guarded too: a request cancelled mid-flight
// rejects there rather than at the fulfill.
let response;
try {
response = await route.fetch();
} catch {
return;
}
if (!corruptAuthPayload) {
await route.fulfill({ response });
await route.fulfill({ response }).catch(() => {});
return;
}
await route.fulfill({
response,
json: withDivergentActiveAccountAccess(await response.json()),
});
await route
.fulfill({
response,
json: withDivergentActiveAccountAccess(await response.json()),
})
.catch(() => {});
});

const ownerOnlyReads: string[] = [];
Expand Down
6 changes: 6 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -6912,6 +6912,12 @@
"restoreSkippedVisitReference": "Besuchsverknüpfung",
"restoreSkippedVaccinationReference": "Impfverknüpfung",
"restoreSkippedCheckupClosure": "Vorsorge-Abschluss",
"restoreSkippedReminderReference": "Erinnerungsverknüpfung",
"restoreSkippedCoachAttachment": "Coach-Dokumentverknüpfung",
"restoreSkippedCoachReference": "Coach-Verweis",
"restoreSkippedDocumentConditionLink": "Dokumentzuordnung",
"restoreSkippedExtractedFact": "Dokumentbefund",
"restoreSkippedFactCommitment": "Befundübernahme",
"docsLink": "Dokumentation"
},
"danger-zone": {
Expand Down
6 changes: 6 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -6912,6 +6912,12 @@
"restoreSkippedVisitReference": "Visit reference",
"restoreSkippedVaccinationReference": "Vaccination reference",
"restoreSkippedCheckupClosure": "Checkup closure",
"restoreSkippedReminderReference": "Reminder reference",
"restoreSkippedCoachAttachment": "Coach document link",
"restoreSkippedCoachReference": "Coach reference",
"restoreSkippedDocumentConditionLink": "Document filing",
"restoreSkippedExtractedFact": "Document fact",
"restoreSkippedFactCommitment": "Fact commitment",
"docsLink": "Documentation"
},
"danger-zone": {
Expand Down
6 changes: 6 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -6912,6 +6912,12 @@
"restoreSkippedVisitReference": "Referencia de visita",
"restoreSkippedVaccinationReference": "Referencia de vacunación",
"restoreSkippedCheckupClosure": "Cierre de revisión",
"restoreSkippedReminderReference": "Referencia de recordatorio",
"restoreSkippedCoachAttachment": "Enlace de documento del Coach",
"restoreSkippedCoachReference": "Referencia del Coach",
"restoreSkippedDocumentConditionLink": "Clasificación de documento",
"restoreSkippedExtractedFact": "Dato del documento",
"restoreSkippedFactCommitment": "Registro del dato",
"docsLink": "Documentación"
},
"danger-zone": {
Expand Down
6 changes: 6 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -6912,6 +6912,12 @@
"restoreSkippedVisitReference": "Référence de visite",
"restoreSkippedVaccinationReference": "Référence de vaccination",
"restoreSkippedCheckupClosure": "Clôture d'un examen",
"restoreSkippedReminderReference": "Référence de rappel",
"restoreSkippedCoachAttachment": "Lien de document du Coach",
"restoreSkippedCoachReference": "Référence du Coach",
"restoreSkippedDocumentConditionLink": "Classement du document",
"restoreSkippedExtractedFact": "Donnée du document",
"restoreSkippedFactCommitment": "Enregistrement de la donnée",
"docsLink": "Documentation"
},
"danger-zone": {
Expand Down
6 changes: 6 additions & 0 deletions messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -6912,6 +6912,12 @@
"restoreSkippedVisitReference": "Riferimento alla visita",
"restoreSkippedVaccinationReference": "Riferimento alla vaccinazione",
"restoreSkippedCheckupClosure": "Chiusura del controllo",
"restoreSkippedReminderReference": "Riferimento al promemoria",
"restoreSkippedCoachAttachment": "Collegamento documento del Coach",
"restoreSkippedCoachReference": "Riferimento del Coach",
"restoreSkippedDocumentConditionLink": "Classificazione del documento",
"restoreSkippedExtractedFact": "Dato del documento",
"restoreSkippedFactCommitment": "Registrazione del dato",
"docsLink": "Documentazione"
},
"danger-zone": {
Expand Down
6 changes: 6 additions & 0 deletions messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -6912,6 +6912,12 @@
"restoreSkippedVisitReference": "Odwołanie wizyty",
"restoreSkippedVaccinationReference": "Odwołanie szczepienia",
"restoreSkippedCheckupClosure": "Zamknięcie badania",
"restoreSkippedReminderReference": "Odwołanie do przypomnienia",
"restoreSkippedCoachAttachment": "Powiązanie dokumentu z Coachem",
"restoreSkippedCoachReference": "Odwołanie Coacha",
"restoreSkippedDocumentConditionLink": "Przypisanie dokumentu",
"restoreSkippedExtractedFact": "Fakt z dokumentu",
"restoreSkippedFactCommitment": "Zapis faktu",
"docsLink": "Dokumentacja"
},
"danger-zone": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,10 @@ function sourceClient() {
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
// What a document was filed against, and what was read out of it.
// Empty for the same reason as the sections above.
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: {
findMany: vi.fn().mockResolvedValue([
{
Expand Down
41 changes: 40 additions & 1 deletion src/app/api/admin/backups/[id]/restore/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
restoreCoachMemoryData,
} from "@/lib/export/coach-backup";
import { restoreRemindersData } from "@/lib/export/reminders-backup";
import { restoreDocumentFilingData } from "@/lib/export/document-filing-backup";
import { invalidateUserData } from "@/lib/cache/invalidate";

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -90,6 +91,8 @@ interface RestoreResponse {
familyHistory: number;
workouts: number;
documents: number;
documentConditionLinks: number;
extractedFacts: number;
healthProfile: number;
healthProfileFactRevisions: number;
customMetrics: number;
Expand Down Expand Up @@ -1215,6 +1218,10 @@ const handler = apiHandler(
restoredBiomarkerIds.add(created.id);
}

// Collected for the staged facts further down: an approved fact's
// `committedRecordId` names the lab result it was committed to, and
// that reference is resolved against the rows this loop writes.
const restoredLabResultIds = new Set<string>();
for (const lab of payload.labResults) {
const biomarkerId =
lab.biomarkerId !== undefined
Expand All @@ -1231,7 +1238,7 @@ const handler = apiHandler(
`Unknown biomarker reference: ${lab.biomarkerId ?? lab.biomarkerName}`,
);
}
await tx.labResult.create({
const createdLab = await tx.labResult.create({
data: {
...(lab.id ? { id: lab.id } : {}),
userId: ownerId,
Expand Down Expand Up @@ -1265,6 +1272,7 @@ const handler = apiHandler(
: {}),
},
});
restoredLabResultIds.add(createdLab.id);
}

const episodeIds = new Set(
Expand Down Expand Up @@ -1538,6 +1546,34 @@ const handler = apiHandler(
});
}

// What the vault was filed against, and what was read out of it.
// AFTER the documents and the condition episodes because both are
// foreign keys here — a filing written before either exists does not
// drop quietly, it violates a constraint and costs the operator the
// whole restore. AFTER the lab results and the medications for a
// second reason: an approved fact's `committedRecordId` is resolved
// against the rows those branches wrote, and resolving before they
// exist would null every commitment and still report success. Both
// ends of this section live in
// `src/lib/export/document-filing-backup.ts`.
const documentFilingCleared = await restoreDocumentFilingData(
tx,
ownerId,
payload,
{
documentIds: new Set(
payload.documents.map((document) => document.id),
),
episodeIds,
committedRecordIds: new Set([
...restoredLabResultIds,
...episodeIds,
...restoredMedicationIds,
]),
},
skips,
);

// The Vorsorge reminders and their completion ledger (v1.37.20,
// #223 / iOS #68), BEFORE the visits and the vaccinations: both of
// those remap a `reminderId` against the reminders in the database,
Expand Down Expand Up @@ -1637,6 +1673,9 @@ const handler = apiHandler(
familyHistory: familyHistory.count,
workouts: workouts.count,
documents: documents.count,
documentConditionLinks:
documentFilingCleared.documentConditionLinks,
extractedFacts: documentFilingCleared.extractedFacts,
healthProfile: profileCleared.healthProfile,
healthProfileFactRevisions:
profileCleared.healthProfileFactRevisions,
Expand Down
8 changes: 8 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 @@ -50,6 +50,10 @@ vi.mock("@/lib/db", () => ({
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
// What a document was filed against, and what was read out of it.
// Empty for the same reason as the sections above.
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
Expand Down Expand Up @@ -147,6 +151,10 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.consentReceipt.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
});

afterEach(() => {
Expand Down
7 changes: 7 additions & 0 deletions src/app/api/export/__tests__/soft-delete-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ vi.mock("@/lib/db", () => ({
familyHistoryEntry: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
inboundDocument: { findMany: vi.fn() },
// What a document was filed against, and what was read out of it.
documentConditionLink: { findMany: vi.fn() },
extractedFact: { findMany: vi.fn() },
},
}));

Expand Down Expand Up @@ -167,6 +170,10 @@ beforeEach(() => {
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
});

describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/export/encrypted/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ vi.mock("@/lib/db", () => ({
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
// What a document was filed against, and what was read out of it.
// Empty for the same reason as the sections above.
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
Expand Down
29 changes: 28 additions & 1 deletion src/components/admin/backups-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,34 @@ function catalogueLabel(
if (catalogue === "checkupClosure") {
return t("admin.section.backups.restoreSkippedCheckupClosure");
}
return t("admin.section.backups.restoreSkippedMoodFactor");
if (catalogue === "reminderReference") {
return t("admin.section.backups.restoreSkippedReminderReference");
}
if (catalogue === "coachAttachment") {
return t("admin.section.backups.restoreSkippedCoachAttachment");
}
if (catalogue === "coachReference") {
return t("admin.section.backups.restoreSkippedCoachReference");
}
if (catalogue === "documentConditionLink") {
return t("admin.section.backups.restoreSkippedDocumentConditionLink");
}
if (catalogue === "extractedFact") {
return t("admin.section.backups.restoreSkippedExtractedFact");
}
if (catalogue === "factCommitment") {
return t("admin.section.backups.restoreSkippedFactCommitment");
}
if (catalogue === "moodFactor") {
return t("admin.section.backups.restoreSkippedMoodFactor");
}
// 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,
// which is what three of them were already doing — a dropped Coach
// attachment reached the operator's screen labelled "mood factor".
const unlabelled: never = catalogue;
return unlabelled;
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/lib/export/__tests__/full-backup-payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,10 @@ function makePrisma() {
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
// What a document was filed against, and what was read out of it.
// Empty for the same reason as the sections above.
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
// Left unmocked on purpose: `buildProfileBackupSection` runs for real
// against these, so the assertions below exercise the builder rather than
// a stand-in that would agree with whatever the payload happened to do.
Expand Down
40 changes: 30 additions & 10 deletions src/lib/export/backup-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
// The screener history and the consent record, disaster-recovery only. The
// reasons for that live in the module itself.
"src/lib/export/sensitive-backup.ts",
// What a document was filed against and what was read out of it. The
// 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",
"src/lib/cycle/backup.ts",
];

Expand All @@ -239,6 +243,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
"src/lib/export/reminders-backup.ts",
"src/lib/export/coach-backup.ts",
"src/lib/export/sensitive-backup.ts",
"src/lib/export/document-filing-backup.ts",
"src/lib/cycle/backup.ts",
];

Expand Down Expand Up @@ -304,12 +309,13 @@ export const TWO_ENDED_MODELS = [
"EncounterLabLink",
"EncounterConditionLink",
// Doses travel both ways from the release that introduces them, and the
// link with them. `DocumentConditionLink` one list down says what the
// alternative costs — documents and conditions both restore, the filing
// between them does not. Repeating that here would return a restored
// Impfpass scan and a restored dose with nothing between them, which is the
// same regret against a record a person cannot reconstruct from anywhere
// else.
// link with them. `DocumentConditionLink` was the register entry that said
// what the alternative costs — documents and conditions both restore, the
// filing between them does not — and it has since landed carried itself, at
// the bottom of this list. Repeating that debt here would have returned a
// restored Impfpass scan and a restored dose with nothing between them,
// which is the same regret against a record a person cannot reconstruct
// from anywhere else.
"VaccinationRecord",
"VaccinationDocumentLink",
// The Vorsorge reminders, off the debt register at last (v1.37.20, #223 /
Expand Down Expand Up @@ -388,6 +394,24 @@ export const TWO_ENDED_MODELS = [
// neither.
"MentalHealthAssessment",
"ConsentReceipt",
// What the vault was sorted into, and what was read out of it. The register
// called the first "a restored vault is unsorted" and the second
// "re-derivable only by re-running the extraction against a provider, at the
// operator's cost". The second understated it: what a re-run cannot produce
// is the REVIEW DECISION on each fact, and every column holding one defaults
// to "nobody has looked at this yet". The confirm endpoint acts on a PENDING
// fact by committing it into the structured store, so a restore that
// defaulted them would offer an already-committed reading for approval a
// second time and write a second lab result behind it.
//
// Both references are real foreign keys, unlike the Coach's, so the failure
// they guard against is not a dead pointer but an aborted transaction: the
// builder carries a filing or a fact only when both of its ends are carried,
// and the restore drops and names what a hand-edited file still cannot
// place. `committedRecordId` is the one bare id column here, and it is
// nulled together with its type when it resolves to nothing.
"DocumentConditionLink",
"ExtractedFact",
] as const;

/** One model claimed to travel both ways. */
Expand Down Expand Up @@ -442,10 +466,6 @@ export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
"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.",
DocumentConditionLink:
"Which documents were filed against which condition. Documents and conditions both restore; the filing between them does not, so a restored vault is unsorted.",
ExtractedFact:
"Facts read out of a document by the AI pass, with their provenance back to the page. Re-derivable only by re-running the extraction against a provider, at the operator's cost.",
};

/**
Expand Down
Loading
Loading