feat: consolidates create and duplicate operations (#9866)

This commit is contained in:
Jarrod Flesch
2024-12-10 21:44:47 -05:00
committed by GitHub
parent 5bfc92d71d
commit ca52a50dd9
12 changed files with 201 additions and 408 deletions

View File

@@ -131,6 +131,9 @@ const post = await payload.create({
// Alternatively, you can directly pass a File,
// if file is provided, filePath will be omitted
file: uploadedFile,
// If you want to create a document that is a duplicate of another document
duplicateFromID: 'document-id-to-duplicate',
})
```

View File

@@ -30,15 +30,13 @@ export function createResolver<TSlug extends CollectionSlug>(
context.req.locale = args.locale
}
const options = {
const result = await createOperation({
collection,
data: args.data,
depth: 0,
draft: args.draft,
req: isolateObjectProperty(context.req, 'transactionID'),
}
const result = await createOperation(options)
})
return result
}

View File

@@ -7,6 +7,7 @@ import type { Context } from '../types.js'
export type Resolver<TData> = (
_: unknown,
args: {
data: TData
draft: boolean
fallbackLocale?: string
id: string
@@ -28,15 +29,14 @@ export function duplicateResolver<TSlug extends CollectionSlug>(
req.fallbackLocale = args.fallbackLocale || fallbackLocale
context.req = req
const options = {
const result = await duplicateOperation({
id: args.id,
collection,
data: args.data,
depth: 0,
draft: args.draft,
req: isolateObjectProperty(req, 'transactionID'),
}
const result = await duplicateOperation(options)
})
return result
}

View File

@@ -280,6 +280,9 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
type: collection.graphQL.type,
args: {
id: { type: new GraphQLNonNull(idType) },
...(createMutationInputType
? { data: { type: collection.graphQL.mutationInputType } }
: {}),
},
resolve: duplicateResolver(collection),
}

View File

@@ -27,6 +27,7 @@ export const duplicate: CollectionRouteHandlerWithID = async ({
const doc = await duplicateOperation({
id,
collection,
data: req.data,
depth: isNumber(depth) ? Number(depth) : undefined,
draft,
populate: sanitizePopulateParam(req.query.populate),

View File

@@ -13,6 +13,7 @@ import type {
BeforeOperationHook,
BeforeValidateHook,
Collection,
DataFromCollectionSlug,
RequiredDataFromCollectionSlug,
SelectFromCollectionSlug,
} from '../config/types.js'
@@ -21,6 +22,7 @@ import { ensureUsernameOrEmail } from '../../auth/ensureUsernameOrEmail.js'
import executeAccess from '../../auth/executeAccess.js'
import { sendVerificationEmail } from '../../auth/sendVerificationEmail.js'
import { registerLocalStrategy } from '../../auth/strategies/local/register.js'
import { getDuplicateDocumentData } from '../../duplicateDocument/index.js'
import { afterChange } from '../../fields/hooks/afterChange/index.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { beforeChange } from '../../fields/hooks/beforeChange/index.js'
@@ -43,6 +45,7 @@ export type Arguments<TSlug extends CollectionSlug> = {
disableTransaction?: boolean
disableVerificationEmail?: boolean
draft?: boolean
duplicateFromID?: DataFromCollectionSlug<TSlug>['id']
overrideAccess?: boolean
overwriteExistingFiles?: boolean
populate?: PopulateType
@@ -97,6 +100,7 @@ export const createOperation = async <
depth,
disableVerificationEmail,
draft = false,
duplicateFromID,
overrideAccess,
overwriteExistingFiles = false,
populate,
@@ -115,6 +119,23 @@ export const createOperation = async <
const shouldSaveDraft = Boolean(draft && collectionConfig.versions.drafts)
let duplicatedFromDocWithLocales: JsonObject = {}
let duplicatedFromDoc: JsonObject = {}
if (duplicateFromID) {
const duplicateResult = await getDuplicateDocumentData({
id: duplicateFromID,
collectionConfig,
draftArg: shouldSaveDraft,
overrideAccess,
req,
shouldSaveDraft,
})
duplicatedFromDoc = duplicateResult.duplicatedFromDoc
duplicatedFromDocWithLocales = duplicateResult.duplicatedFromDocWithLocales
}
// /////////////////////////////////////
// Access
// /////////////////////////////////////
@@ -131,7 +152,9 @@ export const createOperation = async <
collection,
config,
data,
isDuplicating: Boolean(duplicateFromID),
operation: 'create',
originalDoc: duplicatedFromDoc,
overwriteExistingFiles,
req,
throwOnMissingFile:
@@ -148,7 +171,7 @@ export const createOperation = async <
collection: collectionConfig,
context: req.context,
data,
doc: {},
doc: duplicatedFromDoc,
global: null,
operation: 'create',
overrideAccess,
@@ -169,6 +192,7 @@ export const createOperation = async <
context: req.context,
data,
operation: 'create',
originalDoc: duplicatedFromDoc,
req,
})) || data
},
@@ -188,6 +212,7 @@ export const createOperation = async <
context: req.context,
data,
operation: 'create',
originalDoc: duplicatedFromDoc,
req,
})) || data
}, Promise.resolve())
@@ -200,8 +225,8 @@ export const createOperation = async <
collection: collectionConfig,
context: req.context,
data,
doc: {},
docWithLocales: {},
doc: duplicatedFromDoc,
docWithLocales: duplicatedFromDocWithLocales,
global: null,
operation: 'create',
req,

View File

@@ -1,391 +1,26 @@
import type { DeepPartial } from 'ts-essentials'
import httpStatus from 'http-status'
import type { FindOneArgs } from '../../database/types.js'
import type { CollectionSlug } from '../../index.js'
import type {
PayloadRequest,
PopulateType,
SelectType,
TransformCollectionWithSelect,
} from '../../types/index.js'
import type {
Collection,
DataFromCollectionSlug,
SelectFromCollectionSlug,
} from '../config/types.js'
import type { TransformCollectionWithSelect } from '../../types/index.js'
import type { RequiredDataFromCollectionSlug, SelectFromCollectionSlug } from '../config/types.js'
import executeAccess from '../../auth/executeAccess.js'
import { hasWhereAccessResult } from '../../auth/types.js'
import { combineQueries } from '../../database/combineQueries.js'
import { APIError, Forbidden, NotFound } from '../../errors/index.js'
import { afterChange } from '../../fields/hooks/afterChange/index.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { beforeChange } from '../../fields/hooks/beforeChange/index.js'
import { beforeDuplicate } from '../../fields/hooks/beforeDuplicate/index.js'
import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js'
import { generateFileData } from '../../uploads/generateFileData.js'
import { uploadFiles } from '../../uploads/uploadFiles.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields.js'
import { getLatestCollectionVersion } from '../../versions/getLatestCollectionVersion.js'
import { saveVersion } from '../../versions/saveVersion.js'
import { buildAfterOperation } from './utils.js'
import { type Arguments as CreateArguments, createOperation } from './create.js'
export type Arguments = {
collection: Collection
depth?: number
disableTransaction?: boolean
draft?: boolean
export type Arguments<TSlug extends CollectionSlug> = {
data?: DeepPartial<RequiredDataFromCollectionSlug<TSlug>>
id: number | string
overrideAccess?: boolean
populate?: PopulateType
req: PayloadRequest
select?: SelectType
showHiddenFields?: boolean
}
} & Omit<CreateArguments<TSlug>, 'data' | 'duplicateFromID'>
export const duplicateOperation = async <
TSlug extends CollectionSlug,
TSelect extends SelectFromCollectionSlug<TSlug>,
>(
incomingArgs: Arguments,
incomingArgs: Arguments<TSlug>,
): Promise<TransformCollectionWithSelect<TSlug, TSelect>> => {
let args = incomingArgs
const operation = 'create'
try {
const shouldCommit = !args.disableTransaction && (await initTransaction(args.req))
// /////////////////////////////////////
// beforeOperation - Collection
// /////////////////////////////////////
await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => {
await priorHook
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation,
req: args.req,
})) || args
}, Promise.resolve())
const {
id,
collection: { config: collectionConfig },
depth,
draft: draftArg = true,
overrideAccess,
populate,
req: { fallbackLocale, locale: localeArg, payload },
req,
select,
showHiddenFields,
} = args
if (!id) {
throw new APIError('Missing ID of document to duplicate.', httpStatus.BAD_REQUEST)
}
const shouldSaveDraft = Boolean(draftArg && collectionConfig.versions.drafts)
// /////////////////////////////////////
// Read Access
// /////////////////////////////////////
const accessResults = !overrideAccess
? await executeAccess({ id, req }, collectionConfig.access.read)
: true
const hasWherePolicy = hasWhereAccessResult(accessResults)
// /////////////////////////////////////
// Retrieve document
// /////////////////////////////////////
const findOneArgs: FindOneArgs = {
collection: collectionConfig.slug,
locale: req.locale,
req,
where: combineQueries({ id: { equals: id } }, accessResults),
}
let docWithLocales = await getLatestCollectionVersion({
id,
config: collectionConfig,
payload,
query: findOneArgs,
req,
})
if (!docWithLocales && !hasWherePolicy) {
throw new NotFound(req.t)
}
if (!docWithLocales && hasWherePolicy) {
throw new Forbidden(req.t)
}
// remove the createdAt timestamp and id to rely on the db to set the default it
delete docWithLocales.createdAt
delete docWithLocales.id
docWithLocales = await beforeDuplicate({
id,
collection: collectionConfig,
context: req.context,
doc: docWithLocales,
overrideAccess,
req,
})
// for version enabled collections, override the current status with draft, unless draft is explicitly set to false
if (shouldSaveDraft) {
docWithLocales._status = 'draft'
}
let result
let originalDoc = await afterRead({
collection: collectionConfig,
context: req.context,
depth: 0,
doc: docWithLocales,
draft: draftArg,
fallbackLocale: null,
global: null,
locale: req.locale,
overrideAccess: true,
req,
showHiddenFields: true,
})
const { data: newFileData, files: filesToUpload } = await generateFileData({
collection: args.collection,
config: req.payload.config,
data: originalDoc,
operation: 'create',
overwriteExistingFiles: 'forceDisable',
req,
throwOnMissingFile: true,
})
originalDoc = newFileData
// /////////////////////////////////////
// Create Access
// /////////////////////////////////////
if (!overrideAccess) {
await executeAccess({ data: originalDoc, req }, collectionConfig.access.create)
}
// /////////////////////////////////////
// beforeValidate - Fields
// /////////////////////////////////////
let data = await beforeValidate<DeepPartial<DataFromCollectionSlug<TSlug>>>({
id,
collection: collectionConfig,
context: req.context,
data: originalDoc,
doc: originalDoc,
duplicate: true,
global: null,
operation,
overrideAccess,
req,
})
// /////////////////////////////////////
// beforeValidate - Collection
// /////////////////////////////////////
await collectionConfig.hooks.beforeValidate.reduce(async (priorHook, hook) => {
await priorHook
data =
(await hook({
collection: collectionConfig,
context: req.context,
data,
operation,
originalDoc,
req,
})) || result
}, Promise.resolve())
// /////////////////////////////////////
// beforeChange - Collection
// /////////////////////////////////////
await collectionConfig.hooks.beforeChange.reduce(async (priorHook, hook) => {
await priorHook
data =
(await hook({
collection: collectionConfig,
context: req.context,
data,
operation,
originalDoc: result,
req,
})) || result
}, Promise.resolve())
// /////////////////////////////////////
// beforeChange - Fields
// /////////////////////////////////////
result = await beforeChange({
id,
collection: collectionConfig,
context: req.context,
data,
doc: originalDoc,
docWithLocales,
global: null,
operation,
req,
skipValidation:
shouldSaveDraft &&
collectionConfig.versions.drafts &&
!collectionConfig.versions.drafts.validate,
})
// set req.locale back to the original locale
req.locale = localeArg
// /////////////////////////////////////
// Create / Update
// /////////////////////////////////////
// /////////////////////////////////////
// Write files to local storage
// /////////////////////////////////////
if (!collectionConfig.upload.disableLocalStorage) {
await uploadFiles(payload, filesToUpload, req)
}
let versionDoc = await payload.db.create({
collection: collectionConfig.slug,
data: result,
req,
select,
})
versionDoc = sanitizeInternalFields(versionDoc)
// /////////////////////////////////////
// Create version
// /////////////////////////////////////
if (collectionConfig.versions) {
result = await saveVersion({
id: versionDoc.id,
collection: collectionConfig,
docWithLocales: versionDoc,
draft: shouldSaveDraft,
payload,
req,
})
}
// /////////////////////////////////////
// afterRead - Fields
// /////////////////////////////////////
result = await afterRead({
collection: collectionConfig,
context: req.context,
depth,
doc: versionDoc,
draft: draftArg,
fallbackLocale,
global: null,
locale: localeArg,
overrideAccess,
populate,
req,
select,
showHiddenFields,
})
// /////////////////////////////////////
// afterRead - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
req,
})) || result
}, Promise.resolve())
// /////////////////////////////////////
// afterChange - Fields
// /////////////////////////////////////
result = await afterChange({
collection: collectionConfig,
context: req.context,
data: versionDoc,
doc: result,
global: null,
operation,
previousDoc: {},
req,
})
// /////////////////////////////////////
// afterChange - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterChange.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
operation,
previousDoc: {},
req,
})) || result
}, Promise.resolve())
// /////////////////////////////////////
// afterOperation - Collection
// /////////////////////////////////////
result = await buildAfterOperation({
args,
collection: collectionConfig,
operation,
result,
})
// /////////////////////////////////////
// Return results
// /////////////////////////////////////
if (shouldCommit) {
await commitTransaction(req)
}
return result
} catch (error: unknown) {
await killTransaction(args.req)
throw error
}
const { id, ...args } = incomingArgs
return createOperation({
...args,
data: incomingArgs?.data || {},
duplicateFromID: id,
})
}

View File

@@ -8,6 +8,7 @@ import type {
} from '../../../types/index.js'
import type { File } from '../../../uploads/types.js'
import type {
DataFromCollectionSlug,
RequiredDataFromCollectionSlug,
SelectFromCollectionSlug,
} from '../../config/types.js'
@@ -28,6 +29,7 @@ export type Options<TSlug extends CollectionSlug, TSelect extends SelectType> =
disableTransaction?: boolean
disableVerificationEmail?: boolean
draft?: boolean
duplicateFromID?: DataFromCollectionSlug<TSlug>['id']
fallbackLocale?: false | TypedLocale
file?: File
filePath?: string
@@ -56,6 +58,7 @@ export default async function createLocal<
disableTransaction,
disableVerificationEmail,
draft,
duplicateFromID,
file,
filePath,
overrideAccess = true,
@@ -82,6 +85,7 @@ export default async function createLocal<
disableTransaction,
disableVerificationEmail,
draft,
duplicateFromID,
overrideAccess,
overwriteExistingFiles,
populate,

View File

@@ -1,3 +1,5 @@
import type { DeepPartial } from 'ts-essentials'
import type { CollectionSlug, TypedLocale } from '../../..//index.js'
import type { Payload, RequestContext } from '../../../index.js'
import type {
@@ -7,7 +9,10 @@ import type {
SelectType,
TransformCollectionWithSelect,
} from '../../../types/index.js'
import type { SelectFromCollectionSlug } from '../../config/types.js'
import type {
RequiredDataFromCollectionSlug,
SelectFromCollectionSlug,
} from '../../config/types.js'
import { APIError } from '../../../errors/index.js'
import { createLocalReq } from '../../../utilities/createLocalReq.js'
@@ -19,6 +24,7 @@ export type Options<TSlug extends CollectionSlug, TSelect extends SelectType> =
* context, which will then be passed to req.context, which can be read by hooks
*/
context?: RequestContext
data?: DeepPartial<RequiredDataFromCollectionSlug<TSlug>>
depth?: number
disableTransaction?: boolean
draft?: boolean
@@ -43,6 +49,7 @@ export async function duplicate<
const {
id,
collection: collectionSlug,
data,
depth,
disableTransaction,
draft,
@@ -71,6 +78,7 @@ export async function duplicate<
return duplicateOperation<TSlug, TSelect>({
id,
collection,
data,
depth,
disableTransaction,
draft,

View File

@@ -0,0 +1,106 @@
import type { SanitizedCollectionConfig } from '../collections/config/types.js'
import type { FindOneArgs } from '../database/types.js'
import type { JsonObject, PayloadRequest } from '../types/index.js'
import executeAccess from '../auth/executeAccess.js'
import { hasWhereAccessResult } from '../auth/types.js'
import { combineQueries } from '../database/combineQueries.js'
import { Forbidden } from '../errors/Forbidden.js'
import { NotFound } from '../errors/NotFound.js'
import { afterRead } from '../fields/hooks/afterRead/index.js'
import { beforeDuplicate } from '../fields/hooks/beforeDuplicate/index.js'
import { getLatestCollectionVersion } from '../versions/getLatestCollectionVersion.js'
type GetDuplicateDocumentArgs = {
collectionConfig: SanitizedCollectionConfig
draftArg?: boolean
id: number | string
overrideAccess?: boolean
req: PayloadRequest
shouldSaveDraft?: boolean
}
export const getDuplicateDocumentData = async ({
id,
collectionConfig,
draftArg,
overrideAccess,
req,
shouldSaveDraft,
}: GetDuplicateDocumentArgs): Promise<{
duplicatedFromDoc: JsonObject
duplicatedFromDocWithLocales: JsonObject
}> => {
const { payload } = req
// /////////////////////////////////////
// Read Access
// /////////////////////////////////////
const accessResults = !overrideAccess
? await executeAccess({ id, req }, collectionConfig.access.read)
: true
const hasWherePolicy = hasWhereAccessResult(accessResults)
// /////////////////////////////////////
// Retrieve document
// /////////////////////////////////////
const findOneArgs: FindOneArgs = {
collection: collectionConfig.slug,
locale: req.locale,
req,
where: combineQueries({ id: { equals: id } }, accessResults),
}
let duplicatedFromDocWithLocales = await getLatestCollectionVersion({
id,
config: collectionConfig,
payload,
query: findOneArgs,
req,
})
if (!duplicatedFromDocWithLocales && !hasWherePolicy) {
throw new NotFound(req.t)
}
if (!duplicatedFromDocWithLocales && hasWherePolicy) {
throw new Forbidden(req.t)
}
// remove the createdAt timestamp and rely on the db to set it
if ('createdAt' in duplicatedFromDocWithLocales) {
delete duplicatedFromDocWithLocales.createdAt
}
// remove the id and rely on the db to set it
if ('id' in duplicatedFromDocWithLocales) {
delete duplicatedFromDocWithLocales.id
}
duplicatedFromDocWithLocales = await beforeDuplicate({
id,
collection: collectionConfig,
context: req.context,
doc: duplicatedFromDocWithLocales,
overrideAccess,
req,
})
// for version enabled collections, override the current status with draft, unless draft is explicitly set to false
if (shouldSaveDraft) {
duplicatedFromDocWithLocales._status = 'draft'
}
const duplicatedFromDoc = await afterRead({
collection: collectionConfig,
context: req.context,
depth: 0,
doc: duplicatedFromDocWithLocales,
draft: draftArg,
fallbackLocale: null,
global: null,
locale: req.locale,
overrideAccess: true,
req,
showHiddenFields: true,
})
return { duplicatedFromDoc, duplicatedFromDocWithLocales }
}

View File

@@ -286,7 +286,6 @@ export const promise = async ({
}
case 'collapsible':
case 'row': {
await traverseFields({
id,

View File

@@ -25,10 +25,10 @@ type Args<T> = {
collection: Collection
config: SanitizedConfig
data: T
isDuplicating?: boolean
operation: 'create' | 'update'
originalDoc?: T
/** pass forceDisable to not overwrite existing files even if they already exist in `data` */
overwriteExistingFiles?: 'forceDisable' | boolean
overwriteExistingFiles?: boolean
req: PayloadRequest
throwOnMissingFile?: boolean
}
@@ -41,6 +41,7 @@ type Result<T> = Promise<{
export const generateFileData = async <T>({
collection: { config: collectionConfig },
data,
isDuplicating,
operation,
originalDoc,
overwriteExistingFiles,
@@ -60,6 +61,7 @@ export const generateFileData = async <T>({
const uploadEdits = parseUploadEditsFromReqOrIncomingData({
data,
isDuplicating,
operation,
originalDoc,
req,
@@ -78,33 +80,31 @@ export const generateFileData = async <T>({
const staticPath = staticDir
if (!file && uploadEdits && data) {
const { filename, url } = data as FileData
const incomingFileData = isDuplicating ? originalDoc : data
if (!file && uploadEdits && incomingFileData) {
const { filename, url } = incomingFileData as FileData
try {
if (url && url.startsWith('/') && !disableLocalStorage) {
const filePath = `${staticPath}/${filename}`
const response = await getFileByPath(filePath)
file = response
if (overwriteExistingFiles !== 'forceDisable') {
overwriteExistingFiles = true
}
overwriteExistingFiles = true
} else if (filename && url) {
file = await getExternalFile({
data: data as FileData,
data: incomingFileData as FileData,
req,
uploadConfig: collectionConfig.upload,
})
if (overwriteExistingFiles !== 'forceDisable') {
overwriteExistingFiles = true
}
overwriteExistingFiles = true
}
} catch (err: unknown) {
throw new FileRetrievalError(req.t, err instanceof Error ? err.message : undefined)
}
}
if (overwriteExistingFiles === 'forceDisable') {
if (isDuplicating) {
overwriteExistingFiles = false
}
@@ -362,11 +362,12 @@ export const generateFileData = async <T>({
*/
function parseUploadEditsFromReqOrIncomingData(args: {
data: unknown
isDuplicating?: boolean
operation: 'create' | 'update'
originalDoc: unknown
req: PayloadRequest
}): UploadEdits {
const { data, operation, originalDoc, req } = args
const { data, isDuplicating, operation, originalDoc, req } = args
// Get intended focal point change from query string or incoming data
const uploadEdits =
@@ -381,10 +382,19 @@ function parseUploadEditsFromReqOrIncomingData(args: {
const incomingData = data as FileData
const origDoc = originalDoc as FileData
// If no change in focal point, return undefined.
// This prevents a refocal operation triggered from admin, because it always sends the focal point.
if (origDoc && incomingData.focalX === origDoc.focalX && incomingData.focalY === origDoc.focalY) {
return undefined
if (origDoc && 'focalX' in origDoc && 'focalY' in origDoc) {
// If no change in focal point, return undefined.
// This prevents a refocal operation triggered from admin, because it always sends the focal point.
if (incomingData.focalX === origDoc.focalX && incomingData.focalY === origDoc.focalY) {
return undefined
}
if (isDuplicating) {
uploadEdits.focalPoint = {
x: incomingData?.focalX || origDoc.focalX,
y: incomingData?.focalY || origDoc.focalX,
}
}
}
if (incomingData?.focalX && incomingData?.focalY) {
@@ -402,5 +412,6 @@ function parseUploadEditsFromReqOrIncomingData(args: {
y: 50,
}
}
return uploadEdits
}