diff --git a/packages/api/v1/contract.ts b/packages/api/v1/contract.ts index 438fa9cee..a7dd27f7c 100644 --- a/packages/api/v1/contract.ts +++ b/packages/api/v1/contract.ts @@ -4,14 +4,20 @@ import { ZSendDocumentForSigningMutationSchema as SendDocumentMutationSchema, ZAuthorizationHeadersSchema, ZCreateDocumentMutationSchema, + ZCreateFieldMutationSchema, ZCreateRecipientMutationSchema, ZDeleteDocumentMutationSchema, + ZDeleteFieldMutationSchema, + ZDeleteRecipientMutationSchema, ZGetDocumentsQuerySchema, ZSuccessfulDocumentResponseSchema, + ZSuccessfulFieldResponseSchema, ZSuccessfulRecipientResponseSchema, ZSuccessfulResponseSchema, ZSuccessfulSigningResponseSchema, ZUnsuccessfulResponseSchema, + ZUpdateFieldMutationSchema, + ZUpdateRecipientMutationSchema, ZUploadDocumentSuccessfulSchema, } from './schema'; @@ -93,6 +99,76 @@ export const ApiContractV1 = c.router( }, summary: 'Create a recipient for a document', }, + + updateRecipient: { + method: 'PATCH', + path: '/api/v1/documents/:id/recipients/:recipientId', + body: ZUpdateRecipientMutationSchema, + responses: { + 200: ZSuccessfulRecipientResponseSchema, + 400: ZUnsuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + 500: ZUnsuccessfulResponseSchema, + }, + summary: 'Update a recipient for a document', + }, + + deleteRecipient: { + method: 'DELETE', + path: '/api/v1/documents/:id/recipients/:recipientId', + body: ZDeleteRecipientMutationSchema, + responses: { + 200: ZSuccessfulRecipientResponseSchema, + 400: ZUnsuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + 500: ZUnsuccessfulResponseSchema, + }, + summary: 'Delete a recipient from a document', + }, + + createField: { + method: 'POST', + path: '/api/v1/documents/:id/fields', + body: ZCreateFieldMutationSchema, + responses: { + 200: ZSuccessfulFieldResponseSchema, + 400: ZUnsuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + 500: ZUnsuccessfulResponseSchema, + }, + summary: 'Create a field for a document', + }, + + updateField: { + method: 'PATCH', + path: '/api/v1/documents/:id/fields/:fieldId', + body: ZUpdateFieldMutationSchema, + responses: { + 200: ZSuccessfulFieldResponseSchema, + 400: ZUnsuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + 500: ZUnsuccessfulResponseSchema, + }, + summary: 'Update a field for a document', + }, + + deleteField: { + method: 'DELETE', + path: '/api/v1/documents/:id/fields/:fieldId', + body: ZDeleteFieldMutationSchema, + responses: { + 200: ZSuccessfulFieldResponseSchema, + 400: ZUnsuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + 500: ZUnsuccessfulResponseSchema, + }, + summary: 'Delete a field from a document', + }, }, { baseHeaders: ZAuthorizationHeadersSchema, diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 4dd709246..e9b710c46 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -4,10 +4,17 @@ import { deleteDocument } from '@documenso/lib/server-only/document/delete-docum import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { sendDocument } from '@documenso/lib/server-only/document/send-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'; +import { deleteRecipient } from '@documenso/lib/server-only/recipient/delete-recipient'; +import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; +import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; -import { DocumentStatus } from '@documenso/prisma/client'; +import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { ApiContractV1 } from './contract'; import { authenticatedMiddleware } from './middleware/authenticated'; @@ -250,4 +257,347 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { }; } }), + + updateRecipient: authenticatedMiddleware(async (args, user) => { + const { id: documentId, recipientId } = args.params; + const { name, email } = args.body; + + const document = await getDocumentById({ + id: Number(documentId), + userId: user.id, + }); + + if (!document) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + + if (document.status === DocumentStatus.COMPLETED) { + return { + status: 400, + body: { + message: 'Document is already completed', + }, + }; + } + + const updatedRecipient = await updateRecipient({ + documentId: Number(documentId), + recipientId: Number(recipientId), + email, + name, + }).catch(() => null); + + if (!updatedRecipient) { + return { + status: 404, + body: { + message: 'Recipient not found', + }, + }; + } + + return { + status: 200, + body: updatedRecipient, + }; + }), + + deleteRecipient: authenticatedMiddleware(async (args, user) => { + const { id: documentId, recipientId } = args.params; + + const document = await getDocumentById({ + id: Number(documentId), + userId: user.id, + }); + + if (!document) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + + if (document.status === DocumentStatus.COMPLETED) { + return { + status: 400, + body: { + message: 'Document is already completed', + }, + }; + } + + const deletedRecipient = await deleteRecipient({ + documentId: Number(documentId), + recipientId: Number(recipientId), + }).catch(() => null); + + if (!deletedRecipient) { + return { + status: 400, + body: { + message: 'Unable to delete recipient', + }, + }; + } + + return { + status: 200, + body: deletedRecipient, + }; + }), + + createField: authenticatedMiddleware(async (args, user) => { + const { id: documentId } = args.params; + const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body; + + const document = await getDocumentById({ + id: Number(documentId), + userId: user.id, + }); + + if (!document) { + return { + status: 404, + 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', + }, + }; + } + + const field = await createField({ + documentId: Number(documentId), + recipientId: Number(recipientId), + type, + pageNumber, + pageX, + pageY, + pageWidth, + pageHeight, + }); + + const remappedField = { + 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, + inserted: field.inserted, + }; + + return { + status: 200, + body: remappedField, + }; + }), + + updateField: authenticatedMiddleware(async (args, user) => { + const { id: documentId, fieldId } = args.params; + const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body; + + const document = await getDocumentById({ + id: Number(documentId), + userId: user.id, + }); + + if (!document) { + return { + status: 404, + 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', + }, + }; + } + + const updatedField = await updateField({ + fieldId: Number(fieldId), + documentId: Number(documentId), + recipientId: recipientId ? Number(recipientId) : undefined, + type, + pageNumber, + pageX, + pageY, + pageWidth, + pageHeight, + }); + + const remappedField = { + documentId: updatedField.documentId, + 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, + }; + + return { + status: 200, + body: remappedField, + }; + }), + + deleteField: authenticatedMiddleware(async (args, user) => { + const { id: documentId, fieldId } = args.params; + + const document = await getDocumentById({ + id: Number(documentId), + userId: user.id, + }); + + if (!document) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + + if (document.status === DocumentStatus.COMPLETED) { + return { + status: 400, + body: { + message: 'Document is already completed', + }, + }; + } + + const field = await getFieldById({ + fieldId: Number(fieldId), + documentId: Number(documentId), + }).catch(() => null); + + if (!field) { + return { + status: 404, + body: { + message: 'Field not found', + }, + }; + } + + const recipient = await getRecipientById({ + id: Number(field.recipientId), + documentId: Number(documentId), + }).catch(() => null); + + if (recipient?.signingStatus === SigningStatus.SIGNED) { + return { + status: 400, + body: { + message: 'Recipient has already signed the document', + }, + }; + } + + const deletedField = await deleteField({ + documentId: Number(documentId), + fieldId: Number(fieldId), + }).catch(() => null); + + if (!deletedField) { + return { + status: 400, + body: { + message: 'Unable to delete field', + }, + }; + } + + const remappedField = { + documentId: deletedField.documentId, + 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, + }; + }), }); diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index 91c35ce28..f6fba2f0f 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -1,7 +1,10 @@ import { z } from 'zod'; -import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client'; +import { FieldType, ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client'; +/** + * Documents + */ export const ZGetDocumentsQuerySchema = z.object({ page: z.string().optional(), perPage: z.string().optional(), @@ -49,8 +52,19 @@ export const ZCreateRecipientMutationSchema = z.object({ email: z.string().email().min(1), }); +/** + * Recipients + */ export type TCreateRecipientMutationSchema = z.infer; +export const ZUpdateRecipientMutationSchema = ZCreateRecipientMutationSchema.partial(); + +export type TUpdateRecipientMutationSchema = z.infer; + +export const ZDeleteRecipientMutationSchema = null; + +export type TDeleteRecipientMutationSchema = typeof ZDeleteRecipientMutationSchema; + export const ZSuccessfulRecipientResponseSchema = z.object({ id: z.number(), documentId: z.number(), @@ -67,6 +81,44 @@ export const ZSuccessfulRecipientResponseSchema = z.object({ export type TSuccessfulRecipientResponseSchema = z.infer; +/** + * Fields + */ +export const ZCreateFieldMutationSchema = z.object({ + recipientId: z.number(), + type: z.nativeEnum(FieldType), + pageNumber: z.number(), + pageX: z.number(), + pageY: z.number(), + pageWidth: z.number(), + pageHeight: z.number(), +}); + +export type TCreateFieldMutationSchema = z.infer; + +export const ZUpdateFieldMutationSchema = ZCreateFieldMutationSchema.partial(); + +export type TUpdateFieldMutationSchema = z.infer; + +export const ZDeleteFieldMutationSchema = null; + +export type TDeleteFieldMutationSchema = typeof ZDeleteFieldMutationSchema; + +export const ZSuccessfulFieldResponseSchema = z.object({ + 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(), + inserted: z.boolean(), +}); + +export type TSuccessfulFieldResponseSchema = z.infer; + export const ZSuccessfulResponseSchema = z.object({ documents: ZSuccessfulDocumentResponseSchema.array(), totalPages: z.number(), @@ -80,14 +132,17 @@ export const ZSuccessfulSigningResponseSchema = z.object({ export type TSuccessfulSigningResponseSchema = z.infer; -export const ZUnsuccessfulResponseSchema = z.object({ - message: z.string(), -}); - -export type TUnsuccessfulResponseSchema = z.infer; - +/** + * General + */ export const ZAuthorizationHeadersSchema = z.object({ authorization: z.string(), }); export type TAuthorizationHeadersSchema = z.infer; + +export const ZUnsuccessfulResponseSchema = z.object({ + message: z.string(), +}); + +export type TUnsuccessfulResponseSchema = z.infer; diff --git a/packages/lib/server-only/field/create-field.ts b/packages/lib/server-only/field/create-field.ts new file mode 100644 index 000000000..c61e36340 --- /dev/null +++ b/packages/lib/server-only/field/create-field.ts @@ -0,0 +1,41 @@ +import { prisma } from '@documenso/prisma'; +import type { FieldType } from '@documenso/prisma/client'; + +export type CreateFieldOptions = { + documentId: number; + recipientId: number; + type: FieldType; + pageNumber: number; + pageX: number; + pageY: number; + pageWidth: number; + pageHeight: number; +}; + +export const createField = async ({ + documentId, + recipientId, + type, + pageNumber, + pageX, + pageY, + pageWidth, + pageHeight, +}: CreateFieldOptions) => { + const field = await prisma.field.create({ + data: { + documentId, + recipientId, + type, + page: pageNumber, + positionX: pageX, + positionY: pageY, + width: pageWidth, + height: pageHeight, + customText: '', + inserted: false, + }, + }); + + return field; +}; diff --git a/packages/lib/server-only/field/delete-field.ts b/packages/lib/server-only/field/delete-field.ts new file mode 100644 index 000000000..d775c84bd --- /dev/null +++ b/packages/lib/server-only/field/delete-field.ts @@ -0,0 +1,17 @@ +import { prisma } from '@documenso/prisma'; + +export type DeleteFieldOptions = { + fieldId: number; + documentId: number; +}; + +export const deleteField = async ({ fieldId, documentId }: DeleteFieldOptions) => { + const field = await prisma.field.delete({ + where: { + id: fieldId, + documentId, + }, + }); + + return field; +}; diff --git a/packages/lib/server-only/field/get-field-by-id.ts b/packages/lib/server-only/field/get-field-by-id.ts new file mode 100644 index 000000000..0e0f9b2dd --- /dev/null +++ b/packages/lib/server-only/field/get-field-by-id.ts @@ -0,0 +1,17 @@ +import { prisma } from '@documenso/prisma'; + +export type GetFieldByIdOptions = { + fieldId: number; + documentId: number; +}; + +export const getFieldById = async ({ fieldId, documentId }: GetFieldByIdOptions) => { + const field = await prisma.field.findFirst({ + where: { + id: fieldId, + documentId, + }, + }); + + return field; +}; diff --git a/packages/lib/server-only/field/update-field.ts b/packages/lib/server-only/field/update-field.ts new file mode 100644 index 000000000..4d949a8cb --- /dev/null +++ b/packages/lib/server-only/field/update-field.ts @@ -0,0 +1,44 @@ +import { prisma } from '@documenso/prisma'; +import type { FieldType } from '@documenso/prisma/client'; + +export type UpdateFieldOptions = { + fieldId: number; + documentId: number; + recipientId?: number; + type?: FieldType; + pageNumber?: number; + pageX?: number; + pageY?: number; + pageWidth?: number; + pageHeight?: number; +}; + +export const updateField = async ({ + fieldId, + documentId, + recipientId, + type, + pageNumber, + pageX, + pageY, + pageWidth, + pageHeight, +}: UpdateFieldOptions) => { + const field = await prisma.field.update({ + where: { + id: fieldId, + documentId, + }, + data: { + recipientId, + type, + page: pageNumber, + positionX: pageX, + positionY: pageY, + width: pageWidth, + height: pageHeight, + }, + }); + + return field; +}; diff --git a/packages/lib/server-only/recipient/delete-recipient.ts b/packages/lib/server-only/recipient/delete-recipient.ts new file mode 100644 index 000000000..67b948f6a --- /dev/null +++ b/packages/lib/server-only/recipient/delete-recipient.ts @@ -0,0 +1,32 @@ +import { prisma } from '@documenso/prisma'; +import { SendStatus } from '@documenso/prisma/client'; + +export type DeleteRecipientOptions = { + documentId: number; + recipientId: number; +}; + +export const deleteRecipient = async ({ documentId, recipientId }: DeleteRecipientOptions) => { + const recipient = await prisma.recipient.findFirst({ + where: { + id: recipientId, + documentId, + }, + }); + + if (!recipient) { + throw new Error('Recipient not found'); + } + + if (recipient.sendStatus !== SendStatus.NOT_SENT) { + throw new Error('Can not delete a recipient that has already been sent a document'); + } + + const deletedRecipient = await prisma.recipient.delete({ + where: { + id: recipient.id, + }, + }); + + return deletedRecipient; +}; diff --git a/packages/lib/server-only/recipient/get-recipient-by-email.ts b/packages/lib/server-only/recipient/get-recipient-by-email.ts new file mode 100644 index 000000000..349149105 --- /dev/null +++ b/packages/lib/server-only/recipient/get-recipient-by-email.ts @@ -0,0 +1,21 @@ +import { prisma } from '@documenso/prisma'; + +export type GetRecipientByEmailOptions = { + documentId: number; + email: string; +}; + +export const getRecipientByEmail = async ({ documentId, email }: GetRecipientByEmailOptions) => { + const recipient = await prisma.recipient.findFirst({ + where: { + documentId, + email: email.toLowerCase(), + }, + }); + + if (!recipient) { + throw new Error('Recipient not found'); + } + + return recipient; +}; diff --git a/packages/lib/server-only/recipient/get-recipient-by-id.ts b/packages/lib/server-only/recipient/get-recipient-by-id.ts new file mode 100644 index 000000000..0db306b80 --- /dev/null +++ b/packages/lib/server-only/recipient/get-recipient-by-id.ts @@ -0,0 +1,21 @@ +import { prisma } from '@documenso/prisma'; + +export type GetRecipientByIdOptions = { + id: number; + documentId: number; +}; + +export const getRecipientById = async ({ documentId, id }: GetRecipientByIdOptions) => { + const recipient = await prisma.recipient.findFirst({ + where: { + documentId, + id, + }, + }); + + if (!recipient) { + throw new Error('Recipient not found'); + } + + return recipient; +}; diff --git a/packages/lib/server-only/recipient/update-recipient.ts b/packages/lib/server-only/recipient/update-recipient.ts new file mode 100644 index 000000000..0b1fa046d --- /dev/null +++ b/packages/lib/server-only/recipient/update-recipient.ts @@ -0,0 +1,38 @@ +import { prisma } from '@documenso/prisma'; + +export type UpdateRecipientOptions = { + documentId: number; + recipientId: number; + email?: string; + name?: string; +}; + +export const updateRecipient = async ({ + documentId, + recipientId, + email, + name, +}: UpdateRecipientOptions) => { + const recipient = await prisma.recipient.findFirst({ + where: { + id: recipientId, + documentId, + }, + }); + + if (!recipient) { + throw new Error('Recipient not found'); + } + + const updatedRecipient = await prisma.recipient.update({ + where: { + id: recipient.id, + }, + data: { + email: email?.toLowerCase() ?? recipient.email, + name: name ?? recipient.name, + }, + }); + + return updatedRecipient; +};