mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 16:23:06 +10:00
feat: better document rejection (#1702)
Improves the existing document rejection process by actually marking a document as completed cancelling further actions. ## Related Issue N/A ## Changes Made - Added a new rejection status for documents - Updated a million areas that check for document completion - Updated email sending, so rejection is confirmed for the rejecting recipient while other recipients are notified that the document is now cancelled. ## Testing Performed - Ran the testing suite to ensure there are no regressions. - Performed manual testing of current core flows.
This commit is contained in:
@ -13,6 +13,7 @@ export const getDocumentStats = async () => {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
};
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ import {
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
@ -161,7 +162,7 @@ const handleDocumentOwnerDelete = async ({
|
||||
}
|
||||
|
||||
// Soft delete completed documents.
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
if (isDocumentCompleted(document.status)) {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
|
||||
@ -356,6 +356,24 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
},
|
||||
],
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.REJECTED, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.REJECTED,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.REJECTED,
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.REJECTED,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@ -548,5 +566,38 @@ const findTeamDocumentsFilter = (
|
||||
|
||||
return filter;
|
||||
})
|
||||
.with(ExtendedDocumentStatus.REJECTED, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
status: ExtendedDocumentStatus.REJECTED,
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (teamEmail && filter.OR) {
|
||||
filter.OR.push(
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.REJECTED,
|
||||
},
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
{
|
||||
user: {
|
||||
email: teamEmail,
|
||||
},
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return filter;
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@ -16,6 +16,7 @@ export const getDocumentCertificateAuditLogs = async ({
|
||||
type: {
|
||||
in: [
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
],
|
||||
@ -29,6 +30,9 @@ export const getDocumentCertificateAuditLogs = async ({
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
),
|
||||
|
||||
@ -44,6 +44,7 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.INBOX]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
};
|
||||
@ -64,6 +65,10 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
|
||||
if (stat.status === ExtendedDocumentStatus.PENDING) {
|
||||
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
|
||||
}
|
||||
|
||||
if (stat.status === ExtendedDocumentStatus.REJECTED) {
|
||||
stats[ExtendedDocumentStatus.REJECTED] += stat._count._all;
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(stats).forEach((key) => {
|
||||
|
||||
@ -1,18 +1,12 @@
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type RejectDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
@ -84,7 +78,16 @@ export async function rejectDocumentWithToken({
|
||||
}),
|
||||
]);
|
||||
|
||||
// Send email notifications
|
||||
// Trigger the seal document job to process the document asynchronously
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.seal-document',
|
||||
payload: {
|
||||
documentId,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
// Send email notifications to the rejecting recipient
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.rejected.emails',
|
||||
payload: {
|
||||
@ -93,27 +96,14 @@ export async function rejectDocumentWithToken({
|
||||
},
|
||||
});
|
||||
|
||||
// Get the updated document with all recipients
|
||||
const updatedDocument = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: document.id,
|
||||
// Send cancellation emails to other recipients
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.cancelled.emails',
|
||||
payload: {
|
||||
documentId,
|
||||
cancellationReason: reason,
|
||||
requestMetadata,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!updatedDocument) {
|
||||
throw new Error('Document not found after update');
|
||||
}
|
||||
|
||||
// Trigger webhook for document rejection
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_REJECTED,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
});
|
||||
|
||||
return updatedRecipient;
|
||||
|
||||
@ -20,6 +20,7 @@ import { prisma } from '@documenso/prisma';
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
@ -88,7 +89,7 @@ export const resendDocument = async ({
|
||||
throw new Error('Can not send draft document');
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
if (isDocumentCompleted(document.status)) {
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
|
||||
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
||||
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
|
||||
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
||||
import { flattenForm } from '../pdf/flatten-form';
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
@ -41,11 +42,6 @@ export const sealDocument = async ({
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
recipients: {
|
||||
every: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
@ -78,7 +74,21 @@ export const sealDocument = async ({
|
||||
},
|
||||
});
|
||||
|
||||
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) {
|
||||
// Determine if the document has been rejected by checking if any recipient has rejected it
|
||||
const rejectedRecipient = recipients.find(
|
||||
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
|
||||
);
|
||||
|
||||
const isRejected = Boolean(rejectedRecipient);
|
||||
|
||||
// Get the rejection reason from the rejected recipient
|
||||
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
|
||||
|
||||
// If the document is not rejected, ensure all recipients have signed
|
||||
if (
|
||||
!isRejected &&
|
||||
recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)
|
||||
) {
|
||||
throw new Error(`Document ${document.id} has unsigned recipients`);
|
||||
}
|
||||
|
||||
@ -91,7 +101,8 @@ export const sealDocument = async ({
|
||||
},
|
||||
});
|
||||
|
||||
if (fieldsContainUnsignedRequiredField(fields)) {
|
||||
// Skip the field check if the document is rejected
|
||||
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
|
||||
throw new Error(`Document ${document.id} has unsigned required fields`);
|
||||
}
|
||||
|
||||
@ -119,6 +130,11 @@ export const sealDocument = async ({
|
||||
flattenForm(doc);
|
||||
flattenAnnotations(doc);
|
||||
|
||||
// Add rejection stamp if the document is rejected
|
||||
if (isRejected && rejectionReason) {
|
||||
await addRejectionStampToPdf(doc, rejectionReason);
|
||||
}
|
||||
|
||||
if (certificateData) {
|
||||
const certificate = await PDFDocument.load(certificateData);
|
||||
|
||||
@ -142,8 +158,11 @@ export const sealDocument = async ({
|
||||
|
||||
const { name } = path.parse(document.title);
|
||||
|
||||
// Add suffix based on document status
|
||||
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
|
||||
|
||||
const { data: newData } = await putPdfFileServerSide({
|
||||
name: `${name}_signed.pdf`,
|
||||
name: `${name}${suffix}`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
||||
});
|
||||
@ -156,6 +175,7 @@ export const sealDocument = async ({
|
||||
event: 'App: Document Sealed',
|
||||
properties: {
|
||||
documentId: document.id,
|
||||
isRejected,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -166,7 +186,7 @@ export const sealDocument = async ({
|
||||
id: document.id,
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.COMPLETED,
|
||||
status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
@ -188,6 +208,7 @@ export const sealDocument = async ({
|
||||
user: null,
|
||||
data: {
|
||||
transactionId: nanoid(),
|
||||
...(isRejected ? { isRejected: true, rejectionReason } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
@ -209,7 +230,9 @@ export const sealDocument = async ({
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||
event: isRejected
|
||||
? WebhookTriggerEvents.DOCUMENT_REJECTED
|
||||
: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
} from '../../types/webhook-payload';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
@ -74,7 +75,7 @@ export const sendDocument = async ({
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
if (isDocumentCompleted(document.status)) {
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
|
||||
87
packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts
Normal file
87
packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import type { PDFDocument } from 'pdf-lib';
|
||||
import { TextAlignment, rgb, setFontAndSize } from 'pdf-lib';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
/**
|
||||
* Adds a rejection stamp to each page of a PDF document.
|
||||
* The stamp is placed in the center of the page.
|
||||
*/
|
||||
export async function addRejectionStampToPdf(
|
||||
pdfDoc: PDFDocument,
|
||||
reason: string,
|
||||
): Promise<PDFDocument> {
|
||||
const pages = pdfDoc.getPages();
|
||||
pdfDoc.registerFontkit(fontkit);
|
||||
|
||||
const fontBytes = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
|
||||
async (res) => res.arrayBuffer(),
|
||||
);
|
||||
|
||||
const font = await pdfDoc.embedFont(fontBytes, {
|
||||
customName: 'Noto',
|
||||
});
|
||||
|
||||
const form = pdfDoc.getForm();
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const page = pages[i];
|
||||
const { width, height } = page.getSize();
|
||||
|
||||
// Draw the "REJECTED" text
|
||||
const rejectedTitleText = 'DOCUMENT REJECTED';
|
||||
const rejectedTitleFontSize = 36;
|
||||
const rejectedTitleTextField = form.createTextField(`internal-document-rejected-title-${i}`);
|
||||
|
||||
if (!rejectedTitleTextField.acroField.getDefaultAppearance()) {
|
||||
rejectedTitleTextField.acroField.setDefaultAppearance(
|
||||
setFontAndSize('Noto', rejectedTitleFontSize).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
rejectedTitleTextField.updateAppearances(font);
|
||||
|
||||
rejectedTitleTextField.setFontSize(rejectedTitleFontSize);
|
||||
rejectedTitleTextField.setText(rejectedTitleText);
|
||||
rejectedTitleTextField.setAlignment(TextAlignment.Center);
|
||||
|
||||
const rejectedTitleTextWidth =
|
||||
font.widthOfTextAtSize(rejectedTitleText, rejectedTitleFontSize) * 1.2;
|
||||
const rejectedTitleTextHeight = font.heightAtSize(rejectedTitleFontSize);
|
||||
|
||||
// Calculate the center position of the page
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
// Position the title text at the center of the page
|
||||
const rejectedTitleTextX = centerX - rejectedTitleTextWidth / 2;
|
||||
const rejectedTitleTextY = centerY - rejectedTitleTextHeight / 2;
|
||||
|
||||
// Add padding for the rectangle
|
||||
const padding = 20;
|
||||
|
||||
// Draw the stamp background
|
||||
page.drawRectangle({
|
||||
x: rejectedTitleTextX - padding / 2,
|
||||
y: rejectedTitleTextY - padding / 2,
|
||||
width: rejectedTitleTextWidth + padding,
|
||||
height: rejectedTitleTextHeight + padding,
|
||||
borderColor: rgb(220 / 255, 38 / 255, 38 / 255),
|
||||
borderWidth: 4,
|
||||
});
|
||||
|
||||
rejectedTitleTextField.addToPage(page, {
|
||||
x: rejectedTitleTextX,
|
||||
y: rejectedTitleTextY,
|
||||
width: rejectedTitleTextWidth,
|
||||
height: rejectedTitleTextHeight,
|
||||
textColor: rgb(220 / 255, 38 / 255, 38 / 255),
|
||||
backgroundColor: undefined,
|
||||
borderWidth: 0,
|
||||
borderColor: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return pdfDoc;
|
||||
}
|
||||
@ -29,7 +29,7 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
||||
where: {
|
||||
userId: user.id,
|
||||
status: {
|
||||
in: [DocumentStatus.PENDING, DocumentStatus.COMPLETED],
|
||||
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
|
||||
},
|
||||
},
|
||||
data: {
|
||||
|
||||
Reference in New Issue
Block a user