import { DocumentDataType, EnvelopeType, SigningStatus } from '@prisma/client'; import { tsr } from '@ts-rest/serverless/fetch'; import { match } from 'ts-pattern'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { AppError } from '@documenso/lib/errors/app-error'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { updateDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope'; import { getEnvelopeById, getEnvelopeWhereInput, } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field'; import { updateDocumentFields } from '@documenso/lib/server-only/field/update-document-fields'; import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf'; import { deleteDocumentRecipient } from '@documenso/lib/server-only/recipient/delete-document-recipient'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients'; import { updateDocumentRecipients } from '@documenso/lib/server-only/recipient/update-document-recipients'; import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; import { findTemplates } from '@documenso/lib/server-only/template/find-templates'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { extractDerivedDocumentEmailSettings } from '@documenso/lib/types/document-email'; import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZFieldMetaSchema, ZNumberFieldMeta, ZRadioFieldMeta, ZTextFieldMeta, } from '@documenso/lib/types/field-meta'; import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server'; import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { getPresignGetUrl, getPresignPostUrl, } from '@documenso/lib/universal/upload/server-actions'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId, } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { ApiContractV1 } from './contract'; import { authenticatedMiddleware } from './middleware/authenticated'; export const ApiContractV1Implementation = tsr.router(ApiContractV1, { getDocuments: authenticatedMiddleware(async (args, user, team) => { const page = Number(args.query.page) || 1; const perPage = Number(args.query.perPage) || 10; const { data: documents, totalPages } = await findDocuments({ page, perPage, userId: user.id, teamId: team.id, }); return { status: 200, body: { documents: documents.map((document) => ({ id: mapSecondaryIdToDocumentId(document.secondaryId), externalId: document.externalId, userId: document.userId, teamId: document.teamId, title: document.title, status: document.status, createdAt: document.createdAt, updatedAt: document.updatedAt, completedAt: document.completedAt, })), totalPages, }, }; }), getDocument: authenticatedMiddleware(async (args, user, team, { logger }) => { const { id: documentId } = args.params; logger.info({ input: { id: documentId, }, }); try { const { envelopeWhereInput } = await getEnvelopeWhereInput({ id: { type: 'documentId', id: Number(documentId), }, type: EnvelopeType.DOCUMENT, userId: user.id, teamId: team.id, }); const envelope = await prisma.envelope.findFirstOrThrow({ where: envelopeWhereInput, include: { recipients: { orderBy: { id: 'asc', }, }, fields: { include: { signature: true, recipient: { select: { name: true, email: true, signingStatus: true, }, }, }, orderBy: { id: 'asc', }, }, }, }); const { fields, recipients } = envelope; const parsedMetaFields = fields.map((field) => { let parsedMetaOrNull = null; if (field.fieldMeta) { const result = ZFieldMetaSchema.safeParse(field.fieldMeta); if (!result.success) { throw new Error('Field meta parsing failed for field ' + field.id); } parsedMetaOrNull = result.data; } return { ...field, fieldMeta: parsedMetaOrNull, }; }); const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId); return { status: 200, body: { id: legacyDocumentId, externalId: envelope.externalId, userId: envelope.userId, teamId: envelope.teamId, title: envelope.title, status: envelope.status, createdAt: envelope.createdAt, updatedAt: envelope.updatedAt, completedAt: envelope.completedAt, recipients: recipients.map((recipient) => ({ id: recipient.id, documentId: legacyDocumentId, email: recipient.email, name: recipient.name, role: recipient.role, signingOrder: recipient.signingOrder, token: recipient.token, signedAt: recipient.signedAt, readStatus: recipient.readStatus, signingStatus: recipient.signingStatus, sendStatus: recipient.sendStatus, signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`, })), fields: parsedMetaFields, }, }; } catch (err) { return { status: 404, body: { message: 'Document not found', }, }; } }), downloadSignedDocument: authenticatedMiddleware(async (args, user, team, { logger }) => { const { id: documentId } = args.params; const { downloadOriginalDocument } = args.query; logger.info({ input: { id: documentId, }, }); try { const envelope = await getEnvelopeById({ id: { type: 'documentId', id: Number(documentId), }, type: EnvelopeType.DOCUMENT, userId: user.id, teamId: team.id, }).catch(() => null); const firstDocumentData = envelope?.envelopeItems[0]?.documentData; if (!envelope || !firstDocumentData) { return { status: 404, body: { message: 'Document not found', }, }; } // This error is done AFTER the get envelope so we can test access controls without S3. if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') { return { status: 500, body: { message: 'Document downloads are only available when S3 storage is configured.', }, }; } if (DocumentDataType.S3_PATH !== firstDocumentData.type) { return { status: 400, body: { message: 'Invalid document data type', }, }; } if (!downloadOriginalDocument && !isDocumentCompleted(envelope.status)) { return { status: 400, body: { message: 'Document is not completed yet.', }, }; } if (envelope.envelopeItems.length !== 1) { return { status: 400, body: { message: 'API V1 does not support items', }, }; } const { url } = await getPresignGetUrl( downloadOriginalDocument ? firstDocumentData.initialData : firstDocumentData.data, ); return { status: 200, body: { downloadUrl: url }, }; } catch (err) { return { status: 500, body: { message: 'Error downloading the document. Please try again.', }, }; } }), deleteDocument: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => { const { id: documentId } = args.params; logger.info({ input: { id: documentId, }, }); try { const legacyDocumentId = Number(documentId); const envelope = await getEnvelopeById({ id: { type: 'documentId', id: legacyDocumentId, }, type: EnvelopeType.DOCUMENT, userId: user.id, teamId: team.id, }); if (!envelope) { return { status: 404, body: { message: 'Document not found', }, }; } const deletedDocument = await deleteDocument({ id: { type: 'documentId', id: legacyDocumentId, }, userId: user.id, teamId: team.id, requestMetadata: metadata, }); return { status: 200, body: { id: legacyDocumentId, externalId: deletedDocument.externalId, userId: deletedDocument.userId, teamId: deletedDocument.teamId, title: deletedDocument.title, status: deletedDocument.status, createdAt: deletedDocument.createdAt, updatedAt: deletedDocument.updatedAt, completedAt: deletedDocument.completedAt, }, }; } catch (err) { return { status: 404, body: { message: 'Document not found', }, }; } }), createDocument: authenticatedMiddleware(async (args, user, team, { metadata }) => { const { body } = args; try { if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') { return { status: 500, body: { message: 'Create document is not available without S3 transport.', }, }; } const { remaining } = await getServerLimits({ userId: user.id, teamId: team.id }); if (remaining.documents <= 0) { return { status: 400, body: { message: 'You have reached the maximum number of documents allowed for this month', }, }; } const dateFormat = body.meta.dateFormat ? DATE_FORMATS.find((format) => format.value === body.meta.dateFormat) : DATE_FORMATS.find((format) => format.value === DEFAULT_DOCUMENT_DATE_FORMAT); if (body.meta.dateFormat && !dateFormat) { return { status: 400, body: { message: 'Invalid date format. Please provide a valid date format', }, }; } const timezone = body.meta.timezone ? TIME_ZONES.find((tz) => tz === body.meta.timezone) : DEFAULT_DOCUMENT_TIME_ZONE; const isTimeZoneValid = body.meta.timezone ? TIME_ZONES.includes(String(timezone)) : true; if (!isTimeZoneValid) { return { status: 400, body: { message: 'Invalid timezone. Please provide a valid timezone', }, }; } const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`; const { url, key } = await getPresignPostUrl(fileName, 'application/pdf'); const documentData = await createDocumentData({ data: key, type: DocumentDataType.S3_PATH, }); const envelope = await createEnvelope({ userId: user.id, teamId: team.id, internalVersion: 1, data: { title: body.title, type: EnvelopeType.DOCUMENT, externalId: body.externalId || undefined, formValues: body.formValues, folderId: body.folderId, envelopeItems: [ { documentDataId: documentData.id, }, ], globalAccessAuth: body.authOptions?.globalAccessAuth, globalActionAuth: body.authOptions?.globalActionAuth, }, attachments: body.attachments, meta: { subject: body.meta.subject, message: body.meta.message, timezone, dateFormat: dateFormat?.value, redirectUrl: body.meta.redirectUrl, signingOrder: body.meta.signingOrder, allowDictateNextSigner: body.meta.allowDictateNextSigner, language: body.meta.language, typedSignatureEnabled: body.meta.typedSignatureEnabled, uploadSignatureEnabled: body.meta.uploadSignatureEnabled, drawSignatureEnabled: body.meta.drawSignatureEnabled, distributionMethod: body.meta.distributionMethod, emailSettings: body.meta.emailSettings, }, requestMetadata: metadata, }); const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId); const { recipients } = await setDocumentRecipients({ userId: user.id, teamId: team.id, id: { type: 'documentId', id: legacyDocumentId, }, recipients: body.recipients, requestMetadata: metadata, }); return { status: 200, body: { uploadUrl: url, documentId: legacyDocumentId, recipients: recipients.map((recipient) => ({ recipientId: recipient.id, name: recipient.name, email: recipient.email, token: recipient.token, role: recipient.role, signingOrder: recipient.signingOrder, signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`, })), }, }; } catch (err) { return { status: 404, body: { message: 'An error has occured while uploading the file', }, }; } }), createTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => { const { body } = args; const { title, folderId, externalId, visibility, globalAccessAuth, globalActionAuth, publicTitle, publicDescription, type, meta, attachments, } = body; try { if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') { return { status: 500, body: { message: 'Create template is not available without S3 transport.', }, }; } const dateFormat = meta?.dateFormat ? DATE_FORMATS.find((format) => format.value === meta?.dateFormat) : DATE_FORMATS.find((format) => format.value === DEFAULT_DOCUMENT_DATE_FORMAT); if (meta?.dateFormat && !dateFormat) { return { status: 400, body: { message: 'Invalid date format. Please provide a valid date format', }, }; } const timezone = meta?.timezone ? TIME_ZONES.find((tz) => tz === meta?.timezone) : DEFAULT_DOCUMENT_TIME_ZONE; const isTimeZoneValid = meta?.timezone ? TIME_ZONES.includes(String(timezone)) : true; if (!isTimeZoneValid) { return { status: 400, body: { message: 'Invalid timezone. Please provide a valid timezone', }, }; } const fileName = title?.endsWith('.pdf') ? title : `${title}.pdf`; const { url, key } = await getPresignPostUrl(fileName, 'application/pdf'); const templateDocumentData = await createDocumentData({ data: key, type: DocumentDataType.S3_PATH, }); const createdTemplate = await createEnvelope({ userId: user.id, teamId: team.id, internalVersion: 1, data: { type: EnvelopeType.TEMPLATE, envelopeItems: [ { documentDataId: templateDocumentData.id, }, ], templateType: type, title, folderId, externalId: externalId ?? undefined, visibility, globalAccessAuth, globalActionAuth, publicTitle, publicDescription, }, meta, attachments, requestMetadata: metadata, }); const fullTemplate = await getTemplateById({ id: { type: 'envelopeId', id: createdTemplate.id, }, userId: user.id, teamId: team.id, }); return { status: 200, body: { uploadUrl: url, template: fullTemplate, }, }; } catch (err) { return { status: 404, body: { message: 'An error has occured while creating the template', }, }; } }), deleteTemplate: authenticatedMiddleware(async (args, user, team, { logger }) => { const { id: templateId } = args.params; logger.info({ input: { id: templateId, }, }); try { const deletedTemplate = await deleteTemplate({ id: { type: 'templateId', id: Number(templateId), }, userId: user.id, teamId: team.id, }); const legacyTemplateId = mapSecondaryIdToTemplateId(deletedTemplate.secondaryId); return { status: 200, body: { id: legacyTemplateId, externalId: deletedTemplate.externalId, type: deletedTemplate.templateType, title: deletedTemplate.title, userId: deletedTemplate.userId, teamId: deletedTemplate.teamId, createdAt: deletedTemplate.createdAt, updatedAt: deletedTemplate.updatedAt, }, }; } catch (err) { return { status: 404, body: { message: 'Template not found', }, }; } }), getTemplate: authenticatedMiddleware(async (args, user, team, { logger }) => { const { id: templateId } = args.params; logger.info({ input: { id: templateId, }, }); try { const template = await getTemplateById({ id: { type: 'templateId', id: Number(templateId), }, userId: user.id, teamId: team.id, }); return { status: 200, body: { ...template, templateMeta: template.templateMeta ? { ...template.templateMeta, templateId: template.id, } : null, Field: template.fields.map((field) => ({ ...field, fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : null, })), Recipient: template.recipients, }, }; } catch (err) { return AppError.toRestAPIError(err); } }), getTemplates: authenticatedMiddleware(async (args, user, team) => { const page = Number(args.query.page) || 1; const perPage = Number(args.query.perPage) || 10; try { const { data: templates, totalPages } = await findTemplates({ page, perPage, userId: user.id, teamId: team.id, }); return { status: 200, body: { templates: templates.map((template) => ({ id: mapSecondaryIdToTemplateId(template.secondaryId), externalId: template.externalId, type: template.templateType, title: template.title, userId: template.userId, teamId: template.teamId, createdAt: template.createdAt, updatedAt: template.updatedAt, directLink: template.directLink, Field: template.fields.map((field) => ({ ...field, templateId: mapSecondaryIdToTemplateId(template.secondaryId), fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : null, })), Recipient: template.recipients, })), totalPages, }, }; } catch (err) { return AppError.toRestAPIError(err); } }), createDocumentFromTemplate: authenticatedMiddleware( async (args, user, team, { logger, metadata }) => { const { body, params } = args; logger.info({ input: { templateId: params.templateId, }, }); const { remaining } = await getServerLimits({ userId: user.id, teamId: team.id }); if (remaining.documents <= 0) { return { status: 400, body: { message: 'You have reached the maximum number of documents allowed for this month', }, }; } const templateId = Number(params.templateId); const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`; const template = await getEnvelopeById({ id: { type: 'templateId', id: templateId, }, type: EnvelopeType.TEMPLATE, userId: user.id, teamId: team.id, }); if (template.envelopeItems.length !== 1) { throw new Error('API V1 does not support templates with multiple documents'); } // V1 API request schema uses indices for recipients // So we remap the recipients to attach the IDs const mappedRecipients = body.recipients.map((recipient, index) => { const existingRecipient = template.recipients.at(index); if (!existingRecipient) { throw new Error('Recipient not found.'); } return { id: existingRecipient.id, name: recipient.name, email: recipient.email, signingOrder: recipient.signingOrder, role: recipient.role, // You probably shouldn't be able to change the role. }; }); const createdEnvelope = await createDocumentFromTemplate({ id: { type: 'templateId', id: templateId, }, externalId: body.externalId || null, userId: user.id, teamId: team.id, recipients: mappedRecipients, override: { ...body.meta, title: body.title, }, attachments: body.attachments, requestMetadata: metadata, }); const envelopeItems = await prisma.envelopeItem.findMany({ where: { envelopeId: createdEnvelope.id, }, include: { documentData: true, }, }); const firstEnvelopeItemData = envelopeItems[0].documentData; if (!firstEnvelopeItemData) { throw new Error('Document data not found.'); } if (body.formValues) { const pdf = await getFileServerSide(firstEnvelopeItemData); const prefilled = await insertFormValuesInPdf({ pdf: Buffer.from(pdf), formValues: body.formValues, }); const newDocumentData = await putPdfFileServerSide({ name: fileName, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(prefilled), }); await prisma.envelopeItem.update({ where: { id: firstEnvelopeItemData.id, }, data: { title: body.title || fileName, documentDataId: newDocumentData.id, }, }); } if (body.authOptions || body.formValues) { await prisma.envelope.update({ where: { id: createdEnvelope.id, }, data: { formValues: body.formValues, authOptions: body.authOptions, }, }); } return { status: 200, body: { documentId: mapSecondaryIdToDocumentId(createdEnvelope.secondaryId), recipients: createdEnvelope.recipients.map((recipient) => ({ recipientId: recipient.id, name: recipient.name, email: recipient.email, token: recipient.token, role: recipient.role, signingOrder: recipient.signingOrder, signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`, })), }, }; }, ), generateDocumentFromTemplate: authenticatedMiddleware( async (args, user, team, { logger, metadata }) => { const { body, params } = args; logger.info({ input: { templateId: params.templateId, }, }); const { remaining } = await getServerLimits({ userId: user.id, teamId: team.id }); if (remaining.documents <= 0) { return { status: 400, body: { message: 'You have reached the maximum number of documents allowed for this month', }, }; } const templateId = Number(params.templateId); let envelope: Awaited> | null = null; try { envelope = await createDocumentFromTemplate({ id: { type: 'templateId', id: templateId, }, externalId: body.externalId || null, userId: user.id, teamId: team.id, recipients: body.recipients, prefillFields: body.prefillFields, folderId: body.folderId, override: { title: body.title, ...body.meta, }, requestMetadata: metadata, }); } catch (err) { return AppError.toRestAPIError(err); } if (envelope.envelopeItems.length !== 1) { throw new Error('API V1 does not support envelopes'); } const firstEnvelopeDocumentData = await prisma.envelopeItem.findFirstOrThrow({ where: { envelopeId: envelope.id, }, include: { documentData: true, }, }); if (body.formValues) { const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title}.pdf`; const pdf = await getFileServerSide(firstEnvelopeDocumentData.documentData); const prefilled = await insertFormValuesInPdf({ pdf: Buffer.from(pdf), formValues: body.formValues, }); const newDocumentData = await putPdfFileServerSide({ name: fileName, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(prefilled), }); await prisma.envelope.update({ where: { id: envelope.id, }, data: { formValues: body.formValues, envelopeItems: { update: { where: { id: firstEnvelopeDocumentData.id, }, data: { documentDataId: newDocumentData.id, }, }, }, }, }); } if (body.authOptions) { await prisma.envelope.update({ where: { id: envelope.id, }, data: { authOptions: body.authOptions, }, }); } const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId); return { status: 200, body: { documentId: legacyDocumentId, recipients: envelope.recipients.map((recipient) => ({ recipientId: recipient.id, name: recipient.name, email: recipient.email, token: recipient.token, role: recipient.role, signingOrder: recipient.signingOrder, signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`, })), }, }; }, ), sendDocument: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => { const { id: documentId } = args.params; const { sendEmail, sendCompletionEmails } = args.body; logger.info({ input: { id: documentId, }, }); try { const legacyDocumentId = Number(documentId); const envelope = await getEnvelopeById({ id: { type: 'documentId', id: legacyDocumentId, }, type: EnvelopeType.DOCUMENT, userId: user.id, teamId: team.id, }); if (!envelope) { return { status: 404, body: { message: 'Document not found', }, }; } if (isDocumentCompleted(envelope.status)) { return { status: 400, body: { message: 'Document is already complete', }, }; } const emailSettings = extractDerivedDocumentEmailSettings(envelope.documentMeta); // Update document email settings if sendCompletionEmails is provided if (typeof sendCompletionEmails === 'boolean') { await updateDocumentMeta({ id: { type: 'envelopeId', id: envelope.id, }, userId: user.id, teamId: team.id, emailSettings: { ...emailSettings, documentCompleted: sendCompletionEmails, ownerDocumentCompleted: sendCompletionEmails, }, requestMetadata: metadata, }); } const { recipients, ...sentDocument } = await sendDocument({ id: { type: 'envelopeId', id: envelope.id, }, userId: user.id, teamId: team.id, sendEmail, requestMetadata: metadata, }); return { status: 200, body: { message: 'Document sent for signing successfully', id: mapSecondaryIdToDocumentId(sentDocument.secondaryId), externalId: sentDocument.externalId, userId: sentDocument.userId, teamId: sentDocument.teamId, title: sentDocument.title, status: sentDocument.status, createdAt: sentDocument.createdAt, updatedAt: sentDocument.updatedAt, completedAt: sentDocument.completedAt, recipients: recipients.map((recipient) => ({ ...recipient, signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`, })), }, }; } catch (err) { return { status: 500, body: { message: 'An error has occured while sending the document for signing', }, }; } }), resendDocument: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => { const { id: documentId } = args.params; const { recipients } = args.body; logger.info({ input: { id: documentId, }, }); try { await resendDocument({ userId: user.id, id: { type: 'documentId', id: Number(documentId), }, recipients, teamId: team.id, requestMetadata: metadata, }); return { status: 200, body: { message: 'Document resend successfully initiated', }, }; } catch (err) { return { status: 500, body: { message: 'An error has occured while resending the document', }, }; } }), createRecipient: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => { const { id: documentId } = args.params; const { name, email, role, authOptions, signingOrder } = args.body; logger.info({ input: { id: documentId, }, }); const legacyDocumentId = Number(documentId); const envelope = await getEnvelopeById({ id: { type: 'documentId', id: legacyDocumentId, }, type: EnvelopeType.DOCUMENT, userId: user.id, teamId: team.id, }); if (!envelope) { return { status: 404, body: { message: 'Document not found', }, }; } if (isDocumentCompleted(envelope.status)) { return { status: 400, body: { message: 'Document is already completed', }, }; } const recipients = await getRecipientsForDocument({ documentId: Number(documentId), userId: user.id, teamId: team.id, }); const recipientAlreadyExists = recipients.some((recipient) => recipient.email === email); if (recipientAlreadyExists) { return { status: 400, body: { message: 'Recipient already exists', }, }; } try { const { recipients: newRecipients } = await setDocumentRecipients({ id: { type: 'documentId', id: Number(documentId), }, userId: user.id, teamId: team.id, recipients: [ ...recipients.map((recipient) => ({ email: recipient.email, name: recipient.name, role: recipient.role, signingOrder: recipient.signingOrder, actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? [], })), { email, name, role, signingOrder, actionAuth: authOptions?.actionAuth ?? [], }, ], requestMetadata: metadata, }); const newRecipient = newRecipients.find((recipient) => recipient.email === email); if (!newRecipient) { throw new Error('Recipient not found'); } return { status: 200, body: { ...newRecipient, documentId: Number(documentId), signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${newRecipient.token}`, }, }; } catch (err) { return { status: 500, body: { message: 'An error has occured while creating the recipient', }, }; } }), updateRecipient: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => { const { id: documentId, recipientId } = args.params; const { name, email, role, authOptions, signingOrder } = args.body; logger.info({ input: { id: documentId, recipientId, }, }); const legacyDocumentId = Number(documentId); const envelope = await getEnvelopeById({ id: { type: 'documentId', id: legacyDocumentId, }, type: EnvelopeType.DOCUMENT, userId: user.id, teamId: team.id, }); if (!envelope) { return { status: 404, body: { message: 'Document not found', }, }; } if (isDocumentCompleted(envelope.status)) { return { status: 400, body: { message: 'Document is already completed', }, }; } const updatedRecipient = await updateDocumentRecipients({ userId: user.id, teamId: team.id, id: { type: 'envelopeId', id: envelope.id, }, recipients: [ { id: Number(recipientId), email, name, role, signingOrder, actionAuth: authOptions?.actionAuth ?? [], }, ], requestMetadata: metadata, }) .then(({ recipients }) => recipients[0]) .catch(null); if (!updatedRecipient) { return { status: 404, body: { message: 'Recipient not found', }, }; } return { status: 200, body: { ...updatedRecipient, documentId: Number(documentId), signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${updatedRecipient.token}`, }, }; }), deleteRecipient: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => { const { id: documentId, recipientId } = args.params; logger.info({ input: { id: documentId, recipientId, }, }); const deletedRecipient = await deleteDocumentRecipient({ userId: user.id, teamId: team.id, recipientId: Number(recipientId), requestMetadata: { requestMetadata: metadata.requestMetadata, source: 'apiV1', auth: 'api', auditUser: { id: team.id, email: team.name, name: team.name, }, }, }); return { status: 200, body: { ...deletedRecipient, documentId: Number(documentId), signingUrl: '', }, }; }), createField: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => { const { id: documentId } = args.params; logger.info({ input: { id: documentId, }, }); const fields = Array.isArray(args.body) ? args.body : [args.body]; const { envelopeWhereInput } = await getEnvelopeWhereInput({ id: { type: 'documentId', id: Number(documentId), }, type: EnvelopeType.DOCUMENT, teamId: team.id, userId: user.id, }); const envelope = await prisma.envelope.findFirst({ where: envelopeWhereInput, select: { id: true, secondaryId: true, status: true, envelopeItems: { select: { id: true }, }, }, }); if (!envelope) { return { status: 404, body: { message: 'Document not found' }, }; } const firstEnvelopeItemId = envelope.envelopeItems[0].id; if (!firstEnvelopeItemId) { throw new Error('Missing envelope item ID'); } if (envelope.envelopeItems.length !== 1) { throw new Error('API V1 does not support multiple documents'); } if (isDocumentCompleted(envelope.status)) { return { status: 400, body: { message: 'Document is already completed' }, }; } try { const createdFields = await prisma.$transaction(async (tx) => { return Promise.all( fields.map(async (fieldData) => { const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta, } = fieldData; if (pageNumber <= 0) { throw new Error('Invalid page number'); } const recipient = await prisma.recipient.findFirst({ where: { id: Number(recipientId), envelopeId: envelope.id, }, }); 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: undefined, })) .with('FREE_SIGNATURE', () => ({ success: false, error: 'FREE_SIGNATURE is not supported', data: undefined, })) .exhaustive(); if (!result.success) { throw new Error('Field meta parsing failed'); } const field = await tx.field.create({ data: { envelopeId: envelope.id, envelopeItemId: firstEnvelopeItemId, 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', envelopeId: envelope.id, 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: metadata.requestMetadata, }), }); return { id: field.id, documentId: mapSecondaryIdToDocumentId(envelope.secondaryId), 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: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined, inserted: field.inserted, }; }), ); }); return { status: 200, body: { fields: createdFields, documentId: Number(documentId), }, }; } catch (err) { return AppError.toRestAPIError(err); } }), updateField: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => { const { id: documentId, fieldId } = args.params; const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta } = args.body; logger.info({ input: { id: documentId, fieldId, }, }); const envelope = await getEnvelopeById({ id: { type: 'documentId', id: Number(documentId), }, type: EnvelopeType.DOCUMENT, userId: user.id, teamId: team.id, }); if (!envelope) { return { status: 404, body: { message: 'Document not found', }, }; } const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId); const firstEnvelopeItemId = envelope.envelopeItems[0].id; if (!firstEnvelopeItemId) { throw new Error('Missing document data'); } if (envelope.envelopeItems.length > 1) { throw new Error('API V1 does not support multiple documents'); } if (isDocumentCompleted(envelope.status)) { return { status: 400, body: { message: 'Document is already completed', }, }; } const recipient = await prisma.recipient.findFirst({ where: { id: Number(recipientId), envelopeId: envelope.id, }, }); 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', }, }; } const { fields } = await updateDocumentFields({ userId: user.id, teamId: team.id, documentId: legacyDocumentId, fields: [ { id: Number(fieldId), type, pageNumber, pageX, pageY, width: pageWidth, height: pageHeight, fieldMeta: fieldMeta ? ZFieldMetaSchema.parse(fieldMeta) : undefined, }, ], requestMetadata: { requestMetadata: metadata.requestMetadata, source: 'apiV1', auth: 'api', auditUser: { id: team.id, email: team.name, name: team.name, }, }, }); const updatedField = fields[0]; return { status: 200, body: { id: updatedField.id, documentId: legacyDocumentId, recipientId: updatedField.recipientId ?? -1, type: updatedField.type, pageNumber: updatedField.page, pageX: Number(updatedField.positionX), pageY: Number(updatedField.positionY), pageWidth: Number(updatedField.width), pageHeight: Number(updatedField.height), customText: updatedField.customText, inserted: updatedField.inserted, }, }; }), deleteField: authenticatedMiddleware(async (args, user, team, { logger, metadata }) => { // Note: documentId isn't actually used anywhere, so we just return it. const { id: unverifiedDocumentId, fieldId } = args.params; logger.info({ input: { id: unverifiedDocumentId, fieldId, }, }); const deletedField = await deleteDocumentField({ fieldId: Number(fieldId), userId: user.id, teamId: team.id, requestMetadata: { requestMetadata: metadata.requestMetadata, source: 'apiV1', auth: 'api', auditUser: { id: team.id, email: team.name, name: team.name, }, }, }); const remappedField = { id: deletedField.id, documentId: Number(unverifiedDocumentId), recipientId: deletedField.recipientId ?? -1, type: deletedField.type, pageNumber: deletedField.page, pageX: Number(deletedField.positionX), pageY: Number(deletedField.positionY), pageWidth: Number(deletedField.width), pageHeight: Number(deletedField.height), customText: deletedField.customText, inserted: deletedField.inserted, }; return { status: 200, body: remappedField, }; }), });