feat: update createFields api endpoint (#1311)

Allow users to add 1 or more fields to a document via the /document/<id>/fields API Endpoint.
This commit is contained in:
Catalin Pit
2024-09-02 14:16:48 +03:00
committed by GitHub
parent d599ab0630
commit 4c13176c52
5 changed files with 200 additions and 97 deletions

View File

@ -21,6 +21,7 @@ import {
ZSendDocumentForSigningMutationSchema, ZSendDocumentForSigningMutationSchema,
ZSuccessfulDeleteTemplateResponseSchema, ZSuccessfulDeleteTemplateResponseSchema,
ZSuccessfulDocumentResponseSchema, ZSuccessfulDocumentResponseSchema,
ZSuccessfulFieldCreationResponseSchema,
ZSuccessfulFieldResponseSchema, ZSuccessfulFieldResponseSchema,
ZSuccessfulGetDocumentResponseSchema, ZSuccessfulGetDocumentResponseSchema,
ZSuccessfulGetTemplateResponseSchema, ZSuccessfulGetTemplateResponseSchema,
@ -236,7 +237,7 @@ export const ApiContractV1 = c.router(
path: '/api/v1/documents/:id/fields', path: '/api/v1/documents/:id/fields',
body: ZCreateFieldMutationSchema, body: ZCreateFieldMutationSchema,
responses: { responses: {
200: ZSuccessfulFieldResponseSchema, 200: ZSuccessfulFieldCreationResponseSchema,
400: ZUnsuccessfulResponseSchema, 400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema, 401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema, 404: ZUnsuccessfulResponseSchema,

View File

@ -1,4 +1,5 @@
import { createNextRoute } from '@ts-rest/next'; import { createNextRoute } from '@ts-rest/next';
import { match } from 'ts-pattern';
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; 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 { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-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 { deleteField } from '@documenso/lib/server-only/field/delete-field';
import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id'; import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id';
import { updateField } from '@documenso/lib/server-only/field/update-field'; 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 { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; 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 { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
@ -39,6 +46,8 @@ import {
getPresignGetUrl, getPresignGetUrl,
getPresignPostUrl, getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions'; } 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 { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { ApiContractV1 } from './contract'; import { ApiContractV1 } from './contract';
@ -870,100 +879,167 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
createField: authenticatedMiddleware(async (args, user, team) => { createField: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params; const { id: documentId } = args.params;
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta } = const fields = Array.isArray(args.body) ? args.body : [args.body];
args.body;
if (pageNumber <= 0) { const document = await prisma.document.findFirst({
return { select: { id: true, status: true },
status: 400, where: {
body: { id: Number(documentId),
message: 'Invalid page number', ...(team?.id
}, ? {
}; team: {
} id: team.id,
members: { some: { userId: user.id } },
const document = await getDocumentById({ },
id: Number(documentId), }
userId: user.id, : {
teamId: team?.id, userId: user.id,
teamId: null,
}),
},
}); });
if (!document) { if (!document) {
return { return {
status: 404, status: 404,
body: { body: { message: 'Document not found' },
message: 'Document not found',
},
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (document.status === DocumentStatus.COMPLETED) {
return { return {
status: 400, status: 400,
body: { body: { message: 'Document is already completed' },
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',
},
}; };
} }
try { try {
const field = await createField({ const createdFields = await prisma.$transaction(async (tx) => {
documentId: Number(documentId), return Promise.all(
recipientId: Number(recipientId), fields.map(async (fieldData) => {
userId: user.id, const {
teamId: team?.id, recipientId,
type, type,
pageNumber, pageNumber,
pageX, pageWidth,
pageY, pageHeight,
pageWidth, pageX,
pageHeight, pageY,
fieldMeta, fieldMeta,
requestMetadata: extractNextApiRequestMetadata(args.req), } = fieldData;
});
const remappedField = { if (pageNumber <= 0) {
id: field.id, throw new Error('Invalid page number');
documentId: field.documentId, }
recipientId: field.recipientId ?? -1,
type: field.type, const recipient = await getRecipientById({
pageNumber: field.page, id: Number(recipientId),
pageX: Number(field.positionX), documentId: Number(documentId),
pageY: Number(field.positionY), }).catch(() => null);
pageWidth: Number(field.width),
pageHeight: Number(field.height), if (!recipient) {
customText: field.customText, throw new Error('Recipient not found');
fieldMeta: ZFieldMetaSchema.parse(field.fieldMeta), }
inserted: field.inserted,
}; 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 { return {
status: 200, status: 200,
body: { body: {
...remappedField, fields: createdFields,
documentId: Number(documentId), documentId: Number(documentId),
}, },
}; };

View File

@ -293,7 +293,7 @@ export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecip
/** /**
* Fields * Fields
*/ */
export const ZCreateFieldMutationSchema = z.object({ const ZCreateFieldSchema = z.object({
recipientId: z.number(), recipientId: z.number(),
type: z.nativeEnum(FieldType), type: z.nativeEnum(FieldType),
pageNumber: z.number(), pageNumber: z.number(),
@ -301,12 +301,17 @@ export const ZCreateFieldMutationSchema = z.object({
pageY: z.number(), pageY: z.number(),
pageWidth: z.number(), pageWidth: z.number(),
pageHeight: z.number(), pageHeight: z.number(),
fieldMeta: ZFieldMetaSchema, fieldMeta: ZFieldMetaSchema.openapi({}),
}); });
export const ZCreateFieldMutationSchema = z.union([
ZCreateFieldSchema,
z.array(ZCreateFieldSchema).min(1),
]);
export type TCreateFieldMutationSchema = z.infer<typeof ZCreateFieldMutationSchema>; export type TCreateFieldMutationSchema = z.infer<typeof ZCreateFieldMutationSchema>;
export const ZUpdateFieldMutationSchema = ZCreateFieldMutationSchema.partial(); export const ZUpdateFieldMutationSchema = ZCreateFieldSchema.partial();
export type TUpdateFieldMutationSchema = z.infer<typeof ZUpdateFieldMutationSchema>; export type TUpdateFieldMutationSchema = z.infer<typeof ZUpdateFieldMutationSchema>;
@ -314,6 +319,26 @@ export const ZDeleteFieldMutationSchema = null;
export type TDeleteFieldMutationSchema = typeof ZDeleteFieldMutationSchema; 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({ export const ZSuccessfulFieldResponseSchema = z.object({
id: z.number(), id: z.number(),
documentId: z.number(), documentId: z.number(),

View File

@ -110,24 +110,21 @@ export const createField = async ({
} }
const result = match(type) const result = match(type)
.with('RADIO', () => { .with('RADIO', () => ZRadioFieldMeta.safeParse(fieldMeta))
return ZRadioFieldMeta.safeParse(fieldMeta); .with('CHECKBOX', () => ZCheckboxFieldMeta.safeParse(fieldMeta))
}) .with('DROPDOWN', () => ZDropdownFieldMeta.safeParse(fieldMeta))
.with('CHECKBOX', () => { .with('NUMBER', () => ZNumberFieldMeta.safeParse(fieldMeta))
return ZCheckboxFieldMeta.safeParse(fieldMeta); .with('TEXT', () => ZTextFieldMeta.safeParse(fieldMeta))
}) .with('SIGNATURE', 'INITIALS', 'DATE', 'EMAIL', 'NAME', () => ({
.with('DROPDOWN', () => { success: true,
return ZDropdownFieldMeta.safeParse(fieldMeta); data: {},
}) }))
.with('NUMBER', () => { .with('FREE_SIGNATURE', () => ({
return ZNumberFieldMeta.safeParse(fieldMeta); success: false,
}) error: 'FREE_SIGNATURE is not supported',
.with('TEXT', () => { data: {},
return ZTextFieldMeta.safeParse(fieldMeta); }))
}) .exhaustive();
.otherwise(() => {
return { success: false, data: {} };
});
if (!result.success) { if (!result.success) {
throw new Error('Field meta parsing failed'); throw new Error('Field meta parsing failed');
@ -145,7 +142,7 @@ export const createField = async ({
height: pageHeight, height: pageHeight,
customText: '', customText: '',
inserted: false, inserted: false,
fieldMeta: advancedField ? result.data : undefined, fieldMeta: result.data,
}, },
include: { include: {
Recipient: true, Recipient: true,

View File

@ -37,6 +37,10 @@ export const updateField = async ({
requestMetadata, requestMetadata,
fieldMeta, fieldMeta,
}: UpdateFieldOptions) => { }: UpdateFieldOptions) => {
if (type === 'FREE_SIGNATURE') {
throw new Error('Cannot update a FREE_SIGNATURE field');
}
const oldField = await prisma.field.findFirstOrThrow({ const oldField = await prisma.field.findFirstOrThrow({
where: { where: {
id: fieldId, id: fieldId,