feat: document authoring

This commit is contained in:
Mythie
2023-08-17 19:56:18 +10:00
parent 9fdc9dcbf7
commit 48ceb1e5c7
50 changed files with 2037 additions and 93 deletions

View File

@ -29,7 +29,7 @@ module.exports = {
rules: {
'react/no-unescaped-entities': 'off',
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
// We never want to use `as` but are required to on occasion to handle

View File

@ -1,10 +1,21 @@
import { Recipient } from '@documenso/prisma/client';
import { ReadStatus, Recipient, SendStatus, SigningStatus } from '@documenso/prisma/client';
export const getRecipientType = (recipient: Recipient) => {
if (recipient.sendStatus === 'SENT' && recipient.signingStatus === 'SIGNED') {
if (
recipient.sendStatus === SendStatus.SENT &&
recipient.signingStatus === SigningStatus.SIGNED
) {
return 'completed';
}
if (
recipient.sendStatus === SendStatus.SENT &&
recipient.readStatus === ReadStatus.OPENED &&
recipient.signingStatus === SigningStatus.NOT_SIGNED
) {
return 'opened';
}
if (recipient.sendStatus === 'SENT' && recipient.signingStatus === 'NOT_SIGNED') {
return 'waiting';
}

View File

@ -1,6 +1,9 @@
import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
import { headers } from 'next/headers';
import { NextRequest } from 'next/server';
import { getServerSession as getNextAuthServerSession } from 'next-auth';
import { getToken } from 'next-auth/jwt';
import { prisma } from '@documenso/prisma';
@ -27,6 +30,18 @@ export const getServerSession = async ({ req, res }: GetServerSessionOptions) =>
return user;
};
export const getServerComponentToken = async () => {
const requestHeaders = Object.fromEntries(headers().entries());
const req = new NextRequest('http://example.com', {
headers: requestHeaders,
});
const token = await getToken({
req,
});
};
export const getServerComponentSession = async () => {
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);

View File

@ -10,23 +10,25 @@
"universal/",
"next-auth/"
],
"scripts": {
},
"scripts": {},
"dependencies": {
"@documenso/email": "*",
"@documenso/prisma": "*",
"@pdf-lib/fontkit": "^1.1.1",
"@next-auth/prisma-adapter": "1.0.7",
"@pdf-lib/fontkit": "^1.1.1",
"@upstash/redis": "^1.20.6",
"bcrypt": "^5.1.0",
"pdf-lib": "^1.17.1",
"luxon": "^3.4.0",
"nanoid": "^4.0.2",
"next": "13.4.12",
"next-auth": "4.22.3",
"pdf-lib": "^1.17.1",
"react": "18.2.0",
"stripe": "^12.7.0"
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0"
"@types/bcrypt": "^5.0.0",
"@types/luxon": "^3.3.1"
}
}

View File

@ -0,0 +1,92 @@
'use server';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { sealDocument } from './seal-document';
export type CompleteDocumentWithTokenOptions = {
token: string;
documentId: number;
};
export const completeDocumentWithToken = async ({
token,
documentId,
}: CompleteDocumentWithTokenOptions) => {
'use server';
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
Recipient: {
some: {
token,
},
},
},
include: {
Recipient: {
where: {
token,
},
},
},
});
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
}
if (document.Recipient.length === 0) {
throw new Error(`Document ${document.id} has no recipient with token ${token}`);
}
const [recipient] = document.Recipient;
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
const fields = await prisma.field.findMany({
where: {
documentId: document.id,
recipientId: recipient.id,
},
});
if (fields.some((field) => !field.inserted)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
},
});
const documents = await prisma.document.updateMany({
where: {
id: document.id,
Recipient: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
},
data: {
status: DocumentStatus.COMPLETED,
},
});
console.log('documents', documents);
if (documents.count > 0) {
console.log('sealing document');
sealDocument({ documentId: document.id });
}
};

View File

@ -0,0 +1,30 @@
import { prisma } from '@documenso/prisma';
export interface GetDocumentAndSenderByTokenOptions {
token: string;
}
export const getDocumentAndSenderByToken = async ({
token,
}: GetDocumentAndSenderByTokenOptions) => {
const result = await prisma.document.findFirstOrThrow({
where: {
Recipient: {
some: {
token,
},
},
},
include: {
User: true,
},
});
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const { password: _password, ...User } = result.User;
return {
...result,
User,
};
};

View File

@ -0,0 +1,74 @@
'use server';
import { PDFDocument } from 'pdf-lib';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
export type SealDocumentOptions = {
documentId: number;
};
export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
'use server';
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
});
if (document.status !== DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has not been completed`);
}
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
});
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) {
throw new Error(`Document ${document.id} has unsigned recipients`);
}
const fields = await prisma.field.findMany({
where: {
documentId: document.id,
},
include: {
Signature: true,
},
});
if (fields.some((field) => !field.inserted)) {
throw new Error(`Document ${document.id} has unsigned fields`);
}
// !: Need to write the fields onto the document as a hard copy
const { document: pdfData } = document;
const doc = await PDFDocument.load(pdfData);
for (const field of fields) {
console.log('inserting field', {
...field,
Signature: null,
});
await insertFieldInPDF(doc, field);
}
const pdfBytes = await doc.save();
await prisma.document.update({
where: {
id: document.id,
status: DocumentStatus.COMPLETED,
},
data: {
document: Buffer.from(pdfBytes).toString('base64'),
},
});
};

View File

@ -48,12 +48,15 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
return;
}
const assetBaseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
const signDocumentLink = `${process.env.NEXT_PUBLIC_SITE_URL}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
assetBaseUrl: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
inviterName: user.name || undefined,
inviterEmail: user.email,
signDocumentLink: 'https://example.com',
assetBaseUrl,
signDocumentLink,
});
mailer.sendMail({

View File

@ -0,0 +1,29 @@
import { prisma } from '@documenso/prisma';
import { ReadStatus } from '@documenso/prisma/client';
export type ViewedDocumentOptions = {
token: string;
};
export const viewedDocument = async ({ token }: ViewedDocumentOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
token,
readStatus: ReadStatus.NOT_OPENED,
},
});
if (!recipient) {
console.warn(`No recipient found for token ${token}`);
return;
}
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
readStatus: ReadStatus.OPENED,
},
});
};

View File

@ -0,0 +1,18 @@
import { prisma } from '@documenso/prisma';
export type GetFieldsForTokenOptions = {
token: string;
};
export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) => {
return await prisma.field.findMany({
where: {
Recipient: {
token,
},
},
include: {
Signature: true,
},
});
};

View File

@ -0,0 +1,59 @@
'use server';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
export type RemovedSignedFieldWithTokenOptions = {
token: string;
fieldId: number;
};
export const removeSignedFieldWithToken = async ({
token,
fieldId,
}: RemovedSignedFieldWithTokenOptions) => {
const field = await prisma.field.findFirstOrThrow({
where: {
id: fieldId,
Recipient: {
token,
},
},
include: {
Document: true,
Recipient: true,
},
});
const { Document: document, Recipient: recipient } = field;
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
}
if (recipient?.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
// Unreachable code based on the above query but we need to satisfy TypeScript
if (field.recipientId === null) {
throw new Error(`Field ${fieldId} has no recipientId`);
}
await Promise.all([
prisma.field.update({
where: {
id: field.id,
},
data: {
customText: '',
inserted: false,
},
}),
prisma.signature.deleteMany({
where: {
fieldId: field.id,
},
}),
]);
};

View File

@ -0,0 +1,89 @@
'use server';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
export type SignFieldWithTokenOptions = {
token: string;
fieldId: number;
value: string;
isBase64?: boolean;
};
export const signFieldWithToken = async ({
token,
fieldId,
value,
isBase64,
}: SignFieldWithTokenOptions) => {
const field = await prisma.field.findFirstOrThrow({
where: {
id: fieldId,
Recipient: {
token,
},
},
include: {
Document: true,
Recipient: true,
},
});
const { Document: document, Recipient: recipient } = field;
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
}
if (recipient?.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
if (field.inserted) {
throw new Error(`Field ${fieldId} has already been inserted`);
}
// Unreachable code based on the above query but we need to satisfy TypeScript
if (field.recipientId === null) {
throw new Error(`Field ${fieldId} has no recipientId`);
}
const isSignatureField =
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
let customText = !isSignatureField ? value : undefined;
const signatureImageAsBase64 = isSignatureField && isBase64 ? value : undefined;
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
if (field.type === FieldType.DATE) {
customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a');
}
await prisma.field.update({
where: {
id: field.id,
},
data: {
customText,
inserted: true,
Signature: isSignatureField
? {
upsert: {
create: {
recipientId: field.recipientId,
signatureImageAsBase64,
typedSignature,
},
update: {
recipientId: field.recipientId,
signatureImageAsBase64,
typedSignature,
},
},
}
: undefined,
},
});
};

View File

@ -0,0 +1,144 @@
import fontkit from '@pdf-lib/fontkit';
import { readFileSync } from 'fs';
import { PDFDocument, StandardFonts } from 'pdf-lib';
import { FieldType } from '@documenso/prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
const DEFAULT_STANDARD_FONT_SIZE = 15;
const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const isSignatureField = isSignatureFieldType(field.type);
pdf.registerFontkit(fontkit);
const fontCaveat = readFileSync('./public/fonts/caveat.ttf');
const pages = pdf.getPages();
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
let fontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
const page = pages.at(field.page - 1);
if (!page) {
throw new Error(`Page ${field.page} does not exist`);
}
const { width: pageWidth, height: pageHeight } = page.getSize();
const fieldWidth = pageWidth * (Number(field.width) / 100);
const fieldHeight = pageHeight * (Number(field.height) / 100);
const fieldX = pageWidth * (Number(field.positionX) / 100);
const fieldY = pageHeight * (Number(field.positionY) / 100);
console.log({
fieldWidth,
fieldHeight,
fieldX,
fieldY,
pageWidth,
pageHeight,
});
const font = await pdf.embedFont(isSignatureField ? fontCaveat : StandardFonts.Helvetica);
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
await pdf.embedFont(fontCaveat);
}
const isInsertingImage =
isSignatureField && typeof field.Signature?.signatureImageAsBase64 === 'string';
if (isSignatureField && isInsertingImage) {
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
let imageWidth = image.width;
let imageHeight = image.height;
const initialDimensions = {
width: imageWidth,
height: imageHeight,
};
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
const imageX = fieldX + (fieldWidth - imageWidth) / 2;
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
imageY = pageHeight - imageY - imageHeight;
console.log({
initialDimensions,
scalingFactor,
imageWidth,
imageHeight,
imageX,
imageY,
});
page.drawImage(image, {
x: imageX,
y: imageY,
width: imageWidth,
height: imageHeight,
});
} else {
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const textHeight = font.heightAtSize(fontSize);
const initialDimensions = {
width: textWidth,
height: textHeight,
};
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(fontSize * scalingFactor, maxFontSize);
textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const textX = fieldX + (fieldWidth - textWidth) / 2;
let textY = fieldY + (fieldHeight - textHeight) / 2;
console.log({
initialDimensions,
scalingFactor,
textWidth,
textHeight,
textX,
textY,
pageWidth,
pageHeight,
});
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
page.drawText(field.customText, {
x: textX,
y: textY,
size: fontSize,
font,
});
}
return pdf;
};
export const insertFieldInPDFBytes = async (
pdf: ArrayBuffer | Uint8Array | string,
field: FieldWithSignature,
) => {
const pdfDoc = await PDFDocument.load(pdf);
await insertFieldInPDF(pdfDoc, field);
return await pdfDoc.save();
};

View File

@ -0,0 +1,13 @@
import { prisma } from '@documenso/prisma';
export interface GetRecipientByTokenOptions {
token: string;
}
export const getRecipientByToken = async ({ token }: GetRecipientByTokenOptions) => {
return await prisma.recipient.findFirstOrThrow({
where: {
token,
},
});
};

View File

@ -0,0 +1,9 @@
import { FieldType } from '@prisma/client';
const SignatureFieldTypes = [FieldType.SIGNATURE, FieldType.FREE_SIGNATURE] as const;
type SignatureFieldType = (typeof SignatureFieldTypes)[number];
export const isSignatureFieldType = (type: FieldType): type is SignatureFieldType => {
return type === FieldType.SIGNATURE || type === FieldType.FREE_SIGNATURE;
};

View File

@ -0,0 +1,5 @@
import { Field, Signature } from '@documenso/prisma/client';
export type FieldWithSignature = Field & {
Signature?: Signature | null;
};

View File

@ -9,6 +9,7 @@ module.exports = {
extend: {
fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans],
signature: ['var(--font-signature)'],
},
colors: {
border: 'hsl(var(--border))',

View File

@ -0,0 +1,54 @@
import { TRPCError } from '@trpc/server';
import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token';
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
import { procedure, router } from '../trpc';
import {
ZRemovedSignedFieldWithTokenMutationSchema,
ZSignFieldWithTokenMutationSchema,
} from './schema';
export const fieldRouter = router({
signFieldWithToken: procedure
.input(ZSignFieldWithTokenMutationSchema)
.mutation(async ({ input }) => {
try {
const { token, fieldId, value, isBase64 } = input;
return await signFieldWithToken({
token,
fieldId,
value,
isBase64,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to sign this field. Please try again later.',
});
}
}),
removeSignedFieldWithToken: procedure
.input(ZRemovedSignedFieldWithTokenMutationSchema)
.mutation(async ({ input }) => {
try {
const { token, fieldId } = input;
return await removeSignedFieldWithToken({
token,
fieldId,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to remove the signature for this field. Please try again later.',
});
}
}),
});

View File

@ -0,0 +1,19 @@
import { z } from 'zod';
export const ZSignFieldWithTokenMutationSchema = z.object({
token: z.string(),
fieldId: z.number(),
value: z.string(),
isBase64: z.boolean().optional(),
});
export type TSignFieldWithTokenMutationSchema = z.infer<typeof ZSignFieldWithTokenMutationSchema>;
export const ZRemovedSignedFieldWithTokenMutationSchema = z.object({
token: z.string(),
fieldId: z.number(),
});
export type TRemovedSignedFieldWithTokenMutationSchema = z.infer<
typeof ZRemovedSignedFieldWithTokenMutationSchema
>;

View File

@ -1,5 +1,6 @@
import { authRouter } from './auth-router/router';
import { documentRouter } from './document-router/router';
import { fieldRouter } from './field-router/router';
import { profileRouter } from './profile-router/router';
import { procedure, router } from './trpc';
@ -8,6 +9,7 @@ export const appRouter = router({
auth: authRouter,
profile: profileRouter,
document: documentRouter,
field: fieldRouter,
});
export type AppRouter = typeof appRouter;

View File

@ -0,0 +1,35 @@
'use client';
import React, { useEffect, useState } from 'react';
export type ElementVisibleProps = {
target: string;
children: React.ReactNode;
};
export const ElementVisible = ({ target, children }: ElementVisibleProps) => {
const [visible, setVisible] = useState(false);
useEffect(() => {
const observer = new MutationObserver((_mutations) => {
const $el = document.querySelector(target);
setVisible(!!$el);
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, [target]);
if (!visible) {
return null;
}
return <>{children}</>;
};