diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index 7b500d295..1dcb2d76b 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -8,23 +8,23 @@ import { useRouter } from 'next/navigation'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { base64 } from '@documenso/lib/universal/base64'; import { putFile } from '@documenso/lib/universal/upload/put-file'; -import { DocumentDataType, Field, Prisma, Recipient } from '@documenso/prisma/client'; +import type { Field, Recipient } from '@documenso/prisma/client'; +import { DocumentDataType, Prisma } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields'; -import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types'; +import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types'; import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature'; -import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types'; +import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types'; import { DocumentFlowFormContainer, DocumentFlowFormContainerHeader, } from '@documenso/ui/primitives/document-flow/document-flow-root'; -import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action'; - type SinglePlayerModeStep = 'fields' | 'sign'; // !: This entire file is a hack to get around failed prerendering of @@ -41,6 +41,9 @@ export const SinglePlayerClient = () => { const [step, setStep] = useState('fields'); const [fields, setFields] = useState([]); + const { mutateAsync: createSinglePlayerDocument } = + trpc.singleplayer.createSinglePlayerDocument.useMutation(); + const documentFlow: Record = { fields: { title: 'Add document', diff --git a/apps/marketing/src/components/(marketing)/single-player-mode/create-single-player-document.action.ts b/apps/marketing/src/components/(marketing)/single-player-mode/create-single-player-document.action.ts deleted file mode 100644 index 29321f31e..000000000 --- a/apps/marketing/src/components/(marketing)/single-player-mode/create-single-player-document.action.ts +++ /dev/null @@ -1,233 +0,0 @@ -'use server'; - -import { createElement } from 'react'; - -import { DateTime } from 'luxon'; -import { PDFDocument } from 'pdf-lib'; -import { match } from 'ts-pattern'; -import { z } from 'zod'; - -import { mailer } from '@documenso/email/mailer'; -import { render } from '@documenso/email/render'; -import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed'; -import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email'; -import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf'; -import { alphaid } from '@documenso/lib/universal/id'; -import { getFile } from '@documenso/lib/universal/upload/get-file'; -import { putFile } from '@documenso/lib/universal/upload/put-file'; -import { prisma } from '@documenso/prisma'; -import { - DocumentDataType, - DocumentStatus, - FieldType, - Prisma, - ReadStatus, - SendStatus, - SigningStatus, -} from '@documenso/prisma/client'; -import { signPdf } from '@documenso/signing'; - -const ZCreateSinglePlayerDocumentSchema = z.object({ - documentData: z.object({ - data: z.string(), - type: z.nativeEnum(DocumentDataType), - }), - documentName: z.string(), - signer: z.object({ - email: z.string().email().min(1), - name: z.string(), - signature: z.string(), - }), - fields: z.array( - z.object({ - page: z.number(), - type: z.nativeEnum(FieldType), - positionX: z.number(), - positionY: z.number(), - width: z.number(), - height: z.number(), - }), - ), -}); - -export type TCreateSinglePlayerDocumentSchema = z.infer; - -/** - * Create and self signs a document. - * - * Returns the document token. - */ -export const createSinglePlayerDocument = async ( - value: TCreateSinglePlayerDocumentSchema, -): Promise => { - const { signer, fields, documentData, documentName } = - ZCreateSinglePlayerDocumentSchema.parse(value); - - const document = await getFile({ - data: documentData.data, - type: documentData.type, - }); - - const doc = await PDFDocument.load(document); - const createdAt = new Date(); - - const isBase64 = signer.signature.startsWith('data:image/png;base64,'); - const signatureImageAsBase64 = isBase64 ? signer.signature : null; - const typedSignature = !isBase64 ? signer.signature : null; - - // Update the document with the fields inserted. - for (const field of fields) { - const isSignatureField = field.type === FieldType.SIGNATURE; - - await insertFieldInPDF(doc, { - ...mapField(field, signer), - Signature: isSignatureField - ? { - created: createdAt, - signatureImageAsBase64, - typedSignature, - // Dummy data. - id: -1, - recipientId: -1, - fieldId: -1, - } - : null, - // Dummy data. - id: -1, - documentId: -1, - recipientId: -1, - }); - } - - const unsignedPdfBytes = await doc.save(); - - const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) }); - - const { token } = await prisma.$transaction( - async (tx) => { - const token = alphaid(); - - // Fetch service user who will be the owner of the document. - const serviceUser = await tx.user.findFirstOrThrow({ - where: { - email: SERVICE_USER_EMAIL, - }, - }); - - const { id: documentDataId } = await putFile({ - name: `${documentName}.pdf`, - type: 'application/pdf', - arrayBuffer: async () => Promise.resolve(signedPdfBuffer), - }); - - // Create document. - const document = await tx.document.create({ - data: { - title: documentName, - status: DocumentStatus.COMPLETED, - documentDataId, - userId: serviceUser.id, - createdAt, - }, - }); - - // Create recipient. - const recipient = await tx.recipient.create({ - data: { - documentId: document.id, - name: signer.name, - email: signer.email, - token, - signedAt: createdAt, - readStatus: ReadStatus.OPENED, - signingStatus: SigningStatus.SIGNED, - sendStatus: SendStatus.SENT, - }, - }); - - // Create fields and signatures. - await Promise.all( - fields.map(async (field) => { - const insertedField = await tx.field.create({ - data: { - documentId: document.id, - recipientId: recipient.id, - ...mapField(field, signer), - }, - }); - - if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) { - await tx.signature.create({ - data: { - fieldId: insertedField.id, - signatureImageAsBase64, - typedSignature, - recipientId: recipient.id, - }, - }); - } - }), - ); - - return { document, token }; - }, - { - maxWait: 5000, - timeout: 30000, - }, - ); - - const template = createElement(DocumentSelfSignedEmailTemplate, { - documentName: documentName, - assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000', - }); - - // Send email to signer. - await mailer.sendMail({ - to: { - address: signer.email, - name: signer.name, - }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, - subject: 'Document signed', - html: render(template), - text: render(template, { plainText: true }), - attachments: [{ content: signedPdfBuffer, filename: documentName }], - }); - - return token; -}; - -/** - * Map the fields provided by the user to fields compatible with Prisma. - * - * Signature fields are handled separately. - * - * @param field The field passed in by the user. - * @param signer The details of the person who is signing this document. - * @returns A field compatible with Prisma. - */ -const mapField = ( - field: TCreateSinglePlayerDocumentSchema['fields'][number], - signer: TCreateSinglePlayerDocumentSchema['signer'], -) => { - const customText = match(field.type) - .with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a')) - .with(FieldType.EMAIL, () => signer.email) - .with(FieldType.NAME, () => signer.name) - .otherwise(() => ''); - - return { - type: field.type, - page: field.page, - positionX: new Prisma.Decimal(field.positionX), - positionY: new Prisma.Decimal(field.positionY), - width: new Prisma.Decimal(field.width), - height: new Prisma.Decimal(field.height), - customText, - inserted: true, - }; -}; diff --git a/apps/marketing/src/pages/api/trpc/[trpc].ts b/apps/marketing/src/pages/api/trpc/[trpc].ts index a42844904..0bc991a98 100644 --- a/apps/marketing/src/pages/api/trpc/[trpc].ts +++ b/apps/marketing/src/pages/api/trpc/[trpc].ts @@ -2,6 +2,10 @@ import * as trpcNext from '@documenso/trpc/server/adapters/next'; import { createTrpcContext } from '@documenso/trpc/server/context'; import { appRouter } from '@documenso/trpc/server/router'; +export const config = { + maxDuration: 60, +}; + export default trpcNext.createNextApiHandler({ router: appRouter, createContext: async ({ req, res }) => createTrpcContext({ req, res }), diff --git a/apps/web/src/pages/api/trpc/[trpc].ts b/apps/web/src/pages/api/trpc/[trpc].ts index a42844904..0bc991a98 100644 --- a/apps/web/src/pages/api/trpc/[trpc].ts +++ b/apps/web/src/pages/api/trpc/[trpc].ts @@ -2,6 +2,10 @@ import * as trpcNext from '@documenso/trpc/server/adapters/next'; import { createTrpcContext } from '@documenso/trpc/server/context'; import { appRouter } from '@documenso/trpc/server/router'; +export const config = { + maxDuration: 60, +}; + export default trpcNext.createNextApiHandler({ router: appRouter, createContext: async ({ req, res }) => createTrpcContext({ req, res }), diff --git a/package-lock.json b/package-lock.json index c11ccd14d..9a9b98df0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19826,7 +19826,9 @@ "@trpc/next": "^10.36.0", "@trpc/react-query": "^10.36.0", "@trpc/server": "^10.36.0", + "luxon": "^3.4.0", "superjson": "^1.13.1", + "ts-pattern": "^5.0.5", "zod": "^3.22.4" } }, diff --git a/packages/trpc/package.json b/packages/trpc/package.json index b003509aa..05aed3147 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -17,7 +17,9 @@ "@trpc/next": "^10.36.0", "@trpc/react-query": "^10.36.0", "@trpc/server": "^10.36.0", + "luxon": "^3.4.0", "superjson": "^1.13.1", + "ts-pattern": "^5.0.5", "zod": "^3.22.4" } } diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index b20d5bc3f..dbf6ca03d 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -4,6 +4,7 @@ import { documentRouter } from './document-router/router'; import { fieldRouter } from './field-router/router'; import { profileRouter } from './profile-router/router'; import { shareLinkRouter } from './share-link-router/router'; +import { singleplayerRouter } from './singleplayer-router/router'; import { router } from './trpc'; export const appRouter = router({ @@ -13,6 +14,7 @@ export const appRouter = router({ field: fieldRouter, admin: adminRouter, shareLink: shareLinkRouter, + singleplayer: singleplayerRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/trpc/server/singleplayer-router/helper.ts b/packages/trpc/server/singleplayer-router/helper.ts new file mode 100644 index 000000000..0ec0ba42d --- /dev/null +++ b/packages/trpc/server/singleplayer-router/helper.ts @@ -0,0 +1,37 @@ +import { DateTime } from 'luxon'; +import { match } from 'ts-pattern'; + +import { FieldType, Prisma } from '@documenso/prisma/client'; + +import type { TCreateSinglePlayerDocumentMutationSchema } from './schema'; + +/** + * Map the fields provided by the user to fields compatible with Prisma. + * + * Signature fields are handled separately. + * + * @param field The field passed in by the user. + * @param signer The details of the person who is signing this document. + * @returns A field compatible with Prisma. + */ +export const mapField = ( + field: TCreateSinglePlayerDocumentMutationSchema['fields'][number], + signer: TCreateSinglePlayerDocumentMutationSchema['signer'], +) => { + const customText = match(field.type) + .with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a')) + .with(FieldType.EMAIL, () => signer.email) + .with(FieldType.NAME, () => signer.name) + .otherwise(() => ''); + + return { + type: field.type, + page: field.page, + positionX: new Prisma.Decimal(field.positionX), + positionY: new Prisma.Decimal(field.positionY), + width: new Prisma.Decimal(field.width), + height: new Prisma.Decimal(field.height), + customText, + inserted: true, + }; +}; diff --git a/packages/trpc/server/singleplayer-router/router.ts b/packages/trpc/server/singleplayer-router/router.ts new file mode 100644 index 000000000..36e41f9e1 --- /dev/null +++ b/packages/trpc/server/singleplayer-router/router.ts @@ -0,0 +1,172 @@ +'use server'; + +import { createElement } from 'react'; + +import { PDFDocument } from 'pdf-lib'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed'; +import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email'; +import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf'; +import { alphaid } from '@documenso/lib/universal/id'; +import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { prisma } from '@documenso/prisma'; +import { + DocumentStatus, + FieldType, + ReadStatus, + SendStatus, + SigningStatus, +} from '@documenso/prisma/client'; +import { signPdf } from '@documenso/signing'; + +import { procedure, router } from '../trpc'; +import { mapField } from './helper'; +import { ZCreateSinglePlayerDocumentMutationSchema } from './schema'; + +export const singleplayerRouter = router({ + createSinglePlayerDocument: procedure + .input(ZCreateSinglePlayerDocumentMutationSchema) + .mutation(async ({ input }) => { + const { signer, fields, documentData, documentName } = input; + + const document = await getFile({ + data: documentData.data, + type: documentData.type, + }); + + const doc = await PDFDocument.load(document); + const createdAt = new Date(); + + const isBase64 = signer.signature.startsWith('data:image/png;base64,'); + const signatureImageAsBase64 = isBase64 ? signer.signature : null; + const typedSignature = !isBase64 ? signer.signature : null; + + // Update the document with the fields inserted. + for (const field of fields) { + const isSignatureField = field.type === FieldType.SIGNATURE; + + await insertFieldInPDF(doc, { + ...mapField(field, signer), + Signature: isSignatureField + ? { + created: createdAt, + signatureImageAsBase64, + typedSignature, + // Dummy data. + id: -1, + recipientId: -1, + fieldId: -1, + } + : null, + // Dummy data. + id: -1, + documentId: -1, + recipientId: -1, + }); + } + + const unsignedPdfBytes = await doc.save(); + + const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) }); + + const { token } = await prisma.$transaction( + async (tx) => { + const token = alphaid(); + + // Fetch service user who will be the owner of the document. + const serviceUser = await tx.user.findFirstOrThrow({ + where: { + email: SERVICE_USER_EMAIL, + }, + }); + + const { id: documentDataId } = await putFile({ + name: `${documentName}.pdf`, + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(signedPdfBuffer), + }); + + // Create document. + const document = await tx.document.create({ + data: { + title: documentName, + status: DocumentStatus.COMPLETED, + documentDataId, + userId: serviceUser.id, + createdAt, + }, + }); + + // Create recipient. + const recipient = await tx.recipient.create({ + data: { + documentId: document.id, + name: signer.name, + email: signer.email, + token, + signedAt: createdAt, + readStatus: ReadStatus.OPENED, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + }, + }); + + // Create fields and signatures. + await Promise.all( + fields.map(async (field) => { + const insertedField = await tx.field.create({ + data: { + documentId: document.id, + recipientId: recipient.id, + ...mapField(field, signer), + }, + }); + + if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) { + await tx.signature.create({ + data: { + fieldId: insertedField.id, + signatureImageAsBase64, + typedSignature, + recipientId: recipient.id, + }, + }); + } + }), + ); + + return { document, token }; + }, + { + maxWait: 5000, + timeout: 30000, + }, + ); + + const template = createElement(DocumentSelfSignedEmailTemplate, { + documentName: documentName, + assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000', + }); + + // Send email to signer. + await mailer.sendMail({ + to: { + address: signer.email, + name: signer.name, + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: 'Document signed', + html: render(template), + text: render(template, { plainText: true }), + attachments: [{ content: signedPdfBuffer, filename: documentName }], + }); + + return token; + }), +}); diff --git a/packages/trpc/server/singleplayer-router/schema.ts b/packages/trpc/server/singleplayer-router/schema.ts new file mode 100644 index 000000000..9fa56e7b1 --- /dev/null +++ b/packages/trpc/server/singleplayer-router/schema.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import { DocumentDataType, FieldType } from '@documenso/prisma/client'; + +export const ZCreateSinglePlayerDocumentMutationSchema = z.object({ + documentData: z.object({ + data: z.string(), + type: z.nativeEnum(DocumentDataType), + }), + documentName: z.string(), + signer: z.object({ + email: z.string().email().min(1), + name: z.string(), + signature: z.string(), + }), + fields: z.array( + z.object({ + page: z.number(), + type: z.nativeEnum(FieldType), + positionX: z.number(), + positionY: z.number(), + width: z.number(), + height: z.number(), + }), + ), +}); + +export type TCreateSinglePlayerDocumentMutationSchema = z.infer< + typeof ZCreateSinglePlayerDocumentMutationSchema +>;