mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: document authoring
This commit is contained in:
@ -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
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
};
|
||||
30
packages/lib/server-only/document/get-document-by-token.ts
Normal file
30
packages/lib/server-only/document/get-document-by-token.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
74
packages/lib/server-only/document/seal-document.ts
Normal file
74
packages/lib/server-only/document/seal-document.ts
Normal 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'),
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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({
|
||||
|
||||
29
packages/lib/server-only/document/viewed-document.ts
Normal file
29
packages/lib/server-only/document/viewed-document.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
18
packages/lib/server-only/field/get-fields-for-token.ts
Normal file
18
packages/lib/server-only/field/get-fields-for-token.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
};
|
||||
89
packages/lib/server-only/field/sign-field-with-token.ts
Normal file
89
packages/lib/server-only/field/sign-field-with-token.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
144
packages/lib/server-only/pdf/insert-field-in-pdf.ts
Normal file
144
packages/lib/server-only/pdf/insert-field-in-pdf.ts
Normal 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();
|
||||
};
|
||||
13
packages/lib/server-only/recipient/get-recipient-by-token.ts
Normal file
13
packages/lib/server-only/recipient/get-recipient-by-token.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
9
packages/prisma/guards/is-signature-field.ts
Normal file
9
packages/prisma/guards/is-signature-field.ts
Normal 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;
|
||||
};
|
||||
5
packages/prisma/types/field-with-signature.ts
Normal file
5
packages/prisma/types/field-with-signature.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Field, Signature } from '@documenso/prisma/client';
|
||||
|
||||
export type FieldWithSignature = Field & {
|
||||
Signature?: Signature | null;
|
||||
};
|
||||
@ -9,6 +9,7 @@ module.exports = {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['var(--font-sans)', ...fontFamily.sans],
|
||||
signature: ['var(--font-signature)'],
|
||||
},
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
|
||||
54
packages/trpc/server/field-router/router.ts
Normal file
54
packages/trpc/server/field-router/router.ts
Normal 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.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
19
packages/trpc/server/field-router/schema.ts
Normal file
19
packages/trpc/server/field-router/schema.ts
Normal 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
|
||||
>;
|
||||
@ -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;
|
||||
|
||||
35
packages/ui/primitives/element-visible.tsx
Normal file
35
packages/ui/primitives/element-visible.tsx
Normal 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}</>;
|
||||
};
|
||||
Reference in New Issue
Block a user