diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index a1b56257a..f4be02d7b 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -85,6 +85,7 @@ export const SinglePlayerClient = () => { setFields( data.fields.map((field, i) => ({ id: i, + secondaryId: i.toString(), documentId: -1, templateId: null, recipientId: -1, diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 9a7e8acbe..99b9d1dd7 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -1,3 +1,4 @@ +import { headers } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; import { match } from 'ts-pattern'; @@ -13,6 +14,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; 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 { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; @@ -39,13 +41,17 @@ export default async function SigningPage({ params: { token } }: SigningPageProp return notFound(); } + const requestHeaders = Object.fromEntries(headers().entries()); + + const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); + const [document, fields, recipient] = await Promise.all([ getDocumentAndSenderByToken({ token, }).catch(() => null), getFieldsForToken({ token }), getRecipientByToken({ token }).catch(() => null), - viewedDocument({ token }).catch(() => null), + viewedDocument({ token, requestMetadata }).catch(() => null), ]); if (!document || !document.documentData || !recipient) { diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts index 920cf1f32..48d9d611b 100644 --- a/packages/lib/constants/recipient-roles.ts +++ b/packages/lib/constants/recipient-roles.ts @@ -24,3 +24,9 @@ export const RECIPIENT_ROLES_DESCRIPTION: { roleName: 'Viewer', }, }; + +export const RECIPIENT_ROLE_TO_EMAIL_TYPE = { + [RecipientRole.SIGNER]: 'SIGNING_REQUEST', + [RecipientRole.VIEWER]: 'VIEW_REQUEST', + [RecipientRole.APPROVER]: 'APPROVE_REQUEST', +} as const; diff --git a/packages/lib/server-only/document-data/create-document-data.ts b/packages/lib/server-only/document-data/create-document-data.ts index e41f00fe7..7f3a7db9d 100644 --- a/packages/lib/server-only/document-data/create-document-data.ts +++ b/packages/lib/server-only/document-data/create-document-data.ts @@ -1,7 +1,7 @@ 'use server'; import { prisma } from '@documenso/prisma'; -import { DocumentDataType } from '@documenso/prisma/client'; +import type { DocumentDataType } from '@documenso/prisma/client'; export type CreateDocumentDataOptions = { type: DocumentDataType; diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index 7bd6d93cc..5a1c1594e 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -1,5 +1,11 @@ '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'; export type CreateDocumentMetaOptions = { @@ -11,6 +17,7 @@ export type CreateDocumentMetaOptions = { dateFormat?: string; redirectUrl?: string; userId: number; + requestMetadata: RequestMetadata; }; export const upsertDocumentMeta = async ({ @@ -19,50 +26,81 @@ export const upsertDocumentMeta = async ({ timezone, dateFormat, documentId, - userId, password, + userId, redirectUrl, + requestMetadata, }: 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: { id: documentId, OR: [ { - userId, + userId: user.id, }, { team: { members: { some: { - userId, + userId: user.id, }, }, }, }, ], }, + include: { + documentMeta: true, + }, }); - return await prisma.documentMeta.upsert({ - where: { - documentId, - }, - create: { - subject, - message, - password, - dateFormat, - timezone, - documentId, - redirectUrl, - }, - update: { - subject, - message, - password, - dateFormat, - timezone, - redirectUrl, - }, + return await prisma.$transaction(async (tx) => { + const upsertedDocumentMeta = await tx.documentMeta.upsert({ + where: { + documentId, + }, + create: { + subject, + message, + password, + dateFormat, + timezone, + documentId, + redirectUrl, + }, + update: { + subject, + message, + 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; }); }; diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index 62db516fa..b0e7e024f 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -1,5 +1,8 @@ '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 { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; @@ -9,11 +12,13 @@ import { sendPendingEmail } from './send-pending-email'; export type CompleteDocumentWithTokenOptions = { token: string; documentId: number; + requestMetadata?: RequestMetadata; }; export const completeDocumentWithToken = async ({ token, documentId, + requestMetadata, }: CompleteDocumentWithTokenOptions) => { '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({ where: { documentId: document.id, @@ -99,6 +122,6 @@ export const completeDocumentWithToken = async ({ }); if (documents.count > 0) { - await sealDocument({ documentId: document.id }); + await sealDocument({ documentId: document.id, requestMetadata }); } }; diff --git a/packages/lib/server-only/document/create-document.ts b/packages/lib/server-only/document/create-document.ts index 93307a7b4..7243652f0 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -1,5 +1,9 @@ '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'; export type CreateDocumentOptions = { @@ -7,6 +11,7 @@ export type CreateDocumentOptions = { userId: number; teamId?: number; documentDataId: string; + requestMetadata?: RequestMetadata; }; export const createDocument = async ({ @@ -14,22 +19,30 @@ export const createDocument = async ({ title, documentDataId, teamId, + requestMetadata, }: CreateDocumentOptions) => { - return await prisma.$transaction(async (tx) => { - if (teamId !== undefined) { - await tx.team.findFirstOrThrow({ - where: { - id: teamId, - members: { - some: { - userId, - }, - }, + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + include: { + teamMembers: { + select: { + 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: { title, documentDataId, @@ -37,5 +50,19 @@ export const createDocument = async ({ teamId, }, }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED, + documentId: document.id, + user, + requestMetadata, + data: { + title, + }, + }), + }); + + return document; }); }; diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx index d72da3a8d..1acc684b9 100644 --- a/packages/lib/server-only/document/resend-document.tsx +++ b/packages/lib/server-only/document/resend-document.tsx @@ -4,12 +4,18 @@ import { mailer } from '@documenso/email/mailer'; import { render } from '@documenso/email/render'; import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite'; 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 { prisma } from '@documenso/prisma'; import { DocumentStatus, RecipientRole, SigningStatus } 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'; export type ResendDocumentOptions = { @@ -17,6 +23,7 @@ export type ResendDocumentOptions = { userId: number; recipients: number[]; teamId?: number; + requestMetadata: RequestMetadata; }; export const resendDocument = async ({ @@ -24,6 +31,7 @@ export const resendDocument = async ({ userId, recipients, teamId, + requestMetadata, }: ResendDocumentOptions) => { const user = await prisma.user.findFirstOrThrow({ where: { @@ -76,6 +84,8 @@ export const resendDocument = async ({ return; } + const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; + const { email, name } = recipient; const customEmailTemplate = { @@ -99,20 +109,39 @@ export const resendDocument = async ({ const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; - await mailer.sendMail({ - to: { - address: email, - name, - }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, - subject: customEmail?.subject - ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) - : `Please ${actionVerb.toLowerCase()} this document`, - html: render(template), - text: render(template, { plainText: true }), + await prisma.$transaction(async (tx) => { + await mailer.sendMail({ + to: { + address: email, + name, + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: customEmail?.subject + ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) + : `Please ${actionVerb.toLowerCase()} this document`, + 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, + }, + }), + }); }); }), ); diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index b24288c3e..09832db7d 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -5,10 +5,13 @@ import path from 'node:path'; import { PDFDocument } from 'pdf-lib'; 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 { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { signPdf } from '@documenso/signing'; +import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { getFile } from '../../universal/upload/get-file'; import { putFile } from '../../universal/upload/put-file'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; @@ -17,9 +20,14 @@ import { sendCompletedEmail } from './send-completed-email'; export type SealDocumentOptions = { documentId: number; sendEmail?: boolean; + requestMetadata?: RequestMetadata; }; -export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumentOptions) => { +export const sealDocument = async ({ + documentId, + sendEmail = true, + requestMetadata, +}: SealDocumentOptions) => { 'use server'; const document = await prisma.document.findFirstOrThrow({ @@ -100,16 +108,30 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen }); } - await prisma.documentData.update({ - where: { - id: documentData.id, - }, - data: { - data: newData, - }, + await prisma.$transaction(async (tx) => { + await tx.documentData.update({ + where: { + id: documentData.id, + }, + 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) { - await sendCompletedEmail({ documentId }); + await sendCompletedEmail({ documentId, requestMetadata }); } }; diff --git a/packages/lib/server-only/document/send-completed-email.ts b/packages/lib/server-only/document/send-completed-email.ts index 226ff43ec..3ab62833c 100644 --- a/packages/lib/server-only/document/send-completed-email.ts +++ b/packages/lib/server-only/document/send-completed-email.ts @@ -5,13 +5,17 @@ import { render } from '@documenso/email/render'; import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed'; import { prisma } from '@documenso/prisma'; +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 { createDocumentAuditLogData } from '../../utils/document-audit-logs'; export interface SendDocumentOptions { documentId: number; + requestMetadata?: RequestMetadata; } -export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) => { +export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDocumentOptions) => { const document = await prisma.document.findUnique({ where: { id: documentId, @@ -44,24 +48,43 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) => downloadLink: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${token}/complete`, }); - await mailer.sendMail({ - to: { - 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), + await prisma.$transaction(async (tx) => { + await mailer.sendMail({ + to: { + 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), + }, + ], + }); + + 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, + }, + }), + }); }); }), ); diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 312b30462..fc174c084 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -4,22 +4,37 @@ import { mailer } from '@documenso/email/mailer'; import { render } from '@documenso/email/render'; import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite'; 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 { prisma } from '@documenso/prisma'; import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client'; -import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles'; - export type SendDocumentOptions = { documentId: 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({ where: { id: userId, }, + select: { + id: true, + name: true, + email: true, + }, }); const document = await prisma.document.findUnique({ @@ -66,6 +81,8 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) return; } + const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; + const { email, name } = recipient; const customEmailTemplate = { @@ -89,29 +106,48 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; - await mailer.sendMail({ - to: { - address: email, - name, - }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, - subject: customEmail?.subject - ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) - : `Please ${actionVerb.toLowerCase()} this document`, - html: render(template), - text: render(template, { plainText: true }), - }); + await prisma.$transaction(async (tx) => { + await mailer.sendMail({ + to: { + address: email, + name, + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: customEmail?.subject + ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) + : `Please ${actionVerb.toLowerCase()} this document`, + html: render(template), + text: render(template, { plainText: true }), + }); - await prisma.recipient.update({ - where: { - id: recipient.id, - }, - data: { - sendStatus: SendStatus.SENT, - }, + await tx.recipient.update({ + where: { + id: recipient.id, + }, + data: { + 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, + }, + }), + }); }); }), ); diff --git a/packages/lib/server-only/document/update-title.ts b/packages/lib/server-only/document/update-title.ts index 19a902930..3e934e7be 100644 --- a/packages/lib/server-only/document/update-title.ts +++ b/packages/lib/server-only/document/update-title.ts @@ -1,34 +1,76 @@ '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'; export type UpdateTitleOptions = { userId: number; documentId: number; title: string; + requestMetadata?: RequestMetadata; }; -export const updateTitle = async ({ userId, documentId, title }: UpdateTitleOptions) => { - return await prisma.document.update({ +export const updateTitle = async ({ + userId, + documentId, + title, + requestMetadata, +}: UpdateTitleOptions) => { + const user = await prisma.user.findFirstOrThrow({ where: { - id: documentId, - OR: [ - { - userId, - }, - { - team: { - members: { - some: { - userId, + id: userId, + }, + }); + + return await prisma.$transaction(async (tx) => { + const document = await tx.document.findFirstOrThrow({ + where: { + id: documentId, + OR: [ + { + 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; }); }; diff --git a/packages/lib/server-only/document/viewed-document.ts b/packages/lib/server-only/document/viewed-document.ts index 5944d4841..452da1460 100644 --- a/packages/lib/server-only/document/viewed-document.ts +++ b/packages/lib/server-only/document/viewed-document.ts @@ -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 { ReadStatus } from '@documenso/prisma/client'; export type ViewedDocumentOptions = { token: string; + requestMetadata?: RequestMetadata; }; -export const viewedDocument = async ({ token }: ViewedDocumentOptions) => { +export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentOptions) => { const recipient = await prisma.recipient.findFirst({ where: { token, @@ -13,16 +17,38 @@ export const viewedDocument = async ({ token }: ViewedDocumentOptions) => { }, }); - if (!recipient) { + if (!recipient || !recipient.documentId) { return; } - await prisma.recipient.update({ - where: { - id: recipient.id, - }, - data: { - readStatus: ReadStatus.OPENED, - }, + const { documentId } = recipient; + + await prisma.$transaction(async (tx) => { + await tx.recipient.update({ + where: { + 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, + }, + }), + }); }); }; diff --git a/packages/lib/server-only/field/remove-signed-field-with-token.ts b/packages/lib/server-only/field/remove-signed-field-with-token.ts index ee472ec9f..6548ae0f1 100644 --- a/packages/lib/server-only/field/remove-signed-field-with-token.ts +++ b/packages/lib/server-only/field/remove-signed-field-with-token.ts @@ -1,16 +1,21 @@ '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 { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; export type RemovedSignedFieldWithTokenOptions = { token: string; fieldId: number; + requestMetadata?: RequestMetadata; }; export const removeSignedFieldWithToken = async ({ token, fieldId, + requestMetadata, }: RemovedSignedFieldWithTokenOptions) => { const field = await prisma.field.findFirstOrThrow({ where: { @@ -44,8 +49,8 @@ export const removeSignedFieldWithToken = async ({ throw new Error(`Field ${fieldId} has no recipientId`); } - await Promise.all([ - prisma.field.update({ + await prisma.$transaction(async (tx) => { + await tx.field.update({ where: { id: field.id, }, @@ -53,11 +58,28 @@ export const removeSignedFieldWithToken = async ({ customText: '', inserted: false, }, - }), - prisma.signature.deleteMany({ + }); + + await tx.signature.deleteMany({ where: { 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, + }, + }), + }); + }); }; diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts index 71508a9c5..7916de554 100644 --- a/packages/lib/server-only/field/set-fields-for-document.ts +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -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 type { FieldType } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client'; @@ -15,12 +21,14 @@ export interface SetFieldsForDocumentOptions { pageWidth: number; pageHeight: number; }[]; + requestMetadata?: RequestMetadata; } export const setFieldsForDocument = async ({ userId, documentId, fields, + requestMetadata, }: SetFieldsForDocumentOptions) => { const document = await prisma.document.findFirst({ where: { @@ -42,6 +50,17 @@ export const setFieldsForDocument = async ({ }, }); + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + select: { + id: true, + name: true, + email: true, + }, + }); + if (!document) { throw new Error('Document not found'); } @@ -79,56 +98,123 @@ export const setFieldsForDocument = async ({ ); }); - const persistedFields = await prisma.$transaction( - // Disabling as wrapping promises here causes type issues - // eslint-disable-next-line @typescript-eslint/promise-function-async - linkedFields.map((field) => - prisma.field.upsert({ - where: { - 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, - }, + const persistedFields = await prisma.$transaction(async (tx) => { + await Promise.all( + linkedFields.map(async (field) => { + const fieldSignerEmail = field.signerEmail.toLowerCase(); + + const upsertedField = await tx.field.upsert({ + where: { + id: field._persisted?.id ?? -1, + documentId, }, - Recipient: { - connect: { - documentId_email: { - documentId, - email: field.signerEmail.toLowerCase(), + 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: { + 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) { - await prisma.field.deleteMany({ - where: { - id: { - in: removedFields.map((field) => field.id), + await prisma.$transaction(async (tx) => { + await tx.field.deleteMany({ + where: { + 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, + }, + }), + ), + }); }); } diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts index 62deccd5a..aa3056f52 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -1,18 +1,23 @@ 'use server'; import { DateTime } from 'luxon'; +import { match } from 'ts-pattern'; import { prisma } from '@documenso/prisma'; import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; 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 = { token: string; fieldId: number; value: string; isBase64?: boolean; + requestMetadata?: RequestMetadata; }; export const signFieldWithToken = async ({ @@ -20,6 +25,7 @@ export const signFieldWithToken = async ({ fieldId, value, isBase64, + requestMetadata, }: SignFieldWithTokenOptions) => { const field = await prisma.field.findFirstOrThrow({ where: { @@ -40,6 +46,10 @@ export const signFieldWithToken = async ({ 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) { 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; }); }; diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts index 82261a446..b18ea6420 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -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 { RecipientRole } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client'; -import { nanoid } from '../../universal/id'; - export interface SetRecipientsForDocumentOptions { userId: number; documentId: number; @@ -13,12 +18,14 @@ export interface SetRecipientsForDocumentOptions { name: string; role: RecipientRole; }[]; + requestMetadata?: RequestMetadata; } export const setRecipientsForDocument = async ({ userId, documentId, recipients, + requestMetadata, }: SetRecipientsForDocumentOptions) => { const document = await prisma.document.findFirst({ where: { @@ -40,6 +47,17 @@ export const setRecipientsForDocument = async ({ }, }); + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + select: { + id: true, + name: true, + email: true, + }, + }); + if (!document) { throw new Error('Document not found'); } @@ -87,45 +105,121 @@ export const setRecipientsForDocument = async ({ ); }); - const persistedRecipients = await prisma.$transaction( - // Disabling as wrapping promises here causes type issues - // eslint-disable-next-line @typescript-eslint/promise-function-async - linkedRecipients.map((recipient) => - prisma.recipient.upsert({ - where: { - id: recipient._persisted?.id ?? -1, - documentId, - }, - update: { - name: recipient.name, - email: recipient.email, - role: recipient.role, - documentId, - sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, - signingStatus: - recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, - }, - create: { - name: recipient.name, - email: recipient.email, - role: recipient.role, - token: nanoid(), - documentId, - sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, - signingStatus: - recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, - }, + const persistedRecipients = await prisma.$transaction(async (tx) => { + await Promise.all( + linkedRecipients.map(async (recipient) => { + const upsertedRecipient = await tx.recipient.upsert({ + where: { + id: recipient._persisted?.id ?? -1, + documentId, + }, + update: { + name: recipient.name, + email: recipient.email, + role: recipient.role, + documentId, + sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, + signingStatus: + recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, + }, + create: { + name: recipient.name, + email: recipient.email, + role: recipient.role, + token: nanoid(), + documentId, + sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, + signingStatus: + 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) { - await prisma.recipient.deleteMany({ - where: { - id: { - in: removedRecipients.map((recipient) => recipient.id), + await prisma.$transaction(async (tx) => { + await tx.recipient.deleteMany({ + where: { + 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, + }, + }), + ), + }); }); } diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts new file mode 100644 index 000000000..e6a954603 --- /dev/null +++ b/packages/lib/types/document-audit-logs.ts @@ -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; +export type TDocumentAuditLogType = z.infer; + +export type TDocumentAuditLogFieldDiffSchema = z.infer; + +export type TDocumentAuditLogDocumentMetaDiffSchema = z.infer< + typeof ZDocumentAuditLogDocumentMetaSchema +>; + +export type TDocumentAuditLogRecipientDiffSchema = z.infer< + typeof ZDocumentAuditLogRecipientDiffSchema +>; diff --git a/packages/lib/universal/extract-request-metadata.ts b/packages/lib/universal/extract-request-metadata.ts index 5549e5de7..d608d5f80 100644 --- a/packages/lib/universal/extract-request-metadata.ts +++ b/packages/lib/universal/extract-request-metadata.ts @@ -25,10 +25,16 @@ export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetad export const extractNextAuthRequestMetadata = ( req: Pick, ): RequestMetadata => { - const parsedIp = ZIpSchema.safeParse(req.headers?.['x-forwarded-for']); + return extractNextHeaderRequestMetadata(req.headers ?? {}); +}; + +export const extractNextHeaderRequestMetadata = ( + headers: Record, +): RequestMetadata => { + const parsedIp = ZIpSchema.safeParse(headers?.['x-forwarded-for']); const ipAddress = parsedIp.success ? parsedIp.data : undefined; - const userAgent = req.headers?.['user-agent']; + const userAgent = headers?.['user-agent']; return { ipAddress, diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts new file mode 100644 index 000000000..dcc3932e9 --- /dev/null +++ b/packages/lib/utils/document-audit-logs.ts @@ -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 = { + documentId: number; + type: T; + data: Extract['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; + +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 = {}, + 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; +}; diff --git a/packages/prisma/migrations/20240209023519_add_document_audit_logs/migration.sql b/packages/prisma/migrations/20240209023519_add_document_audit_logs/migration.sql new file mode 100644 index 000000000..94e5fd097 --- /dev/null +++ b/packages/prisma/migrations/20240209023519_add_document_audit_logs/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index ff2d12319..2887cd6d2 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -170,11 +170,30 @@ model Document { teamId Int? team Team? @relation(fields: [teamId], references: [id]) + auditLogs DocumentAuditLog[] + @@unique([documentDataId]) @@index([userId]) @@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 { S3_PATH BYTES @@ -260,6 +279,7 @@ enum FieldType { model Field { id Int @id @default(autoincrement()) + secondaryId String @unique @default(cuid()) documentId Int? templateId Int? recipientId Int? diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index d0ff48941..aebc6e505 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -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 { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; +import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { @@ -88,6 +89,7 @@ export const documentRouter = router({ teamId, title, documentDataId, + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { if (err instanceof TRPCError) { @@ -131,6 +133,7 @@ export const documentRouter = router({ title, userId, documentId, + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); }), @@ -144,6 +147,7 @@ export const documentRouter = router({ userId: ctx.user.id, documentId, recipients, + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); @@ -166,6 +170,7 @@ export const documentRouter = router({ userId: ctx.user.id, documentId, fields, + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); @@ -198,6 +203,7 @@ export const documentRouter = router({ documentId, password: securePassword, userId: ctx.user.id, + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); @@ -224,12 +230,14 @@ export const documentRouter = router({ timezone: meta.timezone, redirectUrl: meta.redirectUrl, userId: ctx.user.id, + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } return await sendDocument({ userId: ctx.user.id, documentId, + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); @@ -248,6 +256,7 @@ export const documentRouter = router({ return await resendDocument({ userId: ctx.user.id, ...input, + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 5ae3cbe4b..4df1b1ddc 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -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 { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template'; 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 { @@ -33,6 +34,7 @@ export const fieldRouter = router({ pageWidth: field.pageWidth, pageHeight: field.pageHeight, })), + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); @@ -67,7 +69,7 @@ export const fieldRouter = router({ signFieldWithToken: procedure .input(ZSignFieldWithTokenMutationSchema) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { try { const { token, fieldId, value, isBase64 } = input; @@ -76,6 +78,7 @@ export const fieldRouter = router({ fieldId, value, isBase64, + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); @@ -89,13 +92,14 @@ export const fieldRouter = router({ removeSignedFieldWithToken: procedure .input(ZRemovedSignedFieldWithTokenMutationSchema) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { try { const { token, fieldId } = input; return await removeSignedFieldWithToken({ token, fieldId, + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index 9553a8aae..c36b09ec9 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server'; 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 { 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 { @@ -27,6 +28,7 @@ export const recipientRouter = router({ name: signer.name, role: signer.role, })), + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); @@ -65,13 +67,14 @@ export const recipientRouter = router({ completeDocumentWithToken: procedure .input(ZCompleteDocumentWithTokenMutationSchema) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { try { const { token, documentId } = input; return await completeDocumentWithToken({ token, documentId, + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); diff --git a/packages/trpc/server/singleplayer-router/router.ts b/packages/trpc/server/singleplayer-router/router.ts index 8e2266fcc..e2a6dbec0 100644 --- a/packages/trpc/server/singleplayer-router/router.ts +++ b/packages/trpc/server/singleplayer-router/router.ts @@ -62,6 +62,7 @@ export const singleplayerRouter = router({ : null, // Dummy data. id: -1, + secondaryId: '-1', documentId: -1, templateId: null, recipientId: -1,