mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(documents): add Recently deleted for deleted documents
Deleting a document only stamps a tombstone and the purge removes it
after 30 days, but the only way back was the undo toast, gone a few
seconds after the delete. Recently deleted lists the deleted documents
of the record with type, filing date, where an import came from, when
each was deleted and how long it has left, and offers Restore, Restore
all and Delete now.
GET /api/documents/inbound/deleted lists them (owner-only, capped at 200
with the list-cap meta). POST /api/documents/inbound/{id}/purge removes
one deleted document for good through the same function the nightly
purge runs, so its import keys and aliases still move into the ledger
and an importer re-sending it is still answered "deleted"; a live
document answers 409 documents.inbound.notDeleted. The usage read gains
deletedCount, 0 under a grant. The restore route and bulk restore
share one restore, which now also clears ledger entries for the
document's own keys.
The vault header shows a quiet "Recently deleted (n)" link only while
something is in it, and the delete toasts and confirmations say that
deleted documents stay there for 30 days. The purge's queue facts and
grace window move to document-purge-queue.ts so the worker registry
still loads none of the work.
Refs #1275
This commit is contained in:
1 parent
1ea14e9100
commit
4752115d7d
44 files changed
+2277
-270
No files matched your search
+202
-11
@@ -19558,9 +19558,10 @@ paths:
|
||||
tags:
|
||||
- Documents
|
||||
summary: Restore a discarded document
|
||||
description: Clears the soft-delete tombstone within the 30-day undo grace. 409 when the tombstone is already purged
|
||||
(the undo window is over) or when an identical live document blocks the restore (`meta.reason =
|
||||
"duplicateExists"`). Restoring an already-live document is a no-op success.
|
||||
description: Clears the soft-delete tombstone within the 30-day undo grace, and forgets any import-ledger entry for the
|
||||
document's source keys. 409 when the tombstone is already purged (the undo window is over, or the document was
|
||||
deleted for good) or when an identical live document blocks the restore (`meta.reason = "duplicateExists"`).
|
||||
Restoring an already-live document is a no-op success.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -19584,6 +19585,60 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/documents/inbound/deleted:
|
||||
get:
|
||||
tags:
|
||||
- Documents
|
||||
summary: List Recently deleted
|
||||
description: "The caller's deleted documents that the purge has not removed yet, newest deletion first, metadata only.
|
||||
Each carries `purgeAt`, when the daily purge becomes due for it. Restore one with `POST
|
||||
/api/documents/inbound/{id}/restore` (or the bulk `restore` action); remove one for good ahead of time with
|
||||
`POST /api/documents/inbound/{id}/purge`. Capped at 200 rows (`meta.truncated`, `meta.limit`). Owner-only:
|
||||
refused while acting inside somebody else's record."
|
||||
responses:
|
||||
"200":
|
||||
description: Recently deleted.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeletedDocumentListEnvelope"
|
||||
"401": *a1
|
||||
"403": *a9
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/documents/inbound/{id}/purge:
|
||||
post:
|
||||
tags:
|
||||
- Documents
|
||||
summary: Delete a deleted document for good
|
||||
description: "Removes one deleted document now instead of when the grace window ends, through the same purge the daily
|
||||
job runs: the row and everything that cascades from it go, and an imported document's source keys are kept in
|
||||
the import ledger, so an importer re-sending it is still answered as deleted. Only a deleted document can be
|
||||
purged: a live one answers 409 `documents.inbound.notDeleted`. 404 `documents.inbound.notFound` when the caller
|
||||
has no such document. Owner-only."
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Removed for good.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DocumentPurgeResultEnvelope"
|
||||
"401": *a1
|
||||
"403": *a9
|
||||
"409":
|
||||
description: The document is not deleted.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/documents/inbound/{id}/original:
|
||||
get:
|
||||
tags:
|
||||
@@ -38386,14 +38441,15 @@ components:
|
||||
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
|
||||
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
|
||||
documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery,
|
||||
documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound,
|
||||
documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfNeedsAnthropic,
|
||||
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
|
||||
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit,
|
||||
documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound,
|
||||
documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
|
||||
documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
|
||||
documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notDeleted,
|
||||
documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit,
|
||||
documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported,
|
||||
documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate,
|
||||
documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy,
|
||||
documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused,
|
||||
documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress,
|
||||
documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound,
|
||||
documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
|
||||
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
|
||||
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
|
||||
@@ -79845,6 +79901,13 @@ components:
|
||||
- indexedCount
|
||||
- totalCount
|
||||
additionalProperties: false
|
||||
deletedCount:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: How many of the caller's documents are deleted and not yet purged (Recently deleted, `GET
|
||||
/api/documents/inbound/deleted`). Always 0 while acting inside somebody else's record, where Recently
|
||||
deleted is not offered.
|
||||
required:
|
||||
- usedBytes
|
||||
- quotaBytes
|
||||
@@ -79854,6 +79917,7 @@ components:
|
||||
- linkedProcedures
|
||||
- assistAvailable
|
||||
- contentIndex
|
||||
- deletedCount
|
||||
additionalProperties: false
|
||||
DocumentsLayoutEnvelope:
|
||||
type: object
|
||||
@@ -80369,6 +80433,133 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
DeletedDocumentListEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/DeletedDocumentList"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
$ref: "#/components/schemas/ListCapMeta"
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
- meta
|
||||
additionalProperties: false
|
||||
DeletedDocumentList:
|
||||
type: object
|
||||
properties:
|
||||
documents:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DeletedDocument"
|
||||
graceDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Days a deleted document stays restorable.
|
||||
required:
|
||||
- documents
|
||||
- graceDays
|
||||
additionalProperties: false
|
||||
DeletedDocument:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- REFERRAL
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- SICK_NOTE
|
||||
- OTHER
|
||||
title:
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
filename:
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
mimeType:
|
||||
type: string
|
||||
byteSize:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
documentDate:
|
||||
description: User-set filing date (YYYY-MM-DD), or null.
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
sourceSystem:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- PAPERLESS
|
||||
- PAPRA
|
||||
- OTHER
|
||||
- type: "null"
|
||||
description: The system an imported document came from, or null for one added in HealthLog.
|
||||
deletedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
purgeAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: "When the daily purge becomes due for this document: `deletedAt` plus `graceDays`. The purge runs once a
|
||||
day, so the document is removed at the first run after this instant."
|
||||
required:
|
||||
- id
|
||||
- kind
|
||||
- title
|
||||
- filename
|
||||
- mimeType
|
||||
- byteSize
|
||||
- documentDate
|
||||
- sourceSystem
|
||||
- deletedAt
|
||||
- purgeAt
|
||||
additionalProperties: false
|
||||
DocumentPurgeResultEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/DocumentPurgeResult"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
DocumentPurgeResult:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
purged:
|
||||
type: boolean
|
||||
const: true
|
||||
required:
|
||||
- id
|
||||
- purged
|
||||
additionalProperties: false
|
||||
InboundExtractEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+27
-2
@@ -11651,7 +11651,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Bereits gespeichert. Das vorhandene Dokument ist hervorgehoben.",
|
||||
"deleted": "Dokument gelöscht",
|
||||
"restoreFailed": "Das Dokument konnte nicht wiederhergestellt werden."
|
||||
"restoreFailed": "Das Dokument konnte nicht wiederhergestellt werden.",
|
||||
"deletedHint": "Gelöschte Dokumente bleiben {days} Tage unter Zuletzt gelöscht."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Mehr wird geladen…",
|
||||
@@ -11946,8 +11947,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "Dieses Dokument löschen?",
|
||||
"titleMany": "{count} Dokumente löschen?",
|
||||
"body": "Direkt danach lässt sich das noch rückgängig machen. Nach 30 Tagen wird es endgültig entfernt.",
|
||||
"body": "Gelöschte Dokumente bleiben {days} Tage unter Zuletzt gelöscht, danach werden sie endgültig entfernt.",
|
||||
"action": "Löschen"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Zuletzt gelöscht",
|
||||
"subtitle": "Gelöschte Dokumente bleiben {days} Tage hier, danach werden sie endgültig entfernt.",
|
||||
"back": "Dokumente",
|
||||
"entryLabel": "Zuletzt gelöscht ({count})",
|
||||
"loadError": "Zuletzt gelöscht konnte nicht geladen werden.",
|
||||
"emptyTitle": "Nichts unter Zuletzt gelöscht",
|
||||
"emptyDescription": "Was du löschst, wartet hier {days} Tage, bevor es endgültig entfernt wird.",
|
||||
"count": "Gelöschte Dokumente ({count})",
|
||||
"restoreAll": "Alle wiederherstellen",
|
||||
"restore": "Wiederherstellen",
|
||||
"purge": "Jetzt löschen",
|
||||
"deletedOn": "Gelöscht am {date}.",
|
||||
"removesIn": "Wird in {days} Tagen endgültig entfernt.",
|
||||
"removesSoon": "Wird innerhalb eines Tages endgültig entfernt.",
|
||||
"restored": "Dokument wiederhergestellt",
|
||||
"openRestored": "Öffnen",
|
||||
"restoreDuplicate": "Ein identisches Dokument ist schon gespeichert, deshalb kann diese Kopie nicht wiederhergestellt werden.",
|
||||
"restorePartial": "{failed} von {total} konnten nicht wiederhergestellt werden",
|
||||
"purged": "Dokument endgültig gelöscht",
|
||||
"purgeFailed": "Das Dokument konnte nicht gelöscht werden.",
|
||||
"purgeConfirmTitle": "Dieses Dokument endgültig löschen?",
|
||||
"purgeConfirmBody": "Es wird jetzt entfernt statt am Ende der Wartezeit und lässt sich nicht wiederherstellen."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+27
-2
@@ -11651,7 +11651,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Already stored. The existing document is highlighted.",
|
||||
"deleted": "Document deleted",
|
||||
"restoreFailed": "Couldn't restore the document."
|
||||
"restoreFailed": "Couldn't restore the document.",
|
||||
"deletedHint": "Deleted documents stay in Recently deleted for {days} days."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Loading more…",
|
||||
@@ -11946,8 +11947,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "Delete this document?",
|
||||
"titleMany": "Delete {count} documents?",
|
||||
"body": "You can undo this right afterwards. After 30 days it is removed for good.",
|
||||
"body": "Deleted documents stay in Recently deleted for {days} days, then they are removed for good.",
|
||||
"action": "Delete"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Recently deleted",
|
||||
"subtitle": "Deleted documents stay here for {days} days, then they are removed for good.",
|
||||
"back": "Documents",
|
||||
"entryLabel": "Recently deleted ({count})",
|
||||
"loadError": "Couldn't load Recently deleted.",
|
||||
"emptyTitle": "Nothing in Recently deleted",
|
||||
"emptyDescription": "Documents you delete wait here for {days} days before they are removed for good.",
|
||||
"count": "Deleted documents ({count})",
|
||||
"restoreAll": "Restore all",
|
||||
"restore": "Restore",
|
||||
"purge": "Delete now",
|
||||
"deletedOn": "Deleted {date}.",
|
||||
"removesIn": "Removed for good in {days} days.",
|
||||
"removesSoon": "Removed for good within a day.",
|
||||
"restored": "Document restored",
|
||||
"openRestored": "Open",
|
||||
"restoreDuplicate": "An identical document is already stored, so this copy cannot be restored.",
|
||||
"restorePartial": "{failed} of {total} could not be restored",
|
||||
"purged": "Document deleted for good",
|
||||
"purgeFailed": "Couldn't delete the document.",
|
||||
"purgeConfirmTitle": "Delete this document for good?",
|
||||
"purgeConfirmBody": "It is removed now instead of at the end of the waiting period, and cannot be restored."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+27
-2
@@ -11651,7 +11651,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Ya estaba guardado. Se resalta el documento existente.",
|
||||
"deleted": "Documento eliminado",
|
||||
"restoreFailed": "No se pudo restaurar el documento."
|
||||
"restoreFailed": "No se pudo restaurar el documento.",
|
||||
"deletedHint": "Los documentos eliminados se quedan en Eliminados recientemente durante {days} días."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Cargando más…",
|
||||
@@ -11946,8 +11947,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "¿Eliminar este documento?",
|
||||
"titleMany": "¿Eliminar {count} documentos?",
|
||||
"body": "Puedes deshacerlo justo después. A los 30 días se elimina definitivamente.",
|
||||
"body": "Los documentos eliminados se quedan en Eliminados recientemente durante {days} días; después se eliminan definitivamente.",
|
||||
"action": "Eliminar"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Eliminados recientemente",
|
||||
"subtitle": "Los documentos eliminados se quedan aquí {days} días; después se eliminan definitivamente.",
|
||||
"back": "Documentos",
|
||||
"entryLabel": "Eliminados recientemente ({count})",
|
||||
"loadError": "No se pudo cargar Eliminados recientemente.",
|
||||
"emptyTitle": "No hay nada en Eliminados recientemente",
|
||||
"emptyDescription": "Lo que eliminas espera aquí {days} días antes de eliminarse definitivamente.",
|
||||
"count": "Documentos eliminados ({count})",
|
||||
"restoreAll": "Restaurar todo",
|
||||
"restore": "Restaurar",
|
||||
"purge": "Eliminar ahora",
|
||||
"deletedOn": "Eliminado el {date}.",
|
||||
"removesIn": "Se elimina definitivamente en {days} días.",
|
||||
"removesSoon": "Se elimina definitivamente en menos de un día.",
|
||||
"restored": "Documento restaurado",
|
||||
"openRestored": "Abrir",
|
||||
"restoreDuplicate": "Ya hay un documento idéntico guardado, así que esta copia no se puede restaurar.",
|
||||
"restorePartial": "No se pudieron restaurar {failed} de {total}",
|
||||
"purged": "Documento eliminado definitivamente",
|
||||
"purgeFailed": "No se pudo eliminar el documento.",
|
||||
"purgeConfirmTitle": "¿Eliminar este documento definitivamente?",
|
||||
"purgeConfirmBody": "Se elimina ahora en lugar de al final del periodo de espera y no se puede restaurar."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+27
-2
@@ -11651,7 +11651,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Déjà enregistré. Le document existant est mis en évidence.",
|
||||
"deleted": "Document supprimé",
|
||||
"restoreFailed": "Impossible de restaurer le document."
|
||||
"restoreFailed": "Impossible de restaurer le document.",
|
||||
"deletedHint": "Les documents supprimés restent {days} jours dans Supprimés récemment."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Chargement…",
|
||||
@@ -11946,8 +11947,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "Supprimer ce document ?",
|
||||
"titleMany": "Supprimer {count} documents ?",
|
||||
"body": "Vous pouvez annuler juste après. Au bout de 30 jours, la suppression est définitive.",
|
||||
"body": "Les documents supprimés restent {days} jours dans Supprimés récemment, puis ils sont supprimés définitivement.",
|
||||
"action": "Supprimer"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Supprimés récemment",
|
||||
"subtitle": "Les documents supprimés restent ici {days} jours, puis ils sont supprimés définitivement.",
|
||||
"back": "Documents",
|
||||
"entryLabel": "Supprimés récemment ({count})",
|
||||
"loadError": "Impossible de charger Supprimés récemment.",
|
||||
"emptyTitle": "Rien dans Supprimés récemment",
|
||||
"emptyDescription": "Ce que vous supprimez attend ici {days} jours avant d'être supprimé définitivement.",
|
||||
"count": "Documents supprimés ({count})",
|
||||
"restoreAll": "Tout restaurer",
|
||||
"restore": "Restaurer",
|
||||
"purge": "Supprimer maintenant",
|
||||
"deletedOn": "Supprimé le {date}.",
|
||||
"removesIn": "Supprimé définitivement dans {days} jours.",
|
||||
"removesSoon": "Supprimé définitivement dans moins d'un jour.",
|
||||
"restored": "Document restauré",
|
||||
"openRestored": "Ouvrir",
|
||||
"restoreDuplicate": "Un document identique est déjà enregistré, cette copie ne peut donc pas être restaurée.",
|
||||
"restorePartial": "{failed} sur {total} n'ont pas pu être restaurés",
|
||||
"purged": "Document supprimé définitivement",
|
||||
"purgeFailed": "Impossible de supprimer le document.",
|
||||
"purgeConfirmTitle": "Supprimer ce document définitivement ?",
|
||||
"purgeConfirmBody": "Il est supprimé maintenant plutôt qu'à la fin du délai d'attente et ne peut pas être restauré."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+27
-2
@@ -11651,7 +11651,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Già archiviato. Il documento esistente è evidenziato.",
|
||||
"deleted": "Documento eliminato",
|
||||
"restoreFailed": "Impossibile ripristinare il documento."
|
||||
"restoreFailed": "Impossibile ripristinare il documento.",
|
||||
"deletedHint": "I documenti eliminati restano in Eliminati di recente per {days} giorni."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Caricamento…",
|
||||
@@ -11946,8 +11947,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "Eliminare questo documento?",
|
||||
"titleMany": "Eliminare {count} documenti?",
|
||||
"body": "Puoi annullare subito dopo. Dopo 30 giorni viene rimosso definitivamente.",
|
||||
"body": "I documenti eliminati restano in Eliminati di recente per {days} giorni, poi vengono rimossi definitivamente.",
|
||||
"action": "Elimina"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Eliminati di recente",
|
||||
"subtitle": "I documenti eliminati restano qui per {days} giorni, poi vengono rimossi definitivamente.",
|
||||
"back": "Documenti",
|
||||
"entryLabel": "Eliminati di recente ({count})",
|
||||
"loadError": "Impossibile caricare Eliminati di recente.",
|
||||
"emptyTitle": "Niente in Eliminati di recente",
|
||||
"emptyDescription": "Ciò che elimini resta qui per {days} giorni prima di essere rimosso definitivamente.",
|
||||
"count": "Documenti eliminati ({count})",
|
||||
"restoreAll": "Ripristina tutto",
|
||||
"restore": "Ripristina",
|
||||
"purge": "Elimina ora",
|
||||
"deletedOn": "Eliminato il {date}.",
|
||||
"removesIn": "Rimosso definitivamente tra {days} giorni.",
|
||||
"removesSoon": "Rimosso definitivamente entro un giorno.",
|
||||
"restored": "Documento ripristinato",
|
||||
"openRestored": "Apri",
|
||||
"restoreDuplicate": "Un documento identico è già salvato, quindi questa copia non può essere ripristinata.",
|
||||
"restorePartial": "{failed} su {total} non sono stati ripristinati",
|
||||
"purged": "Documento eliminato definitivamente",
|
||||
"purgeFailed": "Impossibile eliminare il documento.",
|
||||
"purgeConfirmTitle": "Eliminare definitivamente questo documento?",
|
||||
"purgeConfirmBody": "Viene rimosso ora anziché alla fine del periodo di attesa e non può essere ripristinato."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+27
-2
@@ -11651,7 +11651,8 @@
|
||||
"toast": {
|
||||
"duplicate": "이미 저장돼 있어요. 기존 문서를 표시해요.",
|
||||
"deleted": "문서를 삭제했어요",
|
||||
"restoreFailed": "문서를 복구하지 못했어요."
|
||||
"restoreFailed": "문서를 복구하지 못했어요.",
|
||||
"deletedHint": "삭제한 문서는 {days}일 동안 최근 삭제됨에 남아 있어요."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "더 불러오는 중…",
|
||||
@@ -11946,8 +11947,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "이 문서를 삭제할까요?",
|
||||
"titleMany": "문서 {count}개를 삭제할까요?",
|
||||
"body": "삭제 직후에는 되돌릴 수 있습니다. 30일이 지나면 완전히 삭제됩니다.",
|
||||
"body": "삭제한 문서는 {days}일 동안 최근 삭제됨에 남아 있다가 완전히 삭제됩니다.",
|
||||
"action": "삭제"
|
||||
},
|
||||
"trash": {
|
||||
"title": "최근 삭제됨",
|
||||
"subtitle": "삭제한 문서는 {days}일 동안 여기 남아 있다가 완전히 삭제됩니다.",
|
||||
"back": "문서",
|
||||
"entryLabel": "최근 삭제됨 ({count})",
|
||||
"loadError": "최근 삭제됨을 불러오지 못했어요.",
|
||||
"emptyTitle": "최근 삭제됨이 비어 있어요",
|
||||
"emptyDescription": "삭제한 문서는 완전히 삭제되기 전까지 {days}일 동안 여기서 기다려요.",
|
||||
"count": "삭제한 문서 ({count})",
|
||||
"restoreAll": "모두 복구",
|
||||
"restore": "복구",
|
||||
"purge": "지금 삭제",
|
||||
"deletedOn": "{date}에 삭제함.",
|
||||
"removesIn": "{days}일 후 완전히 삭제됩니다.",
|
||||
"removesSoon": "하루 안에 완전히 삭제됩니다.",
|
||||
"restored": "문서를 복구했어요",
|
||||
"openRestored": "열기",
|
||||
"restoreDuplicate": "같은 문서가 이미 저장되어 있어서 이 사본은 복구할 수 없어요.",
|
||||
"restorePartial": "{total}개 중 {failed}개를 복구하지 못했어요",
|
||||
"purged": "문서를 완전히 삭제했어요",
|
||||
"purgeFailed": "문서를 삭제하지 못했어요.",
|
||||
"purgeConfirmTitle": "이 문서를 완전히 삭제할까요?",
|
||||
"purgeConfirmBody": "대기 기간이 끝나기 전에 지금 삭제되며 복구할 수 없습니다."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+27
-2
@@ -11651,7 +11651,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Al opgeslagen. Het bestaande document is gemarkeerd.",
|
||||
"deleted": "Document verwijderd",
|
||||
"restoreFailed": "Het document kon niet worden hersteld."
|
||||
"restoreFailed": "Het document kon niet worden hersteld.",
|
||||
"deletedHint": "Verwijderde documenten blijven {days} dagen in Recent verwijderd."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Meer laden…",
|
||||
@@ -11946,8 +11947,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "Dit document verwijderen?",
|
||||
"titleMany": "{count} documenten verwijderen?",
|
||||
"body": "Je kunt dit direct daarna ongedaan maken. Na 30 dagen wordt het definitief verwijderd.",
|
||||
"body": "Verwijderde documenten blijven {days} dagen in Recent verwijderd en worden daarna definitief verwijderd.",
|
||||
"action": "Verwijderen"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Recent verwijderd",
|
||||
"subtitle": "Verwijderde documenten blijven hier {days} dagen en worden daarna definitief verwijderd.",
|
||||
"back": "Documenten",
|
||||
"entryLabel": "Recent verwijderd ({count})",
|
||||
"loadError": "Recent verwijderd kon niet worden geladen.",
|
||||
"emptyTitle": "Niets in Recent verwijderd",
|
||||
"emptyDescription": "Wat je verwijdert, wacht hier {days} dagen voordat het definitief wordt verwijderd.",
|
||||
"count": "Verwijderde documenten ({count})",
|
||||
"restoreAll": "Alles herstellen",
|
||||
"restore": "Herstellen",
|
||||
"purge": "Nu verwijderen",
|
||||
"deletedOn": "Verwijderd op {date}.",
|
||||
"removesIn": "Wordt over {days} dagen definitief verwijderd.",
|
||||
"removesSoon": "Wordt binnen een dag definitief verwijderd.",
|
||||
"restored": "Document hersteld",
|
||||
"openRestored": "Openen",
|
||||
"restoreDuplicate": "Er is al een identiek document opgeslagen, dus deze kopie kan niet worden hersteld.",
|
||||
"restorePartial": "{failed} van {total} konden niet worden hersteld",
|
||||
"purged": "Document definitief verwijderd",
|
||||
"purgeFailed": "Het document kon niet worden verwijderd.",
|
||||
"purgeConfirmTitle": "Dit document definitief verwijderen?",
|
||||
"purgeConfirmBody": "Het wordt nu verwijderd in plaats van aan het einde van de wachttijd en kan niet worden hersteld."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+27
-2
@@ -11651,7 +11651,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Już zapisano. Istniejący dokument został wyróżniony.",
|
||||
"deleted": "Dokument usunięty",
|
||||
"restoreFailed": "Nie udało się przywrócić dokumentu."
|
||||
"restoreFailed": "Nie udało się przywrócić dokumentu.",
|
||||
"deletedHint": "Usunięte dokumenty zostają w sekcji Ostatnio usunięte przez {days} dni."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Wczytywanie…",
|
||||
@@ -11946,8 +11947,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "Usunąć ten dokument?",
|
||||
"titleMany": "Usunąć dokumenty ({count})?",
|
||||
"body": "Zaraz potem możesz to cofnąć. Po 30 dniach zostanie usunięte na stałe.",
|
||||
"body": "Usunięte dokumenty zostają w sekcji Ostatnio usunięte przez {days} dni, a potem są usuwane na stałe.",
|
||||
"action": "Usuń"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Ostatnio usunięte",
|
||||
"subtitle": "Usunięte dokumenty zostają tu przez {days} dni, a potem są usuwane na stałe.",
|
||||
"back": "Dokumenty",
|
||||
"entryLabel": "Ostatnio usunięte ({count})",
|
||||
"loadError": "Nie udało się wczytać sekcji Ostatnio usunięte.",
|
||||
"emptyTitle": "Brak elementów w sekcji Ostatnio usunięte",
|
||||
"emptyDescription": "To, co usuniesz, czeka tu {days} dni, zanim zostanie usunięte na stałe.",
|
||||
"count": "Usunięte dokumenty ({count})",
|
||||
"restoreAll": "Przywróć wszystkie",
|
||||
"restore": "Przywróć",
|
||||
"purge": "Usuń teraz",
|
||||
"deletedOn": "Usunięto {date}.",
|
||||
"removesIn": "Zostanie usunięty na stałe za {days} dni.",
|
||||
"removesSoon": "Zostanie usunięty na stałe w ciągu doby.",
|
||||
"restored": "Przywrócono dokument",
|
||||
"openRestored": "Otwórz",
|
||||
"restoreDuplicate": "Identyczny dokument jest już zapisany, więc tej kopii nie można przywrócić.",
|
||||
"restorePartial": "Nie udało się przywrócić {failed} z {total}",
|
||||
"purged": "Dokument usunięty na stałe",
|
||||
"purgeFailed": "Nie udało się usunąć dokumentu.",
|
||||
"purgeConfirmTitle": "Usunąć ten dokument na stałe?",
|
||||
"purgeConfirmBody": "Zostanie usunięty teraz zamiast po okresie oczekiwania i nie będzie można go przywrócić."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+27
-2
@@ -11651,7 +11651,8 @@
|
||||
"toast": {
|
||||
"duplicate": "Já armazenado. O documento existente está destacado.",
|
||||
"deleted": "Documento excluído",
|
||||
"restoreFailed": "Não foi possível restaurar o documento."
|
||||
"restoreFailed": "Não foi possível restaurar o documento.",
|
||||
"deletedHint": "Documentos excluídos ficam em Excluídos recentemente por {days} dias."
|
||||
},
|
||||
"timeline": {
|
||||
"loadingMore": "Carregando mais…",
|
||||
@@ -11946,8 +11947,32 @@
|
||||
"deleteConfirm": {
|
||||
"titleOne": "Excluir este documento?",
|
||||
"titleMany": "Excluir {count} documentos?",
|
||||
"body": "Você pode desfazer isso logo depois. Após 30 dias, é removido para sempre.",
|
||||
"body": "Documentos excluídos ficam em Excluídos recentemente por {days} dias e depois são removidos para sempre.",
|
||||
"action": "Excluir"
|
||||
},
|
||||
"trash": {
|
||||
"title": "Excluídos recentemente",
|
||||
"subtitle": "Documentos excluídos ficam aqui por {days} dias e depois são removidos para sempre.",
|
||||
"back": "Documentos",
|
||||
"entryLabel": "Excluídos recentemente ({count})",
|
||||
"loadError": "Não foi possível carregar Excluídos recentemente.",
|
||||
"emptyTitle": "Nada em Excluídos recentemente",
|
||||
"emptyDescription": "O que você exclui espera aqui por {days} dias antes de ser removido para sempre.",
|
||||
"count": "Documentos excluídos ({count})",
|
||||
"restoreAll": "Restaurar todos",
|
||||
"restore": "Restaurar",
|
||||
"purge": "Excluir agora",
|
||||
"deletedOn": "Excluído em {date}.",
|
||||
"removesIn": "Removido para sempre em {days} dias.",
|
||||
"removesSoon": "Removido para sempre em menos de um dia.",
|
||||
"restored": "Documento restaurado",
|
||||
"openRestored": "Abrir",
|
||||
"restoreDuplicate": "Já existe um documento idêntico salvo, então esta cópia não pode ser restaurada.",
|
||||
"restorePartial": "{failed} de {total} não puderam ser restaurados",
|
||||
"purged": "Documento excluído para sempre",
|
||||
"purgeFailed": "Não foi possível excluir o documento.",
|
||||
"purgeConfirmTitle": "Excluir este documento para sempre?",
|
||||
"purgeConfirmBody": "Ele é removido agora, e não no fim do período de espera, e não pode ser restaurado."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 88,
|
||||
"total": 98310,
|
||||
"total": 98280,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
@@ -87,7 +87,7 @@
|
||||
"lib/sharing/grants.ts": 807,
|
||||
"lib/support/report.ts": 903,
|
||||
"lib/telegram-webhook-handlers.ts": 1341,
|
||||
"lib/validations/inbound-documents.ts": 1048,
|
||||
"lib/validations/inbound-documents.ts": 1018,
|
||||
"lib/withings/sync-sleep.ts": 884,
|
||||
"lib/withings/sync.ts": 933,
|
||||
"proxy.ts": 870
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 352,
|
||||
"total": 113949,
|
||||
"count": 351,
|
||||
"total": 113742,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
|
||||
@@ -16,7 +16,6 @@
|
||||
"app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213,
|
||||
"app/api/cycle/insights/route.ts :: apiHandler(arg)": 226,
|
||||
"app/api/devices/route.ts :: apiHandler(arg)": 226,
|
||||
"app/api/documents/inbound/bulk/route.ts :: apiHandler(arg)": 207,
|
||||
"app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 269,
|
||||
"app/api/environment/route.ts :: apiHandler(arg)": 201,
|
||||
"app/api/export/health-record/route.ts :: apiHandler(arg)": 241,
|
||||
|
||||
@@ -627,10 +627,18 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
{
|
||||
control: "bulk delete, and the Delete/Backspace key on a focused card",
|
||||
reason:
|
||||
"Same undo-not-confirmation choice as the detail sheet, and the bulk path raises one aggregate undo for the whole batch. The keyboard path is the weakest link in that argument, since a keystroke needs no aim and the undo lives only in a toast with no trash view behind it; it is recorded here rather than silently accepted, and closing it means giving documents a recovery surface, not a dialog.",
|
||||
"Same undo-not-confirmation choice as the detail sheet, and the bulk path raises one aggregate undo for the whole batch. The keyboard path was the weakest link in that argument while the undo lived only in a toast; Recently deleted (/documents/deleted) is now the recovery surface behind it, where every deleted document can be restored for 30 days.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/documents/trash/use-recently-deleted.ts",
|
||||
destroys:
|
||||
"one already-deleted document, for good, ahead of the 30-day purge (Delete now in Recently deleted)",
|
||||
triggers: ["components/documents/trash/recently-deleted-view.tsx"],
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmDialog"],
|
||||
},
|
||||
|
||||
// ── Account sharing ─────────────────────────────────────────────────────
|
||||
{
|
||||
|
||||
@@ -192,6 +192,13 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/documents/vault/use-vault-bulk.ts": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
// Recently deleted: the one restore's toast, which carries an "Open"
|
||||
// action the shared outcome toast has no slot for. It fires only on the
|
||||
// restore route's 200 for that one document, i.e. a document back in the
|
||||
// vault. Restore-all and delete-now go through toastWrittenOutcome.
|
||||
"src/components/documents/trash/use-recently-deleted.ts": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
// The document picker's per-row result: tinted only for the `imported` /
|
||||
// `duplicate` outcome the import route returned for that one document, i.e.
|
||||
// a document the vault now holds. The run's headline goes through
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Document vault: remove one deleted document for good, now.
|
||||
*
|
||||
* "Delete now" in Recently deleted. The daily job purges a deleted document
|
||||
* once the grace window has passed; this does the same for one document
|
||||
* ahead of time, through the same function (`purgeDocumentTombstones` in
|
||||
* `src/lib/documents/tombstones.ts`): the row and everything that cascades
|
||||
* from it go, and an imported document's source keys are carried into the
|
||||
* import ledger in the same transaction, so a re-run of the importer does not
|
||||
* bring it back.
|
||||
*
|
||||
* Only a deleted document can be purged: a live one answers 409
|
||||
* `documents.inbound.notDeleted`, so no single request takes a document from
|
||||
* the vault straight out of the database. Owner-only, like delete and restore.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { purgeDocumentTombstoneNow } from "@/lib/documents/tombstones";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import type { DocumentPurgeDto } from "@/lib/validations/inbound-documents";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Shares the write bucket of the other owner-scoped document mutations. */
|
||||
const WRITE_LIMIT_PER_HOUR = 240;
|
||||
const WRITE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-write:${user.id}`,
|
||||
WRITE_LIMIT_PER_HOUR,
|
||||
WRITE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const row = await prisma.inboundDocument.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
select: { id: true, deletedAt: true },
|
||||
});
|
||||
if (!row) {
|
||||
return apiError("Document not found", 404, {
|
||||
errorCode: "documents.inbound.notFound",
|
||||
});
|
||||
}
|
||||
if (row.deletedAt === null) {
|
||||
return apiError("Only a deleted document can be removed for good.", 409, {
|
||||
errorCode: "documents.inbound.notDeleted",
|
||||
});
|
||||
}
|
||||
|
||||
const purged = await purgeDocumentTombstoneNow(user.id, row.id);
|
||||
if (!purged) {
|
||||
// Lost a race between the read above and the purge: a restore brought
|
||||
// it back (409, as above), or the daily purge took it first, which is
|
||||
// what was asked for.
|
||||
const now = await prisma.inboundDocument.findFirst({
|
||||
where: { id: row.id, userId: user.id },
|
||||
select: { deletedAt: true },
|
||||
});
|
||||
if (now && now.deletedAt === null) {
|
||||
return apiError(
|
||||
"Only a deleted document can be removed for good.",
|
||||
409,
|
||||
{ errorCode: "documents.inbound.notDeleted" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("documents.inbound.purge", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { documentId: row.id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.trash.purge" },
|
||||
meta: { documentId: row.id, raced: !purged },
|
||||
});
|
||||
|
||||
const body: DocumentPurgeDto = { id: row.id, purged: true };
|
||||
return apiSuccess(body);
|
||||
},
|
||||
);
|
||||
@@ -8,6 +8,8 @@
|
||||
* indistinguishable from never-existed by construction, and owner-scoped
|
||||
* either way) or when restoring would collide with a live duplicate of the
|
||||
* same bytes (the `inbound_documents_user_sha_live` partial unique index).
|
||||
* The restore itself is the shared one (`src/lib/documents/tombstones.ts`):
|
||||
* it also clears any import-ledger entry for the document's source keys.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -20,11 +22,10 @@ import {
|
||||
loadDocumentEncounterLinks,
|
||||
} from "@/lib/documents/links";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import { restoreDocumentTombstone } from "@/lib/documents/tombstones";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import type { InboundDocumentStatus } from "@/generated/prisma/client";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -34,18 +35,6 @@ export const dynamic = "force-dynamic";
|
||||
const WRITE_LIMIT_PER_HOUR = 240;
|
||||
const WRITE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Post-restore lifecycle status, derived from the staged facts: no facts →
|
||||
* STORED, any PENDING → EXTRACTED, otherwise every fact was acted on →
|
||||
* CONFIRMED. (DELETE stamped DISCARDED; the prior status is not persisted.)
|
||||
*/
|
||||
function restoredStatus(
|
||||
facts: Array<{ status: string }>,
|
||||
): InboundDocumentStatus {
|
||||
if (facts.length === 0) return "STORED";
|
||||
return facts.some((f) => f.status === "PENDING") ? "EXTRACTED" : "CONFIRMED";
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -68,80 +57,26 @@ export const POST = apiHandler(
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const row = await prisma.inboundDocument.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
omit: { contentEncrypted: true },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
// Purged (past the 30-day grace) or never existed — the tombstone is
|
||||
// gone either way, so the undo window is over: 409, not 404.
|
||||
const outcome = await restoreDocumentTombstone(user.id, id);
|
||||
if (outcome.kind === "gone") {
|
||||
// Purged (past the 30-day grace, or deleted now from Recently deleted)
|
||||
// or never existed — the tombstone is gone either way, so the undo
|
||||
// window is over: 409, not 404.
|
||||
return apiError("This document can no longer be restored.", 409, {
|
||||
errorCode: "documents.inbound.restoreGone",
|
||||
reason: "purged",
|
||||
});
|
||||
}
|
||||
|
||||
if (row.deletedAt === null) {
|
||||
// Already live — restoring twice is a no-op, not an error (the undo
|
||||
// toast may fire after a concurrent restore).
|
||||
const [links, visitLinks] = await Promise.all([
|
||||
loadConditionLinks(user.id, [row.id]),
|
||||
loadDocumentEncounterLinks(user.id, [row.id]),
|
||||
]);
|
||||
return apiSuccess(
|
||||
serialiseDocumentDetail(
|
||||
row,
|
||||
row.facts,
|
||||
links.get(row.id) ?? [],
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
visitLinks.get(row.id) ?? [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// A live duplicate of the same bytes blocks the restore (the partial
|
||||
// unique index would reject it; check first for a clean 409).
|
||||
if (row.contentSha256) {
|
||||
const conflict = await prisma.inboundDocument.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
contentSha256: row.contentSha256,
|
||||
deletedAt: null,
|
||||
id: { not: row.id },
|
||||
},
|
||||
select: { id: true },
|
||||
if (outcome.kind === "duplicate") {
|
||||
return apiError("An identical document is already stored.", 409, {
|
||||
errorCode: "documents.inbound.restoreDuplicate",
|
||||
reason: "duplicateExists",
|
||||
...(outcome.existingId ? { existingId: outcome.existingId } : {}),
|
||||
});
|
||||
if (conflict) {
|
||||
return apiError("An identical document is already stored.", 409, {
|
||||
errorCode: "documents.inbound.restoreDuplicate",
|
||||
reason: "duplicateExists",
|
||||
existingId: conflict.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.inboundDocument.update({
|
||||
where: { id: row.id },
|
||||
data: { deletedAt: null, status: restoredStatus(row.facts) },
|
||||
});
|
||||
} catch (err) {
|
||||
if (isP2002(err)) {
|
||||
// Raced a concurrent identical upload into the partial unique index.
|
||||
return apiError("An identical document is already stored.", 409, {
|
||||
errorCode: "documents.inbound.restoreDuplicate",
|
||||
reason: "duplicateExists",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const restored = await prisma.inboundDocument.findFirstOrThrow({
|
||||
where: { id: row.id },
|
||||
where: { id, userId: user.id },
|
||||
omit: { contentEncrypted: true },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
@@ -150,15 +85,19 @@ export const POST = apiHandler(
|
||||
loadDocumentEncounterLinks(user.id, [restored.id]),
|
||||
]);
|
||||
|
||||
await auditLog("documents.inbound.restore", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { documentId: restored.id },
|
||||
});
|
||||
// Restoring an already-live document is a no-op success (the undo toast
|
||||
// may fire after a concurrent restore); only a real restore is audited.
|
||||
if (outcome.kind === "restored") {
|
||||
await auditLog("documents.inbound.restore", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { documentId: restored.id },
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.vault.restore" },
|
||||
meta: { documentId: restored.id },
|
||||
meta: { documentId: restored.id, alreadyLive: outcome.kind === "live" },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
|
||||
@@ -17,6 +17,7 @@ import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { restoreDocumentTombstone } from "@/lib/documents/tombstones";
|
||||
import { linkTargets, unlinkTargets } from "@/lib/links";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -95,7 +96,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// then never touches a row the caller does not own.
|
||||
const owned = await prisma.inboundDocument.findMany({
|
||||
where: { id: { in: uniqueIds }, userId: user.id },
|
||||
select: { id: true, deletedAt: true, contentSha256: true },
|
||||
select: { id: true, deletedAt: true },
|
||||
});
|
||||
const byId = new Map(owned.map((d) => [d.id, d]));
|
||||
|
||||
@@ -182,39 +183,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
break;
|
||||
}
|
||||
case "restore": {
|
||||
if (row.deletedAt === null) {
|
||||
// Already live — restoring twice is a no-op success.
|
||||
break;
|
||||
// The shared restore: the duplicate check, the status the staged
|
||||
// facts imply, and the import-ledger cleanup. Already live is a
|
||||
// no-op success.
|
||||
const outcome = await restoreDocumentTombstone(user.id, id);
|
||||
if (outcome.kind === "gone") {
|
||||
results.push({ id, ok: false, error: "notFound" });
|
||||
continue;
|
||||
}
|
||||
if (row.contentSha256) {
|
||||
const conflict = await prisma.inboundDocument.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
contentSha256: row.contentSha256,
|
||||
deletedAt: null,
|
||||
id: { not: id },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (conflict) {
|
||||
results.push({ id, ok: false, error: "conflict" });
|
||||
continue;
|
||||
}
|
||||
if (outcome.kind === "duplicate") {
|
||||
results.push({ id, ok: false, error: "conflict" });
|
||||
continue;
|
||||
}
|
||||
const facts = await prisma.extractedFact.findMany({
|
||||
where: { documentId: id, userId: user.id },
|
||||
select: { status: true },
|
||||
});
|
||||
const status =
|
||||
facts.length === 0
|
||||
? "STORED"
|
||||
: facts.some((f) => f.status === "PENDING")
|
||||
? "EXTRACTED"
|
||||
: "CONFIRMED";
|
||||
await prisma.inboundDocument.update({
|
||||
where: { id },
|
||||
data: { deletedAt: null, status },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Document vault: Recently deleted.
|
||||
*
|
||||
* GET lists the caller's deleted documents that the purge has not removed yet,
|
||||
* newest deletion first, each with the moment it becomes due for the purge.
|
||||
* Restoring one is `POST /api/documents/inbound/{id}/restore` (or the bulk
|
||||
* route's `restore`); removing one for good ahead of time is
|
||||
* `POST /api/documents/inbound/{id}/purge`.
|
||||
*
|
||||
* Owner-only, like the restore and delete beside it: a deleted document is the
|
||||
* owner's decision in progress, and nothing a delegate may do (restore, delete
|
||||
* now) is a verb a grant admits. A bare `requireAuth()` therefore refuses the
|
||||
* request while it acts inside somebody else's record, and refuses a narrow
|
||||
* token.
|
||||
*
|
||||
* Metadata only. The encrypted blob is never selected; a deleted document's
|
||||
* original, thumbnail and AI surfaces stay closed until it is restored.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants";
|
||||
import { documentPurgeAt } from "@/lib/documents/tombstones";
|
||||
import { listPage } from "@/lib/http/list-query";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import {
|
||||
toDocumentSourceSystem,
|
||||
type DeletedDocumentListDto,
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Rows per answer. Thirty days of deletions rarely come near it; a person who
|
||||
* cleared out a large import gets the newest ones and the list-cap notice.
|
||||
*/
|
||||
const DELETED_DOCUMENTS_LIMIT = 200;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { rows, cap } = await listPage(DELETED_DOCUMENTS_LIMIT, (take) =>
|
||||
prisma.inboundDocument.findMany({
|
||||
where: { userId: user.id, deletedAt: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
title: true,
|
||||
filename: true,
|
||||
mimeType: true,
|
||||
byteSize: true,
|
||||
documentDate: true,
|
||||
sourceSystem: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
orderBy: [{ deletedAt: "desc" }, { id: "asc" }],
|
||||
take,
|
||||
}),
|
||||
);
|
||||
|
||||
const payload: DeletedDocumentListDto = {
|
||||
graceDays: DOCUMENT_PURGE_GRACE_DAYS,
|
||||
documents: rows.map((row) => {
|
||||
// Selected under `deletedAt: { not: null }`.
|
||||
const deletedAt = row.deletedAt as Date;
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as InboundDocumentKindValue,
|
||||
title: row.title,
|
||||
filename: row.filename,
|
||||
mimeType: row.mimeType,
|
||||
byteSize: row.byteSize,
|
||||
documentDate: row.documentDate ? dateOnlyKey(row.documentDate) : null,
|
||||
sourceSystem: toDocumentSourceSystem(row.sourceSystem),
|
||||
deletedAt: deletedAt.toISOString(),
|
||||
purgeAt: documentPurgeAt(deletedAt).toISOString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.trash.list" },
|
||||
meta: { count: rows.length, truncated: cap.truncated },
|
||||
});
|
||||
|
||||
return apiListSuccess(payload, cap);
|
||||
});
|
||||
@@ -57,6 +57,7 @@ export const GET = apiHandler(async () => {
|
||||
capability,
|
||||
indexedCount,
|
||||
totalCount,
|
||||
deletedCount,
|
||||
] = await Promise.all([
|
||||
resolveDocumentLimits(user.id),
|
||||
prisma.$queryRaw<Array<{ used: bigint }>>`
|
||||
@@ -93,6 +94,15 @@ export const GET = apiHandler(async () => {
|
||||
prisma.inboundDocument.count({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
}),
|
||||
// How many sit in Recently deleted — the vault's way in to it shows only
|
||||
// when there is something there. Recently deleted is the owner's own
|
||||
// (restore and delete-now are owner-only writes), so a delegate gets 0
|
||||
// and the vault offers them no way in.
|
||||
grantId === null
|
||||
? prisma.inboundDocument.count({
|
||||
where: { userId: user.id, deletedAt: { not: null } },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
const usedBytes = Number(rows[0]?.used ?? 0);
|
||||
|
||||
@@ -121,6 +131,7 @@ export const GET = apiHandler(async () => {
|
||||
indexedCount,
|
||||
totalCount,
|
||||
},
|
||||
deletedCount,
|
||||
};
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { RecentlyDeletedView } from "@/components/documents/trash/recently-deleted-view";
|
||||
|
||||
/**
|
||||
* Recently deleted — server shell only. The client surface owns the module
|
||||
* gate, the owner-only redirect and the list; the AuthShell owns the frame.
|
||||
*/
|
||||
export default function RecentlyDeletedPage() {
|
||||
return <RecentlyDeletedView />;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
apiPatch,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
@@ -304,6 +305,9 @@ function useDocumentWrites({
|
||||
onOpenChange(false);
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
toast.success(t("documents.toast.deleted"), {
|
||||
description: t("documents.toast.deletedHint", {
|
||||
days: DOCUMENT_PURGE_GRACE_DAYS,
|
||||
}),
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () => restore.mutate(id),
|
||||
|
||||
@@ -26,6 +26,7 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion";
|
||||
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants";
|
||||
import { formatBytes } from "@/lib/format/bytes";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { RelatedLink } from "@/lib/links/related";
|
||||
@@ -45,6 +46,9 @@ import { DocumentRecordLinks } from "./document-record-links";
|
||||
import { DocumentShareSheet } from "./document-share-sheet";
|
||||
import { DocumentSummaryBlock } from "./document-summary-block";
|
||||
|
||||
/** The grace window the delete confirmation names. */
|
||||
const GRACE = { days: DOCUMENT_PURGE_GRACE_DAYS };
|
||||
|
||||
export function DocumentDetailSheet({
|
||||
documentId,
|
||||
open,
|
||||
@@ -359,7 +363,7 @@ export function DocumentDetailSheet({
|
||||
open={confirmDeleteOpen}
|
||||
onOpenChange={setConfirmDeleteOpen}
|
||||
title={t("documents.deleteConfirm.titleOne")}
|
||||
body={t("documents.deleteConfirm.body")}
|
||||
body={t("documents.deleteConfirm.body", GRACE)}
|
||||
confirmLabel={t("documents.deleteConfirm.action")}
|
||||
onConfirm={() => remove.mutate(doc.id)}
|
||||
pending={remove.isPending}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
@@ -46,6 +47,9 @@ import { useVaultSelection } from "./vault/use-vault-selection";
|
||||
import { VaultDropOverlay } from "./vault/vault-drop-overlay";
|
||||
import { VaultPageActions } from "./vault/vault-page-actions";
|
||||
|
||||
/** The grace window the delete confirmation names. */
|
||||
const GRACE = { days: DOCUMENT_PURGE_GRACE_DAYS };
|
||||
|
||||
/**
|
||||
* The vault page. Its state lives in `./vault/`: the URL filters, the
|
||||
* library (list, usage, uploads, filter choices), the selection and its bulk
|
||||
@@ -334,7 +338,7 @@ export function DocumentsView() {
|
||||
})
|
||||
: t("documents.deleteConfirm.titleOne")
|
||||
}
|
||||
body={t("documents.deleteConfirm.body")}
|
||||
body={t("documents.deleteConfirm.body", GRACE)}
|
||||
confirmLabel={t("documents.deleteConfirm.action")}
|
||||
onConfirm={confirmDelete}
|
||||
slot="documents-delete"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createRef } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Recently deleted and the vault's way into it, pinned via single-pass static
|
||||
* renders (the project's component-test convention):
|
||||
*
|
||||
* - the page paints a skeleton while the list is out, never the empty copy;
|
||||
* - each row says what the document was, where it came from, when it was
|
||||
* deleted and how long it has left, and offers Restore and Delete now;
|
||||
* - Restore all appears only with more than one document;
|
||||
* - an empty list says so and leads back to the vault;
|
||||
* - the vault header links to Recently deleted with the count, and only
|
||||
* while something is in it.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/documents/deleted",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules: { inboundDocuments: true } },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
// Never resolves within the synchronous render: an unseeded query stays
|
||||
// pending.
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiGetList: () => new Promise(() => {}),
|
||||
apiPost: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { DeletedDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
|
||||
import { VaultPageActions } from "../../vault/vault-page-actions";
|
||||
import { RecentlyDeletedView } from "../recently-deleted-view";
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
function doc(over: Partial<DeletedDocumentDto> = {}): DeletedDocumentDto {
|
||||
const deletedAt = new Date(Date.now() - 3 * DAY);
|
||||
return {
|
||||
id: "doc-1",
|
||||
kind: "LAB_RESULT",
|
||||
title: "Blood panel",
|
||||
filename: "blood.pdf",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1234,
|
||||
documentDate: "2024-03-04",
|
||||
sourceSystem: "PAPERLESS",
|
||||
deletedAt: deletedAt.toISOString(),
|
||||
purgeAt: new Date(deletedAt.getTime() + 30 * DAY).toISOString(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, seed?: (qc: QueryClient) => void) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
seed?.(queryClient);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function seedList(documents: DeletedDocumentDto[], truncated = false) {
|
||||
return (qc: QueryClient) =>
|
||||
qc.setQueryData(queryKeys.inboundDocumentsDeleted(), {
|
||||
data: { documents, graceDays: 30 },
|
||||
cap: { truncated, limit: 200 },
|
||||
});
|
||||
}
|
||||
|
||||
describe("<RecentlyDeletedView>", () => {
|
||||
it("first load: header and a skeleton, never the empty copy", () => {
|
||||
const html = render(<RecentlyDeletedView />);
|
||||
expect(html).toContain("Recently deleted");
|
||||
expect(html).toContain(
|
||||
"Deleted documents stay here for 30 days, then they are removed for good.",
|
||||
);
|
||||
expect(html).toContain('data-slot="documents-trash-loading"');
|
||||
expect(html).toContain('href="/documents"');
|
||||
expect(html).not.toContain("Nothing in Recently deleted");
|
||||
});
|
||||
|
||||
it("a row: what it was, where it came from, how long it has left, both ways out", () => {
|
||||
const html = render(<RecentlyDeletedView />, seedList([doc()]));
|
||||
expect(html).toContain('data-slot="documents-trash-card"');
|
||||
expect(html).toContain("Deleted documents (1)");
|
||||
expect(html).toContain("Blood panel");
|
||||
expect(html).toContain("Lab result");
|
||||
expect(html).toContain("Imported from Paperless-ngx");
|
||||
expect(html).toContain("Removed for good in 27 days.");
|
||||
expect(html).toContain('data-slot="documents-trash-restore"');
|
||||
expect(html).toContain('data-slot="documents-trash-purge"');
|
||||
expect(html).toContain("Delete now");
|
||||
// One document: nothing to restore "all" of.
|
||||
expect(html).not.toContain('data-slot="documents-trash-restore-all"');
|
||||
});
|
||||
|
||||
it("falls back to the file name, and says the last day plainly", () => {
|
||||
const html = render(
|
||||
<RecentlyDeletedView />,
|
||||
seedList([
|
||||
doc({
|
||||
title: null,
|
||||
sourceSystem: null,
|
||||
purgeAt: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(html).toContain("blood.pdf");
|
||||
expect(html).not.toContain("Imported from");
|
||||
expect(html).toContain("Removed for good within a day.");
|
||||
});
|
||||
|
||||
it("offers Restore all with more than one document, and the cap notice when cut", () => {
|
||||
const html = render(
|
||||
<RecentlyDeletedView />,
|
||||
seedList([doc(), doc({ id: "doc-2", title: "X-ray" })], true),
|
||||
);
|
||||
expect(html).toContain('data-slot="documents-trash-restore-all"');
|
||||
expect(html).toContain("Restore all");
|
||||
expect(html).toContain("Deleted documents (2)");
|
||||
expect(html).toContain('data-slot="list-cap-notice"');
|
||||
});
|
||||
|
||||
it("empty: says so and leads back to the vault", () => {
|
||||
const html = render(<RecentlyDeletedView />, seedList([]));
|
||||
expect(html).toContain("Nothing in Recently deleted");
|
||||
expect(html).not.toContain('data-slot="documents-trash-card"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<VaultPageActions> — the way into Recently deleted", () => {
|
||||
const actions = (inSharedRecord = false) => (
|
||||
<VaultPageActions
|
||||
canManageDocuments={!inSharedRecord}
|
||||
inSharedRecord={inSharedRecord}
|
||||
hideUpload={false}
|
||||
uploadInputRef={createRef<HTMLInputElement>()}
|
||||
/>
|
||||
);
|
||||
const seedUsage = (deletedCount: number) => (qc: QueryClient) =>
|
||||
qc.setQueryData(queryKeys.inboundDocumentUsage(), { deletedCount });
|
||||
|
||||
it("links to Recently deleted with the count while something is in it", () => {
|
||||
const html = render(actions(), seedUsage(3));
|
||||
expect(html).toContain('data-slot="documents-recently-deleted"');
|
||||
expect(html).toContain('href="/documents/deleted"');
|
||||
expect(html).toContain('aria-label="Recently deleted (3)"');
|
||||
expect(html).toContain("(3)");
|
||||
});
|
||||
|
||||
it("shows nothing while Recently deleted is empty or the usage is out", () => {
|
||||
expect(render(actions(), seedUsage(0))).not.toContain(
|
||||
"documents-recently-deleted",
|
||||
);
|
||||
expect(render(actions())).not.toContain("documents-recently-deleted");
|
||||
});
|
||||
|
||||
it("shows nothing inside somebody else's record", () => {
|
||||
expect(render(actions(true), seedUsage(3))).not.toContain(
|
||||
"documents-recently-deleted",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { daysUntilPurge, purgeCountdownKey } from "../trash-utils";
|
||||
|
||||
const NOW = new Date("2026-10-24T12:00:00Z");
|
||||
|
||||
describe("daysUntilPurge", () => {
|
||||
it("rounds a part day up: the whole of it is still restorable", () => {
|
||||
expect(daysUntilPurge("2026-11-20T15:00:00Z", NOW)).toBe(28);
|
||||
expect(daysUntilPurge("2026-10-25T11:00:00Z", NOW)).toBe(1);
|
||||
expect(daysUntilPurge("2026-10-24T12:00:01Z", NOW)).toBe(1);
|
||||
});
|
||||
|
||||
it("is zero once the moment has passed, and for an unreadable date", () => {
|
||||
expect(daysUntilPurge("2026-10-24T12:00:00Z", NOW)).toBe(0);
|
||||
expect(daysUntilPurge("2026-10-01T00:00:00Z", NOW)).toBe(0);
|
||||
expect(daysUntilPurge("not a date", NOW)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgeCountdownKey", () => {
|
||||
it("names the days while there are two or more", () => {
|
||||
expect(purgeCountdownKey(30)).toEqual({
|
||||
key: "documents.trash.removesIn",
|
||||
params: { days: 30 },
|
||||
});
|
||||
expect(purgeCountdownKey(2)).toEqual({
|
||||
key: "documents.trash.removesIn",
|
||||
params: { days: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("says within a day for the last one, and after the moment passed", () => {
|
||||
expect(purgeCountdownKey(1)).toEqual({
|
||||
key: "documents.trash.removesSoon",
|
||||
});
|
||||
expect(purgeCountdownKey(0)).toEqual({
|
||||
key: "documents.trash.removesSoon",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Recently deleted — the vault's deleted documents, until the nightly purge
|
||||
* removes them for good.
|
||||
*
|
||||
* Deleting in the vault only sets a tombstone; for 30 days the document can
|
||||
* come back. Before this page the only way back was the undo toast, which is
|
||||
* gone a few seconds after the delete. Each row says what the document was
|
||||
* (title, type, filing date, where it came from), when it was deleted and how
|
||||
* long it has left, and offers Restore and "Delete now". Restoring all of them
|
||||
* is one action in the card header.
|
||||
*
|
||||
* Owner-only, like every route behind it: inside somebody else's record the
|
||||
* page sends the person back to the vault, which offers no way in either.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, RotateCcw, Trash2 } from "lucide-react";
|
||||
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { ListCapNotice } from "@/components/ui/list-cap-notice";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
DeletedDocumentDto,
|
||||
DocumentSourceSystemValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
import { DOCUMENT_KIND_ICONS } from "../document-kind-meta";
|
||||
|
||||
import { daysUntilPurge, purgeCountdownKey } from "./trash-utils";
|
||||
import { useRecentlyDeleted } from "./use-recently-deleted";
|
||||
|
||||
export function RecentlyDeletedView() {
|
||||
const { t } = useTranslations();
|
||||
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
const moduleEnabled = user?.modules?.inboundDocuments === true;
|
||||
const ready = !authLoading && isAuthenticated && moduleEnabled;
|
||||
|
||||
// UX redirects only; every route behind the page enforces the same.
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!isAuthenticated) router.push("/auth/login");
|
||||
else if (!moduleEnabled) router.push("/");
|
||||
else if (inSharedRecord) router.push("/documents");
|
||||
}, [authLoading, isAuthenticated, moduleEnabled, inSharedRecord, router]);
|
||||
|
||||
const { list, restore, restoreAll, purge } = useRecentlyDeleted({
|
||||
enabled: ready && !inSharedRecord,
|
||||
});
|
||||
const [pendingPurge, setPendingPurge] = useState<DeletedDocumentDto | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (!ready || inSharedRecord) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center" role="status">
|
||||
<Loader2
|
||||
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only">{t("nav.loadingScreen")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const documents = list.data?.data.documents ?? [];
|
||||
const graceDays = list.data?.data.graceDays ?? DOCUMENT_PURGE_GRACE_DAYS;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t("documents.trash.title")}
|
||||
description={t("documents.trash.subtitle", { days: graceDays })}
|
||||
backLink={{
|
||||
href: "/documents",
|
||||
label: t("documents.trash.back"),
|
||||
dataSlot: "documents-trash-back",
|
||||
}}
|
||||
/>
|
||||
|
||||
{list.isPending ? (
|
||||
<div data-slot="documents-trash-loading" className="space-y-4">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : list.isError ? (
|
||||
<QueryErrorCard
|
||||
error={list.error}
|
||||
title={t("documents.trash.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
) : documents.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Trash2 className="size-6" aria-hidden />}
|
||||
title={t("documents.trash.emptyTitle")}
|
||||
description={t("documents.trash.emptyDescription", {
|
||||
days: graceDays,
|
||||
})}
|
||||
action={
|
||||
<Button asChild variant="outline" className="min-h-11 sm:min-h-9">
|
||||
<Link href="/documents">{t("documents.trash.back")}</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Card data-slot="documents-trash-card">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Trash2}
|
||||
title={t("documents.trash.count", { count: documents.length })}
|
||||
right={
|
||||
documents.length > 1 ? (
|
||||
<InlineAction
|
||||
size="sm"
|
||||
data-slot="documents-trash-restore-all"
|
||||
disabled={restoreAll.isPending}
|
||||
onClick={() =>
|
||||
restoreAll.mutate(documents.map((d) => d.id))
|
||||
}
|
||||
>
|
||||
<RotateCcw aria-hidden />
|
||||
{t("documents.trash.restoreAll")}
|
||||
</InlineAction>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<ul className="divide-border divide-y border-t">
|
||||
{documents.map((doc) => (
|
||||
<DeletedRow
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
busy={
|
||||
restoreAll.isPending ||
|
||||
(restore.isPending && restore.variables === doc.id) ||
|
||||
(purge.isPending && purge.variables === doc.id)
|
||||
}
|
||||
onRestore={() => restore.mutate(doc.id)}
|
||||
onPurge={() => setPendingPurge(doc)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<ListCapNotice cap={list.data?.cap} className="px-4 md:px-6" />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingPurge !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !purge.isPending) setPendingPurge(null);
|
||||
}}
|
||||
title={t("documents.trash.purgeConfirmTitle")}
|
||||
body={t("documents.trash.purgeConfirmBody")}
|
||||
confirmLabel={t("documents.trash.purge")}
|
||||
pending={purge.isPending}
|
||||
holdOpen
|
||||
destructive
|
||||
slot="documents-trash-purge"
|
||||
onConfirm={() => {
|
||||
if (!pendingPurge) return;
|
||||
purge.mutate(pendingPurge.id, {
|
||||
onSettled: () => setPendingPurge(null),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Product names stay untranslated; only "another system" is prose. */
|
||||
function sourceName(
|
||||
system: DocumentSourceSystemValue,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (system === "PAPERLESS") return "Paperless-ngx";
|
||||
if (system === "PAPRA") return "Papra";
|
||||
return t("documents.detail.sourceOther");
|
||||
}
|
||||
|
||||
/** One deleted document: what it was, how long it has left, the two ways out. */
|
||||
export function DeletedRow({
|
||||
doc,
|
||||
busy,
|
||||
onRestore,
|
||||
onPurge,
|
||||
}: {
|
||||
doc: DeletedDocumentDto;
|
||||
busy: boolean;
|
||||
onRestore: () => void;
|
||||
onPurge: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
|
||||
const title = doc.title ?? doc.filename ?? t("documents.card.untitled");
|
||||
const about = [
|
||||
t(`documents.kind.${doc.kind}`),
|
||||
doc.documentDate ? fmt.dateShortSmartCalendar(doc.documentDate) : null,
|
||||
doc.sourceSystem
|
||||
? t("documents.detail.importedFrom", {
|
||||
system: sourceName(doc.sourceSystem, t),
|
||||
})
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const countdown = purgeCountdownKey(daysUntilPurge(doc.purgeAt));
|
||||
|
||||
return (
|
||||
<li
|
||||
data-slot="documents-trash-row"
|
||||
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center md:px-6"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-sm font-medium break-words">{title}</p>
|
||||
<p className="text-muted-foreground text-xs">{about}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.trash.deletedOn", {
|
||||
date: fmt.dateShortSmart(doc.deletedAt),
|
||||
})}{" "}
|
||||
{t(countdown.key, countdown.params)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="documents-trash-restore"
|
||||
disabled={busy}
|
||||
onClick={onRestore}
|
||||
>
|
||||
<RotateCcw className="size-4" aria-hidden />
|
||||
{t("documents.trash.restore")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="documents-trash-purge"
|
||||
disabled={busy}
|
||||
onClick={onPurge}
|
||||
>
|
||||
{t("documents.trash.purge")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* How long a document in Recently deleted has left, in whole days, read from
|
||||
* the server's `purgeAt`.
|
||||
*
|
||||
* Rounded up: a document due in 26 days and 3 hours still has 27 days in which
|
||||
* it can be restored. Zero once the moment has passed; the nightly purge then
|
||||
* takes it at its next run.
|
||||
*/
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export function daysUntilPurge(
|
||||
purgeAt: string,
|
||||
now: Date = new Date(),
|
||||
): number {
|
||||
const left = new Date(purgeAt).getTime() - now.getTime();
|
||||
if (!Number.isFinite(left) || left <= 0) return 0;
|
||||
return Math.ceil(left / DAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* The translation key and parameters for a row's countdown line. The purge
|
||||
* runs once a night, so the last day is said as "within a day" rather than a
|
||||
* time the job does not keep.
|
||||
*/
|
||||
export function purgeCountdownKey(days: number): {
|
||||
key: "documents.trash.removesIn" | "documents.trash.removesSoon";
|
||||
params?: { days: number };
|
||||
} {
|
||||
if (days <= 1) return { key: "documents.trash.removesSoon" };
|
||||
return { key: "documents.trash.removesIn", params: { days } };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { ApiError, apiGetList, apiPost } from "@/lib/api/api-fetch";
|
||||
import { DOCUMENT_BULK_MAX_IDS } from "@/lib/documents/inbound-constants";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
DeletedDocumentListDto,
|
||||
DocumentBulkResultDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* Recently deleted: the list, and the three ways out of it. Restore brings a
|
||||
* document back (one at a time, or all through the bulk route); "Delete now"
|
||||
* removes one for good ahead of the grace window. Every write invalidates the
|
||||
* `["documents"]` prefix, so the vault, its usage figures and this list repaint
|
||||
* together.
|
||||
*/
|
||||
export function useRecentlyDeleted({ enabled }: { enabled: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentsDeleted(),
|
||||
queryFn: () =>
|
||||
apiGetList<DeletedDocumentListDto>("/api/documents/inbound/deleted"),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const refresh = useCallback(
|
||||
() => void invalidateKeys(queryClient, [queryKeys.documents()]),
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const restore = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPost(`/api/documents/inbound/${id}/restore`, {}),
|
||||
onSuccess: (_data, id) => {
|
||||
refresh();
|
||||
toast.success(t("documents.trash.restored"), {
|
||||
action: {
|
||||
label: t("documents.trash.openRestored"),
|
||||
onClick: () =>
|
||||
router.push(`/documents?doc=${encodeURIComponent(id)}`),
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
refresh();
|
||||
const reason =
|
||||
error instanceof ApiError && typeof error.meta?.reason === "string"
|
||||
? error.meta.reason
|
||||
: "";
|
||||
if (reason === "purged") {
|
||||
showErrorToast(t("documents.error.purged"), { cause: error });
|
||||
} else if (reason === "duplicateExists") {
|
||||
showErrorToast(t("documents.trash.restoreDuplicate"), {
|
||||
cause: error,
|
||||
});
|
||||
} else {
|
||||
showErrorToast(t("documents.toast.restoreFailed"), { cause: error });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const restoreAll = useMutation({
|
||||
mutationFn: async (ids: string[]) => {
|
||||
const results: DocumentBulkResultDto[] = [];
|
||||
for (let i = 0; i < ids.length; i += DOCUMENT_BULK_MAX_IDS) {
|
||||
const page = await apiPost<{ results: DocumentBulkResultDto[] }>(
|
||||
"/api/documents/inbound/bulk",
|
||||
{ ids: ids.slice(i, i + DOCUMENT_BULK_MAX_IDS), action: "restore" },
|
||||
);
|
||||
results.push(...page.results);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
onSuccess: (results) => {
|
||||
const restored = results.filter((r) => r.ok).length;
|
||||
const failed = results.length - restored;
|
||||
toastWrittenOutcome(
|
||||
failed === 0 ? "success" : restored > 0 ? "partial" : "failed",
|
||||
failed === 0
|
||||
? t("documents.bulk.restored", { count: restored })
|
||||
: t("documents.trash.restorePartial", {
|
||||
failed,
|
||||
total: results.length,
|
||||
}),
|
||||
);
|
||||
},
|
||||
onError: (error) =>
|
||||
showErrorToast(t("documents.toast.restoreFailed"), { cause: error }),
|
||||
onSettled: refresh,
|
||||
});
|
||||
|
||||
const purge = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPost(`/api/documents/inbound/${id}/purge`, {}),
|
||||
onSuccess: () =>
|
||||
toastWrittenOutcome("success", t("documents.trash.purged")),
|
||||
onError: (error) =>
|
||||
showErrorToast(t("documents.trash.purgeFailed"), { cause: error }),
|
||||
onSettled: refresh,
|
||||
});
|
||||
|
||||
return { list, restore, restoreAll, purge };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* The vault's usage read (`GET /api/documents/inbound/usage`): limits, usage,
|
||||
* filter choices, index coverage and the Recently deleted count. One
|
||||
* definition for every reader, so the shared key never meets a second
|
||||
* `queryFn` shape.
|
||||
*/
|
||||
export function useDocumentUsage(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.inboundDocumentUsage(),
|
||||
enabled,
|
||||
queryFn: () => apiGet<DocumentUsageDto>("/api/documents/inbound/usage"),
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { DOCUMENT_BULK_MAX_IDS } from "@/lib/documents/inbound-constants";
|
||||
import {
|
||||
DOCUMENT_BULK_MAX_IDS,
|
||||
DOCUMENT_PURGE_GRACE_DAYS,
|
||||
} from "@/lib/documents/inbound-constants";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
@@ -155,6 +158,9 @@ export function useVaultBulk({
|
||||
toast.success(
|
||||
t("documents.bulk.deleted", { count: okIds.length }),
|
||||
{
|
||||
description: t("documents.toast.deletedHint", {
|
||||
days: DOCUMENT_PURGE_GRACE_DAYS,
|
||||
}),
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () => restoreBulk(okIds),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useIllnessEpisodes } from "@/components/illness/use-illness";
|
||||
@@ -11,16 +11,14 @@ import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useDocumentsLayout } from "@/lib/queries/use-documents-layout";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
DocumentUsageDto,
|
||||
InboundDocumentDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
|
||||
import { useReindexAll } from "../use-content-index";
|
||||
import { useDocumentUpload } from "../use-document-upload";
|
||||
import { usePageFileDrop } from "../use-page-file-drop";
|
||||
import { buildVaultListApiSearch, hasProcessingDocument } from "../vault-utils";
|
||||
|
||||
import { useDocumentUsage } from "./use-document-usage";
|
||||
import type { VaultFilters } from "./use-vault-filters";
|
||||
import { useVaultFilterChoices } from "./use-vault-filter-choices";
|
||||
|
||||
@@ -51,11 +49,7 @@ export function useVaultLibrary({
|
||||
timezone: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const usage = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentUsage(),
|
||||
enabled: moduleEnabled,
|
||||
queryFn: () => apiGet<DocumentUsageDto>("/api/documents/inbound/usage"),
|
||||
});
|
||||
const usage = useDocumentUsage(moduleEnabled);
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: queryKeys.inboundDocumentList(filters),
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import Link from "next/link";
|
||||
import { Upload, Wrench } from "lucide-react";
|
||||
import { Trash2, Upload, Wrench } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { ImportFromSourceButton } from "../sources/import-from-source-button";
|
||||
import { useDocumentUsage } from "./use-document-usage";
|
||||
|
||||
/**
|
||||
* The page header's actions (rendered only when one of them applies): the display-settings wrench (the caller's own
|
||||
* settings, so never inside somebody else's record), the source import and
|
||||
* the upload button, which an empty vault's empty state carries instead.
|
||||
* settings, so never inside somebody else's record), the way into Recently
|
||||
* deleted (only while something is in it), the source import and the upload
|
||||
* button, which an empty vault's empty state carries instead.
|
||||
*/
|
||||
export function VaultPageActions({
|
||||
canManageDocuments,
|
||||
@@ -26,6 +28,9 @@ export function VaultPageActions({
|
||||
uploadInputRef: RefObject<HTMLInputElement | null>;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
// The vault's own usage read (shared key): how many sit in Recently
|
||||
// deleted. The way in shows only above zero.
|
||||
const deletedCount = useDocumentUsage(true).data?.deletedCount ?? 0;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* The wrench left of the primary action, as on the illness
|
||||
@@ -49,6 +54,31 @@ export function VaultPageActions({
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{/* Quiet, like the wrench, and only while there is something to
|
||||
bring back: deleted documents wait there for 30 days. On a phone
|
||||
the icon and the count; the words from `sm` up. */}
|
||||
{canManageDocuments && !inSharedRecord && deletedCount > 0 ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 gap-1.5 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/documents/deleted"
|
||||
aria-label={t("documents.trash.entryLabel", {
|
||||
count: deletedCount,
|
||||
})}
|
||||
data-slot="documents-recently-deleted"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{t("documents.trash.title")}
|
||||
</span>
|
||||
<span className="tabular-nums">({deletedCount})</span>
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageDocuments ? (
|
||||
<>
|
||||
{/* Renders only with a connected Paperless-ngx or Papra. */}
|
||||
|
||||
@@ -50,6 +50,9 @@ export const DOCUMENT_LIST_DEFAULT_LIMIT = 50;
|
||||
/** Max document ids one bulk request may touch. */
|
||||
export const DOCUMENT_BULK_MAX_IDS = 100;
|
||||
|
||||
/** Days a deleted document stays in Recently deleted (see the purge queue). */
|
||||
export { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/jobs/document-purge-queue";
|
||||
|
||||
/** Max documents attachable to one coach conversation. Bounds prompt size + the
|
||||
* numeric-grounding union; five labelled documents is already past the point a
|
||||
* chat answer stays useful. */
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* The two ways out of a deleted (tombstoned) vault document: back into the
|
||||
* vault, or out of the database for good.
|
||||
*
|
||||
* Delete in the vault only stamps `deletedAt`. From there a document is either
|
||||
* restored (the undo toast, Recently deleted, the bulk bar, the document
|
||||
* picker) or purged (the daily job once the grace window has passed, or the
|
||||
* person choosing "Delete now" in Recently deleted). Every one of those callers
|
||||
* goes through this module, so a restore and a purge mean the same thing
|
||||
* whoever asks:
|
||||
*
|
||||
* - a purge copies the document's import source keys (its own and every
|
||||
* alias) into `DocumentImportKey` in the same transaction as the delete,
|
||||
* so an importer re-sending the document is still answered "deleted" once
|
||||
* the row is gone;
|
||||
* - a restore removes any `DocumentImportKey` entry for those same keys in
|
||||
* the same transaction as clearing the tombstone. The ledger says "the
|
||||
* person deleted this"; once the document is back, that is no longer true,
|
||||
* and a ledger entry left behind would outlive the document's next delete
|
||||
* and restore with nothing to explain it.
|
||||
*/
|
||||
import type { InboundDocumentStatus, Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** The moment a document deleted at `deletedAt` becomes due for the purge. */
|
||||
export function documentPurgeAt(deletedAt: Date): Date {
|
||||
return new Date(deletedAt.getTime() + DOCUMENT_PURGE_GRACE_DAYS * DAY_MS);
|
||||
}
|
||||
|
||||
interface ImportSourceKey {
|
||||
userId: string;
|
||||
sourceSystem: string;
|
||||
sourceId: string;
|
||||
sourceInstance: string | null;
|
||||
}
|
||||
|
||||
/** The columns a row needs for {@link importKeysOf}. */
|
||||
const SOURCE_KEY_SELECT = {
|
||||
userId: true,
|
||||
sourceSystem: true,
|
||||
sourceId: true,
|
||||
sourceInstance: true,
|
||||
sourceAliases: {
|
||||
select: { sourceSystem: true, sourceId: true, sourceInstance: true },
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface KeyedRow {
|
||||
userId: string;
|
||||
sourceSystem: string | null;
|
||||
sourceId: string | null;
|
||||
sourceInstance: string | null;
|
||||
sourceAliases: Array<{
|
||||
sourceSystem: string;
|
||||
sourceId: string;
|
||||
sourceInstance: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Every import source key a document holds: its own, then its aliases. */
|
||||
function importKeysOf(row: KeyedRow): ImportSourceKey[] {
|
||||
return [
|
||||
...(row.sourceSystem && row.sourceId
|
||||
? [
|
||||
{
|
||||
userId: row.userId,
|
||||
sourceSystem: row.sourceSystem,
|
||||
sourceId: row.sourceId,
|
||||
sourceInstance: row.sourceInstance,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...row.sourceAliases.map((alias) => ({
|
||||
userId: row.userId,
|
||||
sourceSystem: alias.sourceSystem,
|
||||
sourceId: alias.sourceId,
|
||||
sourceInstance: alias.sourceInstance,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-delete the tombstones `where` selects, carrying their import source
|
||||
* keys into the ledger first. Only rows with `deletedAt` set are ever touched,
|
||||
* whatever `where` says. Runs inside the caller's transaction, so a purge that
|
||||
* fails halfway forgets nothing. Returns the number of rows removed.
|
||||
*/
|
||||
export async function purgeDocumentTombstones(
|
||||
tx: Prisma.TransactionClient,
|
||||
where: Prisma.InboundDocumentWhereInput,
|
||||
): Promise<number> {
|
||||
const tombstones: Prisma.InboundDocumentWhereInput = {
|
||||
AND: [where, { deletedAt: { not: null } }],
|
||||
};
|
||||
const keyed = await tx.inboundDocument.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
tombstones,
|
||||
{
|
||||
OR: [
|
||||
{ sourceSystem: { not: null }, sourceId: { not: null } },
|
||||
{ sourceAliases: { some: {} } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
select: SOURCE_KEY_SELECT,
|
||||
});
|
||||
const keys = keyed.flatMap(importKeysOf);
|
||||
if (keys.length > 0) {
|
||||
await tx.documentImportKey.createMany({ data: keys, skipDuplicates: true });
|
||||
}
|
||||
const { count } = await tx.inboundDocument.deleteMany({ where: tombstones });
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge one of the person's deleted documents now, ahead of the grace window.
|
||||
* Returns false when there was no such tombstone (never existed, someone
|
||||
* else's, still live, or already gone).
|
||||
*/
|
||||
export async function purgeDocumentTombstoneNow(
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<boolean> {
|
||||
const count = await prisma.$transaction((tx) =>
|
||||
purgeDocumentTombstones(tx, { id, userId }),
|
||||
);
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
export type TombstoneRestoreResult =
|
||||
/** The tombstone was cleared. */
|
||||
| { kind: "restored" }
|
||||
/** The document was live already; restoring twice changes nothing. */
|
||||
| { kind: "live" }
|
||||
/** No such document for this person: purged, or never existed. */
|
||||
| { kind: "gone" }
|
||||
/** A live document with the same bytes holds the slot (null on a race). */
|
||||
| { kind: "duplicate"; existingId: string | null };
|
||||
|
||||
/**
|
||||
* Lifecycle status after a restore, derived from the staged facts: none →
|
||||
* STORED, any PENDING → EXTRACTED, otherwise every fact was acted on →
|
||||
* CONFIRMED. (Delete stamped DISCARDED; the status before it is not kept.)
|
||||
*/
|
||||
function restoredStatus(
|
||||
facts: Array<{ status: string }>,
|
||||
): InboundDocumentStatus {
|
||||
if (facts.length === 0) return "STORED";
|
||||
return facts.some((f) => f.status === "PENDING") ? "EXTRACTED" : "CONFIRMED";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring one of the person's deleted documents back into the vault, and forget
|
||||
* any ledger entry that says its import keys were deleted. Owner-scoped: a
|
||||
* foreign id is `gone`, exactly like a purged one.
|
||||
*/
|
||||
export async function restoreDocumentTombstone(
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<TombstoneRestoreResult> {
|
||||
const row = await prisma.inboundDocument.findFirst({
|
||||
where: { id, userId },
|
||||
select: { id: true, deletedAt: true, contentSha256: true },
|
||||
});
|
||||
if (!row) return { kind: "gone" };
|
||||
if (row.deletedAt === null) return { kind: "live" };
|
||||
|
||||
// A live duplicate of the same bytes blocks the restore (the partial unique
|
||||
// index would reject it; checked first for a clean answer).
|
||||
if (row.contentSha256) {
|
||||
const conflict = await prisma.inboundDocument.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
contentSha256: row.contentSha256,
|
||||
deletedAt: null,
|
||||
id: { not: id },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (conflict) return { kind: "duplicate", existingId: conflict.id };
|
||||
}
|
||||
|
||||
const facts = await prisma.extractedFact.findMany({
|
||||
where: { documentId: id, userId },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
let restored: boolean;
|
||||
try {
|
||||
restored = await prisma.$transaction(async (tx) => {
|
||||
// Guarded on the tombstone, so a purge or a restore that won the race
|
||||
// is not undone or repeated here.
|
||||
const { count } = await tx.inboundDocument.updateMany({
|
||||
where: { id, userId, deletedAt: { not: null } },
|
||||
data: { deletedAt: null, status: restoredStatus(facts) },
|
||||
});
|
||||
if (count === 0) return false;
|
||||
const keyed = await tx.inboundDocument.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: SOURCE_KEY_SELECT,
|
||||
});
|
||||
const keys = importKeysOf(keyed);
|
||||
if (keys.length > 0) {
|
||||
await tx.documentImportKey.deleteMany({ where: { OR: keys } });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
} catch (err) {
|
||||
// Raced a concurrent identical upload into the partial unique index.
|
||||
if (isP2002(err)) return { kind: "duplicate", existingId: null };
|
||||
throw err;
|
||||
}
|
||||
if (restored) return { kind: "restored" };
|
||||
|
||||
const now = await prisma.inboundDocument.findFirst({
|
||||
where: { id, userId },
|
||||
select: { deletedAt: true },
|
||||
});
|
||||
return now && now.deletedAt === null ? { kind: "live" } : { kind: "gone" };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* The payloads of the vault's side reads: the usage and limits the upload path
|
||||
* pre-flights against (`GET /api/documents/inbound/usage`), and Recently
|
||||
* deleted (`GET /api/documents/inbound/deleted`, `POST …/{id}/purge`).
|
||||
* Re-exported from `@/lib/validations/inbound-documents`, where the other
|
||||
* vault DTOs live. Types only.
|
||||
*/
|
||||
import type {
|
||||
DocumentConditionLinkDto,
|
||||
DocumentLinkedProcedureDto,
|
||||
DocumentSourceSystemValue,
|
||||
InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/** The usage endpoint payload the UI reads before offering an upload. */
|
||||
export interface DocumentUsageDto {
|
||||
usedBytes: number;
|
||||
quotaBytes: number;
|
||||
maxFileBytes: number;
|
||||
acceptedExtensions: string[];
|
||||
/**
|
||||
* Episodes carrying at least one LIVE document link — the vault filter
|
||||
* bar's condition chips. Sourced server-side so a chip exists even when
|
||||
* every linked document sits pages deep in the timeline.
|
||||
*/
|
||||
linkedEpisodes: DocumentConditionLinkDto[];
|
||||
/**
|
||||
* Procedures and surgeries (visits of kind PROCEDURE) carrying at least one
|
||||
* LIVE document link — the filter bar's procedure choices, sourced
|
||||
* server-side for the same reason as `linkedEpisodes`. Empty when the
|
||||
* caller's grant does not cover the visits section, because the label is
|
||||
* the visit's own text.
|
||||
*/
|
||||
linkedProcedures: DocumentLinkedProcedureDto[];
|
||||
/**
|
||||
* Whether the AI "Suggest details" action can run for this caller — true when
|
||||
* a provider (vision, or text + local OCR) is configured. The UI hides the
|
||||
* action when false so it never offers what the endpoint would 422.
|
||||
*/
|
||||
assistAvailable: boolean;
|
||||
/**
|
||||
* Content-search index state: whether indexing is available to the caller
|
||||
* (`enabled`, same provider precondition as assist), how many live documents
|
||||
* are indexed, and the live-document total — so the UI can gauge coverage and
|
||||
* offer the "index all documents" backfill.
|
||||
*/
|
||||
contentIndex: {
|
||||
enabled: boolean;
|
||||
indexedCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
/**
|
||||
* How many of the person's documents sit in Recently deleted (deleted, not
|
||||
* yet purged). The vault shows its way into Recently deleted only when this
|
||||
* is above zero. Always 0 while acting inside somebody else's record, where
|
||||
* Recently deleted is not offered.
|
||||
*/
|
||||
deletedCount: number;
|
||||
}
|
||||
|
||||
/** One document in Recently deleted (`GET /api/documents/inbound/deleted`). */
|
||||
export interface DeletedDocumentDto {
|
||||
id: string;
|
||||
kind: InboundDocumentKindValue;
|
||||
title: string | null;
|
||||
filename: string | null;
|
||||
mimeType: string;
|
||||
byteSize: number;
|
||||
/** User-set filing date (YYYY-MM-DD), or null. */
|
||||
documentDate: string | null;
|
||||
/** The system an imported document came from, or null. */
|
||||
sourceSystem: DocumentSourceSystemValue | null;
|
||||
/** When it was deleted (ISO 8601). */
|
||||
deletedAt: string;
|
||||
/**
|
||||
* When the daily purge becomes due for it (ISO 8601): `deletedAt` plus the
|
||||
* grace window. The purge runs once a day, so the row goes at the first run
|
||||
* after this instant.
|
||||
*/
|
||||
purgeAt: string;
|
||||
}
|
||||
|
||||
/** The Recently deleted list, newest deletion first. */
|
||||
export interface DeletedDocumentListDto {
|
||||
documents: DeletedDocumentDto[];
|
||||
/** Days a deleted document stays restorable. */
|
||||
graceDays: number;
|
||||
}
|
||||
|
||||
/** Answer of `POST /api/documents/inbound/{id}/purge`. */
|
||||
export interface DocumentPurgeDto {
|
||||
id: string;
|
||||
purged: true;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The queue facts of `document-purge.ts`: its name, its schedule and the grace
|
||||
* window it enforces. They live apart from the handler so the worker registry
|
||||
* can read them without loading the purge (and the document modules it shares
|
||||
* with "Delete now"). Constants only: client code reads the grace window
|
||||
* through `@/lib/documents/inbound-constants`.
|
||||
*/
|
||||
export const DOCUMENT_PURGE_QUEUE = "document-tombstone-purge";
|
||||
/** Daily at 04:10 Europe/Berlin — after the med-notes backfill tick (04:05). */
|
||||
export const DOCUMENT_PURGE_CRON = "10 4 * * *";
|
||||
|
||||
/**
|
||||
* Days a deleted document stays in Recently deleted (restorable) before the
|
||||
* daily purge removes it for good. The purge, the list of deleted documents
|
||||
* and the copy that names the window all read this one value.
|
||||
*/
|
||||
export const DOCUMENT_PURGE_GRACE_DAYS = 30;
|
||||
@@ -8,20 +8,21 @@
|
||||
* tombstoned document keeps counting against the quota until purged, so
|
||||
* "deleted" bytes are never invisible backup weight). Staged facts cascade
|
||||
* via the FK. Restore-after-purge answers 409 at the API. An imported
|
||||
* document's source key is carried to `DocumentImportKey` on the way out.
|
||||
* document's source key is carried to `DocumentImportKey` on the way out
|
||||
* (`src/lib/documents/tombstones.ts`, shared with "Delete now").
|
||||
*/
|
||||
import { type Job } from "pg-boss";
|
||||
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/jobs/document-purge-queue";
|
||||
import { purgeDocumentTombstones } from "@/lib/documents/tombstones";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { getWorkerPrisma } from "@/lib/jobs/reminder/shared";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
export const DOCUMENT_PURGE_QUEUE = "document-tombstone-purge";
|
||||
/** Daily at 04:10 Europe/Berlin — after the med-notes backfill tick (04:05). */
|
||||
export const DOCUMENT_PURGE_CRON = "10 4 * * *";
|
||||
|
||||
/** Days a tombstoned document stays restorable before the purge claims it. */
|
||||
export const DOCUMENT_PURGE_GRACE_DAYS = 30;
|
||||
export {
|
||||
DOCUMENT_PURGE_CRON,
|
||||
DOCUMENT_PURGE_QUEUE,
|
||||
} from "@/lib/jobs/document-purge-queue";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
@@ -33,66 +34,20 @@ export interface DocumentPurgePayload {
|
||||
* Hard-delete tombstoned `inbound_documents` rows whose `deletedAt` is older
|
||||
* than the grace horizon. Returns the number of rows purged. Rows inside the
|
||||
* grace window and live rows are never touched.
|
||||
*
|
||||
* v1.39.2 (#1038) — an imported document's source key outlives its row: the
|
||||
* shared purge (`purgeDocumentTombstones`, the same one "Delete now" in
|
||||
* Recently deleted runs) copies it into the ledger in the same transaction as
|
||||
* the delete, so a re-run of the importer does not store the document again.
|
||||
*/
|
||||
export async function purgeExpiredDocumentTombstones(
|
||||
prisma: PrismaClient,
|
||||
now: Date = new Date(),
|
||||
): Promise<number> {
|
||||
const cutoff = new Date(now.getTime() - DOCUMENT_PURGE_GRACE_DAYS * DAY_MS);
|
||||
const expired = { deletedAt: { not: null, lt: cutoff } };
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// v1.39.2 (#1038) — an imported document's source key outlives its row.
|
||||
// While the tombstone exists the key sits on it and the upload route
|
||||
// answers "deleted"; once the row goes, this ledger is what keeps a re-run
|
||||
// of the importer from storing the document again. Same transaction as the
|
||||
// delete, so a purge that fails halfway forgets nothing.
|
||||
const keyed = await tx.inboundDocument.findMany({
|
||||
where: {
|
||||
...expired,
|
||||
OR: [
|
||||
{ sourceSystem: { not: null }, sourceId: { not: null } },
|
||||
{ sourceAliases: { some: {} } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
sourceSystem: true,
|
||||
sourceId: true,
|
||||
sourceInstance: true,
|
||||
// Further keys the import answered with this document (same bytes).
|
||||
// They cascade away with the row, so they are copied here first.
|
||||
sourceAliases: {
|
||||
select: { sourceSystem: true, sourceId: true, sourceInstance: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const keys = keyed.flatMap((row) => [
|
||||
...(row.sourceSystem && row.sourceId
|
||||
? [
|
||||
{
|
||||
userId: row.userId,
|
||||
sourceSystem: row.sourceSystem,
|
||||
sourceId: row.sourceId,
|
||||
sourceInstance: row.sourceInstance,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...row.sourceAliases.map((alias) => ({
|
||||
userId: row.userId,
|
||||
sourceSystem: alias.sourceSystem,
|
||||
sourceId: alias.sourceId,
|
||||
sourceInstance: alias.sourceInstance,
|
||||
})),
|
||||
]);
|
||||
if (keys.length > 0) {
|
||||
await tx.documentImportKey.createMany({
|
||||
data: keys,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
const { count } = await tx.inboundDocument.deleteMany({ where: expired });
|
||||
return count;
|
||||
});
|
||||
return prisma.$transaction((tx) =>
|
||||
purgeDocumentTombstones(tx, { deletedAt: { lt: cutoff } }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleDocumentPurge(
|
||||
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
import {
|
||||
DOCUMENT_PURGE_CRON,
|
||||
DOCUMENT_PURGE_QUEUE,
|
||||
} from "@/lib/jobs/document-purge";
|
||||
} from "@/lib/jobs/document-purge-queue";
|
||||
import {
|
||||
DOCUMENT_SUMMARY_CATCHUP_CONCURRENCY,
|
||||
DOCUMENT_SUMMARY_CATCHUP_QUEUE,
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
|
||||
import { getCoachMessageRetentionDays } from "@/lib/jobs/coach-message-cleanup";
|
||||
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/jobs/document-purge";
|
||||
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/jobs/document-purge-queue";
|
||||
import {
|
||||
purgeTombstonesByAccount,
|
||||
type TombstonePurgeOutcome,
|
||||
|
||||
@@ -161,6 +161,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"documents.inbound.invalidQuery",
|
||||
"documents.inbound.invalidUpdate",
|
||||
"documents.inbound.localOcrDisabled",
|
||||
"documents.inbound.notDeleted",
|
||||
"documents.inbound.notFound",
|
||||
"documents.inbound.notIndexed",
|
||||
"documents.inbound.outputLimit",
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
listEnvelope,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
@@ -462,6 +463,47 @@ export const documentIndexResponse = z
|
||||
})
|
||||
.meta({ id: "DocumentIndexResponse" });
|
||||
|
||||
// ─── Recently deleted ──────────────────────────────────────────────────────
|
||||
|
||||
const deletedDocument = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
kind: kindEnum,
|
||||
title: z.string().nullable(),
|
||||
filename: z.string().nullable(),
|
||||
mimeType: z.string(),
|
||||
byteSize: z.number().int(),
|
||||
documentDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("User-set filing date (YYYY-MM-DD), or null."),
|
||||
sourceSystem: z
|
||||
.enum(DOCUMENT_SOURCE_SYSTEMS)
|
||||
.nullable()
|
||||
.describe(
|
||||
"The system an imported document came from, or null for one added in HealthLog.",
|
||||
),
|
||||
deletedAt: isoDateTime(),
|
||||
purgeAt: isoDateTime().describe(
|
||||
"When the daily purge becomes due for this document: `deletedAt` plus `graceDays`. The purge runs once a day, so the document is removed at the first run after this instant.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "DeletedDocument" });
|
||||
|
||||
const deletedDocumentList = z
|
||||
.object({
|
||||
documents: z.array(deletedDocument),
|
||||
graceDays: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Days a deleted document stays restorable."),
|
||||
})
|
||||
.meta({ id: "DeletedDocumentList" });
|
||||
|
||||
const documentPurgeResult = z
|
||||
.object({ id: z.string(), purged: z.literal(true) })
|
||||
.meta({ id: "DocumentPurgeResult" });
|
||||
|
||||
export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/documents/inbound": {
|
||||
get: {
|
||||
@@ -837,6 +879,12 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
indexedCount: z.number(),
|
||||
totalCount: z.number(),
|
||||
}),
|
||||
deletedCount: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"How many of the caller's documents are deleted and not yet purged (Recently deleted, `GET /api/documents/inbound/deleted`). Always 0 while acting inside somebody else's record, where Recently deleted is not offered.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "DocumentUsage" }),
|
||||
"DocumentUsageEnvelope",
|
||||
@@ -1027,7 +1075,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Restore a discarded document",
|
||||
description:
|
||||
'Clears the soft-delete tombstone within the 30-day undo grace. 409 when the tombstone is already purged (the undo window is over) or when an identical live document blocks the restore (`meta.reason = "duplicateExists"`). Restoring an already-live document is a no-op success.',
|
||||
'Clears the soft-delete tombstone within the 30-day undo grace, and forgets any import-ledger entry for the document\'s source keys. 409 when the tombstone is already purged (the undo window is over, or the document was deleted for good) or when an identical live document blocks the restore (`meta.reason = "duplicateExists"`). Restoring an already-live document is a no-op success.',
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
@@ -1057,6 +1105,62 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/deleted": {
|
||||
get: {
|
||||
tags: ["Documents"],
|
||||
summary: "List Recently deleted",
|
||||
description:
|
||||
"The caller's deleted documents that the purge has not removed yet, newest deletion first, metadata only. Each carries `purgeAt`, when the daily purge becomes due for it. Restore one with `POST /api/documents/inbound/{id}/restore` (or the bulk `restore` action); remove one for good ahead of time with `POST /api/documents/inbound/{id}/purge`. Capped at 200 rows (`meta.truncated`, `meta.limit`). Owner-only: refused while acting inside somebody else's record.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Recently deleted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: listEnvelope(
|
||||
deletedDocumentList,
|
||||
"DeletedDocumentListEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/{id}/purge": {
|
||||
post: {
|
||||
tags: ["Documents"],
|
||||
summary: "Delete a deleted document for good",
|
||||
description:
|
||||
"Removes one deleted document now instead of when the grace window ends, through the same purge the daily job runs: the row and everything that cascades from it go, and an imported document's source keys are kept in the import ledger, so an importer re-sending it is still answered as deleted. Only a deleted document can be purged: a live one answers 409 `documents.inbound.notDeleted`. 404 `documents.inbound.notFound` when the caller has no such document. Owner-only.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Removed for good.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
documentPurgeResult,
|
||||
"DocumentPurgeResultEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"409": {
|
||||
description: "The document is not deleted.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/{id}/original": {
|
||||
get: {
|
||||
tags: ["Documents"],
|
||||
|
||||
@@ -85,6 +85,12 @@ export const documentKeys = {
|
||||
* delete so the quota bar tracks reality.
|
||||
*/
|
||||
inboundDocumentUsage: () => ["documents", "inbound", "usage"] as const,
|
||||
/**
|
||||
* Recently deleted (`GET /api/documents/inbound/deleted`). Under the
|
||||
* `["documents"]` prefix on purpose: a delete, a restore or a purge anywhere
|
||||
* in the vault repaints it. The literal segment never collides with a cuid.
|
||||
*/
|
||||
inboundDocumentsDeleted: () => ["documents", "inbound", "deleted"] as const,
|
||||
/**
|
||||
* Document-scoped AI capability probe
|
||||
* (`GET /api/documents/inbound/capability`). Distinct from the labs
|
||||
|
||||
@@ -878,44 +878,14 @@ export interface DocumentBulkResultDto {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** The usage endpoint payload the UI reads before offering an upload. */
|
||||
export interface DocumentUsageDto {
|
||||
usedBytes: number;
|
||||
quotaBytes: number;
|
||||
maxFileBytes: number;
|
||||
acceptedExtensions: string[];
|
||||
/**
|
||||
* Episodes carrying at least one LIVE document link — the vault filter
|
||||
* bar's condition chips. Sourced server-side so a chip exists even when
|
||||
* every linked document sits pages deep in the timeline.
|
||||
*/
|
||||
linkedEpisodes: DocumentConditionLinkDto[];
|
||||
/**
|
||||
* Procedures and surgeries (visits of kind PROCEDURE) carrying at least one
|
||||
* LIVE document link — the filter bar's procedure choices, sourced
|
||||
* server-side for the same reason as `linkedEpisodes`. Empty when the
|
||||
* caller's grant does not cover the visits section, because the label is
|
||||
* the visit's own text.
|
||||
*/
|
||||
linkedProcedures: DocumentLinkedProcedureDto[];
|
||||
/**
|
||||
* Whether the AI "Suggest details" action can run for this caller — true when
|
||||
* a provider (vision, or text + local OCR) is configured. The UI hides the
|
||||
* action when false so it never offers what the endpoint would 422.
|
||||
*/
|
||||
assistAvailable: boolean;
|
||||
/**
|
||||
* Content-search index state: whether indexing is available to the caller
|
||||
* (`enabled`, same provider precondition as assist), how many live documents
|
||||
* are indexed, and the live-document total — so the UI can gauge coverage and
|
||||
* offer the "index all documents" backfill.
|
||||
*/
|
||||
contentIndex: {
|
||||
enabled: boolean;
|
||||
indexedCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
}
|
||||
// The usage and Recently deleted payloads live in their own module; kept
|
||||
// importable from here like every other vault DTO.
|
||||
export type {
|
||||
DeletedDocumentDto,
|
||||
DeletedDocumentListDto,
|
||||
DocumentPurgeDto,
|
||||
DocumentUsageDto,
|
||||
} from "@/lib/documents/vault-read-dtos";
|
||||
|
||||
/** Where a document read egresses, vendor-blind. */
|
||||
export type DocumentEgressClass = "local" | "external";
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Recently deleted (#1275) against a real Postgres and the real routes.
|
||||
*
|
||||
* 1. The list holds the caller's deleted documents only: not live ones, not
|
||||
* someone else's; the usage read counts them.
|
||||
* 2. list → restore → the document is back in the vault list and gone from
|
||||
* Recently deleted.
|
||||
* 3. list → delete now → the row is gone, its import source keys (its own
|
||||
* and an alias) are in the ledger, and an importer re-sending it is still
|
||||
* answered "deleted"; a live or foreign document cannot be purged.
|
||||
* 4. A restore removes any ledger entry for the document's keys.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const pdf = (tag: string) =>
|
||||
Buffer.from(`%PDF-1.7\n% ${tag}\n1 0 obj\n<<>>\nendobj\n%%EOF\n`);
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
const ctx = (id: string): Ctx => ({ params: Promise.resolve({ id }) });
|
||||
type Handler = (r: Request, c: Ctx) => Promise<Response>;
|
||||
|
||||
async function seedUser(username: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email: `${username}@example.test`,
|
||||
role: "USER",
|
||||
timezone: "UTC",
|
||||
modulePreferencesJson: { inboundDocuments: true },
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) },
|
||||
});
|
||||
return { user, sessionId: session.id };
|
||||
}
|
||||
|
||||
function asSession(sessionId: string) {
|
||||
headerJar.clear();
|
||||
cookieJar.clear();
|
||||
cookieJar.set("healthlog_session", sessionId);
|
||||
}
|
||||
|
||||
function upload(bytes: Buffer, fields: Record<string, string> = {}): Request {
|
||||
const payload = new Uint8Array(bytes.byteLength);
|
||||
payload.set(bytes);
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([payload]), "letter.pdf");
|
||||
for (const [k, v] of Object.entries(fields)) form.append(k, v);
|
||||
const headers: Record<string, string> = {};
|
||||
const auth = headerJar.get("authorization");
|
||||
if (auth) headers.authorization = auth;
|
||||
return new Request("http://localhost/api/documents/inbound", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function routes() {
|
||||
const list = await import("@/app/api/documents/inbound/route");
|
||||
const byId = await import("@/app/api/documents/inbound/[id]/route");
|
||||
const restore =
|
||||
await import("@/app/api/documents/inbound/[id]/restore/route");
|
||||
const purge = await import("@/app/api/documents/inbound/[id]/purge/route");
|
||||
const deleted = await import("@/app/api/documents/inbound/deleted/route");
|
||||
const usage = await import("@/app/api/documents/inbound/usage/route");
|
||||
return {
|
||||
upload: (r: Request) =>
|
||||
(list.POST as unknown as (r: Request) => Promise<Response>)(r),
|
||||
vault: async () => {
|
||||
const res = await (
|
||||
list.GET as unknown as (r: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/documents/inbound"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
return (body.data.documents as Array<{ id: string }>).map((d) => d.id);
|
||||
},
|
||||
remove: (id: string) =>
|
||||
(byId.DELETE as unknown as Handler)(
|
||||
new Request(`http://localhost/api/documents/inbound/${id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
ctx(id),
|
||||
),
|
||||
restore: (id: string) =>
|
||||
(restore.POST as unknown as Handler)(
|
||||
new NextRequest(
|
||||
`http://localhost/api/documents/inbound/${id}/restore`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
ctx(id),
|
||||
),
|
||||
purge: (id: string) =>
|
||||
(purge.POST as unknown as Handler)(
|
||||
new NextRequest(`http://localhost/api/documents/inbound/${id}/purge`, {
|
||||
method: "POST",
|
||||
}),
|
||||
ctx(id),
|
||||
),
|
||||
deleted: () =>
|
||||
(deleted.GET as unknown as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/documents/inbound/deleted"),
|
||||
),
|
||||
usage: async () => {
|
||||
const res = await (
|
||||
usage.GET as unknown as (r: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/documents/inbound/usage"));
|
||||
return (await res.json()).data as { deletedCount: number };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("Recently deleted — the list", () => {
|
||||
it("holds the caller's deleted documents only, newest deletion first, with the purge date", async () => {
|
||||
const r = await routes();
|
||||
const other = await seedUser("someone-else");
|
||||
asSession(other.sessionId);
|
||||
const foreign = (await (await r.upload(upload(pdf("theirs")))).json()).data
|
||||
.id as string;
|
||||
expect((await r.remove(foreign)).status).toBe(200);
|
||||
|
||||
const { sessionId } = await seedUser("owner");
|
||||
asSession(sessionId);
|
||||
const live = (await (await r.upload(upload(pdf("live")))).json()).data
|
||||
.id as string;
|
||||
const first = (
|
||||
await (
|
||||
await r.upload(
|
||||
upload(pdf("first"), {
|
||||
title: "Discharge letter",
|
||||
kind: "DISCHARGE_LETTER",
|
||||
documentDate: "2024-05-02",
|
||||
sourceSystem: "PAPERLESS",
|
||||
sourceId: "77",
|
||||
}),
|
||||
)
|
||||
).json()
|
||||
).data.id as string;
|
||||
const second = (await (await r.upload(upload(pdf("second")))).json()).data
|
||||
.id as string;
|
||||
expect((await r.remove(first)).status).toBe(200);
|
||||
expect((await r.remove(second)).status).toBe(200);
|
||||
|
||||
const res = await r.deleted();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toMatchObject({ truncated: false, limit: 200 });
|
||||
expect(body.data.graceDays).toBe(30);
|
||||
const rows = body.data.documents as Array<Record<string, unknown>>;
|
||||
expect(rows.map((d) => d.id)).toEqual([second, first]);
|
||||
expect(rows.map((d) => d.id)).not.toContain(live);
|
||||
expect(rows.map((d) => d.id)).not.toContain(foreign);
|
||||
const letter = rows[1];
|
||||
expect(letter).toMatchObject({
|
||||
title: "Discharge letter",
|
||||
kind: "DISCHARGE_LETTER",
|
||||
documentDate: "2024-05-02",
|
||||
sourceSystem: "PAPERLESS",
|
||||
mimeType: "application/pdf",
|
||||
});
|
||||
expect(
|
||||
new Date(letter.purgeAt as string).getTime() -
|
||||
new Date(letter.deletedAt as string).getTime(),
|
||||
).toBe(30 * 86_400_000);
|
||||
// No content, no source id: metadata to recognise the document by.
|
||||
expect(letter).not.toHaveProperty("contentEncrypted");
|
||||
expect(letter).not.toHaveProperty("sourceId");
|
||||
|
||||
expect((await r.usage()).deletedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("list → restore → the document is back in the vault and out of the list", async () => {
|
||||
const r = await routes();
|
||||
const { sessionId } = await seedUser("restorer");
|
||||
asSession(sessionId);
|
||||
const id = (await (await r.upload(upload(pdf("back")))).json()).data
|
||||
.id as string;
|
||||
await r.remove(id);
|
||||
expect(await r.vault()).toEqual([]);
|
||||
const listed = (await (await r.deleted()).json()).data.documents;
|
||||
expect(listed.map((d: { id: string }) => d.id)).toEqual([id]);
|
||||
|
||||
expect((await r.restore(id)).status).toBe(200);
|
||||
expect(await r.vault()).toEqual([id]);
|
||||
expect((await (await r.deleted()).json()).data.documents).toEqual([]);
|
||||
expect((await r.usage()).deletedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Recently deleted — delete now", () => {
|
||||
it("list → delete now → gone, with its import keys carried into the ledger", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const r = await routes();
|
||||
const { user, sessionId } = await seedUser("purger");
|
||||
asSession(sessionId);
|
||||
const fields = {
|
||||
sourceSystem: "PAPERLESS",
|
||||
sourceId: "412",
|
||||
sourceInstance: "https://paperless.example",
|
||||
};
|
||||
const id = (await (await r.upload(upload(pdf("a"), fields))).json()).data
|
||||
.id as string;
|
||||
// The same bytes under a second key: answered with the stored document,
|
||||
// and the key is remembered on it as an alias.
|
||||
const alias = await r.upload(
|
||||
upload(pdf("a"), { ...fields, sourceId: "9" }),
|
||||
);
|
||||
expect((await alias.json()).meta).toMatchObject({ duplicate: true });
|
||||
|
||||
// A live document cannot be purged in one step.
|
||||
const live = await r.purge(id);
|
||||
expect(live.status).toBe(409);
|
||||
expect((await live.json()).meta.errorCode).toBe(
|
||||
"documents.inbound.notDeleted",
|
||||
);
|
||||
|
||||
await r.remove(id);
|
||||
const listed = (await (await r.deleted()).json()).data.documents;
|
||||
expect(listed.map((d: { id: string }) => d.id)).toEqual([id]);
|
||||
|
||||
const purged = await r.purge(id);
|
||||
expect(purged.status).toBe(200);
|
||||
expect((await purged.json()).data).toEqual({ id, purged: true });
|
||||
|
||||
expect(await prisma.inboundDocument.count({ where: { id } })).toBe(0);
|
||||
expect(await prisma.documentSourceAlias.count()).toBe(0);
|
||||
expect((await (await r.deleted()).json()).data.documents).toEqual([]);
|
||||
expect(
|
||||
await prisma.documentImportKey.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { sourceSystem: true, sourceId: true, sourceInstance: true },
|
||||
orderBy: { sourceId: "asc" },
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
sourceSystem: "PAPERLESS",
|
||||
sourceId: "412",
|
||||
sourceInstance: "https://paperless.example",
|
||||
},
|
||||
{
|
||||
sourceSystem: "PAPERLESS",
|
||||
sourceId: "9",
|
||||
sourceInstance: "https://paperless.example",
|
||||
},
|
||||
]);
|
||||
const audit = await prisma.auditLog.findFirst({
|
||||
where: { userId: user.id, action: "documents.inbound.purge" },
|
||||
});
|
||||
expect(audit).not.toBeNull();
|
||||
|
||||
// An importer re-sending either key is still answered "deleted", and
|
||||
// nothing is stored.
|
||||
for (const sourceId of ["412", "9"]) {
|
||||
const again = await r.upload(upload(pdf("a"), { ...fields, sourceId }));
|
||||
expect(again.status).toBe(200);
|
||||
expect((await again.json()).meta).toEqual({
|
||||
duplicate: true,
|
||||
deleted: true,
|
||||
});
|
||||
}
|
||||
expect(await prisma.inboundDocument.count()).toBe(0);
|
||||
|
||||
// Restoring it now is over.
|
||||
const gone = await r.restore(id);
|
||||
expect(gone.status).toBe(409);
|
||||
expect((await gone.json()).meta.reason).toBe("purged");
|
||||
});
|
||||
|
||||
it("answers 404 for someone else's deleted document and leaves it alone", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const r = await routes();
|
||||
const owner = await seedUser("owner-b");
|
||||
asSession(owner.sessionId);
|
||||
const id = (await (await r.upload(upload(pdf("mine")))).json()).data
|
||||
.id as string;
|
||||
await r.remove(id);
|
||||
|
||||
const intruder = await seedUser("intruder");
|
||||
asSession(intruder.sessionId);
|
||||
const res = await r.purge(id);
|
||||
expect(res.status).toBe(404);
|
||||
expect(await prisma.inboundDocument.count({ where: { id } })).toBe(1);
|
||||
expect((await (await r.deleted()).json()).data.documents).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Recently deleted — restore and the import ledger", () => {
|
||||
it("a restore removes the ledger entries for the document's own and alias keys", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const r = await routes();
|
||||
const { user, sessionId } = await seedUser("ledger");
|
||||
asSession(sessionId);
|
||||
const fields = { sourceSystem: "PAPRA", sourceId: "doc_1" };
|
||||
const id = (await (await r.upload(upload(pdf("k"), fields))).json()).data
|
||||
.id as string;
|
||||
await r.upload(upload(pdf("k"), { ...fields, sourceId: "doc_2" }));
|
||||
await r.remove(id);
|
||||
|
||||
// A ledger entry for each key, as a purge elsewhere (or an older run)
|
||||
// would have left; one for an unrelated key stays.
|
||||
await prisma.documentImportKey.createMany({
|
||||
data: [
|
||||
{ userId: user.id, sourceSystem: "PAPRA", sourceId: "doc_1" },
|
||||
{ userId: user.id, sourceSystem: "PAPRA", sourceId: "doc_2" },
|
||||
{ userId: user.id, sourceSystem: "PAPRA", sourceId: "unrelated" },
|
||||
],
|
||||
});
|
||||
|
||||
expect((await r.restore(id)).status).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await prisma.documentImportKey.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { sourceId: true },
|
||||
})
|
||||
).map((k) => k.sourceId),
|
||||
).toEqual(["unrelated"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user