Compare commits

...

2 Commits

2 changed files with 111 additions and 162 deletions

View File

@ -275,7 +275,15 @@ The environment variables listed above are a subset of those available for confi
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). | | `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). | | `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). | | `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. | | `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). | | `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). | | `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) | | `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
@ -297,6 +305,7 @@ The environment variables listed above are a subset of those available for confi
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. | | `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. | | `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. | | `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. | | `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. | | `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. | | `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
@ -308,6 +317,7 @@ The environment variables listed above are a subset of those available for confi
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). | | `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. | | `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. | | `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
## Run as a Service ## Run as a Service

View File

@ -25,7 +25,6 @@ import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error'; import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email'; import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf'; import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf'; import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf'; import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
@ -62,171 +61,120 @@ export const run = async ({
}) => { }) => {
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload; const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
const envelope = await prisma.envelope.findFirstOrThrow({ const { envelopeId, envelopeStatus, isRejected } = await io.runTask('seal-document', async () => {
where: { const envelope = await prisma.envelope.findFirstOrThrow({
type: EnvelopeType.DOCUMENT, where: {
secondaryId: mapDocumentIdToSecondaryId(documentId), type: EnvelopeType.DOCUMENT,
}, secondaryId: mapDocumentIdToSecondaryId(documentId),
include: { },
documentMeta: true, include: {
recipients: true, documentMeta: true,
envelopeItems: { recipients: true,
include: { envelopeItems: {
documentData: true, include: {
field: { documentData: true,
include: { field: {
signature: true, include: {
signature: true,
},
}, },
}, },
}, },
}, },
},
});
if (envelope.envelopeItems.length === 0) {
throw new Error('At least one envelope item required');
}
const settings = await getTeamSettings({
userId: envelope.userId,
teamId: envelope.teamId,
});
const isComplete =
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
if (!isComplete) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Document is not complete',
}); });
}
// Seems silly but we need to do this in case the job is re-ran if (envelope.envelopeItems.length === 0) {
// after it has already run through the update task further below. throw new Error('At least one envelope item required');
// eslint-disable-next-line @typescript-eslint/require-await }
const documentStatus = await io.runTask('get-document-status', async () => {
return envelope.status;
});
// This is the same case as above. const settings = await getTeamSettings({
let envelopeItems = await io.runTask( userId: envelope.userId,
'get-document-data-id', teamId: envelope.teamId,
// eslint-disable-next-line @typescript-eslint/require-await });
async () => {
// eslint-disable-next-line unused-imports/no-unused-vars
return envelope.envelopeItems.map(({ field, ...rest }) => ({
...rest,
}));
},
);
if (envelopeItems.length < 1) { const isComplete =
throw new Error(`Document ${envelope.id} has no envelope items`); envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
} envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
const recipients = await prisma.recipient.findMany({ if (!isComplete) {
where: { throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
envelopeId: envelope.id, message: 'Document is not complete',
role: { });
not: RecipientRole.CC, }
},
},
});
// Determine if the document has been rejected by checking if any recipient has rejected it let envelopeItems = envelope.envelopeItems;
const rejectedRecipient = recipients.find(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
);
const isRejected = Boolean(rejectedRecipient); if (envelopeItems.length < 1) {
throw new Error(`Document ${envelope.id} has no envelope items`);
}
// Get the rejection reason from the rejected recipient const recipients = await prisma.recipient.findMany({
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
const fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
},
include: {
signature: true,
},
});
// Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${envelope.id} has unsigned required fields`);
}
if (isResealing) {
// If we're resealing we want to use the initial data for the document
// so we aren't placing fields on top of eachother.
envelopeItems = envelopeItems.map((envelopeItem) => ({
...envelopeItem,
documentData: {
...envelopeItem.documentData,
data: envelopeItem.documentData.initialData,
},
}));
}
if (!envelope.qrToken) {
await prisma.envelope.update({
where: { where: {
id: envelope.id, envelopeId: envelope.id,
}, role: {
data: { not: RecipientRole.CC,
qrToken: prefixedId('qr'), },
}, },
}); });
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId); // 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 { certificateData, auditLogData } = await getCertificateAndAuditLogData({ const isRejected = Boolean(rejectedRecipient);
legacyDocumentId,
documentMeta: envelope.documentMeta,
settings,
});
// !: The commented out code is our desired implementation but we're seemingly // Get the rejection reason from the rejected recipient
// !: running into issues with inngest parallelism in production. const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
// !: Until this is resolved we will do this sequentially which is slower but
// !: will actually work.
// const decoratePromises: Array<Promise<{ oldDocumentDataId: string; newDocumentDataId: string }>> =
// [];
// for (const envelopeItem of envelopeItems) { const fields = await prisma.field.findMany({
// const task = io.runTask(`decorate-${envelopeItem.id}`, async () => { where: {
// const envelopeItemFields = envelope.envelopeItems.find( envelopeId: envelope.id,
// (item) => item.id === envelopeItem.id, },
// )?.field; include: {
signature: true,
},
});
// if (!envelopeItemFields) { // Skip the field check if the document is rejected
// throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`); if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
// } throw new Error(`Document ${envelope.id} has unsigned required fields`);
}
// return decorateAndSignPdf({ if (isResealing) {
// envelope, // If we're resealing we want to use the initial data for the document
// envelopeItem, // so we aren't placing fields on top of eachother.
// envelopeItemFields, envelopeItems = envelopeItems.map((envelopeItem) => ({
// isRejected, ...envelopeItem,
// rejectionReason, documentData: {
// certificateData, ...envelopeItem.documentData,
// auditLogData, data: envelopeItem.documentData.initialData,
// }); },
// }); }));
}
// decoratePromises.push(task); if (!envelope.qrToken) {
// } await prisma.envelope.update({
where: {
id: envelope.id,
},
data: {
qrToken: prefixedId('qr'),
},
});
}
// const newDocumentData = await Promise.all(decoratePromises); const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// TODO: Remove once parallelization is working const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = []; legacyDocumentId,
documentMeta: envelope.documentMeta,
settings,
});
for (const envelopeItem of envelopeItems) { const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
const result = await io.runTask(`decorate-${envelopeItem.id}`, async () => {
for (const envelopeItem of envelopeItems) {
const envelopeItemFields = envelope.envelopeItems.find( const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id, (item) => item.id === envelopeItem.id,
)?.field; )?.field;
@ -235,7 +183,7 @@ export const run = async ({
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`); throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
} }
return decorateAndSignPdf({ const result = await decorateAndSignPdf({
envelope, envelope,
envelopeItem, envelopeItem,
envelopeItemFields, envelopeItemFields,
@ -244,25 +192,10 @@ export const run = async ({
certificateData, certificateData,
auditLogData, auditLogData,
}); });
});
newDocumentData.push(result); newDocumentData.push(result);
} }
const postHog = PostHogServerClient();
if (postHog) {
postHog.capture({
distinctId: nanoid(),
event: 'App: Document Sealed',
properties: {
documentId: envelope.id,
isRejected,
},
});
}
await io.runTask('update-document', async () => {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) { for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
const newData = await tx.documentData.findFirstOrThrow({ const newData = await tx.documentData.findFirstOrThrow({
@ -304,18 +237,24 @@ export const run = async ({
}), }),
}); });
}); });
return {
envelopeId: envelope.id,
envelopeStatus: envelope.status,
isRejected,
};
}); });
await io.runTask('send-completed-email', async () => { await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected; let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
if (isResealing && !isDocumentCompleted(envelope.status)) { if (isResealing && !isDocumentCompleted(envelopeStatus)) {
shouldSendCompletedEmail = sendEmail; shouldSendCompletedEmail = sendEmail;
} }
if (shouldSendCompletedEmail) { if (shouldSendCompletedEmail) {
await sendCompletedEmail({ await sendCompletedEmail({
id: { type: 'envelopeId', id: envelope.id }, id: { type: 'envelopeId', id: envelopeId },
requestMetadata, requestMetadata,
}); });
} }
@ -323,7 +262,7 @@ export const run = async ({
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({ const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
where: { where: {
id: envelope.id, id: envelopeId,
}, },
include: { include: {
documentMeta: true, documentMeta: true,