diff --git a/messages/de.json b/messages/de.json index f7ae333e9..593ca7c83 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8177,7 +8177,6 @@ "colSize": "Größe", "colCreatedAt": "Letzter Snapshot", "colActions": "Aktionen", - "retentionLabel": "Aufbewahrung: {days} Tage", "scheduleStaleTitle": "Die wöchentliche Sicherung läuft nicht mehr", "scheduleStaleDescription": "Die neueste geplante Kopie ist {days} Tage alt. Ein funktionierender wöchentlicher Lauf lässt keine älter als {threshold} Tage werden.", "scheduleFailedTitle": "Der letzte geplante Sicherungslauf ist fehlgeschlagen", @@ -8330,7 +8329,9 @@ "restoreSkippedVisitCiphertext": "Text zu Arztbesuch oder Praxis mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne ihn behalten)", "restoreSkippedVaccinationCiphertext": "Impf-Notiz mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Impfung ohne sie behalten)", "restoreSkippedEnvironmentLocationCiphertext": "Ort eines Umweltzeitraums oder -tags mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (verschlüsselt behalten, diese Tage werden nicht neu abgerufen)", - "typePreWipe": "Vor dem Löschen aller Daten" + "typePreWipe": "Vor dem Löschen aller Daten", + "retentionNote": "Die wöchentliche Kopie wird jeden Sonntag ersetzt. Eine Kopie, die vor „Alle Daten löschen“ angelegt wurde, wird nach {days} Tagen gelöscht. Hochgeladene Kopien bleiben, bis ihr Konto gelöscht wird.", + "retentionNoteNoGrace": "Die wöchentliche Kopie wird jeden Sonntag ersetzt. Hochgeladene Kopien bleiben, bis ihr Konto gelöscht wird." }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index 4555be756..e9aaa311f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8177,7 +8177,6 @@ "colSize": "Size", "colCreatedAt": "Last snapshot", "colActions": "Actions", - "retentionLabel": "Retention: {days} days", "scheduleStaleTitle": "The weekly backup has stopped", "scheduleStaleDescription": "The newest scheduled copy is {days} days old. A working weekly run never leaves one older than {threshold} days.", "scheduleFailedTitle": "The last scheduled backup run failed", @@ -8330,7 +8329,9 @@ "restoreSkippedVisitCiphertext": "Visit or practitioner text sealed with a key this server does not hold (kept without it)", "restoreSkippedVaccinationCiphertext": "Vaccination note sealed with a key this server does not hold (dose kept without it)", "restoreSkippedEnvironmentLocationCiphertext": "Location of an environment period or day sealed with a key this server does not hold (kept sealed, those days are not refreshed)", - "typePreWipe": "Before all data was deleted" + "typePreWipe": "Before all data was deleted", + "retentionNote": "The weekly copy is replaced every Sunday. A copy taken before \"Delete all data\" is deleted after {days} days. Uploaded copies stay until their account is erased.", + "retentionNoteNoGrace": "The weekly copy is replaced every Sunday. Uploaded copies stay until their account is erased." }, "danger-zone": { "title": "Danger zone", diff --git a/messages/es.json b/messages/es.json index b3fb385fc..2901074ee 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8177,7 +8177,6 @@ "colSize": "Tamaño", "colCreatedAt": "Última instantánea", "colActions": "Acciones", - "retentionLabel": "Conservación: {days} días", "scheduleStaleTitle": "La copia de seguridad semanal se ha detenido", "scheduleStaleDescription": "La copia programada más reciente tiene {days} días. Una ejecución semanal que funciona nunca deja ninguna con más de {threshold} días.", "scheduleFailedTitle": "La última ejecución programada de la copia de seguridad falló", @@ -8330,7 +8329,9 @@ "restoreSkippedVisitCiphertext": "Texto de visita o profesional cifrado con una clave que este servidor no tiene (se conserva sin él)", "restoreSkippedVaccinationCiphertext": "Nota de vacunación cifrada con una clave que este servidor no tiene (la dosis se conserva sin ella)", "restoreSkippedEnvironmentLocationCiphertext": "Ubicación de un periodo o día ambiental cifrada con una clave que este servidor no tiene (se conserva cifrada, esos días no se actualizan)", - "typePreWipe": "Antes de eliminar todos los datos" + "typePreWipe": "Antes de eliminar todos los datos", + "retentionNote": "La copia semanal se reemplaza cada domingo. Una copia tomada antes de «Eliminar todos los datos» se elimina al cabo de {days} días. Las copias subidas se quedan hasta que se borra su cuenta.", + "retentionNoteNoGrace": "La copia semanal se reemplaza cada domingo. Las copias subidas se quedan hasta que se borra su cuenta." }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index c01ea578a..6fbdaa48e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8177,7 +8177,6 @@ "colSize": "Taille", "colCreatedAt": "Dernier instantané", "colActions": "Actions", - "retentionLabel": "Conservation : {days} jours", "scheduleStaleTitle": "La sauvegarde hebdomadaire s'est arrêtée", "scheduleStaleDescription": "La copie planifiée la plus récente date de {days} jours. Une exécution hebdomadaire qui fonctionne n'en laisse jamais une dépasser {threshold} jours.", "scheduleFailedTitle": "La dernière exécution planifiée de la sauvegarde a échoué", @@ -8330,7 +8329,9 @@ "restoreSkippedVisitCiphertext": "Texte de consultation ou de praticien chiffré avec une clé que ce serveur ne possède pas (conservé sans)", "restoreSkippedVaccinationCiphertext": "Note de vaccination chiffrée avec une clé que ce serveur ne possède pas (dose conservée sans)", "restoreSkippedEnvironmentLocationCiphertext": "Lieu d'une période ou d'un jour environnemental chiffré avec une clé que ce serveur ne possède pas (conservé chiffré, ces jours ne sont pas actualisés)", - "typePreWipe": "Avant la suppression de toutes les données" + "typePreWipe": "Avant la suppression de toutes les données", + "retentionNote": "La copie hebdomadaire est remplacée chaque dimanche. Une copie prise avant « Supprimer toutes les données » est supprimée au bout de {days} jours. Les copies téléversées restent jusqu’à l’effacement de leur compte.", + "retentionNoteNoGrace": "La copie hebdomadaire est remplacée chaque dimanche. Les copies téléversées restent jusqu’à l’effacement de leur compte." }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index 99fcdeec7..798c1b79a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8177,7 +8177,6 @@ "colSize": "Dimensione", "colCreatedAt": "Ultima istantanea", "colActions": "Azioni", - "retentionLabel": "Conservazione: {days} giorni", "scheduleStaleTitle": "Il backup settimanale si è fermato", "scheduleStaleDescription": "La copia pianificata più recente ha {days} giorni. Un'esecuzione settimanale funzionante non ne lascia mai una più vecchia di {threshold} giorni.", "scheduleFailedTitle": "L'ultima esecuzione pianificata del backup non è riuscita", @@ -8330,7 +8329,9 @@ "restoreSkippedVisitCiphertext": "Testo di visita o professionista cifrato con una chiave che questo server non possiede (mantenuto senza)", "restoreSkippedVaccinationCiphertext": "Nota di vaccinazione cifrata con una chiave che questo server non possiede (dose mantenuta senza)", "restoreSkippedEnvironmentLocationCiphertext": "Luogo di un periodo o giorno ambientale cifrato con una chiave che questo server non possiede (mantenuto cifrato, quei giorni non vengono aggiornati)", - "typePreWipe": "Prima dell’eliminazione di tutti i dati" + "typePreWipe": "Prima dell’eliminazione di tutti i dati", + "retentionNote": "La copia settimanale viene sostituita ogni domenica. Una copia presa prima di «Elimina tutti i dati» viene eliminata dopo {days} giorni. Le copie caricate restano finché il loro account non viene cancellato.", + "retentionNoteNoGrace": "La copia settimanale viene sostituita ogni domenica. Le copie caricate restano finché il loro account non viene cancellato." }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index 59c9c62b9..6e38b7d5b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8152,7 +8152,6 @@ "colSize": "크기", "colCreatedAt": "최근 스냅샷", "colActions": "동작", - "retentionLabel": "보관 기간: {days}일", "scheduleStaleTitle": "주간 백업이 중단되었습니다", "scheduleStaleDescription": "가장 최근의 예약 사본이 {days}일 전 것입니다. 정상적으로 도는 주간 실행은 {threshold}일보다 오래된 사본을 남기지 않습니다.", "scheduleFailedTitle": "마지막 예약 백업 실행이 실패했습니다", @@ -8305,7 +8304,9 @@ "restoreSkippedVisitCiphertext": "이 서버에 없는 키로 암호화된 진료 또는 의료진 텍스트(해당 내용 없이 유지됨)", "restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)", "restoreSkippedEnvironmentLocationCiphertext": "이 서버에 없는 키로 암호화된 환경 기간 또는 날짜의 위치(암호화된 채 유지되며 해당 날짜는 새로 가져오지 않음)", - "typePreWipe": "모든 데이터 삭제 전" + "typePreWipe": "모든 데이터 삭제 전", + "retentionNote": "주간 사본은 매주 일요일에 교체돼요. \"모든 데이터 삭제\" 전에 만든 사본은 {days}일 뒤에 삭제돼요. 올린 사본은 해당 계정이 지워질 때까지 남아요.", + "retentionNoteNoGrace": "주간 사본은 매주 일요일에 교체돼요. 올린 사본은 해당 계정이 지워질 때까지 남아요." }, "danger-zone": { "title": "위험 구역", diff --git a/messages/nl.json b/messages/nl.json index be0f707df..7152c3151 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -8177,7 +8177,6 @@ "colSize": "Grootte", "colCreatedAt": "Laatste snapshot", "colActions": "Acties", - "retentionLabel": "Bewaartermijn: {days} dagen", "scheduleStaleTitle": "De wekelijkse back-up is gestopt", "scheduleStaleDescription": "De nieuwste geplande kopie is {days} dagen oud. Een goed werkende wekelijkse run laat nooit een kopie ouder worden dan {threshold} dagen.", "scheduleFailedTitle": "De laatste geplande back-uprun is mislukt", @@ -8330,7 +8329,9 @@ "restoreSkippedVisitCiphertext": "Tekst van bezoek of zorgverlener versleuteld met een sleutel die deze server niet heeft (behouden zonder)", "restoreSkippedVaccinationCiphertext": "Vaccinatienotitie versleuteld met een sleutel die deze server niet heeft (dosis behouden zonder)", "restoreSkippedEnvironmentLocationCiphertext": "Locatie van een omgevingsperiode of -dag versleuteld met een sleutel die deze server niet heeft (versleuteld behouden, die dagen worden niet bijgewerkt)", - "typePreWipe": "Voor het verwijderen van alle gegevens" + "typePreWipe": "Voor het verwijderen van alle gegevens", + "retentionNote": "De wekelijkse kopie wordt elke zondag vervangen. Een kopie die vóór 'Alle gegevens verwijderen' is gemaakt, wordt na {days} dagen verwijderd. Geüploade kopieën blijven tot hun account gewist wordt.", + "retentionNoteNoGrace": "De wekelijkse kopie wordt elke zondag vervangen. Geüploade kopieën blijven tot hun account gewist wordt." }, "danger-zone": { "title": "Gevarenzone", diff --git a/messages/pl.json b/messages/pl.json index 6998eca6c..39dd8dda7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8177,7 +8177,6 @@ "colSize": "Rozmiar", "colCreatedAt": "Ostatnia migawka", "colActions": "Akcje", - "retentionLabel": "Przechowywanie: {days} dni", "scheduleStaleTitle": "Cotygodniowa kopia zapasowa przestała powstawać", "scheduleStaleDescription": "Najnowsza zaplanowana kopia ma {days} dni. Działające cotygodniowe uruchomienie nigdy nie zostawia kopii starszej niż {threshold} dni.", "scheduleFailedTitle": "Ostatnie zaplanowane uruchomienie kopii zapasowej nie powiodło się", @@ -8330,7 +8329,9 @@ "restoreSkippedVisitCiphertext": "Tekst wizyty lub lekarza zaszyfrowany kluczem, którego ten serwer nie ma (zachowany bez niego)", "restoreSkippedVaccinationCiphertext": "Notatka szczepienia zaszyfrowana kluczem, którego ten serwer nie ma (dawka zachowana bez niej)", "restoreSkippedEnvironmentLocationCiphertext": "Lokalizacja okresu lub dnia środowiskowego zaszyfrowana kluczem, którego ten serwer nie ma (zachowana w postaci zaszyfrowanej, te dni nie są odświeżane)", - "typePreWipe": "Przed usunięciem wszystkich danych" + "typePreWipe": "Przed usunięciem wszystkich danych", + "retentionNote": "Cotygodniowa kopia jest zastępowana w każdą niedzielę. Kopia wykonana przed „Usuń wszystkie dane” jest usuwana po {days} dniach. Przesłane kopie zostają, dopóki ich konto nie zostanie usunięte.", + "retentionNoteNoGrace": "Cotygodniowa kopia jest zastępowana w każdą niedzielę. Przesłane kopie zostają, dopóki ich konto nie zostanie usunięte." }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 8da4a4bdd..956b87db2 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -8177,7 +8177,6 @@ "colSize": "Tamanho", "colCreatedAt": "Último instantâneo", "colActions": "Ações", - "retentionLabel": "Retenção: {days} dias", "scheduleStaleTitle": "O backup semanal parou", "scheduleStaleDescription": "A cópia agendada mais recente tem {days} dias. Uma execução semanal funcional nunca deixa uma com mais de {threshold} dias.", "scheduleFailedTitle": "A última execução agendada de backup falhou", @@ -8330,7 +8329,9 @@ "restoreSkippedVisitCiphertext": "Texto de consulta ou profissional selado com uma chave que este servidor não possui (mantido sem ele)", "restoreSkippedVaccinationCiphertext": "Nota de vacinação selada com uma chave que este servidor não possui (dose mantida sem ela)", "restoreSkippedEnvironmentLocationCiphertext": "Local de um período ou dia de ambiente selado com uma chave que este servidor não possui (mantido selado, esses dias não são atualizados)", - "typePreWipe": "Antes da exclusão de todos os dados" + "typePreWipe": "Antes da exclusão de todos os dados", + "retentionNote": "A cópia semanal é substituída todo domingo. Uma cópia feita antes de \"Excluir todos os dados\" é excluída depois de {days} dias. As cópias enviadas ficam até a conta delas ser apagada.", + "retentionNoteNoGrace": "A cópia semanal é substituída todo domingo. As cópias enviadas ficam até a conta delas ser apagada." }, "danger-zone": { "title": "Zona de perigo", diff --git a/src/app/api/admin/backups/__tests__/route.test.ts b/src/app/api/admin/backups/__tests__/route.test.ts index 25c61fb3b..13f3e90ea 100644 --- a/src/app/api/admin/backups/__tests__/route.test.ts +++ b/src/app/api/admin/backups/__tests__/route.test.ts @@ -153,6 +153,16 @@ describe("GET /api/admin/backups — off-host freshness", () => { expect(byName.get("account-five")?.lastAttemptAt).toBeNull(); }); + it("states what ages a stored copy out, and invents no retention", async () => { + configuredMock.mockReturnValue(false); + findManyMock.mockResolvedValue([]); + vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "21"); + const body = await read(); + vi.unstubAllEnvs(); + expect(body.preWipeCopyDays).toBe(21); + expect(body).not.toHaveProperty("retentionDays"); + }); + it("says so when neither the environment nor the ledger knows of any upload", async () => { configuredMock.mockReturnValue(false); findManyMock.mockResolvedValue([ diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index 38ec9db5b..69d441522 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -38,6 +38,7 @@ import { import { OFFHOST_BACKUP_QUEUE } from "@/lib/jobs/offhost-backup-queue"; import { offhostPurgeDue } from "@/lib/jobs/offhost-purge-queue"; import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; +import { accountDeletionGraceDays } from "@/lib/account-deletion/grace"; import { classifyOffhostBackup, OFFHOST_BACKUP_PERIOD_HOURS, @@ -213,9 +214,11 @@ export const GET = apiHandler(async () => { const payload: BackupsList = { rows: list, - // Matches the retention window the backup-prune job enforces so the - // backups page states the same number the worker acts on. - retentionDays: 90, + // What ages these copies out, and nothing more: no job prunes stored + // copies by age. The weekly copy is replaced in place, a pre-wipe copy + // is deleted by the account-deletion purge when its window ends, and an + // uploaded copy stays until its account is erased. + preWipeCopyDays: accountDeletionGraceDays(), schedule: summariseBackupSchedule({ scheduledCreatedAt: rows .filter((row) => row.type === SCHEDULED_BACKUP_TYPE) diff --git a/src/components/admin/__tests__/backups-restore-preview.test.tsx b/src/components/admin/__tests__/backups-restore-preview.test.tsx index 4107fe523..4b7ba9444 100644 --- a/src/components/admin/__tests__/backups-restore-preview.test.tsx +++ b/src/components/admin/__tests__/backups-restore-preview.test.tsx @@ -49,7 +49,7 @@ vi.mock("@tanstack/react-query", () => ({ createdAt: "2026-09-01T03:00:00.000Z", }, ], - retentionDays: 30, + preWipeCopyDays: 14, schedule: { lastSuccessAt: "2026-09-01T03:00:00.000Z", lastSuccessAgeDays: 2, diff --git a/src/components/admin/__tests__/backups-section-empty.test.tsx b/src/components/admin/__tests__/backups-section-empty.test.tsx index b1d44b41d..2ae9f676e 100644 --- a/src/components/admin/__tests__/backups-section-empty.test.tsx +++ b/src/components/admin/__tests__/backups-section-empty.test.tsx @@ -19,7 +19,7 @@ const healthySchedule = { const mocks = vi.hoisted(() => ({ // Mutable so one file can render both a healthy schedule and a broken one; // the query mock reads it on every render. - queryData: { rows: [], retentionDays: 30 } as Record, + queryData: { rows: [], preWipeCopyDays: 14 } as Record, })); /** @@ -80,7 +80,7 @@ primeLocales("de"); function render(locale: "en" | "de" = "en") { mocks.queryData = { rows: [], - retentionDays: 30, + preWipeCopyDays: 14, schedule: healthySchedule, }; return renderToStaticMarkup( @@ -159,7 +159,7 @@ describe("BackupsSection — empty state", () => { */ describe("BackupsSection — schedule health", () => { function withSchedule(schedule: Record) { - mocks.queryData = { rows: [], retentionDays: 30, schedule }; + mocks.queryData = { rows: [], preWipeCopyDays: 14, schedule }; return renderToStaticMarkup( @@ -203,7 +203,7 @@ describe("BackupsSection — schedule health", () => { }); it("survives a response that predates the schedule field", () => { - mocks.queryData = { rows: [], retentionDays: 30 }; + mocks.queryData = { rows: [], preWipeCopyDays: 14 }; expect(() => renderToStaticMarkup( diff --git a/src/components/admin/__tests__/offhost-backups-section.test.tsx b/src/components/admin/__tests__/offhost-backups-section.test.tsx index 3631fe121..4a95cd637 100644 --- a/src/components/admin/__tests__/offhost-backups-section.test.tsx +++ b/src/components/admin/__tests__/offhost-backups-section.test.tsx @@ -36,7 +36,7 @@ function render(data: BackupsList["offhost"] | null): string { ? null : ({ rows: [], - retentionDays: 90, + preWipeCopyDays: 14, schedule: null, offhost: data, } as unknown as BackupsList), diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index d8dcb62c2..514fd9034 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -197,9 +197,11 @@ export function BackupsSection() {

- {t("admin.section.backups.retentionLabel", { - days: data!.retentionDays, - })} + {data!.preWipeCopyDays > 0 + ? t("admin.section.backups.retentionNote", { + days: data!.preWipeCopyDays, + }) + : t("admin.section.backups.retentionNoteNoGrace")}

)} diff --git a/src/lib/import/json/schema.ts b/src/lib/import/json/schema.ts index e3f74c92f..d3e06acc0 100644 --- a/src/lib/import/json/schema.ts +++ b/src/lib/import/json/schema.ts @@ -37,7 +37,7 @@ import { SLEEP_STAGE_NAMES } from "@/lib/validations/sleep-stage-entry"; import { fallbackMoodDateStart } from "./mood-instant"; import { JSON_IMPORT_MAX_ROWS } from "./limits"; -export { JSON_IMPORT_MAX_BYTES, JSON_IMPORT_MAX_ROWS } from "./limits"; +export { JSON_IMPORT_MAX_BYTES } from "./limits"; // The type is the canonical enum, so every type the exporters write comes // back; a hand-kept subset once dropped four of eleven. diff --git a/src/types/backups.ts b/src/types/backups.ts index f3c042999..7eacc4f06 100644 --- a/src/types/backups.ts +++ b/src/types/backups.ts @@ -102,11 +102,12 @@ export interface BackupsList { */ offhost: OffhostBackupOverview; /** - * Soft retention hint — the worker is configured for weekly backups - * (see `DATA_BACKUP_CRON` in `src/lib/jobs/reminder-worker.ts`), and - * the model upserts in-place per (userId, type), so each user has - * exactly one current snapshot. The frontend uses this to label the - * grid; no server-side enforcement. + * How long a copy taken before "Delete all data" is kept: the deletion + * grace window, after which the account-deletion purge deletes it. 0 when + * there is no window and no such copy is taken. Nothing else ages a stored + * copy out: the weekly copy is replaced in place, one per account, and an + * uploaded copy stays until its account is erased. The page states exactly + * that. */ - retentionDays: number; + preWipeCopyDays: number; }