From 4c13176c52fd010b2037b871fc6289cf0e0299cd Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Mon, 2 Sep 2024 14:16:48 +0300 Subject: [PATCH] feat: update createFields api endpoint (#1311) Allow users to add 1 or more fields to a document via the /document//fields API Endpoint. --- packages/api/v1/contract.ts | 3 +- packages/api/v1/implementation.ts | 224 ++++++++++++------ packages/api/v1/schema.ts | 31 ++- .../lib/server-only/field/create-field.ts | 35 ++- .../lib/server-only/field/update-field.ts | 4 + 5 files changed, 200 insertions(+), 97 deletions(-) diff --git a/packages/api/v1/contract.ts b/packages/api/v1/contract.ts index e8efeffe6..d6f8000a4 100644 --- a/packages/api/v1/contract.ts +++ b/packages/api/v1/contract.ts @@ -21,6 +21,7 @@ import { ZSendDocumentForSigningMutationSchema, ZSuccessfulDeleteTemplateResponseSchema, ZSuccessfulDocumentResponseSchema, + ZSuccessfulFieldCreationResponseSchema, ZSuccessfulFieldResponseSchema, ZSuccessfulGetDocumentResponseSchema, ZSuccessfulGetTemplateResponseSchema, @@ -236,7 +237,7 @@ export const ApiContractV1 = c.router( path: '/api/v1/documents/:id/fields', body: ZCreateFieldMutationSchema, responses: { - 200: ZSuccessfulFieldResponseSchema, + 200: ZSuccessfulFieldCreationResponseSchema, 400: ZUnsuccessfulResponseSchema, 401: ZUnsuccessfulResponseSchema, 404: ZUnsuccessfulResponseSchema, diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index ad9aaaac4..bec720812 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -1,4 +1,5 @@ import { createNextRoute } from '@ts-rest/next'; +import { match } from 'ts-pattern'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; @@ -15,7 +16,6 @@ import { getDocumentById } from '@documenso/lib/server-only/document/get-documen import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { updateDocument } from '@documenso/lib/server-only/document/update-document'; -import { createField } from '@documenso/lib/server-only/field/create-field'; import { deleteField } from '@documenso/lib/server-only/field/delete-field'; import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id'; import { updateField } from '@documenso/lib/server-only/field/update-field'; @@ -32,6 +32,13 @@ import { deleteTemplate } from '@documenso/lib/server-only/template/delete-templ import { findTemplates } from '@documenso/lib/server-only/template/find-templates'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; +import { + ZCheckboxFieldMeta, + ZDropdownFieldMeta, + ZNumberFieldMeta, + ZRadioFieldMeta, + ZTextFieldMeta, +} from '@documenso/lib/types/field-meta'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { getFile } from '@documenso/lib/universal/upload/get-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; @@ -39,6 +46,8 @@ import { getPresignGetUrl, getPresignPostUrl, } from '@documenso/lib/universal/upload/server-actions'; +import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; +import { prisma } from '@documenso/prisma'; import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { ApiContractV1 } from './contract'; @@ -870,100 +879,167 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { createField: authenticatedMiddleware(async (args, user, team) => { const { id: documentId } = args.params; - const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta } = - args.body; + const fields = Array.isArray(args.body) ? args.body : [args.body]; - if (pageNumber <= 0) { - return { - status: 400, - body: { - message: 'Invalid page number', - }, - }; - } - - const document = await getDocumentById({ - id: Number(documentId), - userId: user.id, - teamId: team?.id, + const document = await prisma.document.findFirst({ + select: { id: true, status: true }, + where: { + id: Number(documentId), + ...(team?.id + ? { + team: { + id: team.id, + members: { some: { userId: user.id } }, + }, + } + : { + userId: user.id, + teamId: null, + }), + }, }); if (!document) { return { status: 404, - body: { - message: 'Document not found', - }, + body: { message: 'Document not found' }, }; } if (document.status === DocumentStatus.COMPLETED) { return { status: 400, - body: { - message: 'Document is already completed', - }, - }; - } - - const recipient = await getRecipientById({ - id: Number(recipientId), - documentId: Number(documentId), - }).catch(() => null); - - if (!recipient) { - return { - status: 404, - body: { - message: 'Recipient not found', - }, - }; - } - - if (recipient.signingStatus === SigningStatus.SIGNED) { - return { - status: 400, - body: { - message: 'Recipient has already signed the document', - }, + body: { message: 'Document is already completed' }, }; } try { - const field = await createField({ - documentId: Number(documentId), - recipientId: Number(recipientId), - userId: user.id, - teamId: team?.id, - type, - pageNumber, - pageX, - pageY, - pageWidth, - pageHeight, - fieldMeta, - requestMetadata: extractNextApiRequestMetadata(args.req), - }); + const createdFields = await prisma.$transaction(async (tx) => { + return Promise.all( + fields.map(async (fieldData) => { + const { + recipientId, + type, + pageNumber, + pageWidth, + pageHeight, + pageX, + pageY, + fieldMeta, + } = fieldData; - const remappedField = { - id: field.id, - documentId: field.documentId, - recipientId: field.recipientId ?? -1, - type: field.type, - pageNumber: field.page, - pageX: Number(field.positionX), - pageY: Number(field.positionY), - pageWidth: Number(field.width), - pageHeight: Number(field.height), - customText: field.customText, - fieldMeta: ZFieldMetaSchema.parse(field.fieldMeta), - inserted: field.inserted, - }; + if (pageNumber <= 0) { + throw new Error('Invalid page number'); + } + + const recipient = await getRecipientById({ + id: Number(recipientId), + documentId: Number(documentId), + }).catch(() => null); + + if (!recipient) { + throw new Error('Recipient not found'); + } + + if (recipient.signingStatus === SigningStatus.SIGNED) { + throw new Error('Recipient has already signed the document'); + } + + const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes( + type, + ); + + if (advancedField && !fieldMeta) { + throw new Error( + 'Field meta is required for this type of field. Please provide the appropriate field meta object.', + ); + } + + if (fieldMeta && fieldMeta.type.toLowerCase() !== String(type).toLowerCase()) { + throw new Error('Field meta type does not match the field type'); + } + + const result = match(type) + .with('RADIO', () => ZRadioFieldMeta.safeParse(fieldMeta)) + .with('CHECKBOX', () => ZCheckboxFieldMeta.safeParse(fieldMeta)) + .with('DROPDOWN', () => ZDropdownFieldMeta.safeParse(fieldMeta)) + .with('NUMBER', () => ZNumberFieldMeta.safeParse(fieldMeta)) + .with('TEXT', () => ZTextFieldMeta.safeParse(fieldMeta)) + .with('SIGNATURE', 'INITIALS', 'DATE', 'EMAIL', 'NAME', () => ({ + success: true, + data: {}, + })) + .with('FREE_SIGNATURE', () => ({ + success: false, + error: 'FREE_SIGNATURE is not supported', + data: {}, + })) + .exhaustive(); + + if (!result.success) { + throw new Error('Field meta parsing failed'); + } + + const field = await tx.field.create({ + data: { + documentId: Number(documentId), + recipientId: Number(recipientId), + type, + page: pageNumber, + positionX: pageX, + positionY: pageY, + width: pageWidth, + height: pageHeight, + customText: '', + inserted: false, + fieldMeta: result.data, + }, + include: { + Recipient: true, + }, + }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: 'FIELD_CREATED', + documentId: Number(documentId), + user: { + id: team?.id ?? user.id, + email: team?.name ?? user.email, + name: team ? '' : user.name, + }, + data: { + fieldId: field.secondaryId, + fieldRecipientEmail: field.Recipient?.email ?? '', + fieldRecipientId: recipientId, + fieldType: field.type, + }, + requestMetadata: extractNextApiRequestMetadata(args.req), + }), + }); + + return { + id: field.id, + documentId: Number(field.documentId), + recipientId: field.recipientId ?? -1, + type: field.type, + pageNumber: field.page, + pageX: Number(field.positionX), + pageY: Number(field.positionY), + pageWidth: Number(field.width), + pageHeight: Number(field.height), + customText: field.customText, + fieldMeta: ZFieldMetaSchema.parse(field.fieldMeta), + inserted: field.inserted, + }; + }), + ); + }); return { status: 200, body: { - ...remappedField, + fields: createdFields, documentId: Number(documentId), }, }; diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index 42205d540..1800b53dc 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -293,7 +293,7 @@ export type TSuccessfulRecipientResponseSchema = z.infer; -export const ZUpdateFieldMutationSchema = ZCreateFieldMutationSchema.partial(); +export const ZUpdateFieldMutationSchema = ZCreateFieldSchema.partial(); export type TUpdateFieldMutationSchema = z.infer; @@ -314,6 +319,26 @@ export const ZDeleteFieldMutationSchema = null; export type TDeleteFieldMutationSchema = typeof ZDeleteFieldMutationSchema; +const ZSuccessfulFieldSchema = z.object({ + id: z.number(), + documentId: z.number(), + recipientId: z.number(), + type: z.nativeEnum(FieldType), + pageNumber: z.number(), + pageX: z.number(), + pageY: z.number(), + pageWidth: z.number(), + pageHeight: z.number(), + customText: z.string(), + fieldMeta: ZFieldMetaSchema, + inserted: z.boolean(), +}); + +export const ZSuccessfulFieldCreationResponseSchema = z.object({ + fields: z.union([ZSuccessfulFieldSchema, z.array(ZSuccessfulFieldSchema)]), + documentId: z.number(), +}); + export const ZSuccessfulFieldResponseSchema = z.object({ id: z.number(), documentId: z.number(), diff --git a/packages/lib/server-only/field/create-field.ts b/packages/lib/server-only/field/create-field.ts index 9aafc7ab8..da1a26276 100644 --- a/packages/lib/server-only/field/create-field.ts +++ b/packages/lib/server-only/field/create-field.ts @@ -110,24 +110,21 @@ export const createField = async ({ } const result = match(type) - .with('RADIO', () => { - return ZRadioFieldMeta.safeParse(fieldMeta); - }) - .with('CHECKBOX', () => { - return ZCheckboxFieldMeta.safeParse(fieldMeta); - }) - .with('DROPDOWN', () => { - return ZDropdownFieldMeta.safeParse(fieldMeta); - }) - .with('NUMBER', () => { - return ZNumberFieldMeta.safeParse(fieldMeta); - }) - .with('TEXT', () => { - return ZTextFieldMeta.safeParse(fieldMeta); - }) - .otherwise(() => { - return { success: false, data: {} }; - }); + .with('RADIO', () => ZRadioFieldMeta.safeParse(fieldMeta)) + .with('CHECKBOX', () => ZCheckboxFieldMeta.safeParse(fieldMeta)) + .with('DROPDOWN', () => ZDropdownFieldMeta.safeParse(fieldMeta)) + .with('NUMBER', () => ZNumberFieldMeta.safeParse(fieldMeta)) + .with('TEXT', () => ZTextFieldMeta.safeParse(fieldMeta)) + .with('SIGNATURE', 'INITIALS', 'DATE', 'EMAIL', 'NAME', () => ({ + success: true, + data: {}, + })) + .with('FREE_SIGNATURE', () => ({ + success: false, + error: 'FREE_SIGNATURE is not supported', + data: {}, + })) + .exhaustive(); if (!result.success) { throw new Error('Field meta parsing failed'); @@ -145,7 +142,7 @@ export const createField = async ({ height: pageHeight, customText: '', inserted: false, - fieldMeta: advancedField ? result.data : undefined, + fieldMeta: result.data, }, include: { Recipient: true, diff --git a/packages/lib/server-only/field/update-field.ts b/packages/lib/server-only/field/update-field.ts index a8e84d043..5fc415b4b 100644 --- a/packages/lib/server-only/field/update-field.ts +++ b/packages/lib/server-only/field/update-field.ts @@ -37,6 +37,10 @@ export const updateField = async ({ requestMetadata, fieldMeta, }: UpdateFieldOptions) => { + if (type === 'FREE_SIGNATURE') { + throw new Error('Cannot update a FREE_SIGNATURE field'); + } + const oldField = await prisma.field.findFirstOrThrow({ where: { id: fieldId,