Compare commits

..

3 Commits

17 changed files with 4986 additions and 2931 deletions

View File

@ -1,10 +1,6 @@
import { createCookie } from 'react-router';
import { env } from '@documenso/lib/utils/env';
export const langCookie = createCookie('lang', {
path: '/',
maxAge: 60 * 60 * 24 * 365 * 2,
httpOnly: true,
secure: env('NODE_ENV') === 'production',
});

View File

@ -100,5 +100,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.10.3"
"version": "1.10.0"
}

View File

@ -40,6 +40,43 @@ services:
entrypoint: sh
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
triggerdotdev:
image: ghcr.io/triggerdotdev/trigger.dev:latest
container_name: triggerdotdev
environment:
- LOGIN_ORIGIN=http://localhost:3030
- APP_ORIGIN=http://localhost:3030
- PORT=3030
- REMIX_APP_PORT=3030
- MAGIC_LINK_SECRET=secret
- SESSION_SECRET=secret
- ENCRYPTION_KEY=deadbeefcafefeed
- DATABASE_URL=postgresql://trigger:password@triggerdotdev_database:5432/trigger
- DIRECT_URL=postgresql://trigger:password@triggerdotdev_database:5432/trigger
- RUNTIME_PLATFORM=docker-compose
ports:
- 3030:3030
depends_on:
- triggerdotdev_database
triggerdotdev_database:
container_name: triggerdotdev_database
image: postgres:15
volumes:
- triggerdotdev_database:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
interval: 10s
timeout: 5s
retries: 5
environment:
- POSTGRES_USER=trigger
- POSTGRES_PASSWORD=password
- POSTGRES_DB=trigger
ports:
- 54321:5432
volumes:
minio:
documenso_database:
triggerdotdev_database:

4633
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "1.10.3",
"version": "1.10.0",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@ -28,6 +28,7 @@
"with:env": "dotenv -e .env -e .env.local --",
"reset:hard": "npm run clean && npm i && npm run prisma:generate",
"precommit": "npm install && git add package.json package-lock.json",
"trigger:dev": "npm run with:env -- npx trigger-cli dev --handler-path=\"/api/jobs\"",
"inngest:dev": "inngest dev -u http://localhost:3000/api/jobs",
"make:version": "npm version --workspace @documenso/remix --include-workspace-root --no-git-tag-version -m \"v%s\"",
"translate": "npm run translate:extract && npm run translate:compile",
@ -43,6 +44,7 @@
"@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0",
"@lingui/cli": "^5.2.0",
"@trigger.dev/cli": "^2.3.18",
"dotenv": "^16.3.1",
"dotenv-cli": "^7.3.0",
"eslint": "^8.40.0",
@ -74,5 +76,8 @@
},
"overrides": {
"zod": "3.24.1"
},
"trigger.dev": {
"endpointId": "documenso-app"
}
}

View File

@ -1,19 +1,13 @@
import { match } from 'ts-pattern';
import { env } from '../../utils/env';
import type {
JobDefinition,
JobRunIO,
SimpleTriggerJobOptions,
TriggerJobOptions,
} from './_internal/job';
import type { JobDefinition, TriggerJobOptions } from './_internal/job';
import type { BaseJobProvider as JobClientProvider } from './base';
import { InngestJobProvider } from './inngest';
import { LocalJobProvider } from './local';
export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
private _provider: JobClientProvider;
private _jobDefinitions: Record<string, JobDefinition> = {};
public constructor(definitions: T) {
this._provider = match(env('NEXT_PRIVATE_JOBS_PROVIDER'))
@ -22,67 +16,10 @@ export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
definitions.forEach((definition) => {
this._provider.defineJob(definition);
this._jobDefinitions[definition.id] = definition;
});
}
/**
* Check if Inngest background job processing is available.
*
* For Inngest to be considered available:
* 1. NEXT_PRIVATE_JOBS_PROVIDER must be set to 'inngest'
* 2. Either INNGEST_EVENT_KEY or NEXT_PRIVATE_INNGEST_EVENT_KEY must be provided
*
* If Inngest is not available, jobs will be executed synchronously without background scheduling.
*/
public isInngestAvailable(): boolean {
return (
env('NEXT_PRIVATE_JOBS_PROVIDER') === 'inngest' &&
Boolean(env('INNGEST_EVENT_KEY') || env('NEXT_PRIVATE_INNGEST_EVENT_KEY'))
);
}
public async triggerJob(options: TriggerJobOptions<T>): Promise<unknown> {
// When Inngest is not available, execute the job directly
if (!this.isInngestAvailable()) {
const eligibleJob = Object.values(this._jobDefinitions).find(
(job) => job.trigger.name === options.name,
);
if (eligibleJob && eligibleJob.handler) {
// Execute the job directly without scheduling
const payload = options.payload;
const io: JobRunIO = {
wait: async (_cacheKey: string, ms: number): Promise<void> => {
// Create a Promise that resolves after ms milliseconds
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
},
logger: console,
runTask: async <R>(cacheKey: string, callback: () => Promise<R>): Promise<R> => {
return await callback();
},
triggerJob: async (
_cacheKey: string,
jobOptions: SimpleTriggerJobOptions,
): Promise<unknown> => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return await this.triggerJob(jobOptions as TriggerJobOptions<T>);
},
};
try {
const result = await eligibleJob.handler({ payload, io });
return result;
} catch (error) {
console.error(`Direct job execution failed for ${options.name}:`, error);
throw error;
}
}
}
// Use background processing with Inngest if available
public async triggerJob(options: TriggerJobOptions<T>) {
return this._provider.triggerJob(options);
}

View File

@ -32,49 +32,16 @@ export const run = async ({
}) => {
const { userId, documentId, recipientId, requestMetadata } = payload;
try {
// First, check if the document exists directly before performing the multi-promise
const documentExists = await prisma.document.findFirst({
const [user, document, recipient] = await Promise.all([
prisma.user.findFirstOrThrow({
where: {
id: documentId,
id: userId,
},
select: { id: true },
});
if (!documentExists) {
throw new Error(`No Document found with ID ${documentId}`);
}
// Add a small delay to allow any pending transactions to complete
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 100);
});
const [user, recipient] = await Promise.all([
prisma.user.findFirstOrThrow({
where: {
id: userId,
},
}),
prisma.recipient.findFirstOrThrow({
where: {
id: recipientId,
},
}),
]);
// Get the document without restricting to PENDING status
const document = await prisma.document.findFirstOrThrow({
}),
prisma.document.findFirstOrThrow({
where: {
id: documentId,
// Don't restrict to PENDING status, as it might still be in DRAFT status
// if the transaction hasn't fully completed yet
status: {
in: [DocumentStatus.DRAFT, DocumentStatus.PENDING],
},
status: DocumentStatus.PENDING,
},
include: {
documentMeta: true,
@ -86,158 +53,160 @@ export const run = async ({
},
},
},
});
}),
prisma.recipient.findFirstOrThrow({
where: {
id: recipientId,
},
}),
]);
const { documentMeta, team } = document;
const { documentMeta, team } = document;
if (recipient.role === RecipientRole.CC) {
return;
}
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
if (!isRecipientSigningRequestEmailEnabled) {
return;
}
const customEmail = document?.documentMeta;
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
const isTeamDocument = document.teamId !== null;
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const i18n = await getI18nInstance(documentMeta?.language);
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailMessage = customEmail?.message || '';
let emailSubject = i18n._(msg`Please ${recipientActionVerb} this document`);
if (selfSigner) {
emailMessage = i18n._(
msg`You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(msg`Please ${recipientActionVerb} your document`);
}
if (isDirectTemplate) {
emailMessage = i18n._(
msg`A document was created by your direct template that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(
msg`Please ${recipientActionVerb} this document created by your direct template`,
);
}
if (isTeamDocument && team) {
emailSubject = i18n._(msg`${team.name} invited you to ${recipientActionVerb} a document`);
emailMessage = customEmail?.message ?? '';
if (!emailMessage) {
const inviterName = user.name || '';
emailMessage = i18n._(
team.teamGlobalSettings?.includeSenderDetails
? msg`${inviterName} on behalf of "${team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".`
: msg`${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
);
}
}
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': document.title,
};
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: isTeamDocument ? team?.teamEmail?.email || user.email : user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
role: recipient.role,
selfSigner,
isTeamInvite: isTeamDocument,
teamName: team?.name,
teamEmail: team?.teamEmail?.email,
includeSenderDetails: team?.teamGlobalSettings?.includeSenderDetails,
});
await io.runTask('send-signing-email', async () => {
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: renderCustomEmailTemplate(
documentMeta?.subject || emailSubject,
customEmailTemplate,
),
html,
text,
});
});
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
});
await io.runTask('store-audit-log', async () => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
recipientRole: recipient.role,
isResending: false,
},
}),
});
});
} catch (error) {
console.error(`Job failed with error:`, error);
throw error;
if (recipient.role === RecipientRole.CC) {
return;
}
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
if (!isRecipientSigningRequestEmailEnabled) {
return;
}
const customEmail = document?.documentMeta;
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
const isTeamDocument = document.teamId !== null;
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const i18n = await getI18nInstance(documentMeta?.language);
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailMessage = customEmail?.message || '';
let emailSubject = i18n._(msg`Please ${recipientActionVerb} this document`);
if (selfSigner) {
emailMessage = i18n._(
msg`You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(msg`Please ${recipientActionVerb} your document`);
}
if (isDirectTemplate) {
emailMessage = i18n._(
msg`A document was created by your direct template that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(
msg`Please ${recipientActionVerb} this document created by your direct template`,
);
}
if (isTeamDocument && team) {
emailSubject = i18n._(msg`${team.name} invited you to ${recipientActionVerb} a document`);
emailMessage = customEmail?.message ?? '';
if (!emailMessage) {
const inviterName = user.name || '';
emailMessage = i18n._(
team.teamGlobalSettings?.includeSenderDetails
? msg`${inviterName} on behalf of "${team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".`
: msg`${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
);
}
}
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': document.title,
};
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: isTeamDocument ? team?.teamEmail?.email || user.email : user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
role: recipient.role,
selfSigner,
isTeamInvite: isTeamDocument,
teamName: team?.name,
teamEmail: team?.teamEmail?.email,
includeSenderDetails: team?.teamGlobalSettings?.includeSenderDetails,
});
await io.runTask('send-signing-email', async () => {
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: renderCustomEmailTemplate(
documentMeta?.subject || emailSubject,
customEmailTemplate,
),
html,
text,
});
});
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
});
await io.runTask('store-audit-log', async () => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
recipientRole: recipient.role,
isResending: false,
},
}),
});
});
};

View File

@ -28,6 +28,7 @@
"@lingui/core": "^5.2.0",
"@lingui/macro": "^5.2.0",
"@lingui/react": "^5.2.0",
"jose": "^6.0.0",
"@noble/ciphers": "0.4.0",
"@noble/hashes": "1.3.2",
"@node-rs/bcrypt": "^1.10.0",
@ -37,7 +38,6 @@
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",
"jose": "^6.0.0",
"kysely": "0.26.3",
"luxon": "^3.4.0",
"micro": "^10.0.1",
@ -47,7 +47,6 @@
"pg": "^8.11.3",
"playwright": "1.43.0",
"posthog-js": "^1.224.0",
"posthog-node": "^4.17.1",
"react": "^18",
"remeda": "^2.17.3",
"sharp": "0.32.6",
@ -60,4 +59,4 @@
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4"
}
}
}

View File

@ -133,6 +133,31 @@ export const sendDocument = async ({
Object.assign(document, result);
}
// Commented out server side checks for minimum 1 signature per signer now since we need to
// decide if we want to enforce this for API & templates.
// const fields = await getFieldsForDocument({
// documentId: documentId,
// userId: userId,
// });
// const fieldsWithSignerEmail = fields.map((field) => ({
// ...field,
// signerEmail:
// document.Recipient.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
// }));
// const everySignerHasSignature = document?.Recipient.every(
// (recipient) =>
// recipient.role !== RecipientRole.SIGNER ||
// fieldsWithSignerEmail.some(
// (field) => field.type === 'SIGNATURE' && field.signerEmail === recipient.email,
// ),
// );
// if (!everySignerHasSignature) {
// throw new Error('Some signers have not been assigned a signature field.');
// }
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
@ -140,14 +165,52 @@ export const sendDocument = async ({
// Only send email if one of the following is true:
// - It is explicitly set
// - The email is enabled for signing requests AND sendEmail is undefined
const shouldSendEmail =
sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined);
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId,
documentId,
recipientId: recipient.id,
requestMetadata: requestMetadata?.requestMetadata,
},
});
}),
);
}
const allRecipientsHaveNoActionToTake = document.recipients.every(
(recipient) =>
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
);
if (allRecipientsHaveNoActionToTake) {
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId,
requestMetadata: requestMetadata?.requestMetadata,
},
});
// Keep the return type the same for the `sendDocument` method
return await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentMeta: true,
recipients: true,
},
});
}
const updatedDocument = await prisma.$transaction(async (tx) => {
if (document.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
@ -181,47 +244,5 @@ export const sendDocument = async ({
teamId,
});
// Now that the transaction is complete and document status is updated, trigger email jobs
if (shouldSendEmail) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId,
documentId,
recipientId: recipient.id,
requestMetadata: requestMetadata?.requestMetadata,
},
});
}),
);
}
if (allRecipientsHaveNoActionToTake) {
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId,
requestMetadata: requestMetadata?.requestMetadata,
},
});
// Keep the return type the same for the `sendDocument` method
return await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentMeta: true,
recipients: true,
},
});
}
return updatedDocument;
};

View File

@ -1,4 +1,5 @@
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
@ -118,6 +119,7 @@ export const updateDocument = async ({
// If no data just return the document since this function is normally chained after a meta update.
if (!data || Object.values(data).length === 0) {
console.log('no data');
return document;
}

View File

@ -1,6 +1,7 @@
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { FolderType } from '@documenso/prisma/generated/types';
import { TemplateSchema } from '@documenso/prisma/generated/zod/modelSchema//TemplateSchema';
import type { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
@ -44,10 +45,13 @@ export const createTemplate = async ({
}
}
let folder = null;
if (folderId) {
const folder = await prisma.folder.findFirst({
folder = await prisma.folder.findFirst({
where: {
id: folderId,
type: FolderType.TEMPLATE,
...(teamId
? {
team: {
@ -67,23 +71,17 @@ export const createTemplate = async ({
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
throw new AppError(AppErrorCode.NOT_FOUND);
}
}
if (teamId && !team) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return await prisma.template.create({
data: {
title,
userId,
templateDocumentDataId,
teamId,
folderId: folderId,
folderId: folder?.id,
templateMeta: {
create: {
language: team?.teamGlobalSettings?.documentLanguage,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff