mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
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:
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
+100
@@ -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,
|
||||
});
|
||||
};
|
||||
+33
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user