perf: set global prisma transaction timeouts and reduce transaction scope (#2607)

Configure default transaction options (5s maxWait, 10s timeout) on the
PrismaClient instead of per-transaction overrides. Move side effects
like email sending, webhook triggers, and job dispatches out of
$transaction blocks to avoid holding database connections open during
network I/O.

Also extracts the direct template email into a background job and fixes
a bug where prisma was used instead of tx inside a transaction.
This commit is contained in:
Lucas Smith
2026-03-13 14:51:53 +11:00
committed by GitHub
parent 76d96d2f65
commit 9f680c7a61
20 changed files with 455 additions and 364 deletions
+1 -1
View File
@@ -1389,7 +1389,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
throw new Error('Invalid page number');
}
const recipient = await prisma.recipient.findFirst({
const recipient = await tx.recipient.findFirst({
where: {
id: Number(recipientId),
envelopeId: envelope.id,
@@ -111,41 +111,40 @@ export const createEmailDomain = async ({ domain, organisationId }: CreateEmailD
data: privateKeyFlattened,
});
const emailDomain = await prisma.$transaction(async (tx) => {
await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => {
if (err.name === 'AlreadyExistsException') {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Domain already exists in SES',
});
}
// Verify domain with SES outside a transaction to avoid holding a
// connection open during the external API call.
await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => {
if (err.name === 'AlreadyExistsException') {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Domain already exists in SES',
});
}
throw err;
});
throw err;
});
// Create email domain record.
return await tx.emailDomain.create({
data: {
id: generateDatabaseId('email_domain'),
domain,
status: EmailDomainStatus.PENDING,
organisationId,
selector: recordName,
publicKey: publicKeyFlattened,
privateKey: encryptedPrivateKey,
},
select: {
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
publicKey: true,
createdAt: true,
updatedAt: true,
lastVerifiedAt: true,
emails: true,
},
});
const emailDomain = await prisma.emailDomain.create({
data: {
id: generateDatabaseId('email_domain'),
domain,
status: EmailDomainStatus.PENDING,
organisationId,
selector: recordName,
publicKey: publicKeyFlattened,
privateKey: encryptedPrivateKey,
},
select: {
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
publicKey: true,
createdAt: true,
updatedAt: true,
lastVerifiedAt: true,
emails: true,
},
});
return {
@@ -103,61 +103,59 @@ export const linkOrganisationAccount = async ({
return;
}
await prisma.$transaction(
async (tx) => {
// Link the user if not linked yet.
if (!userAlreadyLinked) {
await tx.account.create({
data: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: clientOptions.id,
providerAccountId: oauthConfig.providerAccountId,
access_token: oauthConfig.accessToken,
expires_at: oauthConfig.expiresAt,
token_type: 'Bearer',
id_token: oauthConfig.idToken,
userId: user.id,
},
});
// Log link event.
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
},
});
// If account already exists in an unverified state, remove the password to ensure
// they cannot sign in using that method since we cannot confirm the password
// was set by the user.
if (!user.emailVerified) {
await tx.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
password: null,
// Todo: (RR7) Will need to update the "password" account after the migration.
},
});
}
}
// Only add the user to the organisation if they are not already a member.
if (!organisationMember) {
await addUserToOrganisation({
// Link the user if not linked yet.
if (!userAlreadyLinked) {
await prisma.$transaction(async (tx) => {
await tx.account.create({
data: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: clientOptions.id,
providerAccountId: oauthConfig.providerAccountId,
access_token: oauthConfig.accessToken,
expires_at: oauthConfig.expiresAt,
token_type: 'Bearer',
id_token: oauthConfig.idToken,
userId: user.id,
organisationId: tokenMetadata.data.organisationId,
organisationGroups: organisation.groups,
organisationMemberRole:
organisation.organisationAuthenticationPortal.defaultOrganisationRole,
},
});
// Log link event.
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
},
});
// If account already exists in an unverified state, remove the password to ensure
// they cannot sign in using that method since we cannot confirm the password
// was set by the user.
if (!user.emailVerified) {
await tx.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
password: null,
// Todo: (RR7) Will need to update the "password" account after the migration.
},
});
}
},
{ timeout: 30_000 },
);
});
}
// Only add the user to the organisation if they are not already a member.
// Done outside the above transaction to avoid nested transactions and
// holding connections during the job trigger network I/O.
if (!organisationMember) {
await addUserToOrganisation({
userId: user.id,
organisationId: tokenMetadata.data.organisationId,
organisationGroups: organisation.groups,
organisationMemberRole: organisation.organisationAuthenticationPortal.defaultOrganisationRole,
});
}
};
+2
View File
@@ -1,6 +1,7 @@
import { JobClient } from './client/client';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email';
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
@@ -35,6 +36,7 @@ export const jobsClient = new JobClient([
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION,
SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION,
BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION,
BULK_SEND_TEMPLATE_JOB_DEFINITION,
@@ -0,0 +1,100 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../../utils/teams';
import type { TSendDocumentCreatedFromDirectTemplateEmailJobDefinition } from './send-document-created-from-direct-template-email';
export const run = async ({
payload,
}: {
payload: TSendDocumentCreatedFromDirectTemplateEmailJobDefinition;
}) => {
const { envelopeId, recipientId } = payload;
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
recipients: {
where: {
id: recipientId,
},
},
user: {
select: {
id: true,
email: true,
name: true,
},
},
team: {
select: {
url: true,
},
},
documentMeta: true,
},
});
if (!envelope) {
throw new Error('Envelope not found');
}
if (envelope.recipients.length === 0) {
throw new Error('Recipient not found');
}
const [recipient] = envelope.recipients;
const { user: templateOwner } = envelope;
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const documentLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team?.url ?? '')}/${envelope.id}`;
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: recipient.email,
recipientRole: recipient.role,
documentLink,
documentName: envelope.title,
assetBaseUrl,
});
const i18n = await getI18nInstance(emailLanguage);
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
await mailer.sendMail({
to: [
{
name: templateOwner.name || '',
address: templateOwner.email,
},
],
from: senderEmail,
subject: i18n._(msg`Document created from direct template`),
html,
text,
});
};
@@ -0,0 +1,33 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID =
'send.document.created.from.direct.template.email';
const SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
recipientId: z.number(),
});
export type TSendDocumentCreatedFromDirectTemplateEmailJobDefinition = z.infer<
typeof SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION = {
id: SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID,
name: 'Send Document Created From Direct Template Email',
version: '1.0.0',
trigger: {
name: SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID,
schema: SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-document-created-from-direct-template-email.handler');
await handler.run({ payload });
},
} as const satisfies JobDefinition<
typeof SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_ID,
z.infer<typeof SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION_SCHEMA>
>;
@@ -108,32 +108,29 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject,
html,
text,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
{ timeout: 30_000 },
);
from: senderEmail,
replyTo: replyToEmail,
subject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
};
@@ -367,16 +367,16 @@ export const completeDocumentWithToken = async ({
: {}),
},
});
});
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: envelope.userId,
documentId: legacyDocumentId,
recipientId: nextRecipient.id,
requestMetadata,
},
});
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: envelope.userId,
documentId: legacyDocumentId,
recipientId: nextRecipient.id,
requestMetadata,
},
});
}
}
@@ -213,43 +213,40 @@ export const resendDocument = async ({
}),
]);
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: envelope.documentMeta.subject
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
customEmailTemplate,
)
: emailSubject,
html,
text,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
}),
});
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
to: {
address: email,
name,
},
{ timeout: 30_000 },
);
from: senderEmail,
replyTo: replyToEmail,
subject: envelope.documentMeta.subject
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
customEmailTemplate,
)
: emailSubject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
}),
});
}),
);
@@ -582,7 +582,7 @@ export const createEnvelope = async ({
});
}
// Only create audit logs and webhook events for documents.
// Only create audit logs for documents.
if (type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
@@ -619,17 +619,21 @@ export const createEnvelope = async ({
}),
});
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
}
return createdEnvelope;
});
// Trigger webhook outside the transaction to avoid holding the connection
// open during network I/O.
if (type === EnvelopeType.DOCUMENT) {
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
}
return createdEnvelope;
};
@@ -111,32 +111,27 @@ export const addUserToOrganisation = async ({
});
}
await prisma.$transaction(
async (tx) => {
await tx.organisationMember.create({
data: {
id: generateDatabaseId('member'),
userId,
organisationId,
organisationGroupMembers: {
create: {
id: generateDatabaseId('group_member'),
groupId: organisationGroupToUse.id,
},
},
await prisma.organisationMember.create({
data: {
id: generateDatabaseId('member'),
userId,
organisationId,
organisationGroupMembers: {
create: {
id: generateDatabaseId('group_member'),
groupId: organisationGroupToUse.id,
},
});
if (!bypassEmail) {
await jobs.triggerJob({
name: 'send.organisation-member-joined.email',
payload: {
organisationId,
memberUserId: userId,
},
});
}
},
},
{ timeout: 30_000 },
);
});
if (!bypassEmail) {
await jobs.triggerJob({
name: 'send.organisation-member-joined.email',
payload: {
organisationId,
memberUserId: userId,
},
});
}
};
@@ -52,36 +52,35 @@ export const createTeamEmailVerification = async ({
});
}
await prisma.$transaction(
async (tx) => {
const existingTeamEmail = await tx.teamEmail.findFirst({
where: {
email: data.email,
},
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await prisma.$transaction(async (tx) => {
const existingTeamEmail = await tx.teamEmail.findFirst({
where: {
email: data.email,
},
});
if (existingTeamEmail) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Email already taken by another team.',
});
}
if (existingTeamEmail) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Email already taken by another team.',
});
}
await tx.teamEmailVerification.create({
data: {
token,
expiresAt,
email: data.email,
name: data.name,
teamId,
},
});
});
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.create({
data: {
token,
expiresAt,
email: data.email,
name: data.name,
teamId,
},
});
await sendTeamEmailVerificationEmail(data.email, token, team);
},
{ timeout: 30_000 },
);
// Send email outside the transaction to avoid holding a connection
// open during network I/O.
await sendTeamEmailVerificationEmail(data.email, token, team);
} catch (err) {
console.error(err);
+25 -28
View File
@@ -78,38 +78,35 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
(member) => member.id,
);
await prisma.$transaction(
async (tx) => {
await tx.team.delete({
where: {
id: teamId,
},
});
await prisma.$transaction(async (tx) => {
await tx.team.delete({
where: {
id: teamId,
},
});
// Purge all internal organisation groups that have no teams.
await tx.organisationGroup.deleteMany({
where: {
type: OrganisationGroupType.INTERNAL_TEAM,
teamGroups: {
none: {},
},
// Purge all internal organisation groups that have no teams.
await tx.organisationGroup.deleteMany({
where: {
type: OrganisationGroupType.INTERNAL_TEAM,
teamGroups: {
none: {},
},
});
},
});
});
await jobs.triggerJob({
name: 'send.team-deleted.email',
payload: {
team: {
name: team.name,
url: team.url,
},
members: membersToNotify,
organisationId: team.organisationId,
},
});
await jobs.triggerJob({
name: 'send.team-deleted.email',
payload: {
team: {
name: team.name,
url: team.url,
},
members: membersToNotify,
organisationId: team.organisationId,
},
{ timeout: 30_000 },
);
});
};
type SendTeamDeleteEmailOptions = {
@@ -35,30 +35,27 @@ export const resendTeamEmailVerification = async ({
});
}
await prisma.$transaction(
async (tx) => {
const { emailVerification } = team;
const { emailVerification } = team;
if (!emailVerification) {
throw new AppError('VerificationNotFound', {
message: 'No team email verification exists for this team.',
});
}
if (!emailVerification) {
throw new AppError('VerificationNotFound', {
message: 'No team email verification exists for this team.',
});
}
const { token, expiresAt } = createTokenVerification({ hours: 1 });
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.update({
where: {
teamId,
},
data: {
token,
expiresAt,
},
});
await sendTeamEmailVerificationEmail(emailVerification.email, token, team);
await prisma.teamEmailVerification.update({
where: {
teamId,
},
{ timeout: 30_000 },
);
data: {
token,
expiresAt,
},
});
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await sendTeamEmailVerificationEmail(emailVerification.email, token, team);
};
@@ -1,6 +1,3 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import {
DocumentSigningOrder,
@@ -18,15 +15,12 @@ import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
@@ -48,12 +42,10 @@ import {
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { getEmailContext } from '../email/get-email-context';
import { incrementDocumentId } from '../envelope/increment-id';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentFromDirectTemplateOptions = {
@@ -156,15 +148,12 @@ export const createDocumentFromDirectTemplate = async ({
});
}
const { branding, settings, senderEmail, emailLanguage } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: directTemplateEnvelope.teamId,
},
const settings = await getTeamSettings({
userId: directTemplateEnvelope.userId,
teamId: directTemplateEnvelope.teamId,
});
const { recipients, directLink, user: templateOwner } = directTemplateEnvelope;
const { recipients, directLink } = directTemplateEnvelope;
const directTemplateRecipient = recipients.find(
(recipient) => recipient.id === directLink.directTemplateRecipientId,
@@ -755,37 +744,6 @@ export const createDocumentFromDirectTemplate = async ({
});
}
// Send email to template owner.
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: directRecipientEmail,
recipientRole: directTemplateRecipient.role,
documentLink: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(createdEnvelope.team?.url)}/${
createdEnvelope.id
}`,
documentName: createdEnvelope.title,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
{
name: templateOwner.name || '',
address: templateOwner.email,
},
],
from: senderEmail,
subject: i18n._(msg`Document created from direct template`),
html,
text,
});
return {
createdEnvelope,
token: createdDirectRecipient.token,
@@ -793,6 +751,15 @@ export const createDocumentFromDirectTemplate = async ({
};
});
// Send email to template owner via background job.
await jobs.triggerJob({
name: 'send.document.created.from.direct.template.email',
payload: {
envelopeId: createdEnvelope.id,
recipientId,
},
});
try {
// This handles sending emails and sealing the document if required.
await sendDocument({
@@ -528,7 +528,7 @@ export const createDocumentFromTemplate = async ({
}),
});
return await prisma.$transaction(async (tx) => {
const { envelope, createdEnvelope } = await prisma.$transaction(async (tx) => {
const envelope = await tx.envelope.create({
data: {
id: prefixedId('envelope'),
@@ -761,13 +761,17 @@ export const createDocumentFromTemplate = async ({
throw new Error('Document not found');
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
return envelope;
return { envelope, createdEnvelope };
});
// Trigger webhook outside the transaction to avoid holding the connection
// open during network I/O.
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
return envelope;
};
@@ -77,13 +77,13 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP
ipAddress: requestMetadata?.ipAddress,
},
});
});
await jobsClient.triggerJob({
name: 'send.password.reset.success.email',
payload: {
userId: foundToken.userId,
},
});
await jobsClient.triggerJob({
name: 'send.password.reset.success.email',
payload: {
userId: foundToken.userId,
},
});
return {
+4
View File
@@ -13,6 +13,10 @@ const prisma = remember(
() =>
new PrismaClient({
datasourceUrl: getDatabaseUrl(),
transactionOptions: {
maxWait: 5000,
timeout: 10000,
},
}),
);
@@ -38,19 +38,19 @@ export const createStripeCustomerRoute = adminProcedure
throw new AppError(AppErrorCode.NOT_FOUND);
}
await prisma.$transaction(async (tx) => {
const stripeCustomer = await createCustomer({
name: organisation.name,
email: organisation.owner.email,
});
// Create Stripe customer outside a transaction to avoid holding a
// connection open during the external API call.
const stripeCustomer = await createCustomer({
name: organisation.name,
email: organisation.owner.email,
});
await tx.organisation.update({
where: {
id: organisationId,
},
data: {
customerId: stripeCustomer.id,
},
});
await prisma.organisation.update({
where: {
id: organisationId,
},
data: {
customerId: stripeCustomer.id,
},
});
});
@@ -29,24 +29,22 @@ export const updateSubscriptionClaimRoute = adminProcedure
const newlyEnabledFlags = getNewTruthyFlags(existingClaim.flags, data.flags);
await prisma.$transaction(async (tx) => {
await tx.subscriptionClaim.update({
where: {
id,
},
data,
});
if (Object.keys(newlyEnabledFlags).length > 0) {
await jobsClient.triggerJob({
name: 'internal.backport-subscription-claims',
payload: {
subscriptionClaimId: id,
flags: newlyEnabledFlags,
},
});
}
await prisma.subscriptionClaim.update({
where: {
id,
},
data,
});
if (Object.keys(newlyEnabledFlags).length > 0) {
await jobsClient.triggerJob({
name: 'internal.backport-subscription-claims',
payload: {
subscriptionClaimId: id,
flags: newlyEnabledFlags,
},
});
}
});
function getNewTruthyFlags(