From 9f680c7a614b994b48d12c553a15e1e4b05eb8b2 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 13 Mar 2026 14:51:53 +1100 Subject: [PATCH] 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. --- packages/api/v1/implementation.ts | 2 +- .../ee/server-only/lib/create-email-domain.ts | 65 ++++++----- .../lib/link-organisation-account.ts | 106 +++++++++--------- packages/lib/jobs/client.ts | 2 + ...ated-from-direct-template-email.handler.ts | 100 +++++++++++++++++ ...ment-created-from-direct-template-email.ts | 33 ++++++ .../2fa/email/send-2fa-token-email.ts | 51 ++++----- .../document/complete-document-with-token.ts | 18 +-- .../server-only/document/resend-document.ts | 69 ++++++------ .../server-only/envelope/create-envelope.ts | 20 ++-- .../accept-organisation-invitation.ts | 47 ++++---- .../team/create-team-email-verification.ts | 53 +++++---- packages/lib/server-only/team/delete-team.ts | 53 +++++---- .../team/resend-team-email-verification.ts | 41 ++++--- .../create-document-from-direct-template.ts | 63 +++-------- .../template/create-document-from-template.ts | 22 ++-- .../lib/server-only/user/reset-password.ts | 12 +- packages/prisma/index.ts | 4 + .../admin-router/create-stripe-customer.ts | 26 ++--- .../admin-router/update-subscription-claim.ts | 32 +++--- 20 files changed, 455 insertions(+), 364 deletions(-) create mode 100644 packages/lib/jobs/definitions/emails/send-document-created-from-direct-template-email.handler.ts create mode 100644 packages/lib/jobs/definitions/emails/send-document-created-from-direct-template-email.ts diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 3181537f5..52eee4efa 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -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, diff --git a/packages/ee/server-only/lib/create-email-domain.ts b/packages/ee/server-only/lib/create-email-domain.ts index 061a71cc1..8738c06e4 100644 --- a/packages/ee/server-only/lib/create-email-domain.ts +++ b/packages/ee/server-only/lib/create-email-domain.ts @@ -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 { diff --git a/packages/ee/server-only/lib/link-organisation-account.ts b/packages/ee/server-only/lib/link-organisation-account.ts index 29e296a0c..fcbdd98f7 100644 --- a/packages/ee/server-only/lib/link-organisation-account.ts +++ b/packages/ee/server-only/lib/link-organisation-account.ts @@ -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, + }); + } }; diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index 33ea6ee52..b9f5c6368 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -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, diff --git a/packages/lib/jobs/definitions/emails/send-document-created-from-direct-template-email.handler.ts b/packages/lib/jobs/definitions/emails/send-document-created-from-direct-template-email.handler.ts new file mode 100644 index 000000000..e348ca6c0 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-document-created-from-direct-template-email.handler.ts @@ -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, + }); +}; diff --git a/packages/lib/jobs/definitions/emails/send-document-created-from-direct-template-email.ts b/packages/lib/jobs/definitions/emails/send-document-created-from-direct-template-email.ts new file mode 100644 index 000000000..f92b34d97 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-document-created-from-direct-template-email.ts @@ -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 +>; diff --git a/packages/lib/server-only/2fa/email/send-2fa-token-email.ts b/packages/lib/server-only/2fa/email/send-2fa-token-email.ts index 678b5d6e1..41c4e8740 100644 --- a/packages/lib/server-only/2fa/email/send-2fa-token-email.ts +++ b/packages/lib/server-only/2fa/email/send-2fa-token-email.ts @@ -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, + }, + }), + }); }; diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index 3b1ff67b8..51826f9ae 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -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, + }, }); } } diff --git a/packages/lib/server-only/document/resend-document.ts b/packages/lib/server-only/document/resend-document.ts index 632532830..6f610da54 100644 --- a/packages/lib/server-only/document/resend-document.ts +++ b/packages/lib/server-only/document/resend-document.ts @@ -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, + }, + }), + }); }), ); diff --git a/packages/lib/server-only/envelope/create-envelope.ts b/packages/lib/server-only/envelope/create-envelope.ts index 4c2ed5756..ced6c5610 100644 --- a/packages/lib/server-only/envelope/create-envelope.ts +++ b/packages/lib/server-only/envelope/create-envelope.ts @@ -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; }; diff --git a/packages/lib/server-only/organisation/accept-organisation-invitation.ts b/packages/lib/server-only/organisation/accept-organisation-invitation.ts index 37fb82362..4baeb83d0 100644 --- a/packages/lib/server-only/organisation/accept-organisation-invitation.ts +++ b/packages/lib/server-only/organisation/accept-organisation-invitation.ts @@ -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, + }, + }); + } }; diff --git a/packages/lib/server-only/team/create-team-email-verification.ts b/packages/lib/server-only/team/create-team-email-verification.ts index f2565d6a8..77112495b 100644 --- a/packages/lib/server-only/team/create-team-email-verification.ts +++ b/packages/lib/server-only/team/create-team-email-verification.ts @@ -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); diff --git a/packages/lib/server-only/team/delete-team.ts b/packages/lib/server-only/team/delete-team.ts index 9ae1cfc68..712304e7e 100644 --- a/packages/lib/server-only/team/delete-team.ts +++ b/packages/lib/server-only/team/delete-team.ts @@ -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 = { diff --git a/packages/lib/server-only/team/resend-team-email-verification.ts b/packages/lib/server-only/team/resend-team-email-verification.ts index 79a9b2581..c0021c242 100644 --- a/packages/lib/server-only/team/resend-team-email-verification.ts +++ b/packages/lib/server-only/team/resend-team-email-verification.ts @@ -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); }; diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index e482d4d85..1260cb21d 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -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({ diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 6d848303b..b1d6de632 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -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; }; diff --git a/packages/lib/server-only/user/reset-password.ts b/packages/lib/server-only/user/reset-password.ts index c46b7c58f..fd0ccb852 100644 --- a/packages/lib/server-only/user/reset-password.ts +++ b/packages/lib/server-only/user/reset-password.ts @@ -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 { diff --git a/packages/prisma/index.ts b/packages/prisma/index.ts index 1e0b6c079..c180e3d92 100644 --- a/packages/prisma/index.ts +++ b/packages/prisma/index.ts @@ -13,6 +13,10 @@ const prisma = remember( () => new PrismaClient({ datasourceUrl: getDatabaseUrl(), + transactionOptions: { + maxWait: 5000, + timeout: 10000, + }, }), ); diff --git a/packages/trpc/server/admin-router/create-stripe-customer.ts b/packages/trpc/server/admin-router/create-stripe-customer.ts index fc846e730..438072d0d 100644 --- a/packages/trpc/server/admin-router/create-stripe-customer.ts +++ b/packages/trpc/server/admin-router/create-stripe-customer.ts @@ -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, + }, }); }); diff --git a/packages/trpc/server/admin-router/update-subscription-claim.ts b/packages/trpc/server/admin-router/update-subscription-claim.ts index f5ac9f984..81f65a35a 100644 --- a/packages/trpc/server/admin-router/update-subscription-claim.ts +++ b/packages/trpc/server/admin-router/update-subscription-claim.ts @@ -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(