From e5757bb5c0a815540e796a6c49420d1e244ad3a5 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Mon, 2 Jun 2025 17:13:00 -0400 Subject: [PATCH 01/14] feat: exposes new deletedAt field on collection config --- packages/graphql/src/schema/initCollections.ts | 9 +++++++-- .../payload/src/collections/config/sanitize.ts | 18 ++++++++++++++++++ .../payload/src/collections/config/types.ts | 3 ++- packages/payload/src/folders/types.ts | 1 + .../utils/formatFolderOrDocumentItem.ts | 1 + packages/translations/src/clientKeys.ts | 1 + packages/translations/src/languages/ar.ts | 1 + packages/translations/src/languages/az.ts | 1 + packages/translations/src/languages/bg.ts | 1 + packages/translations/src/languages/ca.ts | 1 + packages/translations/src/languages/cs.ts | 1 + packages/translations/src/languages/da.ts | 1 + packages/translations/src/languages/de.ts | 1 + packages/translations/src/languages/en.ts | 1 + packages/translations/src/languages/es.ts | 2 ++ packages/translations/src/languages/et.ts | 1 + packages/translations/src/languages/fa.ts | 1 + packages/translations/src/languages/fr.ts | 1 + packages/translations/src/languages/he.ts | 1 + packages/translations/src/languages/hr.ts | 1 + packages/translations/src/languages/hu.ts | 1 + packages/translations/src/languages/hy.ts | 1 + packages/translations/src/languages/it.ts | 1 + packages/translations/src/languages/ja.ts | 1 + packages/translations/src/languages/ko.ts | 1 + packages/translations/src/languages/lt.ts | 1 + packages/translations/src/languages/lv.ts | 1 + packages/translations/src/languages/my.ts | 1 + packages/translations/src/languages/nb.ts | 1 + packages/translations/src/languages/nl.ts | 1 + packages/translations/src/languages/pl.ts | 1 + packages/translations/src/languages/pt.ts | 1 + packages/translations/src/languages/ro.ts | 1 + packages/translations/src/languages/rs.ts | 1 + packages/translations/src/languages/rsLatin.ts | 1 + packages/translations/src/languages/ru.ts | 1 + packages/translations/src/languages/sk.ts | 1 + packages/translations/src/languages/sl.ts | 1 + packages/translations/src/languages/sv.ts | 1 + packages/translations/src/languages/th.ts | 1 + packages/translations/src/languages/tr.ts | 1 + packages/translations/src/languages/uk.ts | 1 + packages/translations/src/languages/vi.ts | 1 + packages/translations/src/languages/zh.ts | 1 + packages/translations/src/languages/zhTw.ts | 1 + 45 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/graphql/src/schema/initCollections.ts b/packages/graphql/src/schema/initCollections.ts index 7dda78057dd..eeeb16d1a18 100644 --- a/packages/graphql/src/schema/initCollections.ts +++ b/packages/graphql/src/schema/initCollections.ts @@ -329,12 +329,17 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ { name: 'createdAt', type: 'date', - label: 'Created At', + label: ({ t }) => t('general:createdAt'), }, { name: 'updatedAt', type: 'date', - label: 'Updated At', + label: ({ t }) => t('general:updatedAt'), + }, + { + name: 'deletedAt', + type: 'date', + label: ({ t }) => t('general:deletedAt'), }, ] diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index fd29217f2b5..b7b5bbeb049 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -99,6 +99,7 @@ export const sanitizeCollection = async ( // add default timestamps fields only as needed let hasUpdatedAt: boolean | null = null let hasCreatedAt: boolean | null = null + let hasDeletedAt: boolean | null = null sanitized.fields.some((field) => { if (fieldAffectsData(field)) { @@ -109,6 +110,10 @@ export const sanitizeCollection = async ( if (field.name === 'createdAt') { hasCreatedAt = true } + + if (field.name === 'deletedAt') { + hasDeletedAt = true + } } return hasCreatedAt && hasUpdatedAt @@ -140,6 +145,19 @@ export const sanitizeCollection = async ( label: ({ t }) => t('general:createdAt'), }) } + + if (!hasDeletedAt) { + sanitized.fields.push({ + name: 'deletedAt', + type: 'date', + admin: { + disableBulkEdit: true, + hidden: true, + }, + index: true, + label: ({ t }) => t('general:deletedAt'), + }) + } } sanitized.labels = sanitized.labels || formatLabels(sanitized.slug) diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index b166845ffe8..b450bd1313e 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -535,7 +535,7 @@ export type CollectionConfig = { orderable?: boolean slug: string /** - * Add `createdAt` and `updatedAt` fields + * Add `createdAt`, `deletedAt` and `updatedAt` fields * * @default true */ @@ -657,6 +657,7 @@ export type TypeWithID = { export type TypeWithTimestamps = { [key: string]: unknown createdAt: string + deletedAt?: string id: number | string updatedAt: string } diff --git a/packages/payload/src/folders/types.ts b/packages/payload/src/folders/types.ts index 2584e65c4dd..289f436abe5 100644 --- a/packages/payload/src/folders/types.ts +++ b/packages/payload/src/folders/types.ts @@ -57,6 +57,7 @@ export type FolderOrDocument = { value: { _folderOrDocumentTitle: string createdAt?: string + deletedAt?: string folderID?: number | string id: number | string updatedAt?: string diff --git a/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts b/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts index 825dbb95458..fe4faff3bf9 100644 --- a/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts +++ b/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts @@ -22,6 +22,7 @@ export function formatFolderOrDocumentItem({ id: value?.id, _folderOrDocumentTitle: String((useAsTitle && value?.[useAsTitle]) || value['id']), createdAt: value?.createdAt, + deletedAt: value?.deletedAt, folderID: value?.[folderFieldName], updatedAt: value?.updatedAt, } diff --git a/packages/translations/src/clientKeys.ts b/packages/translations/src/clientKeys.ts index 2d178606bba..b0d355c4b98 100644 --- a/packages/translations/src/clientKeys.ts +++ b/packages/translations/src/clientKeys.ts @@ -196,6 +196,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:dark', 'general:dashboard', 'general:delete', + 'general:deletedAt', 'general:deletedSuccessfully', 'general:deletedCountSuccessfully', 'general:deleting', diff --git a/packages/translations/src/languages/ar.ts b/packages/translations/src/languages/ar.ts index aaa3061737b..a29ecfa38ed 100644 --- a/packages/translations/src/languages/ar.ts +++ b/packages/translations/src/languages/ar.ts @@ -252,6 +252,7 @@ export const arTranslations: DefaultTranslationsObject = { dark: 'غامق', dashboard: 'لوحة التّحكّم', delete: 'حذف', + deletedAt: 'تم الحذف في', deletedCountSuccessfully: 'تمّ حذف {{count}} {{label}} بنجاح.', deletedSuccessfully: 'تمّ الحذف بنجاح.', deleting: 'يتمّ الحذف...', diff --git a/packages/translations/src/languages/az.ts b/packages/translations/src/languages/az.ts index bc833ff6a21..178fff13aef 100644 --- a/packages/translations/src/languages/az.ts +++ b/packages/translations/src/languages/az.ts @@ -257,6 +257,7 @@ export const azTranslations: DefaultTranslationsObject = { dark: 'Tünd', dashboard: 'Panel', delete: 'Sil', + deletedAt: 'Silinib Tarixi', deletedCountSuccessfully: '{{count}} {{label}} uğurla silindi.', deletedSuccessfully: 'Uğurla silindi.', deleting: 'Silinir...', diff --git a/packages/translations/src/languages/bg.ts b/packages/translations/src/languages/bg.ts index c40c7901419..7b2dfbb3e92 100644 --- a/packages/translations/src/languages/bg.ts +++ b/packages/translations/src/languages/bg.ts @@ -256,6 +256,7 @@ export const bgTranslations: DefaultTranslationsObject = { dark: 'Тъмна', dashboard: 'Табло', delete: 'Изтрий', + deletedAt: 'Изтрито на', deletedCountSuccessfully: 'Изтрити {{count}} {{label}} успешно.', deletedSuccessfully: 'Изтрито успешно.', deleting: 'Изтриване...', diff --git a/packages/translations/src/languages/ca.ts b/packages/translations/src/languages/ca.ts index 8af7367f449..a6fe78cdfca 100644 --- a/packages/translations/src/languages/ca.ts +++ b/packages/translations/src/languages/ca.ts @@ -257,6 +257,7 @@ export const caTranslations: DefaultTranslationsObject = { dark: 'Fosc', dashboard: 'Tauler', delete: 'Eliminar', + deletedAt: 'Eliminat en', deletedCountSuccessfully: 'Eliminat {{count}} {{label}} correctament.', deletedSuccessfully: 'Eliminat correntament.', deleting: 'Eliminant...', diff --git a/packages/translations/src/languages/cs.ts b/packages/translations/src/languages/cs.ts index ff1f225f251..5d87470809d 100644 --- a/packages/translations/src/languages/cs.ts +++ b/packages/translations/src/languages/cs.ts @@ -255,6 +255,7 @@ export const csTranslations: DefaultTranslationsObject = { dark: 'Tmavý', dashboard: 'Nástěnka', delete: 'Odstranit', + deletedAt: 'Smazáno dne', deletedCountSuccessfully: 'Úspěšně smazáno {{count}} {{label}}.', deletedSuccessfully: 'Úspěšně odstraněno.', deleting: 'Odstraňování...', diff --git a/packages/translations/src/languages/da.ts b/packages/translations/src/languages/da.ts index c6b867153b0..b97f4c395d6 100644 --- a/packages/translations/src/languages/da.ts +++ b/packages/translations/src/languages/da.ts @@ -254,6 +254,7 @@ export const daTranslations: DefaultTranslationsObject = { dark: 'Mørk', dashboard: 'Dashboard', delete: 'Slet', + deletedAt: 'Slettet Ved', deletedCountSuccessfully: 'Slettet {{count}} {{label}}.', deletedSuccessfully: 'Slettet.', deleting: 'Sletter...', diff --git a/packages/translations/src/languages/de.ts b/packages/translations/src/languages/de.ts index 3c030148f3a..d77ce6f6ea7 100644 --- a/packages/translations/src/languages/de.ts +++ b/packages/translations/src/languages/de.ts @@ -262,6 +262,7 @@ export const deTranslations: DefaultTranslationsObject = { dark: 'Dunkel', dashboard: 'Übersicht', delete: 'Löschen', + deletedAt: 'Gelöscht am', deletedCountSuccessfully: '{{count}} {{label}} erfolgreich gelöscht.', deletedSuccessfully: 'Erfolgreich gelöscht.', deleting: 'Löschen...', diff --git a/packages/translations/src/languages/en.ts b/packages/translations/src/languages/en.ts index efcb025d0fd..b6e9a5d3ff4 100644 --- a/packages/translations/src/languages/en.ts +++ b/packages/translations/src/languages/en.ts @@ -257,6 +257,7 @@ export const enTranslations = { dark: 'Dark', dashboard: 'Dashboard', delete: 'Delete', + deletedAt: 'Deleted At', deletedCountSuccessfully: 'Deleted {{count}} {{label}} successfully.', deletedSuccessfully: 'Deleted successfully.', deleting: 'Deleting...', diff --git a/packages/translations/src/languages/es.ts b/packages/translations/src/languages/es.ts index 80bef99c6cb..aad3828ebcc 100644 --- a/packages/translations/src/languages/es.ts +++ b/packages/translations/src/languages/es.ts @@ -1,4 +1,5 @@ import type { DefaultTranslationsObject, Language } from '../types.js' + export const esTranslations: DefaultTranslationsObject = { authentication: { account: 'Cuenta', @@ -260,6 +261,7 @@ export const esTranslations: DefaultTranslationsObject = { dark: 'Oscuro', dashboard: 'Panel de Control', delete: 'Eliminar', + deletedAt: 'Eliminado En', deletedCountSuccessfully: 'Se eliminaron {{count}} {{label}} correctamente.', deletedSuccessfully: 'Eliminado correctamente.', deleting: 'Eliminando...', diff --git a/packages/translations/src/languages/et.ts b/packages/translations/src/languages/et.ts index 59a4657c3f7..22d17e1a33c 100644 --- a/packages/translations/src/languages/et.ts +++ b/packages/translations/src/languages/et.ts @@ -254,6 +254,7 @@ export const etTranslations: DefaultTranslationsObject = { dark: 'Tume', dashboard: 'Töölaud', delete: 'Kustuta', + deletedAt: 'Kustutatud', deletedCountSuccessfully: 'Kustutatud {{count}} {{label}} edukalt.', deletedSuccessfully: 'Edukalt kustutatud.', deleting: 'Kustutamine...', diff --git a/packages/translations/src/languages/fa.ts b/packages/translations/src/languages/fa.ts index 69b04612eed..b554e132527 100644 --- a/packages/translations/src/languages/fa.ts +++ b/packages/translations/src/languages/fa.ts @@ -255,6 +255,7 @@ export const faTranslations: DefaultTranslationsObject = { dark: 'تاریک', dashboard: 'پیشخوان', delete: 'حذف', + deletedAt: 'حذف شده در', deletedCountSuccessfully: 'تعداد {{count}} {{label}} با موفقیت پاک گردید.', deletedSuccessfully: 'با موفقیت حذف شد.', deleting: 'در حال حذف...', diff --git a/packages/translations/src/languages/fr.ts b/packages/translations/src/languages/fr.ts index 0a643fd239c..6551f80d189 100644 --- a/packages/translations/src/languages/fr.ts +++ b/packages/translations/src/languages/fr.ts @@ -264,6 +264,7 @@ export const frTranslations: DefaultTranslationsObject = { dark: 'Sombre', dashboard: 'Tableau de bord', delete: 'Supprimer', + deletedAt: 'Supprimé à', deletedCountSuccessfully: '{{count}} {{label}} supprimé avec succès.', deletedSuccessfully: 'Supprimé(e) avec succès.', deleting: 'Suppression en cours...', diff --git a/packages/translations/src/languages/he.ts b/packages/translations/src/languages/he.ts index cb96a68f68f..9fbe6ef3a98 100644 --- a/packages/translations/src/languages/he.ts +++ b/packages/translations/src/languages/he.ts @@ -250,6 +250,7 @@ export const heTranslations: DefaultTranslationsObject = { dark: 'כהה', dashboard: 'לוח מחוונים', delete: 'מחיקה', + deletedAt: 'נמחק ב', deletedCountSuccessfully: 'נמחקו {{count}} {{label}} בהצלחה.', deletedSuccessfully: 'נמחק בהצלחה.', deleting: 'מוחק...', diff --git a/packages/translations/src/languages/hr.ts b/packages/translations/src/languages/hr.ts index 72e2ee476f1..9c72a3469e4 100644 --- a/packages/translations/src/languages/hr.ts +++ b/packages/translations/src/languages/hr.ts @@ -257,6 +257,7 @@ export const hrTranslations: DefaultTranslationsObject = { dark: 'Tamno', dashboard: 'Nadzorna ploča', delete: 'Izbriši', + deletedAt: 'Izbrisano U', deletedCountSuccessfully: 'Uspješno izbrisano {{count}} {{label}}.', deletedSuccessfully: 'Uspješno izbrisano.', deleting: 'Brisanje...', diff --git a/packages/translations/src/languages/hu.ts b/packages/translations/src/languages/hu.ts index 6deb56aedc8..ef60df587e6 100644 --- a/packages/translations/src/languages/hu.ts +++ b/packages/translations/src/languages/hu.ts @@ -259,6 +259,7 @@ export const huTranslations: DefaultTranslationsObject = { dark: 'Sötét', dashboard: 'Irányítópult', delete: 'Törlés', + deletedAt: 'Törölve Ekkor', deletedCountSuccessfully: '{{count}} {{label}} sikeresen törölve.', deletedSuccessfully: 'Sikeresen törölve.', deleting: 'Törlés...', diff --git a/packages/translations/src/languages/hy.ts b/packages/translations/src/languages/hy.ts index ddf5def79e4..e1f972acd91 100644 --- a/packages/translations/src/languages/hy.ts +++ b/packages/translations/src/languages/hy.ts @@ -257,6 +257,7 @@ export const hyTranslations: DefaultTranslationsObject = { dark: 'Մուգ', dashboard: 'Վահանակ', delete: 'Ջնջել', + deletedAt: 'Ջնջված է', deletedCountSuccessfully: '{{count}} {{label}} հաջողությամբ ջնջված է։', deletedSuccessfully: 'Հաջողությամբ ջնջված է։', deleting: 'Ջնջվում է...', diff --git a/packages/translations/src/languages/it.ts b/packages/translations/src/languages/it.ts index 0e43e070033..37ccc688558 100644 --- a/packages/translations/src/languages/it.ts +++ b/packages/translations/src/languages/it.ts @@ -260,6 +260,7 @@ export const itTranslations: DefaultTranslationsObject = { dark: 'Scuro', dashboard: 'Dashboard', delete: 'Elimina', + deletedAt: 'Cancellato Alle', deletedCountSuccessfully: '{{count}} {{label}} eliminato con successo.', deletedSuccessfully: 'Eliminato con successo.', deleting: 'Sto eliminando...', diff --git a/packages/translations/src/languages/ja.ts b/packages/translations/src/languages/ja.ts index bfa4e89da59..9907ea56f11 100644 --- a/packages/translations/src/languages/ja.ts +++ b/packages/translations/src/languages/ja.ts @@ -257,6 +257,7 @@ export const jaTranslations: DefaultTranslationsObject = { dark: 'ダークモード', dashboard: 'ダッシュボード', delete: '削除', + deletedAt: '削除された時間', deletedCountSuccessfully: '{{count}}つの{{label}}を正常に削除しました。', deletedSuccessfully: '正常に削除されました。', deleting: '削除しています...', diff --git a/packages/translations/src/languages/ko.ts b/packages/translations/src/languages/ko.ts index 67f278ed0dd..0e2bb258803 100644 --- a/packages/translations/src/languages/ko.ts +++ b/packages/translations/src/languages/ko.ts @@ -254,6 +254,7 @@ export const koTranslations: DefaultTranslationsObject = { dark: '다크', dashboard: '대시보드', delete: '삭제', + deletedAt: '삭제된 시간', deletedCountSuccessfully: '{{count}}개의 {{label}}를 삭제했습니다.', deletedSuccessfully: '삭제되었습니다.', deleting: '삭제 중...', diff --git a/packages/translations/src/languages/lt.ts b/packages/translations/src/languages/lt.ts index c47d67338fa..5cdfb0ad176 100644 --- a/packages/translations/src/languages/lt.ts +++ b/packages/translations/src/languages/lt.ts @@ -259,6 +259,7 @@ export const ltTranslations: DefaultTranslationsObject = { dark: 'Tamsus', dashboard: 'Prietaisų skydelis', delete: 'Ištrinti', + deletedAt: 'Ištrinta', deletedCountSuccessfully: 'Sėkmingai ištrinta {{count}} {{label}}.', deletedSuccessfully: 'Sėkmingai ištrinta.', deleting: 'Trinama...', diff --git a/packages/translations/src/languages/lv.ts b/packages/translations/src/languages/lv.ts index e27d257ab3f..6958cb2fbc1 100644 --- a/packages/translations/src/languages/lv.ts +++ b/packages/translations/src/languages/lv.ts @@ -256,6 +256,7 @@ export const lvTranslations: DefaultTranslationsObject = { dark: 'Tumšs', dashboard: 'Panelis', delete: 'Dzēst', + deletedAt: 'Dzēsts datumā', deletedCountSuccessfully: 'Veiksmīgi izdzēsti {{count}} {{label}}.', deletedSuccessfully: 'Veiksmīgi izdzēsts.', deleting: 'Dzēš...', diff --git a/packages/translations/src/languages/my.ts b/packages/translations/src/languages/my.ts index 0f86fe64b73..ed8d3d88958 100644 --- a/packages/translations/src/languages/my.ts +++ b/packages/translations/src/languages/my.ts @@ -259,6 +259,7 @@ export const myTranslations: DefaultTranslationsObject = { dark: 'အမှောင်', dashboard: 'ပင်မစာမျက်နှာ', delete: 'ဖျက်မည်။', + deletedAt: 'Dihapus Pada', deletedCountSuccessfully: '{{count}} {{label}} ကို အောင်မြင်စွာ ဖျက်လိုက်ပါပြီ။', deletedSuccessfully: 'အောင်မြင်စွာ ဖျက်လိုက်ပါပြီ။', deleting: 'ဖျက်နေဆဲ ...', diff --git a/packages/translations/src/languages/nb.ts b/packages/translations/src/languages/nb.ts index 43b244c3a63..b28ee579e08 100644 --- a/packages/translations/src/languages/nb.ts +++ b/packages/translations/src/languages/nb.ts @@ -257,6 +257,7 @@ export const nbTranslations: DefaultTranslationsObject = { dark: 'Mørk', dashboard: 'Kontrollpanel', delete: 'Slett', + deletedAt: 'Slettet kl.', deletedCountSuccessfully: 'Slettet {{count}} {{label}}.', deletedSuccessfully: 'Slettet.', deleting: 'Sletter...', diff --git a/packages/translations/src/languages/nl.ts b/packages/translations/src/languages/nl.ts index a0b4c82569a..fc461d49de5 100644 --- a/packages/translations/src/languages/nl.ts +++ b/packages/translations/src/languages/nl.ts @@ -260,6 +260,7 @@ export const nlTranslations: DefaultTranslationsObject = { dark: 'Donker', dashboard: 'Dashboard', delete: 'Verwijderen', + deletedAt: 'Verwijderd Op', deletedCountSuccessfully: '{{count}} {{label}} succesvol verwijderd.', deletedSuccessfully: 'Succesvol verwijderd.', deleting: 'Verwijderen...', diff --git a/packages/translations/src/languages/pl.ts b/packages/translations/src/languages/pl.ts index 8f4cee097fe..3266f029e66 100644 --- a/packages/translations/src/languages/pl.ts +++ b/packages/translations/src/languages/pl.ts @@ -257,6 +257,7 @@ export const plTranslations: DefaultTranslationsObject = { dark: 'Ciemny', dashboard: 'Panel', delete: 'Usuń', + deletedAt: 'Usunięto o', deletedCountSuccessfully: 'Pomyślnie usunięto {{count}} {{label}}.', deletedSuccessfully: 'Pomyślnie usunięto.', deleting: 'Usuwanie...', diff --git a/packages/translations/src/languages/pt.ts b/packages/translations/src/languages/pt.ts index c5a25c64cc3..c05ffc538d9 100644 --- a/packages/translations/src/languages/pt.ts +++ b/packages/translations/src/languages/pt.ts @@ -257,6 +257,7 @@ export const ptTranslations: DefaultTranslationsObject = { dark: 'Escuro', dashboard: 'Painel de Controle', delete: 'Excluir', + deletedAt: 'Excluído Em', deletedCountSuccessfully: 'Excluído {{count}} {{label}} com sucesso.', deletedSuccessfully: 'Apagado com sucesso.', deleting: 'Excluindo...', diff --git a/packages/translations/src/languages/ro.ts b/packages/translations/src/languages/ro.ts index 1aed7a3036f..abb21532ef0 100644 --- a/packages/translations/src/languages/ro.ts +++ b/packages/translations/src/languages/ro.ts @@ -261,6 +261,7 @@ export const roTranslations: DefaultTranslationsObject = { dark: 'Dark', dashboard: 'Panoul de bord', delete: 'Șterge', + deletedAt: 'Șters la', deletedCountSuccessfully: 'Șterse cu succes {{count}} {{label}}.', deletedSuccessfully: 'Șters cu succes.', deleting: 'Deleting...', diff --git a/packages/translations/src/languages/rs.ts b/packages/translations/src/languages/rs.ts index b22e9c92cab..a7cb13d7f9d 100644 --- a/packages/translations/src/languages/rs.ts +++ b/packages/translations/src/languages/rs.ts @@ -257,6 +257,7 @@ export const rsTranslations: DefaultTranslationsObject = { dark: 'Тамно', dashboard: 'Контролни панел', delete: 'Обриши', + deletedAt: 'Obrisano u', deletedCountSuccessfully: 'Успешно избрисано {{count}} {{label}}.', deletedSuccessfully: 'Успешно избрисано.', deleting: 'Брисање...', diff --git a/packages/translations/src/languages/rsLatin.ts b/packages/translations/src/languages/rsLatin.ts index 615d9c63483..7c55c62cb83 100644 --- a/packages/translations/src/languages/rsLatin.ts +++ b/packages/translations/src/languages/rsLatin.ts @@ -257,6 +257,7 @@ export const rsLatinTranslations: DefaultTranslationsObject = { dark: 'Tamno', dashboard: 'Kontrolni panel', delete: 'Obriši', + deletedAt: 'Obrisano U', deletedCountSuccessfully: 'Uspešno izbrisano {{count}} {{label}}.', deletedSuccessfully: 'Uspešno izbrisano.', deleting: 'Brisanje...', diff --git a/packages/translations/src/languages/ru.ts b/packages/translations/src/languages/ru.ts index c26a1a0bcb6..2ba9418b045 100644 --- a/packages/translations/src/languages/ru.ts +++ b/packages/translations/src/languages/ru.ts @@ -259,6 +259,7 @@ export const ruTranslations: DefaultTranslationsObject = { dark: 'Тёмная', dashboard: 'Панель', delete: 'Удалить', + deletedAt: 'Удалено В', deletedCountSuccessfully: 'Удалено {{count}} {{label}} успешно.', deletedSuccessfully: 'Удален успешно.', deleting: 'Удаление...', diff --git a/packages/translations/src/languages/sk.ts b/packages/translations/src/languages/sk.ts index 9bcc221e6cf..857674419f0 100644 --- a/packages/translations/src/languages/sk.ts +++ b/packages/translations/src/languages/sk.ts @@ -258,6 +258,7 @@ export const skTranslations: DefaultTranslationsObject = { dark: 'Tmavý', dashboard: 'Nástenka', delete: 'Odstrániť', + deletedAt: 'Vymazané dňa', deletedCountSuccessfully: 'Úspešne zmazané {{count}} {{label}}.', deletedSuccessfully: 'Úspešne odstránené.', deleting: 'Odstraňovanie...', diff --git a/packages/translations/src/languages/sl.ts b/packages/translations/src/languages/sl.ts index b16327bdee7..6b2bf334f3e 100644 --- a/packages/translations/src/languages/sl.ts +++ b/packages/translations/src/languages/sl.ts @@ -256,6 +256,7 @@ export const slTranslations: DefaultTranslationsObject = { dark: 'Temno', dashboard: 'Nadzorna plošča', delete: 'Izbriši', + deletedAt: 'Izbrisano ob', deletedCountSuccessfully: 'Uspešno izbrisano {{count}} {{label}}.', deletedSuccessfully: 'Uspešno izbrisano.', deleting: 'Brisanje...', diff --git a/packages/translations/src/languages/sv.ts b/packages/translations/src/languages/sv.ts index 6ef37ffeb8c..5161f02b00c 100644 --- a/packages/translations/src/languages/sv.ts +++ b/packages/translations/src/languages/sv.ts @@ -257,6 +257,7 @@ export const svTranslations: DefaultTranslationsObject = { dark: 'Mörkt', dashboard: 'Översikt', delete: 'Ta bort', + deletedAt: 'Raderad Vid', deletedCountSuccessfully: 'Raderade {{count}} {{label}}', deletedSuccessfully: 'Borttaget', deleting: 'Tar bort...', diff --git a/packages/translations/src/languages/th.ts b/packages/translations/src/languages/th.ts index 6721c995817..e163889f3c0 100644 --- a/packages/translations/src/languages/th.ts +++ b/packages/translations/src/languages/th.ts @@ -252,6 +252,7 @@ export const thTranslations: DefaultTranslationsObject = { dark: 'มืด', dashboard: 'แดชบอร์ด', delete: 'ลบ', + deletedAt: 'ถูกลบที่', deletedCountSuccessfully: 'Deleted {{count}} {{label}} successfully.', deletedSuccessfully: 'ลบสำเร็จ', deleting: 'กำลังลบ...', diff --git a/packages/translations/src/languages/tr.ts b/packages/translations/src/languages/tr.ts index 64c35959190..7e42c7716be 100644 --- a/packages/translations/src/languages/tr.ts +++ b/packages/translations/src/languages/tr.ts @@ -260,6 +260,7 @@ export const trTranslations: DefaultTranslationsObject = { dark: 'Karanlık', dashboard: 'Anasayfa', delete: 'Sil', + deletedAt: 'Silindiği Tarih', deletedCountSuccessfully: '{{count}} {{label}} başarıyla silindi.', deletedSuccessfully: 'Başarıyla silindi.', deleting: 'Siliniyor...', diff --git a/packages/translations/src/languages/uk.ts b/packages/translations/src/languages/uk.ts index e2119c66d01..52e0b3c1bd2 100644 --- a/packages/translations/src/languages/uk.ts +++ b/packages/translations/src/languages/uk.ts @@ -256,6 +256,7 @@ export const ukTranslations: DefaultTranslationsObject = { dark: 'Темна', dashboard: 'Головна', delete: 'Видалити', + deletedAt: 'Видалено в', deletedCountSuccessfully: 'Успішно видалено {{count}} {{label}}.', deletedSuccessfully: 'Успішно видалено.', deleting: 'Видалення...', diff --git a/packages/translations/src/languages/vi.ts b/packages/translations/src/languages/vi.ts index 3a50b477d2b..89c4d198400 100644 --- a/packages/translations/src/languages/vi.ts +++ b/packages/translations/src/languages/vi.ts @@ -256,6 +256,7 @@ export const viTranslations: DefaultTranslationsObject = { dark: 'Nền tối', dashboard: 'Bảng điều khiển', delete: 'Xóa', + deletedAt: 'Đã Xóa Lúc', deletedCountSuccessfully: 'Đã xóa thành công {{count}} {{label}}.', deletedSuccessfully: 'Đã xoá thành công.', deleting: 'Đang xóa...', diff --git a/packages/translations/src/languages/zh.ts b/packages/translations/src/languages/zh.ts index 25f8c453a11..d34e2b9bd24 100644 --- a/packages/translations/src/languages/zh.ts +++ b/packages/translations/src/languages/zh.ts @@ -244,6 +244,7 @@ export const zhTranslations: DefaultTranslationsObject = { dark: '深色', dashboard: '仪表板', delete: '删除', + deletedAt: '已删除时间', deletedCountSuccessfully: '已成功删除 {{count}} {{label}}。', deletedSuccessfully: '已成功删除。', deleting: '删除中...', diff --git a/packages/translations/src/languages/zhTw.ts b/packages/translations/src/languages/zhTw.ts index 8a4602c5f30..2c64f4e981e 100644 --- a/packages/translations/src/languages/zhTw.ts +++ b/packages/translations/src/languages/zhTw.ts @@ -244,6 +244,7 @@ export const zhTwTranslations: DefaultTranslationsObject = { dark: '深色', dashboard: '控制面板', delete: '刪除', + deletedAt: '刪除於', deletedCountSuccessfully: '已成功刪除 {{count}} 個 {{label}}。', deletedSuccessfully: '已成功刪除。', deleting: '刪除中...', From c3be174b465980141647007fe1697a270b386a98 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Mon, 2 Jun 2025 18:01:34 -0400 Subject: [PATCH 02/14] feat: adds trash arg to collection find operation --- packages/payload/src/collections/operations/find.ts | 13 +++++++++++++ .../src/collections/operations/local/find.ts | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/payload/src/collections/operations/find.ts b/packages/payload/src/collections/operations/find.ts index 390c2ef7952..2bea67155f6 100644 --- a/packages/payload/src/collections/operations/find.ts +++ b/packages/payload/src/collections/operations/find.ts @@ -48,6 +48,7 @@ export type Arguments = { select?: SelectType showHiddenFields?: boolean sort?: Sort + trash?: boolean where?: Where } @@ -98,6 +99,7 @@ export const findOperation = async < select: incomingSelect, showHiddenFields, sort: incomingSort, + trash = false, where, } = args @@ -145,6 +147,17 @@ export const findOperation = async < let fullWhere = combineQueries(where, accessResult) + // If trash is false, restrict to non-trashed docs only + if (!trash) { + const notTrashedFilter = { deletedAt: { exists: false } } + + if (fullWhere?.and) { + fullWhere.and.push(notTrashedFilter) + } else { + fullWhere = { and: [notTrashedFilter] } + } + } + const sort = sanitizeSortQuery({ fields: collection.config.flattenedFields, sort: incomingSort, diff --git a/packages/payload/src/collections/operations/local/find.ts b/packages/payload/src/collections/operations/local/find.ts index 4e771be7217..dd873f2e3ac 100644 --- a/packages/payload/src/collections/operations/local/find.ts +++ b/packages/payload/src/collections/operations/local/find.ts @@ -114,6 +114,13 @@ export type Options = * @example ['group', '-createdAt'] // sort by 2 fields, ASC group and DESC createdAt */ sort?: Sort + /** + * When set to `true`, the query will include both normal and trashed (soft-deleted) documents. + * To query only trashed documents, pass `trash: true` and combine with a `where` clause filtering by `deletedAt`. + * By default (`false`), the query will only include normal documents and exclude those with a `deletedAt` field. + * @default false + */ + trash?: boolean /** * If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. */ @@ -147,6 +154,7 @@ export async function findLocal< select, showHiddenFields, sort, + trash = false, where, } = options @@ -175,6 +183,7 @@ export async function findLocal< select, showHiddenFields, sort, + trash, where, }) } From 5113e3862d8c6833f5267b59a5c9c8a542c80ff9 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Tue, 3 Jun 2025 10:14:48 -0400 Subject: [PATCH 03/14] feat: adds soft-delete test suite --- .../payload/src/collections/config/types.ts | 2 +- test/soft-delete/collections/Posts/index.ts | 16 + test/soft-delete/config.ts | 34 +++ test/soft-delete/int.spec.ts | 99 +++++++ test/soft-delete/payload-types.ts | 278 ++++++++++++++++++ test/soft-delete/tsconfig.eslint.json | 13 + test/soft-delete/tsconfig.json | 3 + 7 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 test/soft-delete/collections/Posts/index.ts create mode 100644 test/soft-delete/config.ts create mode 100644 test/soft-delete/int.spec.ts create mode 100644 test/soft-delete/payload-types.ts create mode 100644 test/soft-delete/tsconfig.eslint.json create mode 100644 test/soft-delete/tsconfig.json diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index b450bd1313e..f33e2e0a3bd 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -66,7 +66,7 @@ export type AuthOperationsFromCollectionSlug = export type RequiredDataFromCollection = MarkOptional< TData, - 'createdAt' | 'id' | 'sizes' | 'updatedAt' + 'createdAt' | 'deletedAt' | 'id' | 'sizes' | 'updatedAt' > export type RequiredDataFromCollectionSlug = diff --git a/test/soft-delete/collections/Posts/index.ts b/test/soft-delete/collections/Posts/index.ts new file mode 100644 index 00000000000..1db897cd066 --- /dev/null +++ b/test/soft-delete/collections/Posts/index.ts @@ -0,0 +1,16 @@ +import type { CollectionConfig } from 'payload' + +export const postsSlug = 'posts' + +export const PostsCollection: CollectionConfig = { + slug: postsSlug, + admin: { + useAsTitle: 'title', + }, + fields: [ + { + name: 'title', + type: 'text', + }, + ], +} diff --git a/test/soft-delete/config.ts b/test/soft-delete/config.ts new file mode 100644 index 00000000000..3f19924112e --- /dev/null +++ b/test/soft-delete/config.ts @@ -0,0 +1,34 @@ +import { lexicalEditor } from '@payloadcms/richtext-lexical' +import { fileURLToPath } from 'node:url' +import path from 'path' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { devUser } from '../credentials.js' +import { PostsCollection } from './collections/Posts/index.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +// eslint-disable-next-line no-restricted-exports +export default buildConfigWithDefaults({ + collections: [PostsCollection], + admin: { + importMap: { + baseDir: path.resolve(dirname), + }, + }, + editor: lexicalEditor({}), + + onInit: async (payload) => { + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }) + }, + typescript: { + outputFile: path.resolve(dirname, 'payload-types.ts'), + }, +}) diff --git a/test/soft-delete/int.spec.ts b/test/soft-delete/int.spec.ts new file mode 100644 index 00000000000..5bb5f8ad466 --- /dev/null +++ b/test/soft-delete/int.spec.ts @@ -0,0 +1,99 @@ +import path from 'path' +import { type Payload } from 'payload' +import { fileURLToPath } from 'url' + +import type { NextRESTClient } from '../helpers/NextRESTClient.js' +import type { Post } from './payload-types.js' + +import { devUser } from '../credentials.js' +import { initPayloadInt } from '../helpers/initPayloadInt.js' +import { postsSlug } from './collections/Posts/index.js' + +let restClient: NextRESTClient +let user: any +let payload: Payload + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +describe('soft-delete', () => { + beforeAll(async () => { + ;({ payload, restClient } = await initPayloadInt(dirname)) + }) + + afterAll(async () => { + if (typeof payload.db.destroy === 'function') { + await payload.db.destroy() + } + }) + + beforeEach(async () => { + await restClient.login({ + slug: 'users', + credentials: devUser, + }) + user = await payload.login({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }) + }) + + describe('find operation', () => { + let postOne: Post + let postTwo: Post + + beforeAll(async () => { + postOne = await payload.create({ + collection: postsSlug, + data: { + title: 'Post one', + }, + }) + + postTwo = await payload.create({ + collection: postsSlug, + data: { + title: 'Post two', + deletedAt: new Date().toISOString(), + }, + }) + }) + + it('should return all docs including soft-deleted docs in find with trash: true', async () => { + const allDocs = await payload.find({ + collection: postsSlug, + trash: true, + }) + + expect(allDocs.totalDocs).toEqual(2) + }) + + it('should return only soft-deleted docs in find with trash: true', async () => { + const softDeletedDocs = await payload.find({ + collection: postsSlug, + where: { + deletedAt: { + exists: true, + }, + }, + trash: true, + }) + + expect(softDeletedDocs.totalDocs).toEqual(1) + expect(softDeletedDocs.docs[0]?.id).toEqual(postTwo.id) + }) + + it('should return only non-soft-deleted docs in find with trash: false', async () => { + const normalDocs = await payload.find({ + collection: postsSlug, + trash: false, + }) + + expect(normalDocs.totalDocs).toEqual(1) + expect(normalDocs.docs[0]?.id).toEqual(postOne.id) + }) + }) +}) diff --git a/test/soft-delete/payload-types.ts b/test/soft-delete/payload-types.ts new file mode 100644 index 00000000000..775fbc656fd --- /dev/null +++ b/test/soft-delete/payload-types.ts @@ -0,0 +1,278 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +/** + * Supported timezones in IANA format. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supportedTimezones". + */ +export type SupportedTimezones = + | 'Pacific/Midway' + | 'Pacific/Niue' + | 'Pacific/Honolulu' + | 'Pacific/Rarotonga' + | 'America/Anchorage' + | 'Pacific/Gambier' + | 'America/Los_Angeles' + | 'America/Tijuana' + | 'America/Denver' + | 'America/Phoenix' + | 'America/Chicago' + | 'America/Guatemala' + | 'America/New_York' + | 'America/Bogota' + | 'America/Caracas' + | 'America/Santiago' + | 'America/Buenos_Aires' + | 'America/Sao_Paulo' + | 'Atlantic/South_Georgia' + | 'Atlantic/Azores' + | 'Atlantic/Cape_Verde' + | 'Europe/London' + | 'Europe/Berlin' + | 'Africa/Lagos' + | 'Europe/Athens' + | 'Africa/Cairo' + | 'Europe/Moscow' + | 'Asia/Riyadh' + | 'Asia/Dubai' + | 'Asia/Baku' + | 'Asia/Karachi' + | 'Asia/Tashkent' + | 'Asia/Calcutta' + | 'Asia/Dhaka' + | 'Asia/Almaty' + | 'Asia/Jakarta' + | 'Asia/Bangkok' + | 'Asia/Shanghai' + | 'Asia/Singapore' + | 'Asia/Tokyo' + | 'Asia/Seoul' + | 'Australia/Brisbane' + | 'Australia/Sydney' + | 'Pacific/Guam' + | 'Pacific/Noumea' + | 'Pacific/Auckland' + | 'Pacific/Fiji'; + +export interface Config { + auth: { + users: UserAuthOperations; + }; + blocks: {}; + collections: { + posts: Post; + users: User; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + collectionsJoins: {}; + collectionsSelect: { + posts: PostsSelect | PostsSelect; + users: UsersSelect | UsersSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + db: { + defaultIDType: string; + }; + globals: {}; + globalsSelect: {}; + locale: null; + user: User & { + collection: 'users'; + }; + jobs: { + tasks: unknown; + workflows: unknown; + }; +} +export interface UserAuthOperations { + forgotPassword: { + email: string; + password: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + password: string; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts". + */ +export interface Post { + id: string; + title?: string | null; + updatedAt: string; + createdAt: string; + deletedAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + updatedAt: string; + createdAt: string; + deletedAt?: string | null; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + password?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: string; + document?: + | ({ + relationTo: 'posts'; + value: string | Post; + } | null) + | ({ + relationTo: 'users'; + value: string | User; + } | null); + globalSlug?: string | null; + user: { + relationTo: 'users'; + value: string | User; + }; + updatedAt: string; + createdAt: string; + deletedAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: string; + user: { + relationTo: 'users'; + value: string | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; + deletedAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: string; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; + deletedAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts_select". + */ +export interface PostsSelect { + title?: T; + updatedAt?: T; + createdAt?: T; + deletedAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + updatedAt?: T; + createdAt?: T; + deletedAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + loginAttempts?: T; + lockUntil?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents_select". + */ +export interface PayloadLockedDocumentsSelect { + document?: T; + globalSlug?: T; + user?: T; + updatedAt?: T; + createdAt?: T; + deletedAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences_select". + */ +export interface PayloadPreferencesSelect { + user?: T; + key?: T; + value?: T; + updatedAt?: T; + createdAt?: T; + deletedAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations_select". + */ +export interface PayloadMigrationsSelect { + name?: T; + batch?: T; + updatedAt?: T; + createdAt?: T; + deletedAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + + +declare module 'payload' { + // @ts-ignore + export interface GeneratedTypes extends Config {} +} \ No newline at end of file diff --git a/test/soft-delete/tsconfig.eslint.json b/test/soft-delete/tsconfig.eslint.json new file mode 100644 index 00000000000..b34cc7afbb8 --- /dev/null +++ b/test/soft-delete/tsconfig.eslint.json @@ -0,0 +1,13 @@ +{ + // extend your base config to share compilerOptions, etc + //"extends": "./tsconfig.json", + "compilerOptions": { + // ensure that nobody can accidentally use this config for a build + "noEmit": true + }, + "include": [ + // whatever paths you intend to lint + "./**/*.ts", + "./**/*.tsx" + ] +} diff --git a/test/soft-delete/tsconfig.json b/test/soft-delete/tsconfig.json new file mode 100644 index 00000000000..3c43903cfdd --- /dev/null +++ b/test/soft-delete/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.json" +} From 6a84864844ab4ca424d09f51d2290a48f38f57d7 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Tue, 3 Jun 2025 13:48:35 -0400 Subject: [PATCH 04/14] feat: adds trash flag to find, update & delete local operations --- .../src/collections/operations/delete.ts | 15 +- .../src/collections/operations/deleteByID.ts | 17 +- .../src/collections/operations/findByID.ts | 15 +- .../collections/operations/local/delete.ts | 8 + .../collections/operations/local/findByID.ts | 9 + .../collections/operations/local/update.ts | 9 + .../src/collections/operations/update.ts | 15 +- .../src/collections/operations/updateByID.ts | 19 +- test/soft-delete/collections/Posts/index.ts | 2 +- test/soft-delete/config.ts | 4 +- test/soft-delete/int.spec.ts | 271 ++++++++++++++++-- 11 files changed, 356 insertions(+), 28 deletions(-) diff --git a/packages/payload/src/collections/operations/delete.ts b/packages/payload/src/collections/operations/delete.ts index 15d60afbd60..b2b4341eecd 100644 --- a/packages/payload/src/collections/operations/delete.ts +++ b/packages/payload/src/collections/operations/delete.ts @@ -37,6 +37,7 @@ export type Arguments = { req: PayloadRequest select?: SelectType showHiddenFields?: boolean + trash?: boolean where: Where } @@ -82,6 +83,7 @@ export const deleteOperation = async < req, select: incomingSelect, showHiddenFields, + trash = false, where, } = args @@ -106,7 +108,18 @@ export const deleteOperation = async < where, }) - const fullWhere = combineQueries(where, accessResult) + let fullWhere = combineQueries(where, accessResult) + + // If trash is false, restrict to non-trashed docs only + if (!trash) { + const notTrashedFilter = { deletedAt: { exists: false } } + + if (fullWhere?.and) { + fullWhere.and.push(notTrashedFilter) + } else { + fullWhere = { and: [notTrashedFilter] } + } + } const select = sanitizeSelect({ fields: collectionConfig.flattenedFields, diff --git a/packages/payload/src/collections/operations/deleteByID.ts b/packages/payload/src/collections/operations/deleteByID.ts index be3cfe54164..b44cd55c2a7 100644 --- a/packages/payload/src/collections/operations/deleteByID.ts +++ b/packages/payload/src/collections/operations/deleteByID.ts @@ -35,6 +35,7 @@ export type Arguments = { req: PayloadRequest select?: SelectType showHiddenFields?: boolean + trash?: boolean } export const deleteByIDOperation = async ( @@ -78,6 +79,7 @@ export const deleteByIDOperation = async = { * @example ['group', '-createdAt'] // sort by 2 fields, ASC group and DESC createdAt */ sort?: Sort + trash?: boolean where: Where } @@ -105,6 +106,7 @@ export const updateOperation = async < select: incomingSelect, showHiddenFields, sort: incomingSort, + trash = false, where, } = args @@ -135,7 +137,18 @@ export const updateOperation = async < // Retrieve documents // ///////////////////////////////////// - const fullWhere = combineQueries(where, accessResult) + let fullWhere = combineQueries(where, accessResult) + + // If trash is false, restrict to non-trashed docs only + if (!trash) { + const notTrashedFilter = { deletedAt: { exists: false } } + + if (fullWhere?.and) { + fullWhere.and.push(notTrashedFilter) + } else { + fullWhere = { and: [notTrashedFilter] } + } + } const sort = sanitizeSortQuery({ fields: collection.config.flattenedFields, diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index c80487686ce..3a09b4265b8 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -48,6 +48,7 @@ export type Arguments = { req: PayloadRequest select?: SelectType showHiddenFields?: boolean + trash?: boolean } export const updateByIDOperation = async < @@ -103,6 +104,7 @@ export const updateByIDOperation = async < req, select: incomingSelect, showHiddenFields, + trash = false, } = args if (!id) { @@ -124,11 +126,26 @@ export const updateByIDOperation = async < // Retrieve document // ///////////////////////////////////// + const where = { id: { equals: id } } + + let fullWhere = combineQueries(where, accessResults) + + // If trash is false, restrict to non-trashed docs only + if (!trash) { + const notTrashedFilter = { deletedAt: { exists: false } } + + if (fullWhere?.and) { + fullWhere.and.push(notTrashedFilter) + } else { + fullWhere = { and: [notTrashedFilter] } + } + } + const findOneArgs: FindOneArgs = { collection: collectionConfig.slug, locale, req, - where: combineQueries({ id: { equals: id } }, accessResults), + where: fullWhere, } const docWithLocales = await getLatestCollectionVersion({ diff --git a/test/soft-delete/collections/Posts/index.ts b/test/soft-delete/collections/Posts/index.ts index 1db897cd066..6e5ea98aaf6 100644 --- a/test/soft-delete/collections/Posts/index.ts +++ b/test/soft-delete/collections/Posts/index.ts @@ -2,7 +2,7 @@ import type { CollectionConfig } from 'payload' export const postsSlug = 'posts' -export const PostsCollection: CollectionConfig = { +export const Posts: CollectionConfig = { slug: postsSlug, admin: { useAsTitle: 'title', diff --git a/test/soft-delete/config.ts b/test/soft-delete/config.ts index 3f19924112e..a2213cc7186 100644 --- a/test/soft-delete/config.ts +++ b/test/soft-delete/config.ts @@ -4,14 +4,14 @@ import path from 'path' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { devUser } from '../credentials.js' -import { PostsCollection } from './collections/Posts/index.js' +import { Posts } from './collections/Posts/index.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) // eslint-disable-next-line no-restricted-exports export default buildConfigWithDefaults({ - collections: [PostsCollection], + collections: [Posts], admin: { importMap: { baseDir: path.resolve(dirname), diff --git a/test/soft-delete/int.spec.ts b/test/soft-delete/int.spec.ts index 5bb5f8ad466..b1e6a3f5433 100644 --- a/test/soft-delete/int.spec.ts +++ b/test/soft-delete/int.spec.ts @@ -1,5 +1,6 @@ +import type { Payload } from 'payload' + import path from 'path' -import { type Payload } from 'payload' import { fileURLToPath } from 'url' import type { NextRESTClient } from '../helpers/NextRESTClient.js' @@ -10,15 +11,18 @@ import { initPayloadInt } from '../helpers/initPayloadInt.js' import { postsSlug } from './collections/Posts/index.js' let restClient: NextRESTClient -let user: any let payload: Payload +let user: any const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) describe('soft-delete', () => { beforeAll(async () => { - ;({ payload, restClient } = await initPayloadInt(dirname)) + const initResult = await initPayloadInt(dirname) + + payload = initResult.payload as Payload + restClient = initResult.restClient as NextRESTClient }) afterAll(async () => { @@ -27,11 +31,15 @@ describe('soft-delete', () => { } }) + let postOne: Post + let postTwo: Post + beforeEach(async () => { await restClient.login({ slug: 'users', credentials: devUser, }) + user = await payload.login({ collection: 'users', data: { @@ -39,29 +47,36 @@ describe('soft-delete', () => { password: devUser.password, }, }) - }) - describe('find operation', () => { - let postOne: Post - let postTwo: Post + postOne = await payload.create({ + collection: postsSlug, + data: { + title: 'Post one', + }, + }) - beforeAll(async () => { - postOne = await payload.create({ - collection: postsSlug, - data: { - title: 'Post one', - }, - }) + postTwo = await payload.create({ + collection: postsSlug, + data: { + title: 'Post two', + deletedAt: new Date().toISOString(), + }, + }) + }) - postTwo = await payload.create({ - collection: postsSlug, - data: { - title: 'Post two', - deletedAt: new Date().toISOString(), + afterEach(async () => { + await payload.delete({ + collection: postsSlug, + trash: true, + where: { + title: { + exists: true, }, - }) + }, }) + }) + describe('find / findByID operation', () => { it('should return all docs including soft-deleted docs in find with trash: true', async () => { const allDocs = await payload.find({ collection: postsSlug, @@ -95,5 +110,221 @@ describe('soft-delete', () => { expect(normalDocs.totalDocs).toEqual(1) expect(normalDocs.docs[0]?.id).toEqual(postOne.id) }) + + it('should return a soft-deleted document when trash: true', async () => { + const softDeletedPost: Post = await payload.findByID({ + collection: postsSlug, + id: postTwo.id, + trash: true, + }) + + expect(softDeletedPost).toBeDefined() + expect(softDeletedPost?.id).toEqual(postTwo.id) + expect(softDeletedPost?.deletedAt).toBeDefined() + expect(softDeletedPost?.deletedAt).toEqual(postTwo.deletedAt) + }) + + it('should throw NotFound error when trying to find a soft-deleted document w/o trash: true', async () => { + await expect( + payload.findByID({ + collection: postsSlug, + id: postTwo.id, + }), + ).rejects.toThrow('Not Found') + + await expect( + payload.findByID({ + collection: postsSlug, + id: postTwo.id, + trash: false, + }), + ).rejects.toThrow('Not Found') + }) + }) + + describe('updateByID operation', () => { + it('should update a single soft-deleted document when trash: true', async () => { + const updatedPost: Post = await payload.update({ + collection: postsSlug, + id: postTwo.id, + data: { + title: 'Updated Post Two', + }, + trash: true, + }) + + expect(updatedPost).toBeDefined() + expect(updatedPost.id).toEqual(postTwo.id) + expect(updatedPost.title).toEqual('Updated Post Two') + expect(updatedPost.deletedAt).toBeDefined() + expect(updatedPost.deletedAt).toEqual(postTwo.deletedAt) + }) + + it('should throw NotFound error when trying to update a soft-deleted document w/o trash: true', async () => { + await expect( + payload.update({ + collection: postsSlug, + id: postTwo.id, + data: { + title: 'Updated Post Two', + }, + }), + ).rejects.toThrow('Not Found') + + await expect( + payload.update({ + collection: postsSlug, + id: postTwo.id, + data: { + title: 'Updated Post Two', + }, + trash: false, + }), + ).rejects.toThrow('Not Found') + }) + + it('should update a single normal document when trash: false', async () => { + const updatedPost: Post = await payload.update({ + collection: postsSlug, + id: postOne.id, + data: { + title: 'Updated Post One', + }, + }) + + expect(updatedPost).toBeDefined() + expect(updatedPost.id).toEqual(postOne.id) + expect(updatedPost.title).toEqual('Updated Post One') + expect(updatedPost.deletedAt).toBeUndefined() + }) + }) + + describe('update operation', () => { + it('should update only normal document when trash: false', async () => { + const result = await payload.update({ + collection: postsSlug, + data: { + title: 'Updated Post', + }, + trash: false, + where: { + title: { + exists: true, + }, + }, + }) + + expect(result.docs).toBeDefined() + expect(result.docs.length).toBeGreaterThan(0) + + const updatedPost: Post = result.docs[0]! + + expect(updatedPost?.id).toEqual(postOne.id) + expect(updatedPost?.title).toEqual('Updated Post') + expect(updatedPost?.deletedAt).toBeUndefined() + }) + + it('should update all documents including soft-deleted documents when trash: true', async () => { + const result = await payload.update({ + collection: postsSlug, + data: { + title: 'A New Updated Post', + }, + trash: true, + where: { + title: { + exists: true, + }, + }, + }) + + expect(result.docs).toBeDefined() + expect(result.docs.length).toBeGreaterThan(0) + + const updatedPostOne: Post = result.docs.find((doc) => doc.id === postOne.id)! + const updatedPostTwo: Post = result.docs.find((doc) => doc.id === postTwo.id)! + + expect(updatedPostOne?.title).toEqual('A New Updated Post') + expect(updatedPostOne?.deletedAt).toBeUndefined() + + expect(updatedPostTwo?.title).toEqual('A New Updated Post') + expect(updatedPostTwo?.deletedAt).toBeDefined() + }) + }) + + describe('delete operation', () => { + it('should perma delete all docs including soft-deleted documents when trash: true', async () => { + await payload.delete({ + collection: postsSlug, + trash: true, + where: { + title: { + exists: true, + }, + }, + }) + + const allDocs = await payload.find({ + collection: postsSlug, + trash: true, + }) + + expect(allDocs.totalDocs).toEqual(0) + }) + + it('should only perma delete normal docs when trash: false', async () => { + await payload.delete({ + collection: postsSlug, + trash: false, + where: { + title: { + exists: true, + }, + }, + }) + + const allDocs = await payload.find({ + collection: postsSlug, + trash: true, + }) + + expect(allDocs.totalDocs).toEqual(1) + expect(allDocs.docs[0]?.id).toEqual(postTwo.id) + }) + }) + + describe('deleteByID operation', () => { + it('should throw NotFound error when trying to delete a soft-deleted document w/o trash: true', async () => { + await expect( + payload.delete({ + collection: postsSlug, + id: postTwo.id, + }), + ).rejects.toThrow('Not Found') + + await expect( + payload.delete({ + collection: postsSlug, + id: postTwo.id, + trash: false, + }), + ).rejects.toThrow('Not Found') + }) + + it('should delete a soft-deleted document when trash: true', async () => { + await payload.delete({ + collection: postsSlug, + id: postTwo.id, + trash: true, + }) + + const allDocs = await payload.find({ + collection: postsSlug, + trash: true, + }) + + expect(allDocs.totalDocs).toEqual(1) + expect(allDocs.docs[0]?.id).toEqual(postOne.id) + }) }) }) From 667f3268743683d28742c15fd053a44ee40a3ed5 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:54:14 -0400 Subject: [PATCH 05/14] feat: adds trash flag to find, update & delete endpoints --- .../src/collections/endpoints/delete.ts | 4 +- .../src/collections/endpoints/deleteByID.ts | 2 + .../payload/src/collections/endpoints/find.ts | 4 +- .../src/collections/endpoints/findByID.ts | 2 + .../src/collections/endpoints/update.ts | 4 +- .../src/collections/endpoints/updateByID.ts | 2 + test/soft-delete/int.spec.ts | 512 +++++++++++------- 7 files changed, 343 insertions(+), 187 deletions(-) diff --git a/packages/payload/src/collections/endpoints/delete.ts b/packages/payload/src/collections/endpoints/delete.ts index 4367420e32b..be47ce0717c 100644 --- a/packages/payload/src/collections/endpoints/delete.ts +++ b/packages/payload/src/collections/endpoints/delete.ts @@ -13,11 +13,12 @@ import { deleteOperation } from '../operations/delete.js' export const deleteHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { depth, overrideLock, populate, select, where } = req.query as { + const { depth, overrideLock, populate, select, trash, where } = req.query as { depth?: string overrideLock?: string populate?: Record select?: Record + trash?: string where?: Where } @@ -28,6 +29,7 @@ export const deleteHandler: PayloadHandler = async (req) => { populate: sanitizePopulateParam(populate), req, select: sanitizeSelectParam(select), + trash: trash === 'true', where: where!, }) diff --git a/packages/payload/src/collections/endpoints/deleteByID.ts b/packages/payload/src/collections/endpoints/deleteByID.ts index 1563ade65a3..7faa13cd02b 100644 --- a/packages/payload/src/collections/endpoints/deleteByID.ts +++ b/packages/payload/src/collections/endpoints/deleteByID.ts @@ -14,6 +14,7 @@ export const deleteByIDHandler: PayloadHandler = async (req) => { const { searchParams } = req const depth = searchParams.get('depth') const overrideLock = searchParams.get('overrideLock') + const trash = searchParams.get('trash') === 'true' const doc = await deleteByIDOperation({ id, @@ -23,6 +24,7 @@ export const deleteByIDHandler: PayloadHandler = async (req) => { populate: sanitizePopulateParam(req.query.populate), req, select: sanitizeSelectParam(req.query.select), + trash, }) const headers = headersWithCors({ diff --git a/packages/payload/src/collections/endpoints/find.ts b/packages/payload/src/collections/endpoints/find.ts index 30a36089190..0d532c4bd57 100644 --- a/packages/payload/src/collections/endpoints/find.ts +++ b/packages/payload/src/collections/endpoints/find.ts @@ -14,7 +14,7 @@ import { findOperation } from '../operations/find.js' export const findHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { depth, draft, joins, limit, page, pagination, populate, select, sort, where } = + const { depth, draft, joins, limit, page, pagination, populate, select, sort, trash, where } = req.query as { depth?: string draft?: string @@ -25,6 +25,7 @@ export const findHandler: PayloadHandler = async (req) => { populate?: Record select?: Record sort?: string + trash?: string where?: Where } @@ -40,6 +41,7 @@ export const findHandler: PayloadHandler = async (req) => { req, select: sanitizeSelectParam(select), sort: typeof sort === 'string' ? sort.split(',') : undefined, + trash: trash === 'true', where, }) diff --git a/packages/payload/src/collections/endpoints/findByID.ts b/packages/payload/src/collections/endpoints/findByID.ts index fbdb9b6f272..70413c770a6 100644 --- a/packages/payload/src/collections/endpoints/findByID.ts +++ b/packages/payload/src/collections/endpoints/findByID.ts @@ -14,6 +14,7 @@ export const findByIDHandler: PayloadHandler = async (req) => { const { searchParams } = req const { id, collection } = getRequestCollectionWithID(req) const depth = searchParams.get('depth') + const trash = searchParams.get('trash') === 'true' const result = await findByIDOperation({ id, @@ -24,6 +25,7 @@ export const findByIDHandler: PayloadHandler = async (req) => { populate: sanitizePopulateParam(req.query.populate), req, select: sanitizeSelectParam(req.query.select), + trash, }) return Response.json(result, { diff --git a/packages/payload/src/collections/endpoints/update.ts b/packages/payload/src/collections/endpoints/update.ts index 543c3836542..9db2d54883a 100644 --- a/packages/payload/src/collections/endpoints/update.ts +++ b/packages/payload/src/collections/endpoints/update.ts @@ -13,7 +13,7 @@ import { updateOperation } from '../operations/update.js' export const updateHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { depth, draft, limit, overrideLock, populate, select, sort, where } = req.query as { + const { depth, draft, limit, overrideLock, populate, select, sort, trash, where } = req.query as { depth?: string draft?: string limit?: string @@ -21,6 +21,7 @@ export const updateHandler: PayloadHandler = async (req) => { populate?: Record select?: Record sort?: string + trash?: string where?: Where } @@ -35,6 +36,7 @@ export const updateHandler: PayloadHandler = async (req) => { req, select: sanitizeSelectParam(select), sort: typeof sort === 'string' ? sort.split(',') : undefined, + trash: trash === 'true', where: where!, }) diff --git a/packages/payload/src/collections/endpoints/updateByID.ts b/packages/payload/src/collections/endpoints/updateByID.ts index df2f7a4f6ed..78d2f74adae 100644 --- a/packages/payload/src/collections/endpoints/updateByID.ts +++ b/packages/payload/src/collections/endpoints/updateByID.ts @@ -16,6 +16,7 @@ export const updateByIDHandler: PayloadHandler = async (req) => { const autosave = searchParams.get('autosave') === 'true' const draft = searchParams.get('draft') === 'true' const overrideLock = searchParams.get('overrideLock') + const trash = searchParams.get('trash') === 'true' const publishSpecificLocale = req.query.publishSpecificLocale as string | undefined const doc = await updateByIDOperation({ @@ -30,6 +31,7 @@ export const updateByIDHandler: PayloadHandler = async (req) => { publishSpecificLocale, req, select: sanitizeSelectParam(req.query.select), + trash, }) let message = req.t('general:updatedSuccessfully') diff --git a/test/soft-delete/int.spec.ts b/test/soft-delete/int.spec.ts index b1e6a3f5433..282776cfe0c 100644 --- a/test/soft-delete/int.spec.ts +++ b/test/soft-delete/int.spec.ts @@ -76,255 +76,399 @@ describe('soft-delete', () => { }) }) - describe('find / findByID operation', () => { - it('should return all docs including soft-deleted docs in find with trash: true', async () => { - const allDocs = await payload.find({ - collection: postsSlug, - trash: true, - }) + describe('LOCAL', () => { + describe('find / findByID operation', () => { + it('should return all docs including soft-deleted docs in find with trash: true', async () => { + const allDocs = await payload.find({ + collection: postsSlug, + trash: true, + }) - expect(allDocs.totalDocs).toEqual(2) - }) + expect(allDocs.totalDocs).toEqual(2) + }) - it('should return only soft-deleted docs in find with trash: true', async () => { - const softDeletedDocs = await payload.find({ - collection: postsSlug, - where: { - deletedAt: { - exists: true, + it('should return only soft-deleted docs in find with trash: true', async () => { + const softDeletedDocs = await payload.find({ + collection: postsSlug, + where: { + deletedAt: { + exists: true, + }, }, - }, - trash: true, + trash: true, + }) + + expect(softDeletedDocs.totalDocs).toEqual(1) + expect(softDeletedDocs.docs[0]?.id).toEqual(postTwo.id) }) - expect(softDeletedDocs.totalDocs).toEqual(1) - expect(softDeletedDocs.docs[0]?.id).toEqual(postTwo.id) - }) + it('should return only non-soft-deleted docs in find with trash: false', async () => { + const normalDocs = await payload.find({ + collection: postsSlug, + trash: false, + }) - it('should return only non-soft-deleted docs in find with trash: false', async () => { - const normalDocs = await payload.find({ - collection: postsSlug, - trash: false, + expect(normalDocs.totalDocs).toEqual(1) + expect(normalDocs.docs[0]?.id).toEqual(postOne.id) }) - expect(normalDocs.totalDocs).toEqual(1) - expect(normalDocs.docs[0]?.id).toEqual(postOne.id) - }) + it('should return a soft-deleted document when trash: true', async () => { + const softDeletedPost: Post = await payload.findByID({ + collection: postsSlug, + id: postTwo.id, + trash: true, + }) - it('should return a soft-deleted document when trash: true', async () => { - const softDeletedPost: Post = await payload.findByID({ - collection: postsSlug, - id: postTwo.id, - trash: true, + expect(softDeletedPost).toBeDefined() + expect(softDeletedPost?.id).toEqual(postTwo.id) + expect(softDeletedPost?.deletedAt).toBeDefined() + expect(softDeletedPost?.deletedAt).toEqual(postTwo.deletedAt) }) - expect(softDeletedPost).toBeDefined() - expect(softDeletedPost?.id).toEqual(postTwo.id) - expect(softDeletedPost?.deletedAt).toBeDefined() - expect(softDeletedPost?.deletedAt).toEqual(postTwo.deletedAt) + it('should throw NotFound error when trying to find a soft-deleted document w/o trash: true', async () => { + await expect( + payload.findByID({ + collection: postsSlug, + id: postTwo.id, + }), + ).rejects.toThrow('Not Found') + + await expect( + payload.findByID({ + collection: postsSlug, + id: postTwo.id, + trash: false, + }), + ).rejects.toThrow('Not Found') + }) }) - it('should throw NotFound error when trying to find a soft-deleted document w/o trash: true', async () => { - await expect( - payload.findByID({ + describe('updateByID operation', () => { + it('should update a single soft-deleted document when trash: true', async () => { + const updatedPost: Post = await payload.update({ collection: postsSlug, id: postTwo.id, - }), - ).rejects.toThrow('Not Found') + data: { + title: 'Updated Post Two', + }, + trash: true, + }) + + expect(updatedPost).toBeDefined() + expect(updatedPost.id).toEqual(postTwo.id) + expect(updatedPost.title).toEqual('Updated Post Two') + expect(updatedPost.deletedAt).toBeDefined() + expect(updatedPost.deletedAt).toEqual(postTwo.deletedAt) + }) + + it('should throw NotFound error when trying to update a soft-deleted document w/o trash: true', async () => { + await expect( + payload.update({ + collection: postsSlug, + id: postTwo.id, + data: { + title: 'Updated Post Two', + }, + }), + ).rejects.toThrow('Not Found') + + await expect( + payload.update({ + collection: postsSlug, + id: postTwo.id, + data: { + title: 'Updated Post Two', + }, + trash: false, + }), + ).rejects.toThrow('Not Found') + }) - await expect( - payload.findByID({ + it('should update a single normal document when trash: false', async () => { + const updatedPost: Post = await payload.update({ collection: postsSlug, - id: postTwo.id, - trash: false, - }), - ).rejects.toThrow('Not Found') - }) - }) + id: postOne.id, + data: { + title: 'Updated Post One', + }, + }) - describe('updateByID operation', () => { - it('should update a single soft-deleted document when trash: true', async () => { - const updatedPost: Post = await payload.update({ - collection: postsSlug, - id: postTwo.id, - data: { - title: 'Updated Post Two', - }, - trash: true, + expect(updatedPost).toBeDefined() + expect(updatedPost.id).toEqual(postOne.id) + expect(updatedPost.title).toEqual('Updated Post One') + expect(updatedPost.deletedAt).toBeUndefined() }) - - expect(updatedPost).toBeDefined() - expect(updatedPost.id).toEqual(postTwo.id) - expect(updatedPost.title).toEqual('Updated Post Two') - expect(updatedPost.deletedAt).toBeDefined() - expect(updatedPost.deletedAt).toEqual(postTwo.deletedAt) }) - it('should throw NotFound error when trying to update a soft-deleted document w/o trash: true', async () => { - await expect( - payload.update({ + describe('update operation', () => { + it('should update only normal document when trash: false', async () => { + const result = await payload.update({ collection: postsSlug, - id: postTwo.id, data: { - title: 'Updated Post Two', + title: 'Updated Post', + }, + trash: false, + where: { + title: { + exists: true, + }, }, - }), - ).rejects.toThrow('Not Found') + }) - await expect( - payload.update({ + expect(result.docs).toBeDefined() + expect(result.docs.length).toBeGreaterThan(0) + + const updatedPost: Post = result.docs[0]! + + expect(updatedPost?.id).toEqual(postOne.id) + expect(updatedPost?.title).toEqual('Updated Post') + expect(updatedPost?.deletedAt).toBeUndefined() + }) + + it('should update all documents including soft-deleted documents when trash: true', async () => { + const result = await payload.update({ collection: postsSlug, - id: postTwo.id, data: { - title: 'Updated Post Two', + title: 'A New Updated Post', }, - trash: false, - }), - ).rejects.toThrow('Not Found') - }) + trash: true, + where: { + title: { + exists: true, + }, + }, + }) - it('should update a single normal document when trash: false', async () => { - const updatedPost: Post = await payload.update({ - collection: postsSlug, - id: postOne.id, - data: { - title: 'Updated Post One', - }, - }) + expect(result.docs).toBeDefined() + expect(result.docs.length).toBeGreaterThan(0) + + const updatedPostOne: Post = result.docs.find((doc) => doc.id === postOne.id)! + const updatedPostTwo: Post = result.docs.find((doc) => doc.id === postTwo.id)! - expect(updatedPost).toBeDefined() - expect(updatedPost.id).toEqual(postOne.id) - expect(updatedPost.title).toEqual('Updated Post One') - expect(updatedPost.deletedAt).toBeUndefined() + expect(updatedPostOne?.title).toEqual('A New Updated Post') + expect(updatedPostOne?.deletedAt).toBeUndefined() + + expect(updatedPostTwo?.title).toEqual('A New Updated Post') + expect(updatedPostTwo?.deletedAt).toBeDefined() + }) }) - }) - describe('update operation', () => { - it('should update only normal document when trash: false', async () => { - const result = await payload.update({ - collection: postsSlug, - data: { - title: 'Updated Post', - }, - trash: false, - where: { - title: { - exists: true, + describe('delete operation', () => { + it('should perma delete all docs including soft-deleted documents when trash: true', async () => { + await payload.delete({ + collection: postsSlug, + trash: true, + where: { + title: { + exists: true, + }, }, - }, + }) + + const allDocs = await payload.find({ + collection: postsSlug, + trash: true, + }) + + expect(allDocs.totalDocs).toEqual(0) }) - expect(result.docs).toBeDefined() - expect(result.docs.length).toBeGreaterThan(0) + it('should only perma delete normal docs when trash: false', async () => { + await payload.delete({ + collection: postsSlug, + trash: false, + where: { + title: { + exists: true, + }, + }, + }) - const updatedPost: Post = result.docs[0]! + const allDocs = await payload.find({ + collection: postsSlug, + trash: true, + }) - expect(updatedPost?.id).toEqual(postOne.id) - expect(updatedPost?.title).toEqual('Updated Post') - expect(updatedPost?.deletedAt).toBeUndefined() + expect(allDocs.totalDocs).toEqual(1) + expect(allDocs.docs[0]?.id).toEqual(postTwo.id) + }) }) - it('should update all documents including soft-deleted documents when trash: true', async () => { - const result = await payload.update({ - collection: postsSlug, - data: { - title: 'A New Updated Post', - }, - trash: true, - where: { - title: { - exists: true, - }, - }, + describe('deleteByID operation', () => { + it('should throw NotFound error when trying to delete a soft-deleted document w/o trash: true', async () => { + await expect( + payload.delete({ + collection: postsSlug, + id: postTwo.id, + }), + ).rejects.toThrow('Not Found') + + await expect( + payload.delete({ + collection: postsSlug, + id: postTwo.id, + trash: false, + }), + ).rejects.toThrow('Not Found') }) - expect(result.docs).toBeDefined() - expect(result.docs.length).toBeGreaterThan(0) - - const updatedPostOne: Post = result.docs.find((doc) => doc.id === postOne.id)! - const updatedPostTwo: Post = result.docs.find((doc) => doc.id === postTwo.id)! + it('should delete a soft-deleted document when trash: true', async () => { + await payload.delete({ + collection: postsSlug, + id: postTwo.id, + trash: true, + }) - expect(updatedPostOne?.title).toEqual('A New Updated Post') - expect(updatedPostOne?.deletedAt).toBeUndefined() + const allDocs = await payload.find({ + collection: postsSlug, + trash: true, + }) - expect(updatedPostTwo?.title).toEqual('A New Updated Post') - expect(updatedPostTwo?.deletedAt).toBeDefined() + expect(allDocs.totalDocs).toEqual(1) + expect(allDocs.docs[0]?.id).toEqual(postOne.id) + }) }) }) - describe('delete operation', () => { - it('should perma delete all docs including soft-deleted documents when trash: true', async () => { - await payload.delete({ - collection: postsSlug, - trash: true, - where: { - title: { - exists: true, - }, - }, + describe('REST', () => { + describe('find / findByID endpoint', () => { + it('should return all docs including soft-deleted docs in find with trash=true', async () => { + const res = await restClient.GET(`/${postsSlug}?trash=true`) + expect(res.status).toBe(200) + const data = await res.json() + expect(data.docs).toHaveLength(2) + }) + + it('should return only soft-deleted docs with trash=true and where[deletedAt][exists]=true', async () => { + const res = await restClient.GET(`/${postsSlug}?trash=true&where[deletedAt][exists]=true`) + const data = await res.json() + expect(data.docs).toHaveLength(1) + expect(data.docs[0]?.id).toEqual(postTwo.id) + }) + + it('should return only normal docs when trash=false', async () => { + const res = await restClient.GET(`/${postsSlug}?trash=false`) + const data = await res.json() + expect(data.docs).toHaveLength(1) + expect(data.docs[0]?.id).toEqual(postOne.id) }) - const allDocs = await payload.find({ - collection: postsSlug, - trash: true, + it('should return a soft-deleted doc by ID with trash=true', async () => { + const res = await restClient.GET(`/${postsSlug}/${postTwo.id}?trash=true`) + const data = await res.json() + expect(data?.id).toEqual(postTwo.id) + expect(data?.deletedAt).toEqual(postTwo.deletedAt) }) - expect(allDocs.totalDocs).toEqual(0) + it('should 404 when trying to get a soft-deleted doc without trash=true', async () => { + const res = await restClient.GET(`/${postsSlug}/${postTwo.id}`) + expect(res.status).toBe(404) + }) }) - it('should only perma delete normal docs when trash: false', async () => { - await payload.delete({ - collection: postsSlug, - trash: false, - where: { - title: { - exists: true, - }, - }, + describe('updateByID endpoint', () => { + it('should update a single oft-deleted doc when trash=true', async () => { + const res = await restClient.PATCH(`/${postsSlug}/${postTwo.id}?trash=true`, { + body: JSON.stringify({ + title: 'Updated via REST', + }), + }) + + const result = await res.json() + expect(result.doc.title).toBe('Updated via REST') + expect(result.doc.deletedAt).toEqual(postTwo.deletedAt) }) - const allDocs = await payload.find({ - collection: postsSlug, - trash: true, + it('should throw NotFound error when trying to update a soft-deleted document w/o trash: true', async () => { + const res = await restClient.PATCH(`/${postsSlug}/${postTwo.id}`, { + body: JSON.stringify({ title: 'Fail Update' }), + }) + expect(res.status).toBe(404) }) - expect(allDocs.totalDocs).toEqual(1) - expect(allDocs.docs[0]?.id).toEqual(postTwo.id) + it('should update a single normal document when trash: false', async () => { + const res = await restClient.PATCH(`/${postsSlug}/${postOne.id}?trash=false`, { + body: JSON.stringify({ title: 'Updated Normal via REST' }), + }) + const result = await res.json() + expect(result.doc.title).toBe('Updated Normal via REST') + expect(result.doc.deletedAt).toBeUndefined() + }) }) - }) - describe('deleteByID operation', () => { - it('should throw NotFound error when trying to delete a soft-deleted document w/o trash: true', async () => { - await expect( - payload.delete({ - collection: postsSlug, - id: postTwo.id, - }), - ).rejects.toThrow('Not Found') + describe('update endpoint', () => { + it('should update only normal document when trash: false', async () => { + const query = `?trash=false&where[id][equals]=${postOne.id}` - await expect( - payload.delete({ - collection: postsSlug, - id: postTwo.id, - trash: false, - }), - ).rejects.toThrow('Not Found') + const res = await restClient.PATCH(`/${postsSlug}${query}`, { + body: JSON.stringify({ title: 'Updated Normal via REST' }), + }) + + const result = await res.json() + expect(result.docs).toHaveLength(1) + expect(result.docs[0].id).toBe(postOne.id) + expect(result.docs[0].title).toBe('Updated Normal via REST') + expect(result.docs[0].deletedAt).toBeUndefined() + }) + + it('should update all documents including soft-deleted documents when trash: true', async () => { + const query = `?trash=true&where[title][exists]=true` + + const res = await restClient.PATCH(`/${postsSlug}${query}`, { + body: JSON.stringify({ title: 'Bulk Updated All' }), + }) + + const result = await res.json() + expect(result.docs).toHaveLength(2) + expect(result.docs.every((doc: Post) => doc.title === 'Bulk Updated All')).toBe(true) + }) }) - it('should delete a soft-deleted document when trash: true', async () => { - await payload.delete({ - collection: postsSlug, - id: postTwo.id, - trash: true, + describe('delete endpoint', () => { + it('should perma delete all docs including soft-deleted documents when trash: true', async () => { + const query = `?trash=true&where[title][exists]=true` + + const res = await restClient.DELETE(`/${postsSlug}${query}`) + expect(res.status).toBe(200) + + const result = await res.json() + expect(result.docs).toHaveLength(2) + + const check = await restClient.GET(`/${postsSlug}?trash=true`) + const checkData = await check.json() + expect(checkData.docs).toHaveLength(0) + }) + + it('should only perma delete normal docs when trash: false', async () => { + const query = `?trash=false&where[title][exists]=true` + + const res = await restClient.DELETE(`/${postsSlug}${query}`) + expect(res.status).toBe(200) + + const result = await res.json() + expect(result.docs).toHaveLength(1) + expect(result.docs[0]?.id).toBe(postOne.id) + + const check = await restClient.GET(`/${postsSlug}?trash=true`) + const checkData = await check.json() + + // Make sure postTwo (soft-deleted) is still there + expect(checkData.docs.some((doc: Post) => doc.id === postTwo.id)).toBe(true) }) + }) - const allDocs = await payload.find({ - collection: postsSlug, - trash: true, + describe('deleteByID endpoint', () => { + it('should throw NotFound error when trying to delete a soft-deleted document w/o trash: true', async () => { + const res = await restClient.DELETE(`/${postsSlug}/${postTwo.id}`) + expect(res.status).toBe(404) }) - expect(allDocs.totalDocs).toEqual(1) - expect(allDocs.docs[0]?.id).toEqual(postOne.id) + it('should delete a soft-deleted document when trash: true', async () => { + const res = await restClient.DELETE(`/${postsSlug}/${postTwo.id}?trash=true`) + expect(res.status).toBe(200) + const result = await res.json() + expect(result.doc.id).toBe(postTwo.id) + }) }) }) }) From 283f0f3bd64e93e9d0f54c61692e2fd108be4a7d Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Tue, 3 Jun 2025 15:33:09 -0400 Subject: [PATCH 06/14] feat: adds softDeletes root level prop to collection configs to enable soft deleting --- packages/payload/src/collections/config/sanitize.ts | 4 ++-- packages/payload/src/collections/config/types.ts | 10 ++++++++++ packages/payload/src/folders/types.ts | 1 - .../src/folders/utils/formatFolderOrDocumentItem.ts | 1 - test/soft-delete/collections/Posts/index.ts | 1 + 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 5a0ff43f93c..8ca190ad06c 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -114,7 +114,7 @@ export const sanitizeCollection = async ( } } - return hasCreatedAt && hasUpdatedAt + return hasCreatedAt && hasUpdatedAt && (!sanitized.softDeletes || hasDeletedAt) }) if (!hasUpdatedAt) { @@ -144,7 +144,7 @@ export const sanitizeCollection = async ( }) } - if (!hasDeletedAt) { + if (sanitized.softDeletes && !hasDeletedAt) { sanitized.fields.push({ name: 'deletedAt', type: 'date', diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index e8a2f4c9571..a60e68cac89 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -538,6 +538,16 @@ export type CollectionConfig = { */ orderable?: boolean slug: string + /** + * Enables soft delete support for this collection. + * + * When enabled, documents will include a `deletedAt` timestamp field. + * This allows documents to be marked as deleted without being permanently removed. + * The `deletedAt` field will be set to the current date and time when a document is deleted. + * + * @default false + */ + softDeletes?: boolean /** * Add `createdAt`, `deletedAt` and `updatedAt` fields * diff --git a/packages/payload/src/folders/types.ts b/packages/payload/src/folders/types.ts index 289f436abe5..2584e65c4dd 100644 --- a/packages/payload/src/folders/types.ts +++ b/packages/payload/src/folders/types.ts @@ -57,7 +57,6 @@ export type FolderOrDocument = { value: { _folderOrDocumentTitle: string createdAt?: string - deletedAt?: string folderID?: number | string id: number | string updatedAt?: string diff --git a/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts b/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts index fe4faff3bf9..825dbb95458 100644 --- a/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts +++ b/packages/payload/src/folders/utils/formatFolderOrDocumentItem.ts @@ -22,7 +22,6 @@ export function formatFolderOrDocumentItem({ id: value?.id, _folderOrDocumentTitle: String((useAsTitle && value?.[useAsTitle]) || value['id']), createdAt: value?.createdAt, - deletedAt: value?.deletedAt, folderID: value?.[folderFieldName], updatedAt: value?.updatedAt, } diff --git a/test/soft-delete/collections/Posts/index.ts b/test/soft-delete/collections/Posts/index.ts index 6e5ea98aaf6..b5ecd6fc38b 100644 --- a/test/soft-delete/collections/Posts/index.ts +++ b/test/soft-delete/collections/Posts/index.ts @@ -7,6 +7,7 @@ export const Posts: CollectionConfig = { admin: { useAsTitle: 'title', }, + softDeletes: true, fields: [ { name: 'title', From 48cf719bf7b3f3c70f7e807cc73f2f719194cd18 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Tue, 3 Jun 2025 16:21:25 -0400 Subject: [PATCH 07/14] feat: only push query on trash arg if softDeletes is enabled on the collection --- packages/payload/src/collections/operations/delete.ts | 4 ++-- packages/payload/src/collections/operations/deleteByID.ts | 4 ++-- packages/payload/src/collections/operations/find.ts | 4 ++-- packages/payload/src/collections/operations/findByID.ts | 4 ++-- packages/payload/src/collections/operations/local/delete.ts | 2 ++ packages/payload/src/collections/operations/local/find.ts | 2 ++ packages/payload/src/collections/operations/local/findByID.ts | 2 ++ packages/payload/src/collections/operations/update.ts | 4 ++-- packages/payload/src/collections/operations/updateByID.ts | 4 ++-- 9 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/payload/src/collections/operations/delete.ts b/packages/payload/src/collections/operations/delete.ts index 0fed6321bcb..e823b05f2a2 100644 --- a/packages/payload/src/collections/operations/delete.ts +++ b/packages/payload/src/collections/operations/delete.ts @@ -110,8 +110,8 @@ export const deleteOperation = async < let fullWhere = combineQueries(where, accessResult!) - // If trash is false, restrict to non-trashed docs only - if (!trash) { + // If softDeletes is enabled and trash is false, restrict to non-trashed documents only + if (collectionConfig.softDeletes && !trash) { const notTrashedFilter = { deletedAt: { exists: false } } if (fullWhere?.and) { diff --git a/packages/payload/src/collections/operations/deleteByID.ts b/packages/payload/src/collections/operations/deleteByID.ts index dcfb6f8c6a6..7113b368621 100644 --- a/packages/payload/src/collections/operations/deleteByID.ts +++ b/packages/payload/src/collections/operations/deleteByID.ts @@ -111,8 +111,8 @@ export const deleteByIDOperation = async = * When set to `true`, the query will include both normal and trashed (soft-deleted) documents. * To query only trashed documents, pass `trash: true` and combine with a `where` clause filtering by `deletedAt`. * By default (`false`), the query will only include normal documents and exclude those with a `deletedAt` field. + * + * This argument has no effect unless `softDeletes` is enabled on the collection. * @default false */ trash?: boolean diff --git a/packages/payload/src/collections/operations/local/findByID.ts b/packages/payload/src/collections/operations/local/findByID.ts index 156f4e596b4..dd38b0a2973 100644 --- a/packages/payload/src/collections/operations/local/findByID.ts +++ b/packages/payload/src/collections/operations/local/findByID.ts @@ -104,6 +104,8 @@ export type Options< * When set to `true`, the operation will return a document by ID, even if it is soft-deleted (trashed). * By default (`false`), the operation will exclude soft-deleted documents. * To fetch a soft-deleted document, set `trash: true`. + * + * This argument has no effect unless `softDeletes` is enabled on the collection. * @default false */ trash?: boolean diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index b1ffd65b33f..6c7a7e8888e 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -139,8 +139,8 @@ export const updateOperation = async < let fullWhere = combineQueries(where, accessResult!) - // If trash is false, restrict to non-trashed docs only - if (!trash) { + // If softDeletes is enabled and trash is false, restrict to non-trashed documents only + if (collectionConfig.softDeletes && !trash) { const notTrashedFilter = { deletedAt: { exists: false } } if (fullWhere?.and) { diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index 907ff02b7da..f3a4184ff67 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -129,8 +129,8 @@ export const updateByIDOperation = async < let fullWhere = combineQueries(where, accessResults) - // If trash is false, restrict to non-trashed docs only - if (!trash) { + // If softDeletes is enabled and trash is false, restrict to non-trashed documents only + if (collectionConfig.softDeletes && !trash) { const notTrashedFilter = { deletedAt: { exists: false } } if (fullWhere?.and) { From 8d38ac760d2cd293d23a72e1a117699154bb2604 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Tue, 3 Jun 2025 16:45:22 -0400 Subject: [PATCH 08/14] feat: adjust tests for sql adapters --- packages/payload/src/collections/operations/delete.ts | 2 +- .../payload/src/collections/operations/deleteByID.ts | 2 +- packages/payload/src/collections/operations/find.ts | 2 +- .../payload/src/collections/operations/findByID.ts | 2 +- packages/payload/src/collections/operations/update.ts | 2 +- .../payload/src/collections/operations/updateByID.ts | 2 +- test/soft-delete/int.spec.ts | 10 +++++----- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/payload/src/collections/operations/delete.ts b/packages/payload/src/collections/operations/delete.ts index e823b05f2a2..818808aa9d8 100644 --- a/packages/payload/src/collections/operations/delete.ts +++ b/packages/payload/src/collections/operations/delete.ts @@ -110,7 +110,7 @@ export const deleteOperation = async < let fullWhere = combineQueries(where, accessResult!) - // If softDeletes is enabled and trash is false, restrict to non-trashed documents only + // If trash is false, restrict to non-trashed documents only if (collectionConfig.softDeletes && !trash) { const notTrashedFilter = { deletedAt: { exists: false } } diff --git a/packages/payload/src/collections/operations/deleteByID.ts b/packages/payload/src/collections/operations/deleteByID.ts index 7113b368621..b1101b4615f 100644 --- a/packages/payload/src/collections/operations/deleteByID.ts +++ b/packages/payload/src/collections/operations/deleteByID.ts @@ -111,7 +111,7 @@ export const deleteByIDOperation = async { expect(updatedPost).toBeDefined() expect(updatedPost.id).toEqual(postOne.id) expect(updatedPost.title).toEqual('Updated Post One') - expect(updatedPost.deletedAt).toBeUndefined() + expect(updatedPost.deletedAt).toBeFalsy() }) }) @@ -222,7 +222,7 @@ describe('soft-delete', () => { expect(updatedPost?.id).toEqual(postOne.id) expect(updatedPost?.title).toEqual('Updated Post') - expect(updatedPost?.deletedAt).toBeUndefined() + expect(updatedPost?.deletedAt).toBeFalsy() }) it('should update all documents including soft-deleted documents when trash: true', async () => { @@ -246,7 +246,7 @@ describe('soft-delete', () => { const updatedPostTwo: Post = result.docs.find((doc) => doc.id === postTwo.id)! expect(updatedPostOne?.title).toEqual('A New Updated Post') - expect(updatedPostOne?.deletedAt).toBeUndefined() + expect(updatedPostOne?.deletedAt).toBeFalsy() expect(updatedPostTwo?.title).toEqual('A New Updated Post') expect(updatedPostTwo?.deletedAt).toBeDefined() @@ -392,7 +392,7 @@ describe('soft-delete', () => { }) const result = await res.json() expect(result.doc.title).toBe('Updated Normal via REST') - expect(result.doc.deletedAt).toBeUndefined() + expect(result.doc.deletedAt).toBeFalsy() }) }) @@ -408,7 +408,7 @@ describe('soft-delete', () => { expect(result.docs).toHaveLength(1) expect(result.docs[0].id).toBe(postOne.id) expect(result.docs[0].title).toBe('Updated Normal via REST') - expect(result.docs[0].deletedAt).toBeUndefined() + expect(result.docs[0].deletedAt).toBeFalsy() }) it('should update all documents including soft-deleted documents when trash: true', async () => { From 2d420351ce35043d4bf2e5995138a38e84b13c66 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:01:46 -0400 Subject: [PATCH 09/14] feat: adds trash flag to find, update & delete graphql operations --- .../src/resolvers/collections/delete.ts | 2 + .../graphql/src/resolvers/collections/find.ts | 2 + .../src/resolvers/collections/findByID.ts | 2 + .../src/resolvers/collections/update.ts | 2 + .../graphql/src/schema/initCollections.ts | 5 + .../operations/local/findVersions.ts | 1 + test/soft-delete/int.spec.ts | 260 +++++++++++++++++- 7 files changed, 273 insertions(+), 1 deletion(-) diff --git a/packages/graphql/src/resolvers/collections/delete.ts b/packages/graphql/src/resolvers/collections/delete.ts index 2624d5c636b..b033447b877 100644 --- a/packages/graphql/src/resolvers/collections/delete.ts +++ b/packages/graphql/src/resolvers/collections/delete.ts @@ -11,6 +11,7 @@ export type Resolver = ( fallbackLocale?: string id: number | string locale?: string + trash?: boolean }, context: { req: PayloadRequest @@ -49,6 +50,7 @@ export function getDeleteResolver( collection, depth: 0, req: isolateObjectProperty(req, 'transactionID'), + trash: args.trash, } const result = await deleteByIDOperation(options) diff --git a/packages/graphql/src/resolvers/collections/find.ts b/packages/graphql/src/resolvers/collections/find.ts index 3285e16c76b..4bd567a96af 100644 --- a/packages/graphql/src/resolvers/collections/find.ts +++ b/packages/graphql/src/resolvers/collections/find.ts @@ -15,6 +15,7 @@ export type Resolver = ( page?: number pagination?: boolean sort?: string + trash?: boolean where?: Where }, context: { @@ -57,6 +58,7 @@ export function findResolver(collection: Collection): Resolver { pagination: args.pagination, req, sort: args.sort, + trash: args.trash, where: args.where, } diff --git a/packages/graphql/src/resolvers/collections/findByID.ts b/packages/graphql/src/resolvers/collections/findByID.ts index 22e8403cc07..72a1ac4241d 100644 --- a/packages/graphql/src/resolvers/collections/findByID.ts +++ b/packages/graphql/src/resolvers/collections/findByID.ts @@ -11,6 +11,7 @@ export type Resolver = ( fallbackLocale?: string id: string locale?: string + trash?: boolean }, context: { req: PayloadRequest @@ -50,6 +51,7 @@ export function findByIDResolver( depth: 0, draft: args.draft, req: isolateObjectProperty(req, 'transactionID'), + trash: args.trash, } const result = await findByIDOperation(options) diff --git a/packages/graphql/src/resolvers/collections/update.ts b/packages/graphql/src/resolvers/collections/update.ts index 5e8d894cf77..0feff36fb69 100644 --- a/packages/graphql/src/resolvers/collections/update.ts +++ b/packages/graphql/src/resolvers/collections/update.ts @@ -13,6 +13,7 @@ export type Resolver = ( fallbackLocale?: string id: number | string locale?: string + trash?: boolean }, context: { req: PayloadRequest @@ -54,6 +55,7 @@ export function updateResolver( depth: 0, draft: args.draft, req: isolateObjectProperty(req, 'transactionID'), + trash: args.trash, } const result = await updateByIDOperation(options) diff --git a/packages/graphql/src/schema/initCollections.ts b/packages/graphql/src/schema/initCollections.ts index eeeb16d1a18..09b43030efd 100644 --- a/packages/graphql/src/schema/initCollections.ts +++ b/packages/graphql/src/schema/initCollections.ts @@ -205,6 +205,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ locale: { type: graphqlResult.types.localeInputType }, } : {}), + trash: { type: GraphQLBoolean }, }, resolve: findByIDResolver(collection), } @@ -224,6 +225,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ page: { type: GraphQLInt }, pagination: { type: GraphQLBoolean }, sort: { type: GraphQLString }, + trash: { type: GraphQLBoolean }, }, resolve: findResolver(collection), } @@ -292,6 +294,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ locale: { type: graphqlResult.types.localeInputType }, } : {}), + trash: { type: GraphQLBoolean }, }, resolve: updateResolver(collection), } @@ -300,6 +303,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ type: collection.graphQL.type, args: { id: { type: new GraphQLNonNull(idType) }, + trash: { type: GraphQLBoolean }, }, resolve: getDeleteResolver(collection), } @@ -390,6 +394,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ page: { type: GraphQLInt }, pagination: { type: GraphQLBoolean }, sort: { type: GraphQLString }, + trash: { type: GraphQLBoolean }, }, resolve: findVersionsResolver(collection), } diff --git a/packages/payload/src/collections/operations/local/findVersions.ts b/packages/payload/src/collections/operations/local/findVersions.ts index 0de541a77a1..547e135a729 100644 --- a/packages/payload/src/collections/operations/local/findVersions.ts +++ b/packages/payload/src/collections/operations/local/findVersions.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-exports */ import type { PaginatedDocs } from '../../../database/types.js' import type { CollectionSlug, Payload, RequestContext, TypedLocale } from '../../../index.js' import type { diff --git a/test/soft-delete/int.spec.ts b/test/soft-delete/int.spec.ts index 3fcf485619a..a650319b7ec 100644 --- a/test/soft-delete/int.spec.ts +++ b/test/soft-delete/int.spec.ts @@ -251,6 +251,43 @@ describe('soft-delete', () => { expect(updatedPostTwo?.title).toEqual('A New Updated Post') expect(updatedPostTwo?.deletedAt).toBeDefined() }) + + it('should only update soft-deleted documents when trash: true and where[deletedAt][exists]=true', async () => { + const postThree = await payload.create({ + collection: postsSlug, + data: { + title: 'Post three', + deletedAt: new Date().toISOString(), + }, + }) + + const result = await payload.update({ + collection: postsSlug, + data: { + title: 'Updated Soft Deleted Post', + }, + trash: true, + where: { + deletedAt: { + exists: true, + }, + }, + }) + expect(result.docs).toBeDefined() + expect(result.docs[0]?.id).toEqual(postThree.id) + expect(result.docs[0]?.title).toEqual('Updated Soft Deleted Post') + expect(result.docs[0]?.deletedAt).toEqual(postThree.deletedAt) + expect(result.docs[1]?.id).toEqual(postTwo.id) + expect(result.docs[1]?.title).toEqual('Updated Soft Deleted Post') + expect(result.docs[1]?.deletedAt).toEqual(postTwo.deletedAt) + + // Clean up + await payload.delete({ + collection: postsSlug, + id: postThree.id, + trash: true, + }) + }) }) describe('delete operation', () => { @@ -367,7 +404,7 @@ describe('soft-delete', () => { }) describe('updateByID endpoint', () => { - it('should update a single oft-deleted doc when trash=true', async () => { + it('should update a single soft-deleted doc when trash=true', async () => { const res = await restClient.PATCH(`/${postsSlug}/${postTwo.id}?trash=true`, { body: JSON.stringify({ title: 'Updated via REST', @@ -422,6 +459,40 @@ describe('soft-delete', () => { expect(result.docs).toHaveLength(2) expect(result.docs.every((doc: Post) => doc.title === 'Bulk Updated All')).toBe(true) }) + + it('should only update soft-deleted documents when trash: true and where[deletedAt][exists]=true', async () => { + const query = `?trash=true&where[deletedAt][exists]=true` + + const postThree = await payload.create({ + collection: postsSlug, + data: { + title: 'Post three', + deletedAt: new Date().toISOString(), + }, + }) + + const res = await restClient.PATCH(`/${postsSlug}${query}`, { + body: JSON.stringify({ title: 'Updated Soft Deleted Post' }), + }) + + const result = await res.json() + expect(result.docs).toHaveLength(2) + + expect(result.docs).toBeDefined() + expect(result.docs[0]?.id).toEqual(postThree.id) + expect(result.docs[0]?.title).toEqual('Updated Soft Deleted Post') + expect(result.docs[0]?.deletedAt).toEqual(postThree.deletedAt) + expect(result.docs[1]?.id).toEqual(postTwo.id) + expect(result.docs[1]?.title).toEqual('Updated Soft Deleted Post') + expect(result.docs[1]?.deletedAt).toEqual(postTwo.deletedAt) + + // Clean up + await payload.delete({ + collection: postsSlug, + id: postThree.id, + trash: true, + }) + }) }) describe('delete endpoint', () => { @@ -471,4 +542,191 @@ describe('soft-delete', () => { }) }) }) + + describe('GRAPHQL', () => { + describe('find / findByID endpoint', () => { + it('should return all docs including soft-deleted docs in find with trash=true', async () => { + const query = ` + query { + Posts(trash: true) { + docs { + id + title + deletedAt + } + } + } + ` + + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + + expect(res.data.Posts.docs).toHaveLength(2) + }) + + it('should return only soft-deleted docs with trash=true and where[deletedAt][exists]=true', async () => { + const query = ` + query { + Posts( + trash: true + where: { deletedAt: { exists: true } } + ) { + docs { + id + deletedAt + } + } + } + ` + + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + + expect(res.data.Posts.docs).toHaveLength(1) + expect(res.data.Posts.docs[0].id).toEqual(postTwo.id) + }) + + it('should return only normal docs when trash=false', async () => { + const query = ` + query { + Posts(trash: false) { + docs { + id + deletedAt + } + } + } + ` + + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + + expect(res.data.Posts.docs).toHaveLength(1) + expect(res.data.Posts.docs[0].id).toEqual(postOne.id) + expect(res.data.Posts.docs[0].deletedAt).toBeNull() + }) + + it('should return a soft-deleted doc by ID with trash=true', async () => { + const query = ` + query { + Post(id: "${postTwo.id}", trash: true) { + id + deletedAt + } + } + ` + + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + + expect(res.data.Post.id).toBe(postTwo.id) + expect(res.data.Post.deletedAt).toBe(postTwo.deletedAt) + }) + + it('should 404 when trying to get a soft-deleted doc without trash=true', async () => { + const query = ` + query { + Post(id: "${postTwo.id}") { + id + } + } + ` + + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + expect(res.errors?.[0]?.message).toMatch(/not found/i) + }) + }) + + describe('updateByID endpoint', () => { + it('should update a single soft-deleted doc when trash=true', async () => { + const query = ` + mutation { + updatePost(id: "${postTwo.id}", trash: true, data: { title: "Updated Soft Deleted via GQL" }) { + id + title + deletedAt + } + } + ` + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + + expect(res.data.updatePost.id).toBe(postTwo.id) + expect(res.data.updatePost.title).toBe('Updated Soft Deleted via GQL') + expect(res.data.updatePost.deletedAt).toBe(postTwo.deletedAt) + }) + + it('should throw NotFound error when trying to update a soft-deleted document w/o trash: true', async () => { + const query = ` + mutation { + updatePost(id: "${postTwo.id}", data: { title: "Should Fail" }) { + id + } + } + ` + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + expect(res.errors?.[0]?.message).toMatch(/not found/i) + }) + + it('should update a single normal document when trash: false', async () => { + const query = ` + mutation { + updatePost(id: "${postOne.id}", trash: false, data: { title: "Updated Normal via GQL" }) { + id + title + deletedAt + } + } + ` + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + + expect(res.data.updatePost.id).toBe(postOne.id) + expect(res.data.updatePost.title).toBe('Updated Normal via GQL') + expect(res.data.updatePost.deletedAt).toBeNull() + }) + }) + + describe('deleteByID endpoint', () => { + it('should throw NotFound error when trying to delete a soft-deleted document w/o trash: true', async () => { + const query = ` + mutation { + deletePost(id: "${postTwo.id}") { + id + } + } + ` + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + + console.log(res) + expect(res.errors?.[0]?.message).toMatch(/not found/i) + }) + + it('should delete a soft-deleted document when trash: true', async () => { + const query = ` + mutation { + deletePost(id: "${postTwo.id}", trash: true) { + id + } + } + ` + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + expect(res.data.deletePost.id).toBe(postTwo.id) + }) + }) + }) }) From e638bb0ea50bdf701fb7b142cfe0da8d46ea23e1 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:03:48 -0400 Subject: [PATCH 10/14] chore: re-adds commented out graphql tests for update & delete many operations --- test/soft-delete/int.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/soft-delete/int.spec.ts b/test/soft-delete/int.spec.ts index a650319b7ec..129419e2eb5 100644 --- a/test/soft-delete/int.spec.ts +++ b/test/soft-delete/int.spec.ts @@ -697,6 +697,22 @@ describe('soft-delete', () => { }) }) + // describe('update endpoint', () => { + // it.todo('should update only normal document when trash: false') + + // it.todo('should update all documents including soft-deleted documents when trash: true') + + // it.todo( + // 'should only update soft-deleted documents when trash: true and where[deletedAt][exists]=true', + // ) + // }) + + // describe('delete endpoint', () => { + // it.todo('should perma delete all docs including soft-deleted documents when trash: true') + + // it.todo('should only perma delete normal docs when trash: false') + // }) + describe('deleteByID endpoint', () => { it('should throw NotFound error when trying to delete a soft-deleted document w/o trash: true', async () => { const query = ` From 6b5e2fb494b825ff748e8cf6b20eea72f1dd08e9 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Wed, 4 Jun 2025 15:45:40 -0400 Subject: [PATCH 11/14] feat: adds trash arg to findVersions & findVersionByID operations --- .../resolvers/collections/findVersionByID.ts | 2 + .../src/resolvers/collections/findVersions.ts | 2 + .../graphql/src/schema/initCollections.ts | 6 +- .../collections/endpoints/findVersionByID.ts | 2 + .../src/collections/endpoints/findVersions.ts | 4 +- .../collections/operations/findVersionByID.ts | 16 +- .../collections/operations/findVersions.ts | 15 +- .../operations/local/findVersionByID.ts | 12 + .../operations/local/findVersions.ts | 11 + test/soft-delete/collections/Posts/index.ts | 3 + test/soft-delete/int.spec.ts | 573 +++++++++++++++--- 11 files changed, 565 insertions(+), 81 deletions(-) diff --git a/packages/graphql/src/resolvers/collections/findVersionByID.ts b/packages/graphql/src/resolvers/collections/findVersionByID.ts index 25b05f32939..933e9f8105c 100644 --- a/packages/graphql/src/resolvers/collections/findVersionByID.ts +++ b/packages/graphql/src/resolvers/collections/findVersionByID.ts @@ -10,6 +10,7 @@ export type Resolver = ( fallbackLocale?: string id: number | string locale?: string + trash?: boolean }, context: { req: PayloadRequest @@ -33,6 +34,7 @@ export function findVersionByIDResolver(collection: Collection): Resolver { collection, depth: 0, req: isolateObjectProperty(req, 'transactionID'), + trash: args.trash, } const result = await findVersionByIDOperation(options) diff --git a/packages/graphql/src/resolvers/collections/findVersions.ts b/packages/graphql/src/resolvers/collections/findVersions.ts index c747bbdfdcd..2b1eb906a9b 100644 --- a/packages/graphql/src/resolvers/collections/findVersions.ts +++ b/packages/graphql/src/resolvers/collections/findVersions.ts @@ -14,6 +14,7 @@ export type Resolver = ( page?: number pagination?: boolean sort?: string + trash?: boolean where: Where }, context: { @@ -54,6 +55,7 @@ export function findVersionsResolver(collection: Collection): Resolver { pagination: args.pagination, req: isolateObjectProperty(req, 'transactionID'), sort: args.sort, + trash: args.trash, where: args.where, } diff --git a/packages/graphql/src/schema/initCollections.ts b/packages/graphql/src/schema/initCollections.ts index 09b43030efd..dbbe9aaafe8 100644 --- a/packages/graphql/src/schema/initCollections.ts +++ b/packages/graphql/src/schema/initCollections.ts @@ -340,11 +340,6 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ type: 'date', label: ({ t }) => t('general:updatedAt'), }, - { - name: 'deletedAt', - type: 'date', - label: ({ t }) => t('general:deletedAt'), - }, ] collection.graphQL.versionType = buildObjectType({ @@ -368,6 +363,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ locale: { type: graphqlResult.types.localeInputType }, } : {}), + trash: { type: GraphQLBoolean }, }, resolve: findVersionByIDResolver(collection), } diff --git a/packages/payload/src/collections/endpoints/findVersionByID.ts b/packages/payload/src/collections/endpoints/findVersionByID.ts index 737705d939b..701c855f859 100644 --- a/packages/payload/src/collections/endpoints/findVersionByID.ts +++ b/packages/payload/src/collections/endpoints/findVersionByID.ts @@ -12,6 +12,7 @@ import { findVersionByIDOperation } from '../operations/findVersionByID.js' export const findVersionByIDHandler: PayloadHandler = async (req) => { const { searchParams } = req const depth = searchParams.get('depth') + const trash = searchParams.get('trash') === 'true' const { id, collection } = getRequestCollectionWithID(req) @@ -22,6 +23,7 @@ export const findVersionByIDHandler: PayloadHandler = async (req) => { populate: sanitizePopulateParam(req.query.populate), req, select: sanitizeSelectParam(req.query.select), + trash, }) return Response.json(result, { diff --git a/packages/payload/src/collections/endpoints/findVersions.ts b/packages/payload/src/collections/endpoints/findVersions.ts index 57507c27bf3..b4c2d896b7f 100644 --- a/packages/payload/src/collections/endpoints/findVersions.ts +++ b/packages/payload/src/collections/endpoints/findVersions.ts @@ -12,7 +12,7 @@ import { findVersionsOperation } from '../operations/findVersions.js' export const findVersionsHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { depth, limit, page, pagination, populate, select, sort, where } = req.query as { + const { depth, limit, page, pagination, populate, select, sort, trash, where } = req.query as { depth?: string limit?: string page?: string @@ -20,6 +20,7 @@ export const findVersionsHandler: PayloadHandler = async (req) => { populate?: Record select?: Record sort?: string + trash?: string where?: Where } @@ -33,6 +34,7 @@ export const findVersionsHandler: PayloadHandler = async (req) => { req, select: sanitizeSelectParam(select), sort: typeof sort === 'string' ? sort.split(',') : undefined, + trash: trash === 'true', where, }) diff --git a/packages/payload/src/collections/operations/findVersionByID.ts b/packages/payload/src/collections/operations/findVersionByID.ts index 9d28777a553..6f5df37c5f1 100644 --- a/packages/payload/src/collections/operations/findVersionByID.ts +++ b/packages/payload/src/collections/operations/findVersionByID.ts @@ -24,6 +24,7 @@ export type Arguments = { req: PayloadRequest select?: SelectType showHiddenFields?: boolean + trash?: boolean } export const findVersionByIDOperation = async ( @@ -41,6 +42,7 @@ export const findVersionByIDOperation = async ( req, select: incomingSelect, showHiddenFields, + trash = false, } = args if (!id) { @@ -63,7 +65,19 @@ export const findVersionByIDOperation = async ( const hasWhereAccess = typeof accessResults === 'object' - const fullWhere = combineQueries({ id: { equals: id } }, accessResults) + const where = { id: { equals: id } } + + let fullWhere = combineQueries(where, accessResults) + + if (collectionConfig.softDeletes && !trash) { + const notTrashedFilter = { 'version.deletedAt': { exists: false } } + + if (fullWhere?.and) { + fullWhere.and.push(notTrashedFilter) + } else { + fullWhere = { and: [notTrashedFilter] } + } + } // ///////////////////////////////////// // Find by ID diff --git a/packages/payload/src/collections/operations/findVersions.ts b/packages/payload/src/collections/operations/findVersions.ts index 41f15eeba94..2f707d87d55 100644 --- a/packages/payload/src/collections/operations/findVersions.ts +++ b/packages/payload/src/collections/operations/findVersions.ts @@ -26,6 +26,7 @@ export type Arguments = { select?: SelectType showHiddenFields?: boolean sort?: Sort + trash?: boolean where?: Where } @@ -43,6 +44,7 @@ export const findVersionsOperation = async select: incomingSelect, showHiddenFields, sort, + trash = false, where, } = args @@ -70,7 +72,18 @@ export const findVersionsOperation = async where: where!, }) - const fullWhere = combineQueries(where!, accessResults) + let fullWhere = combineQueries(where!, accessResults) + + // If trash is false, restrict to non-trashed documents only + if (collectionConfig.softDeletes && !trash) { + const notTrashedFilter = { 'version.deletedAt': { exists: false } } + + if (fullWhere?.and) { + fullWhere.and.push(notTrashedFilter) + } else { + fullWhere = { and: [notTrashedFilter] } + } + } const select = sanitizeSelect({ fields: buildVersionCollectionFields(payload.config, collectionConfig, true), diff --git a/packages/payload/src/collections/operations/local/findVersionByID.ts b/packages/payload/src/collections/operations/local/findVersionByID.ts index 3c159ccb823..dea77633b39 100644 --- a/packages/payload/src/collections/operations/local/findVersionByID.ts +++ b/packages/payload/src/collections/operations/local/findVersionByID.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-exports */ import type { CollectionSlug, Payload, RequestContext, TypedLocale } from '../../../index.js' import type { Document, PayloadRequest, PopulateType, SelectType } from '../../../types/index.js' import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' @@ -69,6 +70,15 @@ export type Options = { * @default false */ showHiddenFields?: boolean + /** + * When set to `true`, the operation will return a document by ID, even if it is soft-deleted (trashed). + * By default (`false`), the operation will exclude soft-deleted documents. + * To fetch a soft-deleted document, set `trash: true`. + * + * This argument has no effect unless `softDeletes` is enabled on the collection. + * @default false + */ + trash?: boolean /** * If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. */ @@ -88,6 +98,7 @@ export default async function findVersionByIDLocal populate, select, showHiddenFields, + trash = false, } = options const collection = payload.collections[collectionSlug] @@ -110,5 +121,6 @@ export default async function findVersionByIDLocal req: await createLocalReq(options as CreateLocalReqOptions, payload), select, showHiddenFields, + trash, }) } diff --git a/packages/payload/src/collections/operations/local/findVersions.ts b/packages/payload/src/collections/operations/local/findVersions.ts index 547e135a729..65242dca481 100644 --- a/packages/payload/src/collections/operations/local/findVersions.ts +++ b/packages/payload/src/collections/operations/local/findVersions.ts @@ -86,6 +86,15 @@ export type Options = { * @example ['version.group', '-version.createdAt'] // sort by 2 fields, ASC group and DESC createdAt */ sort?: Sort + /** + * When set to `true`, the query will include both normal and trashed (soft-deleted) documents. + * To query only trashed documents, pass `trash: true` and combine with a `where` clause filtering by `deletedAt`. + * By default (`false`), the query will only include normal documents and exclude those with a `deletedAt` field. + * + * This argument has no effect unless `softDeletes` is enabled on the collection. + * @default false + */ + trash?: boolean /** * If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. */ @@ -110,6 +119,7 @@ export default async function findVersionsLocal( select, showHiddenFields, sort, + trash = false, where, } = options @@ -132,6 +142,7 @@ export default async function findVersionsLocal( select, showHiddenFields, sort, + trash, where, }) } diff --git a/test/soft-delete/collections/Posts/index.ts b/test/soft-delete/collections/Posts/index.ts index b5ecd6fc38b..6edefe1262b 100644 --- a/test/soft-delete/collections/Posts/index.ts +++ b/test/soft-delete/collections/Posts/index.ts @@ -14,4 +14,7 @@ export const Posts: CollectionConfig = { type: 'text', }, ], + versions: { + drafts: true, + }, } diff --git a/test/soft-delete/int.spec.ts b/test/soft-delete/int.spec.ts index 129419e2eb5..14c0ad4aae2 100644 --- a/test/soft-delete/int.spec.ts +++ b/test/soft-delete/int.spec.ts @@ -111,7 +111,9 @@ describe('soft-delete', () => { expect(normalDocs.totalDocs).toEqual(1) expect(normalDocs.docs[0]?.id).toEqual(postOne.id) }) + }) + describe('findByID operation', () => { it('should return a soft-deleted document when trash: true', async () => { const softDeletedPost: Post = await payload.findByID({ collection: postsSlug, @@ -143,6 +145,133 @@ describe('soft-delete', () => { }) }) + describe('findVersions operation', () => { + beforeAll(async () => { + await payload.update({ + collection: postsSlug, + data: { + title: 'Some updated title', + }, + trash: true, + where: { + title: { + exists: true, + }, + }, + }) + }) + it('should return all versions including soft-deleted docs in findVersions with trash: true', async () => { + const allVersions = await payload.findVersions({ + collection: postsSlug, + trash: true, + }) + + expect(allVersions.totalDocs).toEqual(2) + expect(allVersions.docs[0]?.parent).toEqual(postTwo.id) + expect(allVersions.docs[1]?.parent).toEqual(postOne.id) + }) + + it('should return only soft-deleted docs in findVersions with trash: true', async () => { + const softDeletedVersions = await payload.findVersions({ + collection: postsSlug, + where: { + 'version.deletedAt': { + exists: true, + }, + }, + trash: true, + }) + + expect(softDeletedVersions.totalDocs).toEqual(1) + expect(softDeletedVersions.docs[0]?.parent).toEqual(postTwo.id) + }) + + it('should return only non-soft-deleted docs in findVersions with trash: false', async () => { + const normalVersions = await payload.findVersions({ + collection: postsSlug, + trash: false, + }) + + expect(normalVersions.totalDocs).toEqual(1) + expect(normalVersions.docs[0]?.parent).toEqual(postOne.id) + }) + }) + + describe('findVersionByID operation', () => { + beforeAll(async () => { + await payload.update({ + collection: postsSlug, + data: { + title: 'Some updated title', + }, + trash: true, + where: { + title: { + exists: true, + }, + }, + }) + }) + + it('should return a soft-deleted version document when trash: true', async () => { + const softDeletedVersions = await payload.findVersions({ + collection: postsSlug, + where: { + 'version.deletedAt': { + exists: true, + }, + }, + trash: true, + }) + + expect(softDeletedVersions.docs).toHaveLength(1) + + const version = softDeletedVersions.docs[0] + + const softDeletedVersionPost = await payload.findVersionByID({ + collection: postsSlug, + id: version!.id, + trash: true, + }) + + expect(softDeletedVersionPost).toBeDefined() + expect(softDeletedVersionPost?.parent).toEqual(postTwo.id) + expect(softDeletedVersionPost?.version?.deletedAt).toBeDefined() + expect(softDeletedVersionPost?.version?.deletedAt).toEqual(postTwo.deletedAt) + }) + + it('should throw NotFound error when trying to find a soft-deleted version document w/o trash: true', async () => { + const softDeletedVersions = await payload.findVersions({ + collection: postsSlug, + where: { + 'version.deletedAt': { + exists: true, + }, + }, + trash: true, + }) + + expect(softDeletedVersions.docs).toHaveLength(1) + + const version = softDeletedVersions.docs[0] + + await expect( + payload.findVersionByID({ + collection: postsSlug, + id: version!.id, + }), + ).rejects.toThrow('Not Found') + + await expect( + payload.findVersionByID({ + collection: postsSlug, + id: version!.id, + trash: false, + }), + ).rejects.toThrow('Not Found') + }) + }) + describe('updateByID operation', () => { it('should update a single soft-deleted document when trash: true', async () => { const updatedPost: Post = await payload.update({ @@ -368,7 +497,7 @@ describe('soft-delete', () => { }) describe('REST', () => { - describe('find / findByID endpoint', () => { + describe('find endpoint', () => { it('should return all docs including soft-deleted docs in find with trash=true', async () => { const res = await restClient.GET(`/${postsSlug}?trash=true`) expect(res.status).toBe(200) @@ -389,7 +518,9 @@ describe('soft-delete', () => { expect(data.docs).toHaveLength(1) expect(data.docs[0]?.id).toEqual(postOne.id) }) + }) + describe('findByID endpoint', () => { it('should return a soft-deleted doc by ID with trash=true', async () => { const res = await restClient.GET(`/${postsSlug}/${postTwo.id}?trash=true`) const data = await res.json() @@ -403,6 +534,100 @@ describe('soft-delete', () => { }) }) + describe('find versions endpoint', () => { + beforeAll(async () => { + await payload.update({ + collection: postsSlug, + data: { + title: 'Some updated title', + }, + trash: true, + where: { + title: { + exists: true, + }, + }, + }) + }) + it('should return all versions including soft-deleted docs in findVersions with trash: true', async () => { + const res = await restClient.GET(`/${postsSlug}/versions?trash=true`) + expect(res.status).toBe(200) + const data = await res.json() + expect(data.docs).toHaveLength(2) + }) + + it('should return only soft-deleted docs in findVersions with trash: true', async () => { + const res = await restClient.GET( + `/${postsSlug}/versions?trash=true&where[version.deletedAt][exists]=true`, + ) + const data = await res.json() + expect(data.docs).toHaveLength(1) + expect(data.docs[0]?.parent).toEqual(postTwo.id) + }) + + it('should return only non-soft-deleted docs in findVersions with trash: false', async () => { + const res = await restClient.GET(`/${postsSlug}/versions?trash=false`) + const data = await res.json() + expect(data.docs).toHaveLength(1) + expect(data.docs[0]?.parent).toEqual(postOne.id) + }) + }) + + describe('findVersionByID endpoint', () => { + beforeAll(async () => { + await payload.update({ + collection: postsSlug, + data: { + title: 'Some updated title', + }, + trash: true, + where: { + title: { + exists: true, + }, + }, + }) + }) + + it('should return a soft-deleted version document when trash: true', async () => { + const softDeletedVersions = await restClient.GET( + `/${postsSlug}/versions?trash=true&where[version.deletedAt][exists]=true`, + ) + + const softDeletedVersionsData = await softDeletedVersions.json() + expect(softDeletedVersionsData.docs).toHaveLength(1) + + const version = softDeletedVersionsData.docs[0] + + const versionPost = await restClient.GET(`/${postsSlug}/versions/${version!.id}?trash=true`) + const softDeletedVersionPost = await versionPost.json() + + expect(softDeletedVersionPost).toBeDefined() + expect(softDeletedVersionPost?.parent).toEqual(postTwo.id) + expect(softDeletedVersionPost?.version?.deletedAt).toBeDefined() + expect(softDeletedVersionPost?.version?.deletedAt).toEqual(postTwo.deletedAt) + }) + + it('should throw NotFound error when trying to find a soft-deleted version document w/o trash: true', async () => { + const softDeletedVersions = await restClient.GET( + `/${postsSlug}/versions?trash=true&where[version.deletedAt][exists]=true`, + ) + + const softDeletedVersionsData = await softDeletedVersions.json() + expect(softDeletedVersionsData.docs).toHaveLength(1) + + const version = softDeletedVersionsData.docs[0] + + const withoutTrash = await restClient.GET(`/${postsSlug}/versions/${version!.id}`) + expect(withoutTrash.status).toBe(404) + + const withTrashFalse = await restClient.GET( + `/${postsSlug}/versions/${version!.id}?trash=false`, + ) + expect(withTrashFalse.status).toBe(404) + }) + }) + describe('updateByID endpoint', () => { it('should update a single soft-deleted doc when trash=true', async () => { const res = await restClient.PATCH(`/${postsSlug}/${postTwo.id}?trash=true`, { @@ -544,19 +769,19 @@ describe('soft-delete', () => { }) describe('GRAPHQL', () => { - describe('find / findByID endpoint', () => { + describe('find query', () => { it('should return all docs including soft-deleted docs in find with trash=true', async () => { const query = ` - query { - Posts(trash: true) { - docs { - id - title - deletedAt + query { + Posts(trash: true) { + docs { + id + title + deletedAt + } } } - } - ` + ` const res = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query }) }) @@ -567,18 +792,18 @@ describe('soft-delete', () => { it('should return only soft-deleted docs with trash=true and where[deletedAt][exists]=true', async () => { const query = ` - query { - Posts( - trash: true - where: { deletedAt: { exists: true } } - ) { - docs { - id - deletedAt + query { + Posts( + trash: true + where: { deletedAt: { exists: true } } + ) { + docs { + id + deletedAt + } } } - } - ` + ` const res = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query }) }) @@ -590,15 +815,15 @@ describe('soft-delete', () => { it('should return only normal docs when trash=false', async () => { const query = ` - query { - Posts(trash: false) { - docs { - id - deletedAt + query { + Posts(trash: false) { + docs { + id + deletedAt + } } } - } - ` + ` const res = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query }) }) @@ -608,16 +833,18 @@ describe('soft-delete', () => { expect(res.data.Posts.docs[0].id).toEqual(postOne.id) expect(res.data.Posts.docs[0].deletedAt).toBeNull() }) + }) + describe('findByID query', () => { it('should return a soft-deleted doc by ID with trash=true', async () => { const query = ` - query { - Post(id: "${postTwo.id}", trash: true) { - id - deletedAt + query { + Post(id: "${postTwo.id}", trash: true) { + id + deletedAt + } } - } - ` + ` const res = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query }) }) @@ -629,31 +856,231 @@ describe('soft-delete', () => { it('should 404 when trying to get a soft-deleted doc without trash=true', async () => { const query = ` - query { - Post(id: "${postTwo.id}") { - id + query { + Post(id: "${postTwo.id}") { + id + } + } + ` + + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + expect(res.errors?.[0]?.message).toMatch(/not found/i) + }) + }) + + describe('find versions query', () => { + beforeAll(async () => { + await payload.update({ + collection: postsSlug, + data: { + title: 'Some updated title', + }, + trash: true, + where: { + title: { + exists: true, + }, + }, + }) + }) + it('should return all versions including soft-deleted docs in findVersions with trash: true', async () => { + const query = ` + query { + versionsPosts(trash: true) { + docs { + id + version { + title + deletedAt + } + } + } } + ` + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + + expect(res.data.versionsPosts.docs).toHaveLength(2) + }) + + it('should return only soft-deleted docs in findVersions with trash: true', async () => { + const query = ` + query { + versionsPosts( + trash: true, + where: { + version__deletedAt: { + exists: true + } + } + ) { + docs { + id + version { + title + deletedAt + } + } + } + } + ` + + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + + const { docs } = res.data.versionsPosts + + // Should only include soft-deleted versions + expect(docs).toHaveLength(1) + + for (const doc of docs) { + expect(doc.version.deletedAt).toBeDefined() } - ` + }) + + it('should return only non-soft-deleted docs in findVersions with trash: false', async () => { + const query = ` + query { + versionsPosts(trash: false) { + docs { + id + version { + title + deletedAt + } + } + } + } + ` const res = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query }) }) .then((r) => r.json()) + + const { docs } = res.data.versionsPosts + + // All versions returned should NOT have deletedAt set + for (const doc of docs) { + expect(doc.version.deletedAt).toBeNull() + } + }) + }) + + describe('findVersionByID endpoint', () => { + beforeAll(async () => { + await payload.update({ + collection: postsSlug, + data: { + title: 'Some updated title', + }, + trash: true, + where: { + title: { + exists: true, + }, + }, + }) + }) + + it('should return a soft-deleted document when trash: true', async () => { + // First, get the version ID of the soft-deleted post + const listQuery = ` + query { + versionsPosts( + trash: true, + where: { + version__deletedAt: { + exists: true + } + } + ) { + docs { + id + version { + deletedAt + } + } + } + } + ` + const listRes = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query: listQuery }) }) + .then((r) => r.json()) + + const softDeletedVersion = listRes.data.versionsPosts.docs[0] + + const detailQuery = ` + query { + versionPost(id: "${softDeletedVersion.id}", trash: true) { + id + version { + deletedAt + } + } + } + ` + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query: detailQuery }) }) + .then((r) => r.json()) + + expect(res.data.versionPost.id).toBe(softDeletedVersion.id) + expect(res.data.versionPost.version.deletedAt).toBe(postTwo.deletedAt) + }) + + it('should throw NotFound error when trying to find a soft-deleted version document w/o trash: true', async () => { + // First, get the version ID of the soft-deleted post + const listQuery = ` + query { + versionsPosts( + trash: true, + where: { + version__deletedAt: { + exists: true + } + } + ) { + docs { + id + } + } + } + ` + const listRes = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query: listQuery }) }) + .then((r) => r.json()) + + const softDeletedVersion = listRes.data.versionsPosts.docs[0] + + const detailQuery = ` + query { + versionPost(id: "${softDeletedVersion.id}") { + id + } + } + ` + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query: detailQuery }) }) + .then((r) => r.json()) + expect(res.errors?.[0]?.message).toMatch(/not found/i) }) }) - describe('updateByID endpoint', () => { + describe('updateByID query', () => { it('should update a single soft-deleted doc when trash=true', async () => { const query = ` - mutation { - updatePost(id: "${postTwo.id}", trash: true, data: { title: "Updated Soft Deleted via GQL" }) { - id - title - deletedAt - } - } - ` + mutation { + updatePost(id: "${postTwo.id}", trash: true, data: { title: "Updated Soft Deleted via GQL" }) { + id + title + deletedAt + } + } + ` const res = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query }) }) .then((r) => r.json()) @@ -665,12 +1092,12 @@ describe('soft-delete', () => { it('should throw NotFound error when trying to update a soft-deleted document w/o trash: true', async () => { const query = ` - mutation { - updatePost(id: "${postTwo.id}", data: { title: "Should Fail" }) { - id - } - } - ` + mutation { + updatePost(id: "${postTwo.id}", data: { title: "Should Fail" }) { + id + } + } + ` const res = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query }) }) .then((r) => r.json()) @@ -679,14 +1106,14 @@ describe('soft-delete', () => { it('should update a single normal document when trash: false', async () => { const query = ` - mutation { - updatePost(id: "${postOne.id}", trash: false, data: { title: "Updated Normal via GQL" }) { - id - title - deletedAt - } - } - ` + mutation { + updatePost(id: "${postOne.id}", trash: false, data: { title: "Updated Normal via GQL" }) { + id + title + deletedAt + } + } + ` const res = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query }) }) .then((r) => r.json()) @@ -713,15 +1140,15 @@ describe('soft-delete', () => { // it.todo('should only perma delete normal docs when trash: false') // }) - describe('deleteByID endpoint', () => { + describe('deleteByID query', () => { it('should throw NotFound error when trying to delete a soft-deleted document w/o trash: true', async () => { const query = ` - mutation { - deletePost(id: "${postTwo.id}") { - id - } - } - ` + mutation { + deletePost(id: "${postTwo.id}") { + id + } + } + ` const res = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query }) }) .then((r) => r.json()) @@ -732,12 +1159,12 @@ describe('soft-delete', () => { it('should delete a soft-deleted document when trash: true', async () => { const query = ` - mutation { - deletePost(id: "${postTwo.id}", trash: true) { - id - } - } - ` + mutation { + deletePost(id: "${postTwo.id}", trash: true) { + id + } + } + ` const res = await restClient .GRAPHQL_POST({ body: JSON.stringify({ query }) }) .then((r) => r.json()) From bbd8dbb8c8b5f86d4d12a546782311e7a4367796 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Wed, 4 Jun 2025 22:00:45 -0400 Subject: [PATCH 12/14] feat: conditionally use string or int graphql literals for ids depending on db --- test/soft-delete/int.spec.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/test/soft-delete/int.spec.ts b/test/soft-delete/int.spec.ts index 14c0ad4aae2..6ebf5b5bd3b 100644 --- a/test/soft-delete/int.spec.ts +++ b/test/soft-delete/int.spec.ts @@ -7,6 +7,7 @@ import type { NextRESTClient } from '../helpers/NextRESTClient.js' import type { Post } from './payload-types.js' import { devUser } from '../credentials.js' +import { idToString } from '../helpers/idToString.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' import { postsSlug } from './collections/Posts/index.js' @@ -839,7 +840,7 @@ describe('soft-delete', () => { it('should return a soft-deleted doc by ID with trash=true', async () => { const query = ` query { - Post(id: "${postTwo.id}", trash: true) { + Post(id: ${idToString(postTwo.id, payload)}, trash: true) { id deletedAt } @@ -857,7 +858,7 @@ describe('soft-delete', () => { it('should 404 when trying to get a soft-deleted doc without trash=true', async () => { const query = ` query { - Post(id: "${postTwo.id}") { + Post(id: ${idToString(postTwo.id, payload)}) { id } } @@ -1015,7 +1016,7 @@ describe('soft-delete', () => { const detailQuery = ` query { - versionPost(id: "${softDeletedVersion.id}", trash: true) { + versionPost(id: ${idToString(softDeletedVersion.id, payload)}, trash: true) { id version { deletedAt @@ -1057,7 +1058,7 @@ describe('soft-delete', () => { const detailQuery = ` query { - versionPost(id: "${softDeletedVersion.id}") { + versionPost(id: ${idToString(softDeletedVersion.id, payload)}) { id } } @@ -1074,7 +1075,7 @@ describe('soft-delete', () => { it('should update a single soft-deleted doc when trash=true', async () => { const query = ` mutation { - updatePost(id: "${postTwo.id}", trash: true, data: { title: "Updated Soft Deleted via GQL" }) { + updatePost(id: ${idToString(postTwo.id, payload)}, trash: true, data: { title: "Updated Soft Deleted via GQL" }) { id title deletedAt @@ -1093,7 +1094,7 @@ describe('soft-delete', () => { it('should throw NotFound error when trying to update a soft-deleted document w/o trash: true', async () => { const query = ` mutation { - updatePost(id: "${postTwo.id}", data: { title: "Should Fail" }) { + updatePost(id: ${idToString(postTwo.id, payload)}, data: { title: "Should Fail" }) { id } } @@ -1107,7 +1108,7 @@ describe('soft-delete', () => { it('should update a single normal document when trash: false', async () => { const query = ` mutation { - updatePost(id: "${postOne.id}", trash: false, data: { title: "Updated Normal via GQL" }) { + updatePost(id: ${idToString(postOne.id, payload)}, trash: false, data: { title: "Updated Normal via GQL" }) { id title deletedAt @@ -1144,7 +1145,7 @@ describe('soft-delete', () => { it('should throw NotFound error when trying to delete a soft-deleted document w/o trash: true', async () => { const query = ` mutation { - deletePost(id: "${postTwo.id}") { + deletePost(id: ${idToString(postTwo.id, payload)}) { id } } @@ -1160,7 +1161,7 @@ describe('soft-delete', () => { it('should delete a soft-deleted document when trash: true', async () => { const query = ` mutation { - deletePost(id: "${postTwo.id}", trash: true) { + deletePost(id: ${idToString(postTwo.id, payload)}, trash: true) { id } } From 40eaaff2898234c896de6d672155dca048c54648 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Thu, 5 Jun 2025 15:57:27 -0400 Subject: [PATCH 13/14] feat: adds delete access control in update operations to prevent soft deleteing if restricted by delete access --- .../src/collections/operations/update.ts | 14 ++++ .../src/collections/operations/updateByID.ts | 13 ++++ test/soft-deletes/collections/Pages/index.ts | 26 +++++++ .../collections/Posts/index.ts | 0 test/soft-deletes/collections/Users/index.ts | 26 +++++++ test/{soft-delete => soft-deletes}/config.ts | 18 ++++- .../{soft-delete => soft-deletes}/int.spec.ts | 75 ++++++++++++++++--- .../payload-types.ts | 43 +++++++++-- .../tsconfig.eslint.json | 0 .../tsconfig.json | 0 10 files changed, 195 insertions(+), 20 deletions(-) create mode 100644 test/soft-deletes/collections/Pages/index.ts rename test/{soft-delete => soft-deletes}/collections/Posts/index.ts (100%) create mode 100644 test/soft-deletes/collections/Users/index.ts rename test/{soft-delete => soft-deletes}/config.ts (63%) rename test/{soft-delete => soft-deletes}/int.spec.ts (94%) rename test/{soft-delete => soft-deletes}/payload-types.ts (88%) rename test/{soft-delete => soft-deletes}/tsconfig.eslint.json (100%) rename test/{soft-delete => soft-deletes}/tsconfig.json (100%) diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index d841f9faac3..f7c656f627e 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -139,6 +139,20 @@ export const updateOperation = async < let fullWhere = combineQueries(where, accessResult!) + const isSoftDeleteAttempt = + collectionConfig.softDeletes && + typeof bulkUpdateData === 'object' && + bulkUpdateData !== null && + 'deletedAt' in bulkUpdateData && + bulkUpdateData.deletedAt != null && + !overrideAccess + + // Enforce delete access if performing a soft-delete + if (isSoftDeleteAttempt) { + const deleteAccessResult = await executeAccess({ req }, collectionConfig.access.delete) + fullWhere = combineQueries(fullWhere, deleteAccessResult) + } + // If trash is false, restrict to non-trashed documents only if (collectionConfig.softDeletes && !trash) { const notTrashedFilter = { deletedAt: { exists: false } } diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index 347bef5a641..cfb7a3836a3 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -129,6 +129,19 @@ export const updateByIDOperation = async < let fullWhere = combineQueries(where, accessResults) + const isSoftDeleteAttempt = + collectionConfig.softDeletes && + typeof data === 'object' && + data !== null && + 'deletedAt' in data && + data.deletedAt != null && + !overrideAccess + + if (isSoftDeleteAttempt) { + const deleteAccessResult = await executeAccess({ req }, collectionConfig.access.delete) + fullWhere = combineQueries(fullWhere, deleteAccessResult) + } + // If trash is false, restrict to non-trashed documents only if (collectionConfig.softDeletes && !trash) { const notTrashedFilter = { deletedAt: { exists: false } } diff --git a/test/soft-deletes/collections/Pages/index.ts b/test/soft-deletes/collections/Pages/index.ts new file mode 100644 index 00000000000..38b9893e7bb --- /dev/null +++ b/test/soft-deletes/collections/Pages/index.ts @@ -0,0 +1,26 @@ +import type { CollectionConfig } from 'payload' + +export const pagesSlug = 'pages' + +export const Pages: CollectionConfig = { + slug: pagesSlug, + admin: { + useAsTitle: 'title', + }, + access: { + delete: ({ req: { user } }) => { + // Allow delete access if the user has the 'is_admin' role + return Boolean(user?.roles?.includes('is_admin')) + }, + }, + softDeletes: true, + fields: [ + { + name: 'title', + type: 'text', + }, + ], + versions: { + drafts: true, + }, +} diff --git a/test/soft-delete/collections/Posts/index.ts b/test/soft-deletes/collections/Posts/index.ts similarity index 100% rename from test/soft-delete/collections/Posts/index.ts rename to test/soft-deletes/collections/Posts/index.ts diff --git a/test/soft-deletes/collections/Users/index.ts b/test/soft-deletes/collections/Users/index.ts new file mode 100644 index 00000000000..52324ecbe20 --- /dev/null +++ b/test/soft-deletes/collections/Users/index.ts @@ -0,0 +1,26 @@ +import type { CollectionConfig } from 'payload' + +export const usersSlug = 'users' + +export const Users: CollectionConfig = { + slug: usersSlug, + admin: { + useAsTitle: 'name', + }, + auth: true, + fields: [ + { + name: 'name', + type: 'text', + }, + { + name: 'roles', + type: 'select', + hasMany: true, + options: [ + { label: 'User', value: 'is_user' }, + { label: 'Admin', value: 'is_admin' }, + ], + }, + ], +} diff --git a/test/soft-delete/config.ts b/test/soft-deletes/config.ts similarity index 63% rename from test/soft-delete/config.ts rename to test/soft-deletes/config.ts index a2213cc7186..b5a7ef70966 100644 --- a/test/soft-delete/config.ts +++ b/test/soft-deletes/config.ts @@ -3,15 +3,17 @@ import { fileURLToPath } from 'node:url' import path from 'path' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' -import { devUser } from '../credentials.js' +import { devUser, regularUser } from '../credentials.js' +import { Pages } from './collections/Pages/index.js' import { Posts } from './collections/Posts/index.js' +import { Users } from './collections/Users/index.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) // eslint-disable-next-line no-restricted-exports export default buildConfigWithDefaults({ - collections: [Posts], + collections: [Pages, Posts, Users], admin: { importMap: { baseDir: path.resolve(dirname), @@ -25,6 +27,18 @@ export default buildConfigWithDefaults({ data: { email: devUser.email, password: devUser.password, + name: 'Admin', + roles: ['is_admin', 'is_user'], + }, + }) + + await payload.create({ + collection: 'users', + data: { + email: regularUser.email, + password: regularUser.password, + name: 'Dev', + roles: ['is_user'], }, }) }, diff --git a/test/soft-delete/int.spec.ts b/test/soft-deletes/int.spec.ts similarity index 94% rename from test/soft-delete/int.spec.ts rename to test/soft-deletes/int.spec.ts index 6ebf5b5bd3b..df1ffb5844d 100644 --- a/test/soft-delete/int.spec.ts +++ b/test/soft-deletes/int.spec.ts @@ -4,12 +4,14 @@ import path from 'path' import { fileURLToPath } from 'url' import type { NextRESTClient } from '../helpers/NextRESTClient.js' -import type { Post } from './payload-types.js' +import type { Page, Post } from './payload-types.js' -import { devUser } from '../credentials.js' +import { regularUser } from '../credentials.js' import { idToString } from '../helpers/idToString.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' +import { pagesSlug } from './collections/Pages/index.js' import { postsSlug } from './collections/Posts/index.js' +import { usersSlug } from './collections/Users/index.js' let restClient: NextRESTClient let payload: Payload @@ -32,20 +34,28 @@ describe('soft-delete', () => { } }) + let pageOne: Page let postOne: Post let postTwo: Post beforeEach(async () => { await restClient.login({ - slug: 'users', - credentials: devUser, + slug: usersSlug, + credentials: regularUser, }) user = await payload.login({ - collection: 'users', + collection: usersSlug, data: { - email: devUser.email, - password: devUser.password, + email: regularUser.email, + password: regularUser.password, + }, + }) + + pageOne = await payload.create({ + collection: pagesSlug, + data: { + title: 'Page one', }, }) @@ -77,7 +87,52 @@ describe('soft-delete', () => { }) }) - describe('LOCAL', () => { + // Access control tests use the Pages collection because it has delete access control enabled. + // The Posts collection does not have any access restrictions and is used for general CRUD tests. + describe('Access control', () => { + it('should not allow bulk soft-deleting documents when restricted by delete access', async () => { + await expect( + payload.update({ + collection: pagesSlug, + data: { + deletedAt: new Date().toISOString(), + }, + user, // Regular user does not have delete access + where: { + // Using where to target multiple documents + title: { + equals: pageOne.title, + }, + }, + overrideAccess: false, // Override access to false to test access control + }), + ).rejects.toMatchObject({ + status: 403, + name: 'Forbidden', + message: expect.stringContaining('You are not allowed'), + }) + }) + + it('should not allow soft-deleting a document when restricted by delete access', async () => { + await expect( + payload.update({ + collection: pagesSlug, + data: { + deletedAt: new Date().toISOString(), + }, + id: pageOne.id, // Using ID to target specific document + user, // Regular user does not have delete access + overrideAccess: false, // Override access to false to test access control + }), + ).rejects.toMatchObject({ + status: 403, + name: 'Forbidden', + message: expect.stringContaining('You are not allowed'), + }) + }) + }) + + describe('LOCAL API', () => { describe('find / findByID operation', () => { it('should return all docs including soft-deleted docs in find with trash: true', async () => { const allDocs = await payload.find({ @@ -497,7 +552,7 @@ describe('soft-delete', () => { }) }) - describe('REST', () => { + describe('REST API', () => { describe('find endpoint', () => { it('should return all docs including soft-deleted docs in find with trash=true', async () => { const res = await restClient.GET(`/${postsSlug}?trash=true`) @@ -769,7 +824,7 @@ describe('soft-delete', () => { }) }) - describe('GRAPHQL', () => { + describe('GRAPHQL API', () => { describe('find query', () => { it('should return all docs including soft-deleted docs in find with trash=true', async () => { const query = ` diff --git a/test/soft-delete/payload-types.ts b/test/soft-deletes/payload-types.ts similarity index 88% rename from test/soft-delete/payload-types.ts rename to test/soft-deletes/payload-types.ts index 775fbc656fd..21e34758566 100644 --- a/test/soft-delete/payload-types.ts +++ b/test/soft-deletes/payload-types.ts @@ -67,6 +67,7 @@ export interface Config { }; blocks: {}; collections: { + pages: Page; posts: Post; users: User; 'payload-locked-documents': PayloadLockedDocument; @@ -75,6 +76,7 @@ export interface Config { }; collectionsJoins: {}; collectionsSelect: { + pages: PagesSelect | PagesSelect; posts: PostsSelect | PostsSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; @@ -113,6 +115,18 @@ export interface UserAuthOperations { password: string; }; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "pages". + */ +export interface Page { + id: string; + title?: string | null; + updatedAt: string; + createdAt: string; + deletedAt?: string | null; + _status?: ('draft' | 'published') | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "posts". @@ -123,6 +137,7 @@ export interface Post { updatedAt: string; createdAt: string; deletedAt?: string | null; + _status?: ('draft' | 'published') | null; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -130,9 +145,10 @@ export interface Post { */ export interface User { id: string; + name?: string | null; + roles?: ('is_user' | 'is_admin')[] | null; updatedAt: string; createdAt: string; - deletedAt?: string | null; email: string; resetPasswordToken?: string | null; resetPasswordExpiration?: string | null; @@ -149,6 +165,10 @@ export interface User { export interface PayloadLockedDocument { id: string; document?: + | ({ + relationTo: 'pages'; + value: string | Page; + } | null) | ({ relationTo: 'posts'; value: string | Post; @@ -164,7 +184,6 @@ export interface PayloadLockedDocument { }; updatedAt: string; createdAt: string; - deletedAt?: string | null; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -188,7 +207,6 @@ export interface PayloadPreference { | null; updatedAt: string; createdAt: string; - deletedAt?: string | null; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -200,7 +218,17 @@ export interface PayloadMigration { batch?: number | null; updatedAt: string; createdAt: string; - deletedAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "pages_select". + */ +export interface PagesSelect { + title?: T; + updatedAt?: T; + createdAt?: T; + deletedAt?: T; + _status?: T; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -211,15 +239,17 @@ export interface PostsSelect { updatedAt?: T; createdAt?: T; deletedAt?: T; + _status?: T; } /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select". */ export interface UsersSelect { + name?: T; + roles?: T; updatedAt?: T; createdAt?: T; - deletedAt?: T; email?: T; resetPasswordToken?: T; resetPasswordExpiration?: T; @@ -238,7 +268,6 @@ export interface PayloadLockedDocumentsSelect { user?: T; updatedAt?: T; createdAt?: T; - deletedAt?: T; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -250,7 +279,6 @@ export interface PayloadPreferencesSelect { value?: T; updatedAt?: T; createdAt?: T; - deletedAt?: T; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -261,7 +289,6 @@ export interface PayloadMigrationsSelect { batch?: T; updatedAt?: T; createdAt?: T; - deletedAt?: T; } /** * This interface was referenced by `Config`'s JSON-Schema diff --git a/test/soft-delete/tsconfig.eslint.json b/test/soft-deletes/tsconfig.eslint.json similarity index 100% rename from test/soft-delete/tsconfig.eslint.json rename to test/soft-deletes/tsconfig.eslint.json diff --git a/test/soft-delete/tsconfig.json b/test/soft-deletes/tsconfig.json similarity index 100% rename from test/soft-delete/tsconfig.json rename to test/soft-deletes/tsconfig.json From fcbaa5c105959052b52469ad754610d017c261b0 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Fri, 6 Jun 2025 09:49:00 -0400 Subject: [PATCH 14/14] feat: adds deletedAt translations for bengali languages --- packages/translations/src/languages/bn-BD.ts | 1 + packages/translations/src/languages/bn-IN.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/translations/src/languages/bn-BD.ts b/packages/translations/src/languages/bn-BD.ts index 6c2b0c1b08c..bb12c724a32 100644 --- a/packages/translations/src/languages/bn-BD.ts +++ b/packages/translations/src/languages/bn-BD.ts @@ -258,6 +258,7 @@ export const bnBDTranslations = { dark: 'ডার্ক', dashboard: 'ড্যাশবোর্ড', delete: 'মুছুন', + deletedAt: 'মুছে ফেলার সময়', deletedCountSuccessfully: '{{count}} {{label}} সফলভাবে মুছে ফেলা হয়েছে।', deletedSuccessfully: 'সফলভাবে মুছে ফেলা হয়েছে।', deleting: 'মুছে ফেলা হচ্ছে...', diff --git a/packages/translations/src/languages/bn-IN.ts b/packages/translations/src/languages/bn-IN.ts index 7d12eb8eb6e..9cf90822a93 100644 --- a/packages/translations/src/languages/bn-IN.ts +++ b/packages/translations/src/languages/bn-IN.ts @@ -258,6 +258,7 @@ export const bnINTranslations = { dark: 'ডার্ক', dashboard: 'ড্যাশবোর্ড', delete: 'মুছুন', + deletedAt: 'মুছে ফেলার সময়', deletedCountSuccessfully: '{{count}} {{label}} সফলভাবে মুছে ফেলা হয়েছে।', deletedSuccessfully: 'সফলভাবে মুছে ফেলা হয়েছে।', deleting: 'মুছে ফেলা হচ্ছে...',