This commit is contained in:
Mythie
2025-01-02 15:33:37 +11:00
committed by David Nguyen
parent 9183f668d3
commit f7a98180d7
413 changed files with 29538 additions and 1606 deletions

View File

@ -1,5 +1,6 @@
import { JobClient } from './client/client';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
@ -19,6 +20,7 @@ export const jobsClient = new JobClient([
SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
] as const);

View File

@ -1,5 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type { Context as HonoContext } from 'hono';
import type { JobDefinition, SimpleTriggerJobOptions } from './_internal/job';
export abstract class BaseJobProvider {
@ -16,4 +18,8 @@ export abstract class BaseJobProvider {
public getApiHandler(): (req: NextApiRequest, res: NextApiResponse) => Promise<Response | void> {
throw new Error('Not implemented');
}
public getHonoApiHandler(): (req: HonoContext) => Promise<Response | void> {
throw new Error('Not implemented');
}
}

View File

@ -27,4 +27,8 @@ export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
public getApiHandler() {
return this._provider.getApiHandler();
}
public getHonoApiHandler() {
return this._provider.getHonoApiHandler();
}
}

View File

@ -1,8 +1,10 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type { NextRequest } from 'next/server';
import type { Context as HonoContext } from 'hono';
import type { Context, Handler, InngestFunction } from 'inngest';
import { Inngest as InngestClient } from 'inngest';
import { serve as createHonoPagesRoute } from 'inngest/hono';
import type { Logger } from 'inngest/middleware/logger';
import { serve as createPagesRoute } from 'inngest/next';
import { json } from 'micro';
@ -94,6 +96,18 @@ export class InngestJobProvider extends BaseJobProvider {
};
}
// Todo: Do we need to handle the above?
public getHonoApiHandler() {
return async (context: HonoContext) => {
const handler = createHonoPagesRoute({
client: this._client,
functions: this._functions,
});
return await handler(context);
};
}
private convertInngestIoToJobRunIo(ctx: Context.Any & { logger: Logger }) {
const { step } = ctx;

View File

@ -1,10 +1,11 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { sha256 } from '@noble/hashes/sha256';
import { BackgroundJobStatus, Prisma } from '@prisma/client';
import type { Context as HonoContext } from 'hono';
import { json } from 'micro';
import { prisma } from '@documenso/prisma';
import { BackgroundJobStatus, Prisma } from '@documenso/prisma/client';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
import { sign } from '../../server-only/crypto/sign';
@ -213,6 +214,152 @@ export class LocalJobProvider extends BaseJobProvider {
};
}
public getHonoApiHandler(): (context: HonoContext) => Promise<Response | void> {
return async (context: HonoContext) => {
const req = context.req;
if (req.method !== 'POST') {
context.text('Method not allowed', 405);
return;
}
const jobId = req.header('x-job-id');
const signature = req.header('x-job-signature');
const isRetry = req.header('x-job-retry') !== undefined;
const options = await req
.json()
.then(async (data) => ZSimpleTriggerJobOptionsSchema.parseAsync(data))
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
.then((data) => data as SimpleTriggerJobOptions)
.catch(() => null);
if (!options) {
context.text('Bad request', 400);
return;
}
const definition = this._jobDefinitions[options.name];
if (
typeof jobId !== 'string' ||
typeof signature !== 'string' ||
typeof options !== 'object'
) {
context.text('Bad request', 400);
return;
}
if (!definition) {
context.text('Job not found', 404);
return;
}
if (definition && !definition.enabled) {
console.log('Attempted to trigger a disabled job', options.name);
context.text('Job not found', 404);
return;
}
if (!signature || !verify(options, signature)) {
context.text('Unauthorized', 401);
return;
}
if (definition.trigger.schema) {
const result = definition.trigger.schema.safeParse(options.payload);
if (!result.success) {
context.text('Bad request', 400);
return;
}
}
console.log(`[JOBS]: Triggering job ${options.name} with payload`, options.payload);
let backgroundJob = await prisma.backgroundJob
.update({
where: {
id: jobId,
status: BackgroundJobStatus.PENDING,
},
data: {
status: BackgroundJobStatus.PROCESSING,
retried: {
increment: isRetry ? 1 : 0,
},
lastRetriedAt: isRetry ? new Date() : undefined,
},
})
.catch(() => null);
if (!backgroundJob) {
context.text('Job not found', 404);
return;
}
try {
await definition.handler({
payload: options.payload,
io: this.createJobRunIO(jobId),
});
backgroundJob = await prisma.backgroundJob.update({
where: {
id: jobId,
status: BackgroundJobStatus.PROCESSING,
},
data: {
status: BackgroundJobStatus.COMPLETED,
completedAt: new Date(),
},
});
} catch (error) {
console.log(`[JOBS]: Job ${options.name} failed`, error);
const taskHasExceededRetries = error instanceof BackgroundTaskExceededRetriesError;
const jobHasExceededRetries =
backgroundJob.retried >= backgroundJob.maxRetries &&
!(error instanceof BackgroundTaskFailedError);
if (taskHasExceededRetries || jobHasExceededRetries) {
backgroundJob = await prisma.backgroundJob.update({
where: {
id: jobId,
status: BackgroundJobStatus.PROCESSING,
},
data: {
status: BackgroundJobStatus.FAILED,
completedAt: new Date(),
},
});
context.text('Task exceeded retries', 500);
return;
}
backgroundJob = await prisma.backgroundJob.update({
where: {
id: jobId,
status: BackgroundJobStatus.PROCESSING,
},
data: {
status: BackgroundJobStatus.PENDING,
},
});
await this.submitJobToEndpoint({
jobId,
jobDefinitionId: backgroundJob.jobId,
data: options,
});
}
context.text('OK', 200);
};
}
private async submitJobToEndpoint(options: {
jobId: string;
jobDefinitionId: string;

View File

@ -58,6 +58,11 @@ export class TriggerJobProvider extends BaseJobProvider {
return handler;
}
// Hono v2 is being deprecated so not sure if we will be required.
// public getHonoApiHandler(): (req: HonoContext) => Promise<Response | void> {
// throw new Error('Not implemented');
// }
private convertTriggerIoToJobRunIo(io: IO) {
return {
wait: io.wait,

View File

@ -0,0 +1,12 @@
import { sendResetPassword } from '../../../server-only/auth/send-reset-password';
import type { TSendPasswordResetSuccessEmailJobDefinition } from './send-password-reset-success-email';
export const run = async ({
payload,
}: {
payload: TSendPasswordResetSuccessEmailJobDefinition;
}) => {
await sendResetPassword({
userId: payload.userId,
});
};

View File

@ -0,0 +1,31 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID = 'send.password.reset.success.email';
const SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
userId: z.number(),
});
export type TSendPasswordResetSuccessEmailJobDefinition = z.infer<
typeof SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION = {
id: SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID,
name: 'Send Password Reset Email',
version: '1.0.0',
trigger: {
name: SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID,
schema: SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload }) => {
const handler = await import('./send-password-reset-success-email.handler');
await handler.run({ payload });
},
} as const satisfies JobDefinition<
typeof SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID,
TSendPasswordResetSuccessEmailJobDefinition
>;

View File

@ -0,0 +1,117 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { DocumentRecipientSignedEmailTemplate } from '@documenso/email/templates/document-recipient-signed';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendRecipientSignedEmailJobDefinition } from './send-recipient-signed-email';
export const run = async ({
payload,
io,
}: {
payload: TSendRecipientSignedEmailJobDefinition;
io: JobRunIO;
}) => {
const { documentId, recipientId } = payload;
const document = await prisma.document.findFirst({
where: {
id: documentId,
recipients: {
some: {
id: recipientId,
},
},
},
include: {
recipients: {
where: {
id: recipientId,
},
},
user: true,
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.recipients.length === 0) {
throw new Error('Document has no recipients');
}
const isRecipientSignedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigned;
if (!isRecipientSignedEmailEnabled) {
return;
}
const [recipient] = document.recipients;
const { email: recipientEmail, name: recipientName } = recipient;
const { user: owner } = document;
const recipientReference = recipientName || recipientEmail;
// Don't send notification if the owner is the one who signed
if (owner.email === recipientEmail) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const i18n = await getI18nInstance(document.documentMeta?.language);
const template = createElement(DocumentRecipientSignedEmailTemplate, {
documentName: document.title,
recipientName,
recipientEmail,
assetBaseUrl,
});
await io.runTask('send-recipient-signed-email', async () => {
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: owner.name ?? '',
address: owner.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`${recipientReference} has signed "${document.title}"`),
html,
text,
});
});
};

View File

@ -1,18 +1,5 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { DocumentRecipientSignedEmailTemplate } from '@documenso/email/templates/document-recipient-signed';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_ID = 'send.recipient.signed.email';
@ -22,6 +9,10 @@ const SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
recipientId: z.number(),
});
export type TSendRecipientSignedEmailJobDefinition = z.infer<
typeof SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION = {
id: SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Recipient Signed Email',
@ -31,98 +22,9 @@ export const SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION = {
schema: SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const { documentId, recipientId } = payload;
const handler = await import('./send-recipient-signed-email.handler');
const document = await prisma.document.findFirst({
where: {
id: documentId,
recipients: {
some: {
id: recipientId,
},
},
},
include: {
recipients: {
where: {
id: recipientId,
},
},
user: true,
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.recipients.length === 0) {
throw new Error('Document has no recipients');
}
const isRecipientSignedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigned;
if (!isRecipientSignedEmailEnabled) {
return;
}
const [recipient] = document.recipients;
const { email: recipientEmail, name: recipientName } = recipient;
const { user: owner } = document;
const recipientReference = recipientName || recipientEmail;
// Don't send notification if the owner is the one who signed
if (owner.email === recipientEmail) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const i18n = await getI18nInstance(document.documentMeta?.language);
const template = createElement(DocumentRecipientSignedEmailTemplate, {
documentName: document.title,
recipientName,
recipientEmail,
assetBaseUrl,
});
await io.runTask('send-recipient-signed-email', async () => {
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: owner.name ?? '',
address: owner.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`${recipientReference} has signed "${document.title}"`),
html,
text,
});
});
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_ID,

View File

@ -1,14 +1,14 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { SendStatus, SigningStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed';
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';

View File

@ -1,18 +1,13 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { DocumentSource, DocumentStatus, RecipientRole, SendStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
import { prisma } from '@documenso/prisma';
import {
DocumentSource,
DocumentStatus,
RecipientRole,
SendStatus,
} from '@documenso/prisma/client';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import {

View File

@ -1,7 +1,6 @@
import { DocumentVisibility } from '@prisma/client';
import { z } from 'zod';
import { DocumentVisibility } from '@documenso/prisma/client';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID = 'send.team-deleted.email';

View File

@ -1,13 +1,13 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { TeamMemberRole } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { WEBAPP_BASE_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';

View File

@ -1,13 +1,13 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { TeamMemberRole } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { WEBAPP_BASE_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';

View File

@ -1,14 +1,9 @@
import { DocumentStatus, RecipientRole, SigningStatus, WebhookTriggerEvents } from '@prisma/client';
import { nanoid } from 'nanoid';
import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
RecipientRole,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';