diff --git a/packages/graphql/src/resolvers/collections/delete.ts b/packages/graphql/src/resolvers/collections/delete.ts index 2624d5c636b..9b98549fbd1 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 + softDeletes?: boolean }, context: { req: PayloadRequest @@ -49,6 +50,7 @@ export function getDeleteResolver( collection, depth: 0, req: isolateObjectProperty(req, 'transactionID'), + softDeletes: args.softDeletes, } 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..a4acd998d7a 100644 --- a/packages/graphql/src/resolvers/collections/find.ts +++ b/packages/graphql/src/resolvers/collections/find.ts @@ -14,6 +14,7 @@ export type Resolver = ( locale?: string page?: number pagination?: boolean + softDeletes?: boolean sort?: string where?: Where }, @@ -56,6 +57,7 @@ export function findResolver(collection: Collection): Resolver { page: args.page, pagination: args.pagination, req, + softDeletes: args.softDeletes, sort: args.sort, where: args.where, } diff --git a/packages/graphql/src/resolvers/collections/findByID.ts b/packages/graphql/src/resolvers/collections/findByID.ts index 22e8403cc07..b81aa8e7e1f 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 + softDeletes?: boolean }, context: { req: PayloadRequest @@ -50,6 +51,7 @@ export function findByIDResolver( depth: 0, draft: args.draft, req: isolateObjectProperty(req, 'transactionID'), + softDeletes: args.softDeletes, } const result = await findByIDOperation(options) diff --git a/packages/graphql/src/resolvers/collections/findVersionByID.ts b/packages/graphql/src/resolvers/collections/findVersionByID.ts index 25b05f32939..a0a941517f3 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 + softDeletes?: boolean }, context: { req: PayloadRequest @@ -33,6 +34,7 @@ export function findVersionByIDResolver(collection: Collection): Resolver { collection, depth: 0, req: isolateObjectProperty(req, 'transactionID'), + softDeletes: args.softDeletes, } 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..5b3fbdec66b 100644 --- a/packages/graphql/src/resolvers/collections/findVersions.ts +++ b/packages/graphql/src/resolvers/collections/findVersions.ts @@ -13,6 +13,7 @@ export type Resolver = ( locale?: string page?: number pagination?: boolean + softDeletes?: boolean sort?: string where: Where }, @@ -53,6 +54,7 @@ export function findVersionsResolver(collection: Collection): Resolver { page: args.page, pagination: args.pagination, req: isolateObjectProperty(req, 'transactionID'), + softDeletes: args.softDeletes, sort: args.sort, where: args.where, } diff --git a/packages/graphql/src/resolvers/collections/update.ts b/packages/graphql/src/resolvers/collections/update.ts index 5e8d894cf77..2a63ae685b6 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 + softDeletes?: boolean }, context: { req: PayloadRequest @@ -54,6 +55,7 @@ export function updateResolver( depth: 0, draft: args.draft, req: isolateObjectProperty(req, 'transactionID'), + softDeletes: args.softDeletes, } const result = await updateByIDOperation(options) diff --git a/packages/graphql/src/schema/initCollections.ts b/packages/graphql/src/schema/initCollections.ts index 7dda78057dd..23b0e0b4d45 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 }, } : {}), + softDeletes: { type: GraphQLBoolean }, }, resolve: findByIDResolver(collection), } @@ -223,6 +224,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ limit: { type: GraphQLInt }, page: { type: GraphQLInt }, pagination: { type: GraphQLBoolean }, + softDeletes: { type: GraphQLBoolean }, sort: { type: GraphQLString }, }, resolve: findResolver(collection), @@ -292,6 +294,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ locale: { type: graphqlResult.types.localeInputType }, } : {}), + softDeletes: { 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) }, + softDeletes: { type: GraphQLBoolean }, }, resolve: getDeleteResolver(collection), } @@ -329,12 +333,12 @@ 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'), }, ] @@ -359,6 +363,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ locale: { type: graphqlResult.types.localeInputType }, } : {}), + softDeletes: { type: GraphQLBoolean }, }, resolve: findVersionByIDResolver(collection), } @@ -384,6 +389,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ limit: { type: GraphQLInt }, page: { type: GraphQLInt }, pagination: { type: GraphQLBoolean }, + softDeletes: { type: GraphQLBoolean }, sort: { type: GraphQLString }, }, resolve: findVersionsResolver(collection), diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index b73e150cdb7..979f499efeb 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -97,6 +97,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)) { @@ -107,9 +108,13 @@ export const sanitizeCollection = async ( if (field.name === 'createdAt') { hasCreatedAt = true } + + if (field.name === 'deletedAt') { + hasDeletedAt = true + } } - return hasCreatedAt && hasUpdatedAt + return hasCreatedAt && hasUpdatedAt && (!sanitized.softDeletes || hasDeletedAt) }) if (!hasUpdatedAt) { @@ -138,6 +143,19 @@ export const sanitizeCollection = async ( label: ({ t }) => t('general:createdAt'), }) } + + if (sanitized.softDeletes && !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 ec68e8a3503..dcd4fbf1548 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -67,7 +67,7 @@ export type AuthOperationsFromCollectionSlug = export type RequiredDataFromCollection = MarkOptional< TData, - 'createdAt' | 'id' | 'sizes' | 'updatedAt' + 'createdAt' | 'deletedAt' | 'id' | 'sizes' | 'updatedAt' > export type RequiredDataFromCollectionSlug = @@ -543,7 +543,17 @@ export type CollectionConfig = { orderable?: boolean slug: string /** - * Add `createdAt` and `updatedAt` fields + * 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 * * @default true */ @@ -666,6 +676,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/collections/endpoints/delete.ts b/packages/payload/src/collections/endpoints/delete.ts index 4367420e32b..dd76c88f5bb 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, softDeletes, where } = req.query as { depth?: string overrideLock?: string populate?: Record select?: Record + softDeletes?: string where?: Where } @@ -28,6 +29,7 @@ export const deleteHandler: PayloadHandler = async (req) => { populate: sanitizePopulateParam(populate), req, select: sanitizeSelectParam(select), + softDeletes: softDeletes === 'true', where: where!, }) diff --git a/packages/payload/src/collections/endpoints/deleteByID.ts b/packages/payload/src/collections/endpoints/deleteByID.ts index 1563ade65a3..dfcaec62c76 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 softDeletes = searchParams.get('softDeletes') === '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), + softDeletes, }) const headers = headersWithCors({ diff --git a/packages/payload/src/collections/endpoints/find.ts b/packages/payload/src/collections/endpoints/find.ts index 30a36089190..837bb25231d 100644 --- a/packages/payload/src/collections/endpoints/find.ts +++ b/packages/payload/src/collections/endpoints/find.ts @@ -14,19 +14,31 @@ 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 } = - req.query as { - depth?: string - draft?: string - joins?: JoinParams - limit?: string - page?: string - pagination?: string - populate?: Record - select?: Record - sort?: string - where?: Where - } + const { + depth, + draft, + joins, + limit, + page, + pagination, + populate, + select, + softDeletes, + sort, + where, + } = req.query as { + depth?: string + draft?: string + joins?: JoinParams + limit?: string + page?: string + pagination?: string + populate?: Record + select?: Record + softDeletes?: string + sort?: string + where?: Where + } const result = await findOperation({ collection, @@ -39,6 +51,7 @@ export const findHandler: PayloadHandler = async (req) => { populate: sanitizePopulateParam(populate), req, select: sanitizeSelectParam(select), + softDeletes: softDeletes === 'true', sort: typeof sort === 'string' ? sort.split(',') : undefined, where, }) diff --git a/packages/payload/src/collections/endpoints/findByID.ts b/packages/payload/src/collections/endpoints/findByID.ts index fbdb9b6f272..7d81c109534 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 softDeletes = searchParams.get('softDeletes') === '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), + softDeletes, }) return Response.json(result, { diff --git a/packages/payload/src/collections/endpoints/findVersionByID.ts b/packages/payload/src/collections/endpoints/findVersionByID.ts index 737705d939b..93fe282a702 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 softDeletes = searchParams.get('softDeletes') === '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), + softDeletes, }) return Response.json(result, { diff --git a/packages/payload/src/collections/endpoints/findVersions.ts b/packages/payload/src/collections/endpoints/findVersions.ts index 57507c27bf3..a1aba170c45 100644 --- a/packages/payload/src/collections/endpoints/findVersions.ts +++ b/packages/payload/src/collections/endpoints/findVersions.ts @@ -12,16 +12,18 @@ 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 { - depth?: string - limit?: string - page?: string - pagination?: string - populate?: Record - select?: Record - sort?: string - where?: Where - } + const { depth, limit, page, pagination, populate, select, softDeletes, sort, where } = + req.query as { + depth?: string + limit?: string + page?: string + pagination?: string + populate?: Record + select?: Record + softDeletes?: string + sort?: string + where?: Where + } const result = await findVersionsOperation({ collection, @@ -32,6 +34,7 @@ export const findVersionsHandler: PayloadHandler = async (req) => { populate: sanitizePopulateParam(populate), req, select: sanitizeSelectParam(select), + softDeletes: softDeletes === 'true', sort: typeof sort === 'string' ? sort.split(',') : undefined, where, }) diff --git a/packages/payload/src/collections/endpoints/update.ts b/packages/payload/src/collections/endpoints/update.ts index 543c3836542..56e13539524 100644 --- a/packages/payload/src/collections/endpoints/update.ts +++ b/packages/payload/src/collections/endpoints/update.ts @@ -13,16 +13,18 @@ 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 { - depth?: string - draft?: string - limit?: string - overrideLock?: string - populate?: Record - select?: Record - sort?: string - where?: Where - } + const { depth, draft, limit, overrideLock, populate, select, softDeletes, sort, where } = + req.query as { + depth?: string + draft?: string + limit?: string + overrideLock?: string + populate?: Record + select?: Record + softDeletes?: string + sort?: string + where?: Where + } const result = await updateOperation({ collection, @@ -34,6 +36,7 @@ export const updateHandler: PayloadHandler = async (req) => { populate: sanitizePopulateParam(populate), req, select: sanitizeSelectParam(select), + softDeletes: softDeletes === 'true', sort: typeof sort === 'string' ? sort.split(',') : undefined, where: where!, }) diff --git a/packages/payload/src/collections/endpoints/updateByID.ts b/packages/payload/src/collections/endpoints/updateByID.ts index df2f7a4f6ed..736228a2114 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 softDeletes = searchParams.get('softDeletes') === '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), + softDeletes, }) let message = req.t('general:updatedSuccessfully') diff --git a/packages/payload/src/collections/operations/delete.ts b/packages/payload/src/collections/operations/delete.ts index 9480da99b76..201913013fc 100644 --- a/packages/payload/src/collections/operations/delete.ts +++ b/packages/payload/src/collections/operations/delete.ts @@ -36,6 +36,7 @@ export type Arguments = { req: PayloadRequest select?: SelectType showHiddenFields?: boolean + softDeletes?: boolean where: Where } @@ -81,6 +82,7 @@ export const deleteOperation = async < req, select: incomingSelect, showHiddenFields, + softDeletes = false, where, } = args @@ -105,7 +107,18 @@ export const deleteOperation = async < where, }) - const fullWhere = combineQueries(where, accessResult!) + let fullWhere = combineQueries(where, accessResult!) + + // If softDeletes is false, restrict to non-softDeleted documents only + if (collectionConfig.softDeletes && !softDeletes) { + const notSoftDeletedFilter = { deletedAt: { exists: false } } + + if (fullWhere?.and) { + fullWhere.and.push(notSoftDeletedFilter) + } else { + fullWhere = { and: [notSoftDeletedFilter] } + } + } 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 2f77784f6e5..78629468e56 100644 --- a/packages/payload/src/collections/operations/deleteByID.ts +++ b/packages/payload/src/collections/operations/deleteByID.ts @@ -34,6 +34,7 @@ export type Arguments = { req: PayloadRequest select?: SelectType showHiddenFields?: boolean + softDeletes?: boolean } export const deleteByIDOperation = async ( @@ -77,6 +78,7 @@ export const deleteByIDOperation = async ( @@ -41,6 +42,7 @@ export const findVersionByIDOperation = async ( req, select: incomingSelect, showHiddenFields, + softDeletes = 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 && !softDeletes) { + const notSoftDeletedFilter = { 'version.deletedAt': { exists: false } } + + if (fullWhere?.and) { + fullWhere.and.push(notSoftDeletedFilter) + } else { + fullWhere = { and: [notSoftDeletedFilter] } + } + } // ///////////////////////////////////// // Find by ID diff --git a/packages/payload/src/collections/operations/findVersions.ts b/packages/payload/src/collections/operations/findVersions.ts index beb12080928..555a77a44a3 100644 --- a/packages/payload/src/collections/operations/findVersions.ts +++ b/packages/payload/src/collections/operations/findVersions.ts @@ -25,6 +25,7 @@ export type Arguments = { req?: PayloadRequest select?: SelectType showHiddenFields?: boolean + softDeletes?: boolean sort?: Sort where?: Where } @@ -42,6 +43,7 @@ export const findVersionsOperation = async populate, select: incomingSelect, showHiddenFields, + softDeletes = false, sort, where, } = args @@ -70,7 +72,18 @@ export const findVersionsOperation = async where: where!, }) - const fullWhere = combineQueries(where!, accessResults) + let fullWhere = combineQueries(where!, accessResults) + + // If softDeletes is false, restrict to non-softDeleted documents only + if (collectionConfig.softDeletes && !softDeletes) { + const notSoftDeletedFilter = { 'version.deletedAt': { exists: false } } + + if (fullWhere?.and) { + fullWhere.and.push(notSoftDeletedFilter) + } else { + fullWhere = { and: [notSoftDeletedFilter] } + } + } const select = sanitizeSelect({ fields: buildVersionCollectionFields(payload.config, collectionConfig, true), diff --git a/packages/payload/src/collections/operations/local/delete.ts b/packages/payload/src/collections/operations/local/delete.ts index e765c3ebb60..8a7828ca98e 100644 --- a/packages/payload/src/collections/operations/local/delete.ts +++ b/packages/payload/src/collections/operations/local/delete.ts @@ -73,6 +73,14 @@ export type BaseOptions = * @default false */ showHiddenFields?: boolean + /** + * When set to `true`, the query will include both normal and trashed (soft-deleted) documents. + * To query only softDeleted documents, pass `softDeletes: 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 + */ + softDeletes?: boolean /** * Sort the documents, can be a string or an array of strings * @example '-createdAt' // Sort DESC by createdAt @@ -146,6 +155,7 @@ export async function findLocal< populate, select, showHiddenFields, + softDeletes = false, sort, where, } = options @@ -174,6 +184,7 @@ export async function findLocal< req: await createLocalReq(options as CreateLocalReqOptions, payload), select, showHiddenFields, + softDeletes, sort, where, }) diff --git a/packages/payload/src/collections/operations/local/findByID.ts b/packages/payload/src/collections/operations/local/findByID.ts index 72b6bea3094..a1cdc317018 100644 --- a/packages/payload/src/collections/operations/local/findByID.ts +++ b/packages/payload/src/collections/operations/local/findByID.ts @@ -100,6 +100,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 `softDeletes: true`. + * + * This argument has no effect unless `softDeletes` is enabled on the collection. + * @default false + */ + softDeletes?: boolean /** * If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. */ @@ -127,6 +136,7 @@ export default async function findByIDLocal< populate, select, showHiddenFields, + softDeletes = false, } = options const collection = payload.collections[collectionSlug] @@ -151,5 +161,6 @@ export default async function findByIDLocal< req: await createLocalReq(options as CreateLocalReqOptions, payload), select, showHiddenFields, + softDeletes, }) } diff --git a/packages/payload/src/collections/operations/local/findVersionByID.ts b/packages/payload/src/collections/operations/local/findVersionByID.ts index 3c159ccb823..6ac7c45585f 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 `softDeletes: true`. + * + * This argument has no effect unless `softDeletes` is enabled on the collection. + * @default false + */ + softDeletes?: 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, + softDeletes = 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, + softDeletes, }) } diff --git a/packages/payload/src/collections/operations/local/findVersions.ts b/packages/payload/src/collections/operations/local/findVersions.ts index 0de541a77a1..cdd8b88a554 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 { @@ -79,6 +80,15 @@ export type Options = { * @default false */ showHiddenFields?: boolean + /** + * When set to `true`, the query will include both normal and trashed (soft-deleted) documents. + * To query only softDeleted documents, pass `softDeletes: 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 + */ + softDeletes?: boolean /** * Sort the documents, can be a string or an array of strings * @example '-version.createdAt' // Sort DESC by createdAt @@ -108,6 +118,7 @@ export default async function findVersionsLocal( populate, select, showHiddenFields, + softDeletes = false, sort, where, } = options @@ -130,6 +141,7 @@ export default async function findVersionsLocal( req: await createLocalReq(options as CreateLocalReqOptions, payload), select, showHiddenFields, + softDeletes, sort, where, }) diff --git a/packages/payload/src/collections/operations/local/update.ts b/packages/payload/src/collections/operations/local/update.ts index c039a565187..d53ff9630c5 100644 --- a/packages/payload/src/collections/operations/local/update.ts +++ b/packages/payload/src/collections/operations/local/update.ts @@ -113,6 +113,13 @@ export type BaseOptions = { req: PayloadRequest select?: SelectType showHiddenFields?: boolean + softDeletes?: boolean /** * Sort the documents, can be a string or an array of strings * @example '-createdAt' // Sort DESC by createdAt @@ -103,6 +104,7 @@ export const updateOperation = async < req, select: incomingSelect, showHiddenFields, + softDeletes = false, sort: incomingSort, where, } = args @@ -134,7 +136,31 @@ export const updateOperation = async < // Retrieve documents // ///////////////////////////////////// - const fullWhere = combineQueries(where, accessResult!) + let fullWhere = combineQueries(where, accessResult!) + + const isSoftDeleteAttempt = + collectionConfig.softDeletes && + typeof bulkUpdateData === 'object' && + bulkUpdateData !== null && + 'deletedAt' in bulkUpdateData && + bulkUpdateData.deletedAt != null + + // Enforce delete access if performing a soft-delete + if (isSoftDeleteAttempt && !overrideAccess) { + const deleteAccessResult = await executeAccess({ req }, collectionConfig.access.delete) + fullWhere = combineQueries(fullWhere, deleteAccessResult) + } + + // If softDeletes is false, restrict to non-softDeleted documents only + if (collectionConfig.softDeletes && !softDeletes) { + const notSoftDeletedFilter = { deletedAt: { exists: false } } + + if (fullWhere?.and) { + fullWhere.and.push(notSoftDeletedFilter) + } else { + fullWhere = { and: [notSoftDeletedFilter] } + } + } 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 9df0440224a..3627430e10e 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -47,6 +47,7 @@ export type Arguments = { req: PayloadRequest select?: SelectType showHiddenFields?: boolean + softDeletes?: boolean } export const updateByIDOperation = async < @@ -102,6 +103,7 @@ export const updateByIDOperation = async < req, select: incomingSelect, showHiddenFields, + softDeletes = false, } = args if (!id) { @@ -123,11 +125,38 @@ export const updateByIDOperation = async < // Retrieve document // ///////////////////////////////////// + const where = { id: { equals: id } } + + let fullWhere = combineQueries(where, accessResults) + + const isSoftDeleteAttempt = + collectionConfig.softDeletes && + typeof data === 'object' && + data !== null && + 'deletedAt' in data && + data.deletedAt != null + + if (isSoftDeleteAttempt && !overrideAccess) { + const deleteAccessResult = await executeAccess({ req }, collectionConfig.access.delete) + fullWhere = combineQueries(fullWhere, deleteAccessResult) + } + + // If softDeletes is false, restrict to non-softDeleted documents only + if (collectionConfig.softDeletes && !softDeletes) { + const notSoftDeletedFilter = { deletedAt: { exists: false } } + + if (fullWhere?.and) { + fullWhere.and.push(notSoftDeletedFilter) + } else { + fullWhere = { and: [notSoftDeletedFilter] } + } + } + const findOneArgs: FindOneArgs = { collection: collectionConfig.slug, locale: locale!, req, - where: combineQueries({ id: { equals: id } }, accessResults), + where: fullWhere, } const docWithLocales = await getLatestCollectionVersion({ 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 d8bea4c0e56..3e738c7bfb6 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 1057b06c703..bf0ee2004e4 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 4c44a87ce57..d27892981a7 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/bnBd.ts b/packages/translations/src/languages/bnBd.ts index 29391de3f7d..c79b12f5df9 100644 --- a/packages/translations/src/languages/bnBd.ts +++ b/packages/translations/src/languages/bnBd.ts @@ -258,6 +258,7 @@ export const bnBdTranslations: DefaultTranslationsObject = { dark: 'ডার্ক', dashboard: 'ড্যাশবোর্ড', delete: 'মুছুন', + deletedAt: 'মুছে ফেলার সময়', deletedCountSuccessfully: '{{count}} {{label}} সফলভাবে মুছে ফেলা হয়েছে।', deletedSuccessfully: 'সফলভাবে মুছে ফেলা হয়েছে।', deleting: 'মুছে ফেলা হচ্ছে...', diff --git a/packages/translations/src/languages/bnIn.ts b/packages/translations/src/languages/bnIn.ts index 2e128503a9b..fc150072aae 100644 --- a/packages/translations/src/languages/bnIn.ts +++ b/packages/translations/src/languages/bnIn.ts @@ -258,6 +258,7 @@ export const bnInTranslations: 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 75eef6ebf6b..ac775479a17 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 69187c4f45f..20cb6db8a2d 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 f164bb77adb..d663e86b8ad 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 843ecd7626c..e1d13605f01 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 17369f47f7c..5e2992bd556 100644 --- a/packages/translations/src/languages/es.ts +++ b/packages/translations/src/languages/es.ts @@ -261,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 675ced36da8..f1e7de40f4e 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 b296855e9fe..8dc68f2f5d8 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 bc32a24d370..6ff27772de5 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 94870999da8..9c71b486b6f 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 6d40fae2a86..e2667bd4b0b 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 857ec3e4837..d940e79a564 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 7fc6c061d0b..35dbd803bf0 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 42301a92fac..e7179dc715f 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 0f0c7c39144..67f9e303caf 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 7c583e2dba5..9ef740566ac 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 afd2035fa6b..0be9e0e1d80 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 7f1dd43cca1..9bf25fd3145 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 d1e0182de1f..834e08f45a1 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 1a68e29109a..e8694e2825f 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 5eeec6b5e2a..348be4cf2ea 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 691cb46b384..063e7de2012 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 0c23f35e599..c28e6f5a08b 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 394f5e03cd2..a10d6f96567 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 c3df336fd22..d52dbbb3032 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 eece418ec10..432914655c2 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 6b2f4e46e4c..5dd15e7c3c9 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 efc0d494b5d..499bc842a86 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 529c9d4f924..e9d891e6cec 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 9e4fc4c2897..2a535c931fa 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 c715c2dc22e..340d75826e9 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 241326b526f..d36954bc5dc 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 6c78e7b8c51..54b262b1cf9 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 52fd471ed07..0980a943ef2 100644 --- a/packages/translations/src/languages/zh.ts +++ b/packages/translations/src/languages/zh.ts @@ -244,7 +244,8 @@ export const zhTranslations: DefaultTranslationsObject = { dark: '深色', dashboard: '仪表板', delete: '删除', - deletedCountSuccessfully: '已成功删除 {{count}}个{{label}}。', + deletedAt: '已删除时间', + deletedCountSuccessfully: '已成功删除 {{count}} {{label}}。', deletedSuccessfully: '已成功删除。', deleting: '删除中...', depth: '深度', diff --git a/packages/translations/src/languages/zhTw.ts b/packages/translations/src/languages/zhTw.ts index e9701906388..68f6ab07847 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: '刪除中...', 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-deletes/collections/Posts/index.ts b/test/soft-deletes/collections/Posts/index.ts new file mode 100644 index 00000000000..6edefe1262b --- /dev/null +++ b/test/soft-deletes/collections/Posts/index.ts @@ -0,0 +1,20 @@ +import type { CollectionConfig } from 'payload' + +export const postsSlug = 'posts' + +export const Posts: CollectionConfig = { + slug: postsSlug, + admin: { + useAsTitle: 'title', + }, + softDeletes: true, + fields: [ + { + name: 'title', + type: 'text', + }, + ], + versions: { + drafts: true, + }, +} 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-deletes/config.ts b/test/soft-deletes/config.ts new file mode 100644 index 00000000000..b5a7ef70966 --- /dev/null +++ b/test/soft-deletes/config.ts @@ -0,0 +1,48 @@ +import { lexicalEditor } from '@payloadcms/richtext-lexical' +import { fileURLToPath } from 'node:url' +import path from 'path' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.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: [Pages, Posts, Users], + admin: { + importMap: { + baseDir: path.resolve(dirname), + }, + }, + editor: lexicalEditor({}), + + onInit: async (payload) => { + await payload.create({ + collection: 'users', + 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'], + }, + }) + }, + typescript: { + outputFile: path.resolve(dirname, 'payload-types.ts'), + }, +}) diff --git a/test/soft-deletes/int.spec.ts b/test/soft-deletes/int.spec.ts new file mode 100644 index 00000000000..10bb7dd11ed --- /dev/null +++ b/test/soft-deletes/int.spec.ts @@ -0,0 +1,1466 @@ +import type { Payload } from 'payload' + +import path from 'path' +import { fileURLToPath } from 'url' + +import type { NextRESTClient } from '../helpers/NextRESTClient.js' +import type { Page, Post } from './payload-types.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 +let user: any + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +describe('soft-delete', () => { + beforeAll(async () => { + const initResult = await initPayloadInt(dirname) + + payload = initResult.payload as Payload + restClient = initResult.restClient as NextRESTClient + }) + + afterAll(async () => { + if (typeof payload.db.destroy === 'function') { + await payload.db.destroy() + } + }) + + let pageOne: Page + let postOne: Post + let postTwo: Post + + beforeEach(async () => { + await restClient.login({ + slug: usersSlug, + credentials: regularUser, + }) + + user = await payload.login({ + collection: usersSlug, + data: { + email: regularUser.email, + password: regularUser.password, + }, + }) + + pageOne = await payload.create({ + collection: pagesSlug, + data: { + title: 'Page one', + }, + }) + + postOne = await payload.create({ + collection: postsSlug, + data: { + title: 'Post one', + }, + }) + + postTwo = await payload.create({ + collection: postsSlug, + data: { + title: 'Post two', + deletedAt: new Date().toISOString(), + }, + }) + }) + + afterEach(async () => { + await payload.delete({ + collection: postsSlug, + softDeletes: true, + where: { + title: { + exists: true, + }, + }, + }) + }) + + // 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', () => { + it('should return all docs including soft-deleted docs in find with softDeletes: true', async () => { + const allDocs = await payload.find({ + collection: postsSlug, + softDeletes: true, + }) + + expect(allDocs.totalDocs).toEqual(2) + }) + + it('should return only soft-deleted docs in find with softDeletes: true', async () => { + const softDeletedDocs = await payload.find({ + collection: postsSlug, + where: { + deletedAt: { + exists: true, + }, + }, + softDeletes: 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 softDeletes: false', async () => { + const normalDocs = await payload.find({ + collection: postsSlug, + softDeletes: false, + }) + + expect(normalDocs.totalDocs).toEqual(1) + expect(normalDocs.docs[0]?.id).toEqual(postOne.id) + }) + + it('should find restored documents after setting deletedAt to null', async () => { + await payload.update({ + collection: postsSlug, + id: postTwo.id, + data: { + deletedAt: null, + }, + softDeletes: true, + }) + + const result = await payload.find({ + collection: postsSlug, + softDeletes: false, // Normal query should return it now + }) + + const restored = result.docs.find((doc) => doc.id === postTwo.id) + + expect(restored).toBeDefined() + expect(restored?.deletedAt).toBeNull() + }) + }) + + describe('findByID operation', () => { + it('should return a soft-deleted document when softDeletes: true', async () => { + const softDeletedPost: Post = await payload.findByID({ + collection: postsSlug, + id: postTwo.id, + softDeletes: 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 softDeletes: true', async () => { + await expect( + payload.findByID({ + collection: postsSlug, + id: postTwo.id, + }), + ).rejects.toThrow('Not Found') + + await expect( + payload.findByID({ + collection: postsSlug, + id: postTwo.id, + softDeletes: false, + }), + ).rejects.toThrow('Not Found') + }) + }) + + describe('findVersions operation', () => { + beforeAll(async () => { + await payload.update({ + collection: postsSlug, + data: { + title: 'Some updated title', + }, + softDeletes: true, + where: { + title: { + exists: true, + }, + }, + }) + }) + it('should return all versions including soft-deleted docs in findVersions with softDeletes: true', async () => { + const allVersions = await payload.findVersions({ + collection: postsSlug, + softDeletes: 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 softDeletes: true', async () => { + const softDeletedVersions = await payload.findVersions({ + collection: postsSlug, + where: { + 'version.deletedAt': { + exists: true, + }, + }, + softDeletes: 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 softDeletes: false', async () => { + const normalVersions = await payload.findVersions({ + collection: postsSlug, + softDeletes: false, + }) + + expect(normalVersions.totalDocs).toEqual(1) + expect(normalVersions.docs[0]?.parent).toEqual(postOne.id) + }) + + it('should find versions where version.deletedAt is null after restore', async () => { + await payload.update({ + collection: postsSlug, + id: postTwo.id, + data: { + deletedAt: null, + }, + softDeletes: true, + }) + + const versions = await payload.findVersions({ + collection: postsSlug, + softDeletes: true, + where: { + 'version.deletedAt': { + equals: null, + }, + }, + }) + + expect(versions.docs.some((v) => v.parent === postTwo.id)).toBe(true) + }) + }) + + describe('findVersionByID operation', () => { + beforeAll(async () => { + await payload.update({ + collection: postsSlug, + data: { + title: 'Some updated title', + }, + softDeletes: true, + where: { + title: { + exists: true, + }, + }, + }) + }) + + it('should return a soft-deleted version document when softDeletes: true', async () => { + const softDeletedVersions = await payload.findVersions({ + collection: postsSlug, + where: { + 'version.deletedAt': { + exists: true, + }, + }, + softDeletes: true, + }) + + expect(softDeletedVersions.docs).toHaveLength(1) + + const version = softDeletedVersions.docs[0] + + const softDeletedVersionPost = await payload.findVersionByID({ + collection: postsSlug, + id: version!.id, + softDeletes: 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 softDeletes: true', async () => { + const softDeletedVersions = await payload.findVersions({ + collection: postsSlug, + where: { + 'version.deletedAt': { + exists: true, + }, + }, + softDeletes: 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, + softDeletes: false, + }), + ).rejects.toThrow('Not Found') + }) + }) + + describe('updateByID operation', () => { + it('should update a single soft-deleted document when softDeletes: true', async () => { + const updatedPost: Post = await payload.update({ + collection: postsSlug, + id: postTwo.id, + data: { + title: 'Updated Post Two', + }, + softDeletes: 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 softDeletes: 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', + }, + softDeletes: false, + }), + ).rejects.toThrow('Not Found') + }) + + it('should update a single normal document when softDeletes: 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).toBeFalsy() + }) + + it('should restore a soft-deleted document by setting deletedAt to null', async () => { + const restored = await payload.update({ + collection: postsSlug, + id: postTwo.id, + data: { + deletedAt: null, + }, + softDeletes: true, + }) + + expect(restored.deletedAt).toBeNull() + + // Should now show up in softDeletes: false queries + const result = await payload.find({ + collection: postsSlug, + softDeletes: false, + }) + + const found = result.docs.find((doc) => doc.id === postTwo.id) + expect(found).toBeDefined() + expect(found?.deletedAt).toBeNull() + }) + }) + + describe('update operation', () => { + it('should update only normal document when softDeletes: false', async () => { + const result = await payload.update({ + collection: postsSlug, + data: { + title: 'Updated Post', + }, + softDeletes: 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).toBeFalsy() + }) + + it('should update all documents including soft-deleted documents when softDeletes: true', async () => { + const result = await payload.update({ + collection: postsSlug, + data: { + title: 'A New Updated Post', + }, + softDeletes: 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).toBeFalsy() + + expect(updatedPostTwo?.title).toEqual('A New Updated Post') + expect(updatedPostTwo?.deletedAt).toBeDefined() + }) + + it('should only update soft-deleted documents when softDeletes: 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', + }, + softDeletes: 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, + softDeletes: true, + }) + }) + }) + + describe('delete operation', () => { + it('should perma delete all docs including soft-deleted documents when softDeletes: true', async () => { + await payload.delete({ + collection: postsSlug, + softDeletes: true, + where: { + title: { + exists: true, + }, + }, + }) + + const allDocs = await payload.find({ + collection: postsSlug, + softDeletes: true, + }) + + expect(allDocs.totalDocs).toEqual(0) + }) + + it('should only perma delete normal docs when softDeletes: false', async () => { + await payload.delete({ + collection: postsSlug, + softDeletes: false, + where: { + title: { + exists: true, + }, + }, + }) + + const allDocs = await payload.find({ + collection: postsSlug, + softDeletes: 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 softDeletes: true', async () => { + await expect( + payload.delete({ + collection: postsSlug, + id: postTwo.id, + }), + ).rejects.toThrow('Not Found') + + await expect( + payload.delete({ + collection: postsSlug, + id: postTwo.id, + softDeletes: false, + }), + ).rejects.toThrow('Not Found') + }) + + it('should delete a soft-deleted document when softDeletes: true', async () => { + await payload.delete({ + collection: postsSlug, + id: postTwo.id, + softDeletes: true, + }) + + const allDocs = await payload.find({ + collection: postsSlug, + softDeletes: true, + }) + + expect(allDocs.totalDocs).toEqual(1) + expect(allDocs.docs[0]?.id).toEqual(postOne.id) + }) + }) + }) + + describe('REST API', () => { + describe('find endpoint', () => { + it('should return all docs including soft-deleted docs in find with softDeletes=true', async () => { + const res = await restClient.GET(`/${postsSlug}?softDeletes=true`) + expect(res.status).toBe(200) + const data = await res.json() + expect(data.docs).toHaveLength(2) + }) + + it('should return only soft-deleted docs with softDeletes=true and where[deletedAt][exists]=true', async () => { + const res = await restClient.GET( + `/${postsSlug}?softDeletes=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 softDeletes=false', async () => { + const res = await restClient.GET(`/${postsSlug}?softDeletes=false`) + const data = await res.json() + expect(data.docs).toHaveLength(1) + expect(data.docs[0]?.id).toEqual(postOne.id) + }) + + it('should find restored documents after setting deletedAt to null', async () => { + await restClient.PATCH(`/${postsSlug}/${postTwo.id}?softDeletes=true`, { + body: JSON.stringify({ + deletedAt: null, + }), + }) + + const res = await restClient.GET(`/${postsSlug}?softDeletes=false`) + const data = await res.json() + + const restored = data.docs.find((doc: Post) => doc.id === postTwo.id) + + expect(restored).toBeDefined() + expect(restored.deletedAt).toBeNull() + }) + }) + + describe('findByID endpoint', () => { + it('should return a soft-deleted doc by ID with softDeletes=true', async () => { + const res = await restClient.GET(`/${postsSlug}/${postTwo.id}?softDeletes=true`) + const data = await res.json() + expect(data?.id).toEqual(postTwo.id) + expect(data?.deletedAt).toEqual(postTwo.deletedAt) + }) + + it('should 404 when trying to get a soft-deleted doc without softDeletes=true', async () => { + const res = await restClient.GET(`/${postsSlug}/${postTwo.id}`) + expect(res.status).toBe(404) + }) + }) + + describe('find versions endpoint', () => { + beforeAll(async () => { + await payload.update({ + collection: postsSlug, + data: { + title: 'Some updated title', + }, + softDeletes: true, + where: { + title: { + exists: true, + }, + }, + }) + }) + it('should return all versions including soft-deleted docs in findVersions with softDeletes: true', async () => { + const res = await restClient.GET(`/${postsSlug}/versions?softDeletes=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 softDeletes: true', async () => { + const res = await restClient.GET( + `/${postsSlug}/versions?softDeletes=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 softDeletes: false', async () => { + const res = await restClient.GET(`/${postsSlug}/versions?softDeletes=false`) + const data = await res.json() + expect(data.docs).toHaveLength(1) + expect(data.docs[0]?.parent).toEqual(postOne.id) + }) + + it('should find versions where version.deletedAt is null after restore via REST', async () => { + await restClient.PATCH(`/${postsSlug}/${postTwo.id}?softDeletes=true`, { + body: JSON.stringify({ + deletedAt: null, + }), + }) + + const res = await restClient.GET( + `/${postsSlug}/versions?softDeletes=true&where[version.deletedAt][equals]=null`, + ) + const data = await res.json() + + const version = data.docs.find((v: any) => v.parent === postTwo.id) + expect(version).toBeDefined() + expect(version.version.deletedAt).toBeNull() + }) + }) + + describe('findVersionByID endpoint', () => { + beforeAll(async () => { + await payload.update({ + collection: postsSlug, + data: { + title: 'Some updated title', + }, + softDeletes: true, + where: { + title: { + exists: true, + }, + }, + }) + }) + + it('should return a soft-deleted version document when softDeletes: true', async () => { + const softDeletedVersions = await restClient.GET( + `/${postsSlug}/versions?softDeletes=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}?softDeletes=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 softDeletes: true', async () => { + const softDeletedVersions = await restClient.GET( + `/${postsSlug}/versions?softDeletes=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}?softDeletes=false`, + ) + expect(withTrashFalse.status).toBe(404) + }) + }) + + describe('updateByID endpoint', () => { + it('should update a single soft-deleted doc when softDeletes=true', async () => { + const res = await restClient.PATCH(`/${postsSlug}/${postTwo.id}?softDeletes=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) + }) + + it('should throw NotFound error when trying to update a soft-deleted document w/o softDeletes: true', async () => { + const res = await restClient.PATCH(`/${postsSlug}/${postTwo.id}`, { + body: JSON.stringify({ title: 'Fail Update' }), + }) + expect(res.status).toBe(404) + }) + + it('should update a single normal document when softDeletes: false', async () => { + const res = await restClient.PATCH(`/${postsSlug}/${postOne.id}?softDeletes=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).toBeFalsy() + }) + + it('should restore a soft-deleted document by setting deletedAt to null', async () => { + const res = await restClient.PATCH(`/${postsSlug}/${postTwo.id}?softDeletes=true`, { + body: JSON.stringify({ + deletedAt: null, + }), + }) + + const result = await res.json() + expect(result.doc.deletedAt).toBeNull() + + const check = await restClient.GET(`/${postsSlug}?softDeletes=false`) + const data = await check.json() + const restored = data.docs.find((doc: Post) => doc.id === postTwo.id) + + expect(restored).toBeDefined() + expect(restored.deletedAt).toBeNull() + }) + }) + + describe('update endpoint', () => { + it('should update only normal document when softDeletes: false', async () => { + const query = `?softDeletes=false&where[id][equals]=${postOne.id}` + + 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).toBeFalsy() + }) + + it('should update all documents including soft-deleted documents when softDeletes: true', async () => { + const query = `?softDeletes=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 only update soft-deleted documents when softDeletes: true and where[deletedAt][exists]=true', async () => { + const query = `?softDeletes=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, + softDeletes: true, + }) + }) + }) + + describe('delete endpoint', () => { + it('should perma delete all docs including soft-deleted documents when softDeletes: true', async () => { + const query = `?softDeletes=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}?softDeletes=true`) + const checkData = await check.json() + expect(checkData.docs).toHaveLength(0) + }) + + it('should only perma delete normal docs when softDeletes: false', async () => { + const query = `?softDeletes=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}?softDeletes=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) + }) + }) + + describe('deleteByID endpoint', () => { + it('should throw NotFound error when trying to delete a soft-deleted document w/o softDeletes: true', async () => { + const res = await restClient.DELETE(`/${postsSlug}/${postTwo.id}`) + expect(res.status).toBe(404) + }) + + it('should delete a soft-deleted document when softDeletes: true', async () => { + const res = await restClient.DELETE(`/${postsSlug}/${postTwo.id}?softDeletes=true`) + expect(res.status).toBe(200) + const result = await res.json() + expect(result.doc.id).toBe(postTwo.id) + }) + }) + }) + + describe('GRAPHQL API', () => { + describe('find query', () => { + it('should return all docs including soft-deleted docs in find with softDeletes=true', async () => { + const query = ` + query { + Posts(softDeletes: 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 softDeletes=true and where[deletedAt][exists]=true', async () => { + const query = ` + query { + Posts( + softDeletes: 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 softDeletes=false', async () => { + const query = ` + query { + Posts(softDeletes: 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 find restored documents after setting deletedAt to null', async () => { + const mutation = ` + mutation { + updatePost(id: ${idToString(postTwo.id, payload)}, softDeletes: true, data: { + deletedAt: null + }) { + id + } + } + ` + await restClient.GRAPHQL_POST({ body: JSON.stringify({ query: mutation }) }) + + const query = ` + query { + Posts(softDeletes: false) { + docs { + id + deletedAt + } + } + } + ` + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + + const restored = res.data.Posts.docs.find((doc: Post) => doc.id === postTwo.id) + expect(restored).toBeDefined() + expect(restored.deletedAt).toBeNull() + }) + }) + + describe('findByID query', () => { + it('should return a soft-deleted doc by ID with softDeletes=true', async () => { + const query = ` + query { + Post(id: ${idToString(postTwo.id, payload)}, softDeletes: 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 softDeletes=true', async () => { + const query = ` + query { + Post(id: ${idToString(postTwo.id, payload)}) { + 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', + }, + softDeletes: true, + where: { + title: { + exists: true, + }, + }, + }) + }) + it('should return all versions including soft-deleted docs in findVersions with softDeletes: true', async () => { + const query = ` + query { + versionsPosts(softDeletes: 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 softDeletes: true', async () => { + const query = ` + query { + versionsPosts( + softDeletes: 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 softDeletes: false', async () => { + const query = ` + query { + versionsPosts(softDeletes: 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() + } + }) + + it('should find versions where version.deletedAt is null after restore', async () => { + const mutation = ` + mutation { + updatePost(id: ${idToString(postTwo.id, payload)}, softDeletes: true, data: { deletedAt: null }) { + id + title + deletedAt + } + } + ` + await restClient.GRAPHQL_POST({ body: JSON.stringify({ query: mutation }) }) + + const query = ` + query { + versionsPosts( + softDeletes: true, + where: { + version__deletedAt: { + equals: null + } + } + ) { + docs { + id + parent { + id + } + version { + deletedAt + } + } + } + } + ` + + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + + const version = res.data.versionsPosts.docs.find( + (v: any) => String(v.parent.id) === String(postTwo.id), + ) + expect(version).toBeDefined() + expect(version.version.deletedAt).toBeNull() + }) + }) + + describe('findVersionByID endpoint', () => { + beforeAll(async () => { + await payload.update({ + collection: postsSlug, + data: { + title: 'Some updated title', + }, + softDeletes: true, + where: { + title: { + exists: true, + }, + }, + }) + }) + + it('should return a soft-deleted document when softDeletes: true', async () => { + // First, get the version ID of the soft-deleted post + const listQuery = ` + query { + versionsPosts( + softDeletes: 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: ${idToString(softDeletedVersion.id, payload)}, softDeletes: 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 softDeletes: true', async () => { + // First, get the version ID of the soft-deleted post + const listQuery = ` + query { + versionsPosts( + softDeletes: 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: ${idToString(softDeletedVersion.id, payload)}) { + 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 query', () => { + it('should update a single soft-deleted doc when softDeletes=true', async () => { + const query = ` + mutation { + updatePost(id: ${idToString(postTwo.id, payload)}, softDeletes: 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 softDeletes: true', async () => { + const query = ` + mutation { + updatePost(id: ${idToString(postTwo.id, payload)}, 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 softDeletes: false', async () => { + const query = ` + mutation { + updatePost(id: ${idToString(postOne.id, payload)}, softDeletes: 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() + }) + + it('should restore a soft-deleted document by setting deletedAt to null', async () => { + const mutation = ` + mutation { + updatePost(id: ${idToString(postTwo.id, payload)}, softDeletes: true, data: { + deletedAt: null + }) { + id + deletedAt + } + } + ` + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query: mutation }) }) + .then((r) => r.json()) + + expect(res.data.updatePost.deletedAt).toBeNull() + + const query = ` + query { + Posts(softDeletes: false) { + docs { + id + deletedAt + } + } + } + ` + const restored = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + + const match = restored.data.Posts.docs.find((doc: Post) => doc.id === postTwo.id) + expect(match).toBeDefined() + expect(match.deletedAt).toBeNull() + }) + }) + + // describe('update endpoint', () => { + // it.todo('should update only normal document when softDeletes: false') + + // it.todo('should update all documents including soft-deleted documents when softDeletes: true') + + // it.todo( + // 'should only update soft-deleted documents when softDeletes: true and where[deletedAt][exists]=true', + // ) + // }) + + // describe('delete endpoint', () => { + // it.todo('should perma delete all docs including soft-deleted documents when softDeletes: true') + + // it.todo('should only perma delete normal docs when softDeletes: false') + // }) + + describe('deleteByID query', () => { + it('should throw NotFound error when trying to delete a soft-deleted document w/o softDeletes: true', async () => { + const query = ` + mutation { + deletePost(id: ${idToString(postTwo.id, payload)}) { + 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 softDeletes: true', async () => { + const query = ` + mutation { + deletePost(id: ${idToString(postTwo.id, payload)}, softDeletes: true) { + id + } + } + ` + const res = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((r) => r.json()) + expect(res.data.deletePost.id).toBe(postTwo.id) + }) + }) + }) +}) diff --git a/test/soft-deletes/payload-types.ts b/test/soft-deletes/payload-types.ts new file mode 100644 index 00000000000..21e34758566 --- /dev/null +++ b/test/soft-deletes/payload-types.ts @@ -0,0 +1,305 @@ +/* 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: { + pages: Page; + posts: Post; + users: User; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + collectionsJoins: {}; + collectionsSelect: { + pages: PagesSelect | PagesSelect; + 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` "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". + */ +export interface Post { + 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` "users". + */ +export interface User { + id: string; + name?: string | null; + roles?: ('is_user' | 'is_admin')[] | null; + updatedAt: string; + createdAt: string; + 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: 'pages'; + value: string | Page; + } | null) + | ({ + 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; +} +/** + * 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; +} +/** + * 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; +} +/** + * 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 + * via the `definition` "posts_select". + */ +export interface PostsSelect { + title?: T; + 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; + 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; +} +/** + * 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; +} +/** + * 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; +} +/** + * 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-deletes/tsconfig.eslint.json b/test/soft-deletes/tsconfig.eslint.json new file mode 100644 index 00000000000..b34cc7afbb8 --- /dev/null +++ b/test/soft-deletes/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-deletes/tsconfig.json b/test/soft-deletes/tsconfig.json new file mode 100644 index 00000000000..3c43903cfdd --- /dev/null +++ b/test/soft-deletes/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.json" +}