Merge branch 'main' into feat/add-runtime-env

This commit is contained in:
Lucas Smith
2024-02-12 12:30:35 +11:00
committed by GitHub
41 changed files with 1554 additions and 301 deletions

View File

@ -86,6 +86,7 @@ export const SinglePlayerClient = () => {
setFields( setFields(
data.fields.map((field, i) => ({ data.fields.map((field, i) => ({
id: i, id: i,
secondaryId: i.toString(),
documentId: -1, documentId: -1,
templateId: null, templateId: null,
recipientId: -1, recipientId: -1,

View File

@ -151,7 +151,7 @@ export const EditDocumentForm = ({
}; };
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message, timezone, dateFormat } = data.meta; const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
try { try {
await sendDocument({ await sendDocument({
@ -159,8 +159,9 @@ export const EditDocumentForm = ({
meta: { meta: {
subject, subject,
message, message,
timezone,
dateFormat, dateFormat,
timezone,
redirectUrl,
}, },
}); });

View File

@ -114,7 +114,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
<DropdownMenuContent className="w-52" align="start" forceMount> <DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel> <DropdownMenuLabel>Action</DropdownMenuLabel>
{recipient?.role !== RecipientRole.CC && ( {recipient && recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild> <DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}> <Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && ( {recipient?.role === RecipientRole.VIEWER && (

View File

@ -26,9 +26,10 @@ export type SigningFormProps = {
document: Document; document: Document;
recipient: Recipient; recipient: Recipient;
fields: Field[]; fields: Field[];
redirectUrl?: string | null;
}; };
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => { export const SigningForm = ({ document, recipient, fields, redirectUrl }: SigningFormProps) => {
const router = useRouter(); const router = useRouter();
const analytics = useAnalytics(); const analytics = useAnalytics();
const { data: session } = useSession(); const { data: session } = useSession();
@ -74,7 +75,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
router.push(`/sign/${recipient.token}/complete`); redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
}; };
return ( return (

View File

@ -118,7 +118,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
<span className="text-muted-foreground">({recipient.email})</span> <span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle> </DialogTitle>
<div className="py-4"> <div>
<Label htmlFor="signature">Full Name</Label> <Label htmlFor="signature">Full Name</Label>
<Input <Input

View File

@ -1,3 +1,4 @@
import { headers } from 'next/headers';
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -8,12 +9,12 @@ import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@ -40,24 +41,26 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound(); return notFound();
} }
const requestHeaders = Object.fromEntries(headers().entries());
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, fields, recipient] = await Promise.all([ const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({ getDocumentAndSenderByToken({
token, token,
}).catch(() => null), }).catch(() => null),
getFieldsForToken({ token }), getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null), getRecipientByToken({ token }).catch(() => null),
viewedDocument({ token }).catch(() => null), viewedDocument({ token, requestMetadata }).catch(() => null),
]); ]);
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
if (!document || !document.documentData || !recipient) { if (!document || !document.documentData || !recipient) {
return notFound(); return notFound();
} }
const truncatedTitle = truncateTitle(document.title); const truncatedTitle = truncateTitle(document.title);
const { documentData } = document; const { documentData, documentMeta } = document;
const { user } = await getServerComponentSession(); const { user } = await getServerComponentSession();
@ -65,7 +68,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
document.status === DocumentStatus.COMPLETED || document.status === DocumentStatus.COMPLETED ||
recipient.signingStatus === SigningStatus.SIGNED recipient.signingStatus === SigningStatus.SIGNED
) { ) {
redirect(`/sign/${token}/complete`); documentMeta?.redirectUrl
? redirect(documentMeta.redirectUrl)
: redirect(`/sign/${token}/complete`);
} }
if (documentMeta?.password) { if (documentMeta?.password) {
@ -133,7 +138,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
</Card> </Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4"> <div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm document={document} recipient={recipient} fields={fields} /> <SigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
/>
</div> </div>
</div> </div>

13
package-lock.json generated
View File

@ -14519,6 +14519,7 @@
"version": "6.9.7", "version": "6.9.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.7.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.7.tgz",
"integrity": "sha512-rUtR77ksqex/eZRLmQ21LKVH5nAAsVicAtAYudK7JgwenEDZ0UIQ1adUGqErz7sMkWYxWTTU1aeP2Jga6WQyJw==", "integrity": "sha512-rUtR77ksqex/eZRLmQ21LKVH5nAAsVicAtAYudK7JgwenEDZ0UIQ1adUGqErz7sMkWYxWTTU1aeP2Jga6WQyJw==",
"peer": true,
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
} }
@ -19511,14 +19512,14 @@
"@react-email/section": "0.0.10", "@react-email/section": "0.0.10",
"@react-email/tailwind": "0.0.9", "@react-email/tailwind": "0.0.9",
"@react-email/text": "0.0.6", "@react-email/text": "0.0.6",
"nodemailer": "^6.9.3", "nodemailer": "^6.9.9",
"react-email": "^1.9.5", "react-email": "^1.9.5",
"resend": "^2.0.0" "resend": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {
"@documenso/tailwind-config": "*", "@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*", "@documenso/tsconfig": "*",
"@types/nodemailer": "^6.4.8", "@types/nodemailer": "^6.4.14",
"tsup": "^7.1.0" "tsup": "^7.1.0"
} }
}, },
@ -19536,6 +19537,14 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"packages/email/node_modules/nodemailer": {
"version": "6.9.9",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.9.tgz",
"integrity": "sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA==",
"engines": {
"node": ">=6.0.0"
}
},
"packages/eslint-config": { "packages/eslint-config": {
"name": "@documenso/eslint-config", "name": "@documenso/eslint-config",
"version": "0.0.0", "version": "0.0.0",

View File

@ -35,14 +35,14 @@
"@react-email/section": "0.0.10", "@react-email/section": "0.0.10",
"@react-email/tailwind": "0.0.9", "@react-email/tailwind": "0.0.9",
"@react-email/text": "0.0.6", "@react-email/text": "0.0.6",
"nodemailer": "^6.9.3", "nodemailer": "^6.9.9",
"react-email": "^1.9.5", "react-email": "^1.9.5",
"resend": "^2.0.0" "resend": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {
"@documenso/tailwind-config": "*", "@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*", "@documenso/tsconfig": "*",
"@types/nodemailer": "^6.4.8", "@types/nodemailer": "^6.4.14",
"tsup": "^7.1.0" "tsup": "^7.1.0"
} }
} }

View File

@ -24,3 +24,9 @@ export const RECIPIENT_ROLES_DESCRIPTION: {
roleName: 'Viewer', roleName: 'Viewer',
}, },
}; };
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',
[RecipientRole.VIEWER]: 'VIEW_REQUEST',
[RecipientRole.APPROVER]: 'APPROVE_REQUEST',
} as const;

View File

@ -0,0 +1,2 @@
export const URL_REGEX =
/^(https?):\/\/(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z0-9()]{2,}(?:\/[a-zA-Z0-9-._?&=/]*)?$/i;

View File

@ -1,7 +1,7 @@
'use server'; 'use server';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentDataType } from '@documenso/prisma/client'; import type { DocumentDataType } from '@documenso/prisma/client';
export type CreateDocumentDataOptions = { export type CreateDocumentDataOptions = {
type: DocumentDataType; type: DocumentDataType;

View File

@ -1,5 +1,11 @@
'use server'; 'use server';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
export type CreateDocumentMetaOptions = { export type CreateDocumentMetaOptions = {
@ -9,7 +15,9 @@ export type CreateDocumentMetaOptions = {
timezone?: string; timezone?: string;
password?: string; password?: string;
dateFormat?: string; dateFormat?: string;
redirectUrl?: string;
userId: number; userId: number;
requestMetadata: RequestMetadata;
}; };
export const upsertDocumentMeta = async ({ export const upsertDocumentMeta = async ({
@ -18,47 +26,81 @@ export const upsertDocumentMeta = async ({
timezone, timezone,
dateFormat, dateFormat,
documentId, documentId,
userId,
password, password,
userId,
redirectUrl,
requestMetadata,
}: CreateDocumentMetaOptions) => { }: CreateDocumentMetaOptions) => {
await prisma.document.findFirstOrThrow({ const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});
const { documentMeta: originalDocumentMeta } = await prisma.document.findFirstOrThrow({
where: { where: {
id: documentId, id: documentId,
OR: [ OR: [
{ {
userId, userId: user.id,
}, },
{ {
team: { team: {
members: { members: {
some: { some: {
userId, userId: user.id,
}, },
}, },
}, },
}, },
], ],
}, },
include: {
documentMeta: true,
},
}); });
return await prisma.documentMeta.upsert({ return await prisma.$transaction(async (tx) => {
where: { const upsertedDocumentMeta = await tx.documentMeta.upsert({
documentId, where: {
}, documentId,
create: { },
subject, create: {
message, subject,
dateFormat, message,
timezone, password,
password, dateFormat,
documentId, timezone,
}, documentId,
update: { redirectUrl,
subject, },
message, update: {
dateFormat, subject,
password, message,
timezone, password,
}, dateFormat,
timezone,
redirectUrl,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
documentId,
user,
requestMetadata,
data: {
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
},
}),
});
return upsertedDocumentMeta;
}); });
}; };

View File

@ -1,5 +1,8 @@
'use server'; 'use server';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
@ -9,11 +12,13 @@ import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = { export type CompleteDocumentWithTokenOptions = {
token: string; token: string;
documentId: number; documentId: number;
requestMetadata?: RequestMetadata;
}; };
export const completeDocumentWithToken = async ({ export const completeDocumentWithToken = async ({
token, token,
documentId, documentId,
requestMetadata,
}: CompleteDocumentWithTokenOptions) => { }: CompleteDocumentWithTokenOptions) => {
'use server'; 'use server';
@ -70,6 +75,24 @@ export const completeDocumentWithToken = async ({
}, },
}); });
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
documentId: document.id,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
},
}),
});
const pendingRecipients = await prisma.recipient.count({ const pendingRecipients = await prisma.recipient.count({
where: { where: {
documentId: document.id, documentId: document.id,
@ -99,6 +122,6 @@ export const completeDocumentWithToken = async ({
}); });
if (documents.count > 0) { if (documents.count > 0) {
await sealDocument({ documentId: document.id }); await sealDocument({ documentId: document.id, requestMetadata });
} }
}; };

View File

@ -1,5 +1,9 @@
'use server'; 'use server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
export type CreateDocumentOptions = { export type CreateDocumentOptions = {
@ -7,6 +11,7 @@ export type CreateDocumentOptions = {
userId: number; userId: number;
teamId?: number; teamId?: number;
documentDataId: string; documentDataId: string;
requestMetadata?: RequestMetadata;
}; };
export const createDocument = async ({ export const createDocument = async ({
@ -14,22 +19,30 @@ export const createDocument = async ({
title, title,
documentDataId, documentDataId,
teamId, teamId,
requestMetadata,
}: CreateDocumentOptions) => { }: CreateDocumentOptions) => {
return await prisma.$transaction(async (tx) => { const user = await prisma.user.findFirstOrThrow({
if (teamId !== undefined) { where: {
await tx.team.findFirstOrThrow({ id: userId,
where: { },
id: teamId, include: {
members: { teamMembers: {
some: { select: {
userId, teamId: true,
},
},
}, },
}); },
} },
});
return await tx.document.create({ if (
teamId !== undefined &&
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
}
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: { data: {
title, title,
documentDataId, documentDataId,
@ -37,5 +50,19 @@ export const createDocument = async ({
teamId, teamId,
}, },
}); });
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
user,
requestMetadata,
data: {
title,
},
}),
});
return document;
}); });
}; };

View File

@ -39,6 +39,7 @@ export const duplicateDocumentById = async ({
dateFormat: true, dateFormat: true,
password: true, password: true,
timezone: true, timezone: true,
redirectUrl: true,
}, },
}, },
}, },

View File

@ -27,6 +27,7 @@ export const getDocumentAndSenderByToken = async ({
include: { include: {
User: true, User: true,
documentData: true, documentData: true,
documentMeta: true,
}, },
}); });

View File

@ -4,12 +4,18 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render'; import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite'; import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '@documenso/lib/constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client'; import type { Prisma } from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
import { getDocumentWhereInput } from './get-document-by-id'; import { getDocumentWhereInput } from './get-document-by-id';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@ -19,6 +25,7 @@ export type ResendDocumentOptions = {
userId: number; userId: number;
recipients: number[]; recipients: number[];
teamId?: number; teamId?: number;
requestMetadata: RequestMetadata;
}; };
export const resendDocument = async ({ export const resendDocument = async ({
@ -26,6 +33,7 @@ export const resendDocument = async ({
userId, userId,
recipients, recipients,
teamId, teamId,
requestMetadata,
}: ResendDocumentOptions) => { }: ResendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({ const user = await prisma.user.findFirstOrThrow({
where: { where: {
@ -78,6 +86,8 @@ export const resendDocument = async ({
return; return;
} }
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient; const { email, name } = recipient;
const customEmailTemplate = { const customEmailTemplate = {
@ -101,20 +111,39 @@ export const resendDocument = async ({
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await mailer.sendMail({ await prisma.$transaction(async (tx) => {
to: { await mailer.sendMail({
address: email, to: {
name, address: email,
}, name,
from: { },
name: FROM_NAME, from: {
address: FROM_ADDRESS, name: FROM_NAME,
}, address: FROM_ADDRESS,
subject: customEmail?.subject },
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) subject: customEmail?.subject
: `Please ${actionVerb.toLowerCase()} this document`, ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
html: render(template), : `Please ${actionVerb.toLowerCase()} this document`,
text: render(template, { plainText: true }), html: render(template),
text: render(template, { plainText: true }),
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
}),
});
}); });
}), }),
); );

View File

@ -5,10 +5,13 @@ import path from 'node:path';
import { PDFDocument } from 'pdf-lib'; import { PDFDocument } from 'pdf-lib';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing'; import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file'; import { putFile } from '../../universal/upload/put-file';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
@ -17,9 +20,14 @@ import { sendCompletedEmail } from './send-completed-email';
export type SealDocumentOptions = { export type SealDocumentOptions = {
documentId: number; documentId: number;
sendEmail?: boolean; sendEmail?: boolean;
requestMetadata?: RequestMetadata;
}; };
export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumentOptions) => { export const sealDocument = async ({
documentId,
sendEmail = true,
requestMetadata,
}: SealDocumentOptions) => {
'use server'; 'use server';
const document = await prisma.document.findFirstOrThrow({ const document = await prisma.document.findFirstOrThrow({
@ -100,16 +108,30 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
}); });
} }
await prisma.documentData.update({ await prisma.$transaction(async (tx) => {
where: { await tx.documentData.update({
id: documentData.id, where: {
}, id: documentData.id,
data: { },
data: newData, data: {
}, data: newData,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
documentId: document.id,
requestMetadata,
user: null,
data: {
transactionId: nanoid(),
},
}),
});
}); });
if (sendEmail) { if (sendEmail) {
await sendCompletedEmail({ documentId }); await sendCompletedEmail({ documentId, requestMetadata });
} }
}; };

View File

@ -6,13 +6,17 @@ import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/docum
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export interface SendDocumentOptions { export interface SendDocumentOptions {
documentId: number; documentId: number;
requestMetadata?: RequestMetadata;
} }
export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) => { export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDocumentOptions) => {
const document = await prisma.document.findUnique({ const document = await prisma.document.findUnique({
where: { where: {
id: documentId, id: documentId,
@ -45,24 +49,43 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) =>
downloadLink: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}/complete`, downloadLink: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}/complete`,
}); });
await mailer.sendMail({ await prisma.$transaction(async (tx) => {
to: { await mailer.sendMail({
address: email, to: {
name, address: email,
}, name,
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
attachments: [
{
filename: document.title,
content: Buffer.from(buffer),
}, },
], from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
attachments: [
{
filename: document.title,
content: Buffer.from(buffer),
},
],
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user: null,
requestMetadata,
data: {
emailType: 'DOCUMENT_COMPLETED',
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
isResending: false,
},
}),
});
}); });
}), }),
); );

View File

@ -4,6 +4,13 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render'; import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite'; import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '@documenso/lib/constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
@ -14,13 +21,23 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
export type SendDocumentOptions = { export type SendDocumentOptions = {
documentId: number; documentId: number;
userId: number; userId: number;
requestMetadata?: RequestMetadata;
}; };
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => { export const sendDocument = async ({
documentId,
userId,
requestMetadata,
}: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({ const user = await prisma.user.findFirstOrThrow({
where: { where: {
id: userId, id: userId,
}, },
select: {
id: true,
name: true,
email: true,
},
}); });
const document = await prisma.document.findUnique({ const document = await prisma.document.findUnique({
@ -67,6 +84,8 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
return; return;
} }
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient; const { email, name } = recipient;
const customEmailTemplate = { const customEmailTemplate = {
@ -90,29 +109,48 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await mailer.sendMail({ await prisma.$transaction(async (tx) => {
to: { await mailer.sendMail({
address: email, to: {
name, address: email,
}, name,
from: { },
name: FROM_NAME, from: {
address: FROM_ADDRESS, name: FROM_NAME,
}, address: FROM_ADDRESS,
subject: customEmail?.subject },
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) subject: customEmail?.subject
: `Please ${actionVerb.toLowerCase()} this document`, ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
html: render(template), : `Please ${actionVerb.toLowerCase()} this document`,
text: render(template, { plainText: true }), html: render(template),
}); text: render(template, { plainText: true }),
});
await prisma.recipient.update({ await tx.recipient.update({
where: { where: {
id: recipient.id, id: recipient.id,
}, },
data: { data: {
sendStatus: SendStatus.SENT, sendStatus: SendStatus.SENT,
}, },
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: false,
},
}),
});
}); });
}), }),
); );

View File

@ -1,34 +1,76 @@
'use server'; 'use server';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
export type UpdateTitleOptions = { export type UpdateTitleOptions = {
userId: number; userId: number;
documentId: number; documentId: number;
title: string; title: string;
requestMetadata?: RequestMetadata;
}; };
export const updateTitle = async ({ userId, documentId, title }: UpdateTitleOptions) => { export const updateTitle = async ({
return await prisma.document.update({ userId,
documentId,
title,
requestMetadata,
}: UpdateTitleOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: { where: {
id: documentId, id: userId,
OR: [ },
{ });
userId,
}, return await prisma.$transaction(async (tx) => {
{ const document = await tx.document.findFirstOrThrow({
team: { where: {
members: { id: documentId,
some: { OR: [
userId, {
userId,
},
{
team: {
members: {
some: {
userId,
},
}, },
}, },
}, },
],
},
});
if (document.title === title) {
return document;
}
const updatedDocument = await tx.document.update({
where: {
id: documentId,
},
data: {
title,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.title,
to: updatedDocument.title,
}, },
], }),
}, });
data: {
title, return updatedDocument;
},
}); });
}; };

View File

@ -1,11 +1,15 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { ReadStatus } from '@documenso/prisma/client'; import { ReadStatus } from '@documenso/prisma/client';
export type ViewedDocumentOptions = { export type ViewedDocumentOptions = {
token: string; token: string;
requestMetadata?: RequestMetadata;
}; };
export const viewedDocument = async ({ token }: ViewedDocumentOptions) => { export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentOptions) => {
const recipient = await prisma.recipient.findFirst({ const recipient = await prisma.recipient.findFirst({
where: { where: {
token, token,
@ -13,16 +17,38 @@ export const viewedDocument = async ({ token }: ViewedDocumentOptions) => {
}, },
}); });
if (!recipient) { if (!recipient || !recipient.documentId) {
return; return;
} }
await prisma.recipient.update({ const { documentId } = recipient;
where: {
id: recipient.id, await prisma.$transaction(async (tx) => {
}, await tx.recipient.update({
data: { where: {
readStatus: ReadStatus.OPENED, id: recipient.id,
}, },
data: {
readStatus: ReadStatus.OPENED,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
documentId,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
},
}),
});
}); });
}; };

View File

@ -1,16 +1,21 @@
'use server'; 'use server';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
export type RemovedSignedFieldWithTokenOptions = { export type RemovedSignedFieldWithTokenOptions = {
token: string; token: string;
fieldId: number; fieldId: number;
requestMetadata?: RequestMetadata;
}; };
export const removeSignedFieldWithToken = async ({ export const removeSignedFieldWithToken = async ({
token, token,
fieldId, fieldId,
requestMetadata,
}: RemovedSignedFieldWithTokenOptions) => { }: RemovedSignedFieldWithTokenOptions) => {
const field = await prisma.field.findFirstOrThrow({ const field = await prisma.field.findFirstOrThrow({
where: { where: {
@ -44,8 +49,8 @@ export const removeSignedFieldWithToken = async ({
throw new Error(`Field ${fieldId} has no recipientId`); throw new Error(`Field ${fieldId} has no recipientId`);
} }
await Promise.all([ await prisma.$transaction(async (tx) => {
prisma.field.update({ await tx.field.update({
where: { where: {
id: field.id, id: field.id,
}, },
@ -53,11 +58,28 @@ export const removeSignedFieldWithToken = async ({
customText: '', customText: '',
inserted: false, inserted: false,
}, },
}), });
prisma.signature.deleteMany({
await tx.signature.deleteMany({
where: { where: {
fieldId: field.id, fieldId: field.id,
}, },
}), });
]);
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
documentId: document.id,
user: {
name: recipient?.name,
email: recipient?.email,
},
requestMetadata,
data: {
field: field.type,
fieldId: field.secondaryId,
},
}),
});
});
}; };

View File

@ -1,3 +1,9 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffFieldChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client'; import type { FieldType } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client';
@ -15,12 +21,14 @@ export interface SetFieldsForDocumentOptions {
pageWidth: number; pageWidth: number;
pageHeight: number; pageHeight: number;
}[]; }[];
requestMetadata?: RequestMetadata;
} }
export const setFieldsForDocument = async ({ export const setFieldsForDocument = async ({
userId, userId,
documentId, documentId,
fields, fields,
requestMetadata,
}: SetFieldsForDocumentOptions) => { }: SetFieldsForDocumentOptions) => {
const document = await prisma.document.findFirst({ const document = await prisma.document.findFirst({
where: { where: {
@ -42,10 +50,25 @@ export const setFieldsForDocument = async ({
}, },
}); });
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
if (!document) { if (!document) {
throw new Error('Document not found'); throw new Error('Document not found');
} }
if (document.completedAt) {
throw new Error('Document already complete');
}
const existingFields = await prisma.field.findMany({ const existingFields = await prisma.field.findMany({
where: { where: {
documentId, documentId,
@ -75,56 +98,123 @@ export const setFieldsForDocument = async ({
); );
}); });
const persistedFields = await prisma.$transaction( const persistedFields = await prisma.$transaction(async (tx) => {
// Disabling as wrapping promises here causes type issues await Promise.all(
// eslint-disable-next-line @typescript-eslint/promise-function-async linkedFields.map(async (field) => {
linkedFields.map((field) => const fieldSignerEmail = field.signerEmail.toLowerCase();
prisma.field.upsert({
where: { const upsertedField = await tx.field.upsert({
id: field._persisted?.id ?? -1, where: {
documentId, id: field._persisted?.id ?? -1,
}, documentId,
update: {
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
},
create: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
customText: '',
inserted: false,
Document: {
connect: {
id: documentId,
},
}, },
Recipient: { update: {
connect: { page: field.pageNumber,
documentId_email: { positionX: field.pageX,
documentId, positionY: field.pageY,
email: field.signerEmail.toLowerCase(), width: field.pageWidth,
height: field.pageHeight,
},
create: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
customText: '',
inserted: false,
Document: {
connect: {
id: documentId,
},
},
Recipient: {
connect: {
documentId_email: {
documentId,
email: fieldSignerEmail,
},
}, },
}, },
}, },
}, });
if (upsertedField.recipientId === null) {
throw new Error('Not possible');
}
const baseAuditLog = {
fieldId: upsertedField.secondaryId,
fieldRecipientEmail: fieldSignerEmail,
fieldRecipientId: upsertedField.recipientId,
fieldType: upsertedField.type,
};
const changes = field._persisted ? diffFieldChanges(field._persisted, upsertedField) : [];
// Handle field updated audit log.
if (field._persisted && changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
documentId: documentId,
user,
requestMetadata,
data: {
changes,
...baseAuditLog,
},
}),
});
}
// Handle field created audit log.
if (!field._persisted) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
documentId: documentId,
user,
requestMetadata,
data: {
...baseAuditLog,
},
}),
});
}
return upsertedField;
}), }),
), );
); });
if (removedFields.length > 0) { if (removedFields.length > 0) {
await prisma.field.deleteMany({ await prisma.$transaction(async (tx) => {
where: { await tx.field.deleteMany({
id: { where: {
in: removedFields.map((field) => field.id), id: {
in: removedFields.map((field) => field.id),
},
}, },
}, });
await tx.documentAuditLog.createMany({
data: removedFields.map((field) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED,
documentId: documentId,
user,
requestMetadata,
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',
fieldRecipientId: field.recipientId ?? -1,
fieldType: field.type,
},
}),
),
});
}); });
} }

View File

@ -1,18 +1,23 @@
'use server'; 'use server';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type SignFieldWithTokenOptions = { export type SignFieldWithTokenOptions = {
token: string; token: string;
fieldId: number; fieldId: number;
value: string; value: string;
isBase64?: boolean; isBase64?: boolean;
requestMetadata?: RequestMetadata;
}; };
export const signFieldWithToken = async ({ export const signFieldWithToken = async ({
@ -20,6 +25,7 @@ export const signFieldWithToken = async ({
fieldId, fieldId,
value, value,
isBase64, isBase64,
requestMetadata,
}: SignFieldWithTokenOptions) => { }: SignFieldWithTokenOptions) => {
const field = await prisma.field.findFirstOrThrow({ const field = await prisma.field.findFirstOrThrow({
where: { where: {
@ -40,6 +46,10 @@ export const signFieldWithToken = async ({
throw new Error(`Document not found for field ${field.id}`); throw new Error(`Document not found for field ${field.id}`);
} }
if (!recipient) {
throw new Error(`Recipient not found for field ${field.id}`);
}
if (document.status === DocumentStatus.COMPLETED) { if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`); throw new Error(`Document ${document.id} has already been completed`);
} }
@ -123,6 +133,38 @@ export const signFieldWithToken = async ({
}); });
} }
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
documentId: document.id,
user: {
email: recipient.email,
name: recipient.name,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
fieldId: updatedField.secondaryId,
field: match(updatedField.type)
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, (type) => ({
type,
data: signatureImageAsBase64 || typedSignature || '',
}))
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({
type,
data: updatedField.customText,
}))
.exhaustive(),
fieldSecurity: {
type: 'NONE',
},
},
}),
});
return updatedField; return updatedField;
}); });
}; };

View File

@ -1,9 +1,14 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid } from '@documenso/lib/universal/id';
import {
createDocumentAuditLogData,
diffRecipientChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@documenso/prisma/client'; import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { nanoid } from '../../universal/id';
export interface SetRecipientsForDocumentOptions { export interface SetRecipientsForDocumentOptions {
userId: number; userId: number;
documentId: number; documentId: number;
@ -13,12 +18,14 @@ export interface SetRecipientsForDocumentOptions {
name: string; name: string;
role: RecipientRole; role: RecipientRole;
}[]; }[];
requestMetadata?: RequestMetadata;
} }
export const setRecipientsForDocument = async ({ export const setRecipientsForDocument = async ({
userId, userId,
documentId, documentId,
recipients, recipients,
requestMetadata,
}: SetRecipientsForDocumentOptions) => { }: SetRecipientsForDocumentOptions) => {
const document = await prisma.document.findFirst({ const document = await prisma.document.findFirst({
where: { where: {
@ -40,10 +47,25 @@ export const setRecipientsForDocument = async ({
}, },
}); });
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
if (!document) { if (!document) {
throw new Error('Document not found'); throw new Error('Document not found');
} }
if (document.completedAt) {
throw new Error('Document already complete');
}
const normalizedRecipients = recipients.map((recipient) => ({ const normalizedRecipients = recipients.map((recipient) => ({
...recipient, ...recipient,
email: recipient.email.toLowerCase(), email: recipient.email.toLowerCase(),
@ -77,49 +99,127 @@ export const setRecipientsForDocument = async ({
}) })
.filter((recipient) => { .filter((recipient) => {
return ( return (
recipient._persisted?.sendStatus !== SendStatus.SENT && recipient._persisted?.role === RecipientRole.CC ||
recipient._persisted?.signingStatus !== SigningStatus.SIGNED (recipient._persisted?.sendStatus !== SendStatus.SENT &&
recipient._persisted?.signingStatus !== SigningStatus.SIGNED)
); );
}); });
const persistedRecipients = await prisma.$transaction( const persistedRecipients = await prisma.$transaction(async (tx) => {
// Disabling as wrapping promises here causes type issues await Promise.all(
// eslint-disable-next-line @typescript-eslint/promise-function-async linkedRecipients.map(async (recipient) => {
linkedRecipients.map((recipient) => const upsertedRecipient = await tx.recipient.upsert({
prisma.recipient.upsert({ where: {
where: { id: recipient._persisted?.id ?? -1,
id: recipient._persisted?.id ?? -1, documentId,
documentId, },
}, update: {
update: { name: recipient.name,
name: recipient.name, email: recipient.email,
email: recipient.email, role: recipient.role,
role: recipient.role, documentId,
documentId, sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus: signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
}, },
create: { create: {
name: recipient.name, name: recipient.name,
email: recipient.email, email: recipient.email,
role: recipient.role, role: recipient.role,
token: nanoid(), token: nanoid(),
documentId, documentId,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus: signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
}, },
});
const recipientId = upsertedRecipient.id;
// Clear all fields if the recipient role is changed to a type that cannot have fields.
if (
recipient._persisted &&
recipient._persisted.role !== recipient.role &&
(recipient.role === RecipientRole.CC || recipient.role === RecipientRole.VIEWER)
) {
await tx.field.deleteMany({
where: {
recipientId,
},
});
}
const baseAuditLog = {
recipientEmail: upsertedRecipient.email,
recipientName: upsertedRecipient.name,
recipientId,
recipientRole: upsertedRecipient.role,
};
const changes = recipient._persisted
? diffRecipientChanges(recipient._persisted, upsertedRecipient)
: [];
// Handle recipient updated audit log.
if (recipient._persisted && changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: documentId,
user,
requestMetadata,
data: {
changes,
...baseAuditLog,
},
}),
});
}
// Handle recipient created audit log.
if (!recipient._persisted) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
documentId: documentId,
user,
requestMetadata,
data: baseAuditLog,
}),
});
}
return upsertedRecipient;
}), }),
), );
); });
if (removedRecipients.length > 0) { if (removedRecipients.length > 0) {
await prisma.recipient.deleteMany({ await prisma.$transaction(async (tx) => {
where: { await tx.recipient.deleteMany({
id: { where: {
in: removedRecipients.map((recipient) => recipient.id), id: {
in: removedRecipients.map((recipient) => recipient.id),
},
}, },
}, });
await tx.documentAuditLog.createMany({
data: removedRecipients.map((recipient) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
documentId: documentId,
user,
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
},
}),
),
});
}); });
} }

View File

@ -0,0 +1,350 @@
/////////////////////////////////////////////////////////////////////////////////////////////
//
// Be aware that any changes to this file may require migrations since we are storing JSON
// data in Prisma.
//
/////////////////////////////////////////////////////////////////////////////////////////////
import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
export const ZDocumentAuditLogTypeSchema = z.enum([
// Document actions.
'EMAIL_SENT',
// Document modification events.
'FIELD_CREATED',
'FIELD_DELETED',
'FIELD_UPDATED',
'RECIPIENT_CREATED',
'RECIPIENT_DELETED',
'RECIPIENT_UPDATED',
// Document events.
'DOCUMENT_COMPLETED',
'DOCUMENT_CREATED',
'DOCUMENT_DELETED',
'DOCUMENT_FIELD_INSERTED',
'DOCUMENT_FIELD_UNINSERTED',
'DOCUMENT_META_UPDATED',
'DOCUMENT_OPENED',
'DOCUMENT_TITLE_UPDATED',
'DOCUMENT_RECIPIENT_COMPLETED',
]);
export const ZDocumentMetaDiffTypeSchema = z.enum([
'DATE_FORMAT',
'MESSAGE',
'PASSWORD',
'REDIRECT_URL',
'SUBJECT',
'TIMEZONE',
]);
export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']);
export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']);
export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum;
export const DOCUMENT_META_DIFF_TYPE = ZDocumentMetaDiffTypeSchema.Enum;
export const FIELD_DIFF_TYPE = ZFieldDiffTypeSchema.Enum;
export const RECIPIENT_DIFF_TYPE = ZRecipientDiffTypeSchema.Enum;
export const ZFieldDiffDimensionSchema = z.object({
type: z.literal(FIELD_DIFF_TYPE.DIMENSION),
from: z.object({
width: z.number(),
height: z.number(),
}),
to: z.object({
width: z.number(),
height: z.number(),
}),
});
export const ZFieldDiffPositionSchema = z.object({
type: z.literal(FIELD_DIFF_TYPE.POSITION),
from: z.object({
page: z.number(),
positionX: z.number(),
positionY: z.number(),
}),
to: z.object({
page: z.number(),
positionX: z.number(),
positionY: z.number(),
}),
});
export const ZDocumentAuditLogDocumentMetaSchema = z.union([
z.object({
type: z.union([
z.literal(DOCUMENT_META_DIFF_TYPE.DATE_FORMAT),
z.literal(DOCUMENT_META_DIFF_TYPE.MESSAGE),
z.literal(DOCUMENT_META_DIFF_TYPE.REDIRECT_URL),
z.literal(DOCUMENT_META_DIFF_TYPE.SUBJECT),
z.literal(DOCUMENT_META_DIFF_TYPE.TIMEZONE),
]),
from: z.string().nullable(),
to: z.string().nullable(),
}),
z.object({
type: z.literal(DOCUMENT_META_DIFF_TYPE.PASSWORD),
}),
]);
export const ZDocumentAuditLogFieldDiffSchema = z.union([
ZFieldDiffDimensionSchema,
ZFieldDiffPositionSchema,
]);
export const ZRecipientDiffNameSchema = z.object({
type: z.literal(RECIPIENT_DIFF_TYPE.NAME),
from: z.string(),
to: z.string(),
});
export const ZRecipientDiffRoleSchema = z.object({
type: z.literal(RECIPIENT_DIFF_TYPE.ROLE),
from: z.string(),
to: z.string(),
});
export const ZRecipientDiffEmailSchema = z.object({
type: z.literal(RECIPIENT_DIFF_TYPE.EMAIL),
from: z.string(),
to: z.string(),
});
export const ZDocumentAuditLogRecipientDiffSchema = z.union([
ZRecipientDiffNameSchema,
ZRecipientDiffRoleSchema,
ZRecipientDiffEmailSchema,
]);
const ZBaseFieldEventDataSchema = z.object({
fieldId: z.string(), // Note: This is the secondary field ID, which will get migrated in the future.
fieldRecipientEmail: z.string(),
fieldRecipientId: z.number(),
fieldType: z.string(), // We specifically don't want to use enums to allow for more flexibility.
});
const ZBaseRecipientDataSchema = z.object({
recipientEmail: z.string(),
recipientName: z.string(),
recipientId: z.number(),
recipientRole: z.string(),
});
/**
* Event: Email sent.
*/
export const ZDocumentAuditLogEventEmailSentSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT),
data: ZBaseRecipientDataSchema.extend({
emailType: z.enum([
'SIGNING_REQUEST',
'VIEW_REQUEST',
'APPROVE_REQUEST',
'CC',
'DOCUMENT_COMPLETED',
]),
isResending: z.boolean(),
}),
});
/**
* Event: Document completed.
*/
export const ZDocumentAuditLogEventDocumentCompletedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED),
data: z.object({
transactionId: z.string(),
}),
});
/**
* Event: Document created.
*/
export const ZDocumentAuditLogEventDocumentCreatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED),
data: z.object({
title: z.string(),
}),
});
/**
* Event: Document field inserted.
*/
export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED),
data: ZBaseRecipientDataSchema.extend({
fieldId: z.string(),
// Organised into union to allow us to extend each field if required.
field: z.union([
z.object({
type: z.literal(FieldType.EMAIL),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.DATE),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.NAME),
data: z.string(),
}),
z.object({
type: z.literal(FieldType.TEXT),
data: z.string(),
}),
z.object({
type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
data: z.string(),
}),
]),
// Todo: Replace with union once we have more field security types.
fieldSecurity: z.object({
type: z.literal('NONE'),
}),
}),
});
/**
* Event: Document field uninserted.
*/
export const ZDocumentAuditLogEventDocumentFieldUninsertedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED),
data: z.object({
field: z.nativeEnum(FieldType),
fieldId: z.string(),
}),
});
/**
* Event: Document meta updated.
*/
export const ZDocumentAuditLogEventDocumentMetaUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED),
data: z.object({
changes: z.array(ZDocumentAuditLogDocumentMetaSchema),
}),
});
/**
* Event: Document opened.
*/
export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED),
data: ZBaseRecipientDataSchema,
});
/**
* Event: Document recipient completed the document (the recipient has fully actioned and completed their required steps for the document).
*/
export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED),
data: ZBaseRecipientDataSchema,
});
/**
* Event: Document title updated.
*/
export const ZDocumentAuditLogEventDocumentTitleUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED),
data: z.object({
from: z.string(),
to: z.string(),
}),
});
/**
* Event: Field created.
*/
export const ZDocumentAuditLogEventFieldCreatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED),
data: ZBaseFieldEventDataSchema,
});
/**
* Event: Field deleted.
*/
export const ZDocumentAuditLogEventFieldRemovedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED),
data: ZBaseFieldEventDataSchema,
});
/**
* Event: Field updated.
*/
export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED),
data: ZBaseFieldEventDataSchema.extend({
changes: z.array(ZDocumentAuditLogFieldDiffSchema),
}),
});
/**
* Event: Recipient added.
*/
export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED),
data: ZBaseRecipientDataSchema,
});
/**
* Event: Recipient updated.
*/
export const ZDocumentAuditLogEventRecipientUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED),
data: ZBaseRecipientDataSchema.extend({
changes: z.array(ZDocumentAuditLogRecipientDiffSchema),
}),
});
/**
* Event: Recipient deleted.
*/
export const ZDocumentAuditLogEventRecipientRemovedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED),
data: ZBaseRecipientDataSchema,
});
export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
documentId: z.number(),
});
export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
z.union([
ZDocumentAuditLogEventEmailSentSchema,
ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
ZDocumentAuditLogEventDocumentMetaUpdatedSchema,
ZDocumentAuditLogEventDocumentOpenedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
ZDocumentAuditLogEventFieldCreatedSchema,
ZDocumentAuditLogEventFieldRemovedSchema,
ZDocumentAuditLogEventFieldUpdatedSchema,
ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
]),
);
export type TDocumentAuditLog = z.infer<typeof ZDocumentAuditLogSchema>;
export type TDocumentAuditLogType = z.infer<typeof ZDocumentAuditLogTypeSchema>;
export type TDocumentAuditLogFieldDiffSchema = z.infer<typeof ZDocumentAuditLogFieldDiffSchema>;
export type TDocumentAuditLogDocumentMetaDiffSchema = z.infer<
typeof ZDocumentAuditLogDocumentMetaSchema
>;
export type TDocumentAuditLogRecipientDiffSchema = z.infer<
typeof ZDocumentAuditLogRecipientDiffSchema
>;

View File

@ -25,10 +25,16 @@ export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetad
export const extractNextAuthRequestMetadata = ( export const extractNextAuthRequestMetadata = (
req: Pick<RequestInternal, 'body' | 'query' | 'headers' | 'method'>, req: Pick<RequestInternal, 'body' | 'query' | 'headers' | 'method'>,
): RequestMetadata => { ): RequestMetadata => {
const parsedIp = ZIpSchema.safeParse(req.headers?.['x-forwarded-for']); return extractNextHeaderRequestMetadata(req.headers ?? {});
};
export const extractNextHeaderRequestMetadata = (
headers: Record<string, string>,
): RequestMetadata => {
const parsedIp = ZIpSchema.safeParse(headers?.['x-forwarded-for']);
const ipAddress = parsedIp.success ? parsedIp.data : undefined; const ipAddress = parsedIp.success ? parsedIp.data : undefined;
const userAgent = req.headers?.['user-agent']; const userAgent = headers?.['user-agent'];
return { return {
ipAddress, ipAddress,

View File

@ -0,0 +1,205 @@
import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client';
import type {
TDocumentAuditLog,
TDocumentAuditLogDocumentMetaDiffSchema,
TDocumentAuditLogFieldDiffSchema,
TDocumentAuditLogRecipientDiffSchema,
} from '../types/document-audit-logs';
import {
DOCUMENT_META_DIFF_TYPE,
FIELD_DIFF_TYPE,
RECIPIENT_DIFF_TYPE,
ZDocumentAuditLogSchema,
} from '../types/document-audit-logs';
import type { RequestMetadata } from '../universal/extract-request-metadata';
type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
documentId: number;
type: T;
data: Extract<TDocumentAuditLog, { type: T }>['data'];
user: { email?: string; id?: number | null; name?: string | null } | null;
requestMetadata?: RequestMetadata;
};
type CreateDocumentAuditLogDataResponse = Pick<
DocumentAuditLog,
'type' | 'ipAddress' | 'userAgent' | 'email' | 'userId' | 'name' | 'documentId'
> & {
data: TDocumentAuditLog['data'];
};
export const createDocumentAuditLogData = ({
documentId,
type,
data,
user,
requestMetadata,
}: CreateDocumentAuditLogDataOptions): CreateDocumentAuditLogDataResponse => {
return {
type,
data,
documentId,
userId: user?.id ?? null,
email: user?.email ?? null,
name: user?.name ?? null,
userAgent: requestMetadata?.userAgent ?? null,
ipAddress: requestMetadata?.ipAddress ?? null,
};
};
/**
* Parse a raw document audit log from Prisma, to a typed audit log.
*
* @param auditLog raw audit log from Prisma.
*/
export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocumentAuditLog => {
const data = ZDocumentAuditLogSchema.safeParse(auditLog);
// Handle any required migrations here.
if (!data.success) {
throw new Error('Migration required');
}
return data.data;
};
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role'>;
export const diffRecipientChanges = (
oldRecipient: PartialRecipient,
newRecipient: PartialRecipient,
): TDocumentAuditLogRecipientDiffSchema[] => {
const diffs: TDocumentAuditLogRecipientDiffSchema[] = [];
if (oldRecipient.email !== newRecipient.email) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: oldRecipient.email,
to: newRecipient.email,
});
}
if (oldRecipient.role !== newRecipient.role) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.ROLE,
from: oldRecipient.role,
to: newRecipient.role,
});
}
if (oldRecipient.name !== newRecipient.name) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.NAME,
from: oldRecipient.name,
to: newRecipient.name,
});
}
return diffs;
};
export const diffFieldChanges = (
oldField: Field,
newField: Field,
): TDocumentAuditLogFieldDiffSchema[] => {
const diffs: TDocumentAuditLogFieldDiffSchema[] = [];
if (
oldField.page !== newField.page ||
!oldField.positionX.equals(newField.positionX) ||
!oldField.positionY.equals(newField.positionY)
) {
diffs.push({
type: FIELD_DIFF_TYPE.POSITION,
from: {
page: oldField.page,
positionX: oldField.positionX.toNumber(),
positionY: oldField.positionY.toNumber(),
},
to: {
page: newField.page,
positionX: newField.positionX.toNumber(),
positionY: newField.positionY.toNumber(),
},
});
}
if (!oldField.width.equals(newField.width) || !oldField.height.equals(newField.height)) {
diffs.push({
type: FIELD_DIFF_TYPE.DIMENSION,
from: {
width: oldField.width.toNumber(),
height: oldField.height.toNumber(),
},
to: {
width: newField.width.toNumber(),
height: newField.height.toNumber(),
},
});
}
return diffs;
};
export const diffDocumentMetaChanges = (
oldData: Partial<DocumentMeta> = {},
newData: DocumentMeta,
): TDocumentAuditLogDocumentMetaDiffSchema[] => {
const diffs: TDocumentAuditLogDocumentMetaDiffSchema[] = [];
const oldDateFormat = oldData?.dateFormat ?? '';
const oldMessage = oldData?.message ?? '';
const oldSubject = oldData?.subject ?? '';
const oldTimezone = oldData?.timezone ?? '';
const oldPassword = oldData?.password ?? null;
const oldRedirectUrl = oldData?.redirectUrl ?? '';
if (oldDateFormat !== newData.dateFormat) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.DATE_FORMAT,
from: oldData?.dateFormat ?? '',
to: newData.dateFormat,
});
}
if (oldMessage !== newData.message) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.MESSAGE,
from: oldMessage,
to: newData.message,
});
}
if (oldSubject !== newData.subject) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.SUBJECT,
from: oldSubject,
to: newData.subject,
});
}
if (oldTimezone !== newData.timezone) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.TIMEZONE,
from: oldTimezone,
to: newData.timezone,
});
}
if (oldRedirectUrl !== newData.redirectUrl) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.REDIRECT_URL,
from: oldRedirectUrl,
to: newData.redirectUrl,
});
}
if (oldPassword !== newData.password) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.PASSWORD,
});
}
return diffs;
};

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "redirectUrl" TEXT;

View File

@ -0,0 +1,37 @@
/*
Warnings:
- A unique constraint covering the columns `[secondaryId]` on the table `Field` will be added. If there are existing duplicate values, this will fail.
- The required column `secondaryId` was added to the `Field` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- AlterTable
ALTER TABLE "Field" ADD COLUMN "secondaryId" TEXT;
-- Set all null secondaryId fields to a uuid
UPDATE "Field" SET "secondaryId" = gen_random_uuid()::text WHERE "secondaryId" IS NULL;
-- Restrict the Field to required
ALTER TABLE "Field" ALTER COLUMN "secondaryId" SET NOT NULL;
-- CreateTable
CREATE TABLE "DocumentAuditLog" (
"id" TEXT NOT NULL,
"documentId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"type" TEXT NOT NULL,
"data" JSONB NOT NULL,
"name" TEXT,
"email" TEXT,
"userId" INTEGER,
"userAgent" TEXT,
"ipAddress" TEXT,
CONSTRAINT "DocumentAuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Field_secondaryId_key" ON "Field"("secondaryId");
-- AddForeignKey
ALTER TABLE "DocumentAuditLog" ADD CONSTRAINT "DocumentAuditLog_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -170,11 +170,30 @@ model Document {
teamId Int? teamId Int?
team Team? @relation(fields: [teamId], references: [id]) team Team? @relation(fields: [teamId], references: [id])
auditLogs DocumentAuditLog[]
@@unique([documentDataId]) @@unique([documentDataId])
@@index([userId]) @@index([userId])
@@index([status]) @@index([status])
} }
model DocumentAuditLog {
id String @id @default(cuid())
documentId Int
createdAt DateTime @default(now())
type String
data Json
// Details of the person who performed the action which caused the audit log.
name String?
email String?
userId Int?
userAgent String?
ipAddress String?
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
}
enum DocumentDataType { enum DocumentDataType {
S3_PATH S3_PATH
BYTES BYTES
@ -199,6 +218,7 @@ model DocumentMeta {
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
} }
enum ReadStatus { enum ReadStatus {
@ -259,6 +279,7 @@ enum FieldType {
model Field { model Field {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
secondaryId String @unique @default(cuid())
documentId Int? documentId Int?
templateId Int? templateId Int?
recipientId Int? recipientId Int?

View File

@ -15,6 +15,7 @@ import { updateTitle } from '@documenso/lib/server-only/document/update-title';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { authenticatedProcedure, procedure, router } from '../trpc'; import { authenticatedProcedure, procedure, router } from '../trpc';
import { import {
@ -88,6 +89,7 @@ export const documentRouter = router({
teamId, teamId,
title, title,
documentDataId, documentDataId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
if (err instanceof TRPCError) { if (err instanceof TRPCError) {
@ -131,6 +133,7 @@ export const documentRouter = router({
title, title,
userId, userId,
documentId, documentId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
}), }),
@ -144,6 +147,7 @@ export const documentRouter = router({
userId: ctx.user.id, userId: ctx.user.id,
documentId, documentId,
recipients, recipients,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -166,6 +170,7 @@ export const documentRouter = router({
userId: ctx.user.id, userId: ctx.user.id,
documentId, documentId,
fields, fields,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -198,6 +203,7 @@ export const documentRouter = router({
documentId, documentId,
password: securePassword, password: securePassword,
userId: ctx.user.id, userId: ctx.user.id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -215,20 +221,23 @@ export const documentRouter = router({
try { try {
const { documentId, meta } = input; const { documentId, meta } = input;
if (meta.message || meta.subject || meta.timezone || meta.dateFormat) { if (meta.message || meta.subject || meta.timezone || meta.dateFormat || meta.redirectUrl) {
await upsertDocumentMeta({ await upsertDocumentMeta({
documentId, documentId,
subject: meta.subject, subject: meta.subject,
message: meta.message, message: meta.message,
dateFormat: meta.dateFormat, dateFormat: meta.dateFormat,
timezone: meta.timezone, timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
userId: ctx.user.id, userId: ctx.user.id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} }
return await sendDocument({ return await sendDocument({
userId: ctx.user.id, userId: ctx.user.id,
documentId, documentId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -247,6 +256,7 @@ export const documentRouter = router({
return await resendDocument({ return await resendDocument({
userId: ctx.user.id, userId: ctx.user.id,
...input, ...input,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@ -1,5 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client'; import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
export const ZGetDocumentByIdQuerySchema = z.object({ export const ZGetDocumentByIdQuerySchema = z.object({
@ -73,6 +74,12 @@ export const ZSendDocumentMutationSchema = z.object({
message: z.string(), message: z.string(),
timezone: z.string(), timezone: z.string(),
dateFormat: z.string(), dateFormat: z.string(),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}), }),
}); });

View File

@ -4,6 +4,7 @@ import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/rem
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template'; import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token'; import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { authenticatedProcedure, procedure, router } from '../trpc'; import { authenticatedProcedure, procedure, router } from '../trpc';
import { import {
@ -33,6 +34,7 @@ export const fieldRouter = router({
pageWidth: field.pageWidth, pageWidth: field.pageWidth,
pageHeight: field.pageHeight, pageHeight: field.pageHeight,
})), })),
requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -67,7 +69,7 @@ export const fieldRouter = router({
signFieldWithToken: procedure signFieldWithToken: procedure
.input(ZSignFieldWithTokenMutationSchema) .input(ZSignFieldWithTokenMutationSchema)
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
try { try {
const { token, fieldId, value, isBase64 } = input; const { token, fieldId, value, isBase64 } = input;
@ -76,6 +78,7 @@ export const fieldRouter = router({
fieldId, fieldId,
value, value,
isBase64, isBase64,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -89,13 +92,14 @@ export const fieldRouter = router({
removeSignedFieldWithToken: procedure removeSignedFieldWithToken: procedure
.input(ZRemovedSignedFieldWithTokenMutationSchema) .input(ZRemovedSignedFieldWithTokenMutationSchema)
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
try { try {
const { token, fieldId } = input; const { token, fieldId } = input;
return await removeSignedFieldWithToken({ return await removeSignedFieldWithToken({
token, token,
fieldId, fieldId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template'; import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { authenticatedProcedure, procedure, router } from '../trpc'; import { authenticatedProcedure, procedure, router } from '../trpc';
import { import {
@ -27,6 +28,7 @@ export const recipientRouter = router({
name: signer.name, name: signer.name,
role: signer.role, role: signer.role,
})), })),
requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -65,13 +67,14 @@ export const recipientRouter = router({
completeDocumentWithToken: procedure completeDocumentWithToken: procedure
.input(ZCompleteDocumentWithTokenMutationSchema) .input(ZCompleteDocumentWithTokenMutationSchema)
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
try { try {
const { token, documentId } = input; const { token, documentId } = input;
return await completeDocumentWithToken({ return await completeDocumentWithToken({
token, token,
documentId, documentId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@ -63,6 +63,7 @@ export const singleplayerRouter = router({
: null, : null,
// Dummy data. // Dummy data.
id: -1, id: -1,
secondaryId: '-1',
documentId: -1, documentId: -1,
templateId: null, templateId: null,
recipientId: -1, recipientId: -1,

View File

@ -304,6 +304,13 @@ export const AddFieldsFormPartial = ({
return recipientsByRole; return recipientsByRole;
}, [recipients]); }, [recipients]);
const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter(
([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER,
);
}, [recipientsByRole]);
return ( return (
<> <>
<DocumentFlowFormContainerHeader <DocumentFlowFormContainerHeader
@ -382,13 +389,10 @@ export const AddFieldsFormPartial = ({
</span> </span>
</CommandEmpty> </CommandEmpty>
{Object.entries(recipientsByRole).map(([role, recipients], roleIndex) => ( {recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
<CommandGroup key={roleIndex}> <CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium"> <div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{ {`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
RECIPIENT_ROLES_DESCRIPTION[role as RecipientRole].roleName
}
</div> </div>
{recipients.length === 0 && ( {recipients.length === 0 && (
@ -403,7 +407,7 @@ export const AddFieldsFormPartial = ({
{recipients.map((recipient) => ( {recipients.map((recipient) => (
<CommandItem <CommandItem
key={recipient.id} key={recipient.id}
className={cn('px-4 last:mb-1 [&:not(:first-child)]:mt-1', { className={cn('px-2 last:mb-1 [&:not(:first-child)]:mt-1', {
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT, 'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
})} })}
onSelect={() => { onSelect={() => {
@ -413,7 +417,7 @@ export const AddFieldsFormPartial = ({
> >
<span <span
className={cn('text-foreground/70 truncate', { className={cn('text-foreground/70 truncate', {
'text-foreground': recipient === selectedSigner, 'text-foreground/80': recipient === selectedSigner,
})} })}
> >
{recipient.name && ( {recipient.name && (

View File

@ -105,7 +105,10 @@ export const AddSignersFormPartial = ({
} }
return recipients.some( return recipients.some(
(recipient) => recipient.id === id && recipient.sendStatus === SendStatus.SENT, (recipient) =>
recipient.id === id &&
recipient.sendStatus === SendStatus.SENT &&
recipient.role !== RecipientRole.CC,
); );
}; };

View File

@ -2,6 +2,8 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Info } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
@ -23,6 +25,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@documenso/ui/primitives/select'; } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { Combobox } from '../combobox'; import { Combobox } from '../combobox';
import { FormErrorMessage } from '../form/form-error-message'; import { FormErrorMessage } from '../form/form-error-message';
@ -30,7 +33,7 @@ import { Input } from '../input';
import { Label } from '../label'; import { Label } from '../label';
import { useStep } from '../stepper'; import { useStep } from '../stepper';
import { Textarea } from '../textarea'; import { Textarea } from '../textarea';
import type { TAddSubjectFormSchema } from './add-subject.types'; import { type TAddSubjectFormSchema, ZAddSubjectFormSchema } from './add-subject.types';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
@ -69,8 +72,10 @@ export const AddSubjectFormPartial = ({
message: document.documentMeta?.message ?? '', message: document.documentMeta?.message ?? '',
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: document.documentMeta?.redirectUrl ?? '',
}, },
}, },
resolver: zodResolver(ZAddSubjectFormSchema),
}); });
const onFormSubmit = handleSubmit(onSubmit); const onFormSubmit = handleSubmit(onSubmit);
@ -163,64 +168,94 @@ export const AddSubjectFormPartial = ({
</ul> </ul>
</div> </div>
{hasDateField && ( <Accordion type="multiple" className="mt-8 border-none">
<Accordion type="multiple" className="mt-8 border-none"> <AccordionItem value="advanced-options" className="border-none">
<AccordionItem value="advanced-options" className="border-none"> <AccordionTrigger className="mb-2 border-b text-left hover:no-underline">
<AccordionTrigger className="mb-2 border-b text-left hover:no-underline"> Advanced Options
Advanced Options </AccordionTrigger>
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 flex max-w-prose flex-col px-1 text-sm leading-relaxed"> <AccordionContent className="text-muted-foreground -mx-1 flex max-w-prose flex-col px-1 pt-2 text-sm leading-relaxed">
<div className="mt-2 flex flex-col"> {hasDateField && (
<Label htmlFor="date-format"> <>
Date Format <span className="text-muted-foreground">(Optional)</span> <div className="flex flex-col">
</Label> <Label htmlFor="date-format">
Date Format <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller <Controller
control={control} control={control}
name={`meta.dateFormat`} name={`meta.dateFormat`}
disabled={documentHasBeenSent} disabled={documentHasBeenSent}
render={({ field: { value, onChange, disabled } }) => ( render={({ field: { value, onChange, disabled } }) => (
<Select value={value} onValueChange={onChange} disabled={disabled}> <Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="bg-background mt-2"> <SelectTrigger className="bg-background mt-2">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{DATE_FORMATS.map((format) => ( {DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}> <SelectItem key={format.key} value={format.value}>
{format.label} {format.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
/> />
</div>
<div className="mt-4 flex flex-col">
<Label htmlFor="time-zone">
Time Zone <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name={`meta.timezone`}
render={({ field: { value, onChange } }) => (
<Combobox
className="bg-background"
options={TIME_ZONES}
value={value}
onChange={(value) => value && onChange(value)}
disabled={documentHasBeenSent}
/>
)}
/>
</div>
</>
)}
<div className="flex flex-col">
<div className="flex flex-col gap-y-4">
<div>
<Label htmlFor="redirectUrl" className="flex items-center">
Redirect URL{' '}
<Tooltip>
<TooltipTrigger>
<Info className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
Add a URL to redirect the user to once the document is signed
</TooltipContent>
</Tooltip>
</Label>
<Input
id="redirectUrl"
type="url"
className="bg-background my-2"
{...register('meta.redirectUrl')}
/>
<FormErrorMessage className="mt-2" error={errors.meta?.redirectUrl} />
</div>
</div> </div>
</div>
<div className="mt-4 flex flex-col"> </AccordionContent>
<Label htmlFor="time-zone"> </AccordionItem>
Time Zone <span className="text-muted-foreground">(Optional)</span> </Accordion>
</Label>
<Controller
control={control}
name={`meta.timezone`}
render={({ field: { value, onChange } }) => (
<Combobox
className="bg-background"
options={TIME_ZONES}
value={value}
onChange={(value) => value && onChange(value)}
disabled={documentHasBeenSent}
/>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div> </div>
</div> </div>
</DocumentFlowFormContainerContent> </DocumentFlowFormContainerContent>

View File

@ -2,6 +2,7 @@ import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
export const ZAddSubjectFormSchema = z.object({ export const ZAddSubjectFormSchema = z.object({
meta: z.object({ meta: z.object({
@ -9,6 +10,12 @@ export const ZAddSubjectFormSchema = z.object({
message: z.string(), message: z.string(),
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE), timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT), dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}), }),
}); });