fix(admin): say what actually ages a stored account copy out

The backups page stated "Retention: 90 days", from a constant whose
comment said it matched a backup-prune job. There is no such job: nothing
deletes a stored copy by age. The weekly copy is replaced in place, one
per account; the copy taken before "Delete all data" is deleted by the
account-deletion purge when the grace window ends; an uploaded copy stays
until its account is erased.

The list now carries preWipeCopyDays (the grace window, 0 when no such
copy is taken) instead of retentionDays, and the page says exactly the
three facts above, in every language.

Also drops a re-export of the JSON import row limit nothing imports.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 14:28:07 +01:00
1 parent 459b5baa9d
commit 694befd635
17 files changed
+62 -37

No files matched your search

+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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": "위험 구역",
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
@@ -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([
+6 -3
View File
@@ -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)
@@ -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,
@@ -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<string, unknown>,
queryData: { rows: [], preWipeCopyDays: 14 } as Record<string, unknown>,
}));
/**
@@ -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<string, unknown>) {
mocks.queryData = { rows: [], retentionDays: 30, schedule };
mocks.queryData = { rows: [], preWipeCopyDays: 14, schedule };
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<BackupsSection />
@@ -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(
<I18nProvider initialLocale="en">
@@ -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),
+5 -3
View File
@@ -197,9 +197,11 @@ export function BackupsSection() {
<BackupRowsCards rows={rows} {...rowActions} />
<p className="text-muted-foreground mt-2 text-xs">
{t("admin.section.backups.retentionLabel", {
days: data!.retentionDays,
})}
{data!.preWipeCopyDays > 0
? t("admin.section.backups.retentionNote", {
days: data!.preWipeCopyDays,
})
: t("admin.section.backups.retentionNoteNoGrace")}
</p>
</div>
)}
+1 -1
View File
@@ -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.
+7 -6
View File
@@ -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;
}