fix: add timeouts to longer transactions

This commit is contained in:
Lucas Smith
2024-02-22 11:05:49 +00:00
parent dd29845934
commit 306e5ff31f
14 changed files with 607 additions and 563 deletions

View File

@ -16,9 +16,8 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client'; import type { Prisma } from '@documenso/prisma/client';
import { getDocumentWhereInput } from './get-document-by-id';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { getDocumentWhereInput } from './get-document-by-id';
export type ResendDocumentOptions = { export type ResendDocumentOptions = {
documentId: number; documentId: number;
@ -111,40 +110,43 @@ export const resendDocument = async ({
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await prisma.$transaction(async (tx) => { await prisma.$transaction(
await mailer.sendMail({ async (tx) => {
to: { await mailer.sendMail({
address: email, to: {
name, address: email,
}, name,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
}, },
}), from: {
}); name: FROM_NAME,
}); address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
}),
});
},
{ timeout: 30_000 },
);
}), }),
); );
}; };

View File

@ -49,44 +49,47 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
downloadLink: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}/complete`, downloadLink: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}/complete`,
}); });
await prisma.$transaction(async (tx) => { await prisma.$transaction(
await mailer.sendMail({ async (tx) => {
to: { await mailer.sendMail({
address: email, to: {
name, address: email,
}, name,
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
attachments: [
{
filename: document.title,
content: Buffer.from(buffer),
}, },
], from: {
}); name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
attachments: [
{
filename: document.title,
content: Buffer.from(buffer),
},
],
});
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id, documentId: document.id,
user: null, user: null,
requestMetadata, requestMetadata,
data: { data: {
emailType: 'DOCUMENT_COMPLETED', emailType: 'DOCUMENT_COMPLETED',
recipientEmail: recipient.email, recipientEmail: recipient.email,
recipientName: recipient.name, recipientName: recipient.name,
recipientId: recipient.id, recipientId: recipient.id,
recipientRole: recipient.role, recipientRole: recipient.role,
isResending: false, isResending: false,
}, },
}), }),
}); });
}); },
{ timeout: 30_000 },
);
}), }),
); );
}; };

View File

@ -108,49 +108,52 @@ export const sendDocument = async ({
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await prisma.$transaction(async (tx) => { await prisma.$transaction(
await mailer.sendMail({ async (tx) => {
to: { await mailer.sendMail({
address: email, to: {
name, address: email,
}, name,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: false,
}, },
}), from: {
}); name: FROM_NAME,
}); address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
documentId: document.id,
user,
requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: false,
},
}),
});
},
{ timeout: 30_000 },
);
}), }),
); );

View File

@ -24,34 +24,38 @@ export const updateTitle = async ({
}, },
}); });
return await prisma.$transaction(async (tx) => { const document = await prisma.document.findFirstOrThrow({
const document = await tx.document.findFirstOrThrow({ where: {
where: { id: documentId,
id: documentId, OR: [
OR: [ {
{ userId,
userId, },
}, {
{ team: {
team: { members: {
members: { some: {
some: { userId,
userId,
},
}, },
}, },
}, },
], },
}, ],
}); },
});
if (document.title === title) { if (document.title === title) {
return document; return document;
} }
return await prisma.$transaction(async (tx) => {
// Instead of doing everything in a transaction we can use our knowledge
// of the current document title to ensure we aren't performing a conflicting
// update.
const updatedDocument = await tx.document.update({ const updatedDocument = await tx.document.update({
where: { where: {
id: documentId, id: documentId,
title: document.title,
}, },
data: { data: {
title, title,

View File

@ -9,55 +9,58 @@ export type AcceptTeamInvitationOptions = {
}; };
export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitationOptions) => { export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitationOptions) => {
await prisma.$transaction(async (tx) => { await prisma.$transaction(
const user = await tx.user.findFirstOrThrow({ async (tx) => {
where: { const user = await tx.user.findFirstOrThrow({
id: userId, where: {
}, id: userId,
}); },
});
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({ const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
where: { where: {
teamId, teamId,
email: user.email, email: user.email,
}, },
include: { include: {
team: { team: {
include: { include: {
subscription: true, subscription: true,
},
}, },
}, },
}, });
});
const { team } = teamMemberInvite; const { team } = teamMemberInvite;
await tx.teamMember.create({ await tx.teamMember.create({
data: { data: {
teamId: teamMemberInvite.teamId,
userId: user.id,
role: teamMemberInvite.role,
},
});
await tx.teamMemberInvite.delete({
where: {
id: teamMemberInvite.id,
},
});
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId: teamMemberInvite.teamId, teamId: teamMemberInvite.teamId,
userId: user.id,
role: teamMemberInvite.role,
}, },
}); });
await updateSubscriptionItemQuantity({ await tx.teamMemberInvite.delete({
priceId: team.subscription.priceId, where: {
subscriptionId: team.subscription.planId, id: teamMemberInvite.id,
quantity: numberOfSeats, },
}); });
}
}); if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId: teamMemberInvite.teamId,
},
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
});
}
},
{ timeout: 30_000 },
);
}; };

View File

@ -28,56 +28,59 @@ export const createTeamEmailVerification = async ({
data, data,
}: CreateTeamEmailVerificationOptions) => { }: CreateTeamEmailVerificationOptions) => {
try { try {
await prisma.$transaction(async (tx) => { await prisma.$transaction(
const team = await tx.team.findFirstOrThrow({ async (tx) => {
where: { const team = await tx.team.findFirstOrThrow({
id: teamId, where: {
members: { id: teamId,
some: { members: {
userId, some: {
role: { userId,
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
}, },
}, },
}, },
}, include: {
include: { teamEmail: true,
teamEmail: true, emailVerification: true,
emailVerification: true, },
}, });
});
if (team.teamEmail || team.emailVerification) { if (team.teamEmail || team.emailVerification) {
throw new AppError( throw new AppError(
AppErrorCode.INVALID_REQUEST, AppErrorCode.INVALID_REQUEST,
'Team already has an email or existing email verification.', 'Team already has an email or existing email verification.',
); );
} }
const existingTeamEmail = await tx.teamEmail.findFirst({ const existingTeamEmail = await tx.teamEmail.findFirst({
where: { where: {
email: data.email, email: data.email,
}, },
}); });
if (existingTeamEmail) { if (existingTeamEmail) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.'); throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
} }
const { token, expiresAt } = createTokenVerification({ hours: 1 }); const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.create({ await tx.teamEmailVerification.create({
data: { data: {
token, token,
expiresAt, expiresAt,
email: data.email, email: data.email,
name: data.name, name: data.name,
teamId, teamId,
}, },
}); });
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url); await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
}); },
{ timeout: 30_000 },
);
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@ -27,76 +27,81 @@ export const deleteTeamMembers = async ({
teamId, teamId,
teamMemberIds, teamMemberIds,
}: DeleteTeamMembersOptions) => { }: DeleteTeamMembersOptions) => {
await prisma.$transaction(async (tx) => { await prisma.$transaction(
// Find the team and validate that the user is allowed to remove members. async (tx) => {
const team = await tx.team.findFirstOrThrow({ // Find the team and validate that the user is allowed to remove members.
where: { const team = await tx.team.findFirstOrThrow({
id: teamId, where: {
members: { id: teamId,
some: { members: {
userId, some: {
role: { userId,
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
}, },
}, },
}, },
}, include: {
include: { members: {
members: { select: {
select: { id: true,
id: true, userId: true,
userId: true, role: true,
role: true, },
},
subscription: true,
},
});
const currentTeamMember = team.members.find((member) => member.userId === userId);
const teamMembersToRemove = team.members.filter((member) =>
teamMemberIds.includes(member.id),
);
if (!currentTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist');
}
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner');
}
const isMemberToRemoveHigherRole = teamMembersToRemove.some(
(member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role),
);
if (isMemberToRemoveHigherRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
}
// Remove the team members.
await tx.teamMember.deleteMany({
where: {
id: {
in: teamMemberIds,
},
teamId,
userId: {
not: team.ownerUserId,
}, },
}, },
subscription: true,
},
});
const currentTeamMember = team.members.find((member) => member.userId === userId);
const teamMembersToRemove = team.members.filter((member) => teamMemberIds.includes(member.id));
if (!currentTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist');
}
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner');
}
const isMemberToRemoveHigherRole = teamMembersToRemove.some(
(member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role),
);
if (isMemberToRemoveHigherRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
}
// Remove the team members.
await tx.teamMember.deleteMany({
where: {
id: {
in: teamMemberIds,
},
teamId,
userId: {
not: team.ownerUserId,
},
},
});
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,
},
}); });
await updateSubscriptionItemQuantity({ if (IS_BILLING_ENABLED() && team.subscription) {
priceId: team.subscription.priceId, const numberOfSeats = await tx.teamMember.count({
subscriptionId: team.subscription.planId, where: {
quantity: numberOfSeats, teamId,
}); },
} });
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
});
}
},
{ timeout: 30_000 },
);
}; };

View File

@ -9,34 +9,37 @@ export type DeleteTeamOptions = {
}; };
export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => { export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
await prisma.$transaction(async (tx) => { await prisma.$transaction(
const team = await tx.team.findFirstOrThrow({ async (tx) => {
where: { const team = await tx.team.findFirstOrThrow({
id: teamId, where: {
ownerUserId: userId, id: teamId,
}, ownerUserId: userId,
include: { },
subscription: true, include: {
}, subscription: true,
}); },
});
if (team.subscription) { if (team.subscription) {
await stripe.subscriptions await stripe.subscriptions
.cancel(team.subscription.planId, { .cancel(team.subscription.planId, {
prorate: false, prorate: false,
invoice_now: true, invoice_now: true,
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
throw AppError.parseError(err); throw AppError.parseError(err);
}); });
} }
await tx.team.delete({ await tx.team.delete({
where: { where: {
id: teamId, id: teamId,
ownerUserId: userId, ownerUserId: userId,
}, },
}); });
}); },
{ timeout: 30_000 },
);
}; };

View File

@ -15,45 +15,48 @@ export type LeaveTeamOptions = {
}; };
export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => { export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => {
await prisma.$transaction(async (tx) => { await prisma.$transaction(
const team = await tx.team.findFirstOrThrow({ async (tx) => {
where: { const team = await tx.team.findFirstOrThrow({
id: teamId, where: {
ownerUserId: { id: teamId,
not: userId,
},
},
include: {
subscription: true,
},
});
await tx.teamMember.delete({
where: {
userId_teamId: {
userId,
teamId,
},
team: {
ownerUserId: { ownerUserId: {
not: userId, not: userId,
}, },
}, },
}, include: {
}); subscription: true,
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,
}, },
}); });
await updateSubscriptionItemQuantity({ await tx.teamMember.delete({
priceId: team.subscription.priceId, where: {
subscriptionId: team.subscription.planId, userId_teamId: {
quantity: numberOfSeats, userId,
teamId,
},
team: {
ownerUserId: {
not: userId,
},
},
},
}); });
}
}); if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,
},
});
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
});
}
},
{ timeout: 30_000 },
);
}; };

View File

@ -44,63 +44,66 @@ export const requestTeamOwnershipTransfer = async ({
// Todo: Clear payment methods disabled for now. // Todo: Clear payment methods disabled for now.
const clearPaymentMethods = false; const clearPaymentMethods = false;
await prisma.$transaction(async (tx) => { await prisma.$transaction(
const team = await tx.team.findFirstOrThrow({ async (tx) => {
where: { const team = await tx.team.findFirstOrThrow({
id: teamId, where: {
ownerUserId: userId, id: teamId,
members: { ownerUserId: userId,
some: { members: {
userId: newOwnerUserId, some: {
userId: newOwnerUserId,
},
}, },
}, },
}, });
});
const newOwnerUser = await tx.user.findFirstOrThrow({ const newOwnerUser = await tx.user.findFirstOrThrow({
where: { where: {
id: newOwnerUserId, id: newOwnerUserId,
}, },
}); });
const { token, expiresAt } = createTokenVerification({ minute: 10 }); const { token, expiresAt } = createTokenVerification({ minute: 10 });
const teamVerificationPayload = { const teamVerificationPayload = {
teamId,
token,
expiresAt,
userId: newOwnerUserId,
name: newOwnerUser.name ?? '',
email: newOwnerUser.email,
clearPaymentMethods,
};
await tx.teamTransferVerification.upsert({
where: {
teamId, teamId,
}, token,
create: teamVerificationPayload, expiresAt,
update: teamVerificationPayload, userId: newOwnerUserId,
}); name: newOwnerUser.name ?? '',
email: newOwnerUser.email,
clearPaymentMethods,
};
const template = createElement(TeamTransferRequestTemplate, { await tx.teamTransferVerification.upsert({
assetBaseUrl: WEBAPP_BASE_URL, where: {
baseUrl: WEBAPP_BASE_URL, teamId,
senderName: userName, },
teamName: team.name, create: teamVerificationPayload,
teamUrl: team.url, update: teamVerificationPayload,
token, });
});
await mailer.sendMail({ const template = createElement(TeamTransferRequestTemplate, {
to: newOwnerUser.email, assetBaseUrl: WEBAPP_BASE_URL,
from: { baseUrl: WEBAPP_BASE_URL,
name: FROM_NAME, senderName: userName,
address: FROM_ADDRESS, teamName: team.name,
}, teamUrl: team.url,
subject: `You have been requested to take ownership of team ${team.name} on Documenso`, token,
html: render(template), });
text: render(template, { plainText: true }),
}); await mailer.sendMail({
}); to: newOwnerUser.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
},
{ timeout: 30_000 },
);
}; };

View File

@ -17,49 +17,52 @@ export const resendTeamEmailVerification = async ({
userId, userId,
teamId, teamId,
}: ResendTeamMemberInvitationOptions) => { }: ResendTeamMemberInvitationOptions) => {
await prisma.$transaction(async (tx) => { await prisma.$transaction(
const team = await tx.team.findUniqueOrThrow({ async (tx) => {
where: { const team = await tx.team.findUniqueOrThrow({
id: teamId, where: {
members: { id: teamId,
some: { members: {
userId, some: {
role: { userId,
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
}, },
}, },
}, },
}, include: {
include: { emailVerification: true,
emailVerification: true, },
}, });
});
if (!team) { if (!team) {
throw new AppError('TeamNotFound', 'User is not a member of the team.'); throw new AppError('TeamNotFound', 'User is not a member of the team.');
} }
const { emailVerification } = team; const { emailVerification } = team;
if (!emailVerification) { if (!emailVerification) {
throw new AppError( throw new AppError(
'VerificationNotFound', 'VerificationNotFound',
'No team email verification exists for this team.', 'No team email verification exists for this team.',
); );
} }
const { token, expiresAt } = createTokenVerification({ hours: 1 }); const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.update({ await tx.teamEmailVerification.update({
where: { where: {
teamId, teamId,
}, },
data: { data: {
token, token,
expiresAt, expiresAt,
}, },
}); });
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url); await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
}); },
{ timeout: 30_000 },
);
}; };

View File

@ -35,42 +35,45 @@ export const resendTeamMemberInvitation = async ({
teamId, teamId,
invitationId, invitationId,
}: ResendTeamMemberInvitationOptions) => { }: ResendTeamMemberInvitationOptions) => {
await prisma.$transaction(async (tx) => { await prisma.$transaction(
const team = await tx.team.findUniqueOrThrow({ async (tx) => {
where: { const team = await tx.team.findUniqueOrThrow({
id: teamId, where: {
members: { id: teamId,
some: { members: {
userId, some: {
role: { userId,
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
}, },
}, },
}, },
}, });
});
if (!team) { if (!team) {
throw new AppError('TeamNotFound', 'User is not a valid member of the team.'); throw new AppError('TeamNotFound', 'User is not a valid member of the team.');
} }
const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({ const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({
where: { where: {
id: invitationId, id: invitationId,
teamId, teamId,
}, },
}); });
if (!teamMemberInvite) { if (!teamMemberInvite) {
throw new AppError('InviteNotFound', 'No invite exists for this user.'); throw new AppError('InviteNotFound', 'No invite exists for this user.');
} }
await sendTeamMemberInviteEmail({ await sendTeamMemberInviteEmail({
email: teamMemberInvite.email, email: teamMemberInvite.email,
token: teamMemberInvite.token, token: teamMemberInvite.token,
teamName: team.name, teamName: team.name,
teamUrl: team.url, teamUrl: team.url,
senderName: userName, senderName: userName,
}); });
}); },
{ timeout: 30_000 },
);
}; };

View File

@ -11,78 +11,81 @@ export type TransferTeamOwnershipOptions = {
}; };
export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOptions) => { export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOptions) => {
await prisma.$transaction(async (tx) => { await prisma.$transaction(
const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({ async (tx) => {
where: { const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({
token, where: {
}, token,
include: { },
team: { include: {
include: { team: {
subscription: true, include: {
subscription: true,
},
}, },
}, },
},
});
const { team, userId: newOwnerUserId } = teamTransferVerification;
await tx.teamTransferVerification.delete({
where: {
teamId: team.id,
},
});
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
teamMembers: {
some: {
teamId: team.id,
},
},
},
include: {
Subscription: true,
},
});
let teamSubscription: Stripe.Subscription | null = null;
if (IS_BILLING_ENABLED()) {
teamSubscription = await transferTeamSubscription({
user: newOwnerUser,
team,
clearPaymentMethods: teamTransferVerification.clearPaymentMethods,
}); });
}
if (teamSubscription) { const { team, userId: newOwnerUserId } = teamTransferVerification;
await tx.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(teamSubscription, undefined, team.id),
);
}
await tx.team.update({ await tx.teamTransferVerification.delete({
where: { where: {
id: team.id, teamId: team.id,
}, },
data: { });
ownerUserId: newOwnerUserId,
members: { const newOwnerUser = await tx.user.findFirstOrThrow({
update: { where: {
where: { id: newOwnerUserId,
userId_teamId: { teamMembers: {
teamId: team.id, some: {
userId: newOwnerUserId, teamId: team.id,
},
},
},
include: {
Subscription: true,
},
});
let teamSubscription: Stripe.Subscription | null = null;
if (IS_BILLING_ENABLED()) {
teamSubscription = await transferTeamSubscription({
user: newOwnerUser,
team,
clearPaymentMethods: teamTransferVerification.clearPaymentMethods,
});
}
if (teamSubscription) {
await tx.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(teamSubscription, undefined, team.id),
);
}
await tx.team.update({
where: {
id: team.id,
},
data: {
ownerUserId: newOwnerUserId,
members: {
update: {
where: {
userId_teamId: {
teamId: team.id,
userId: newOwnerUserId,
},
},
data: {
role: TeamMemberRole.ADMIN,
}, },
}, },
data: {
role: TeamMemberRole.ADMIN,
},
}, },
}, },
}, });
}); },
}); { timeout: 30_000 },
);
}; };

View File

@ -53,47 +53,50 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
await Promise.allSettled( await Promise.allSettled(
acceptedTeamInvites.map(async (invite) => acceptedTeamInvites.map(async (invite) =>
prisma prisma
.$transaction(async (tx) => { .$transaction(
await tx.teamMember.create({ async (tx) => {
data: { await tx.teamMember.create({
teamId: invite.teamId, data: {
userId: user.id, teamId: invite.teamId,
role: invite.role, userId: user.id,
}, role: invite.role,
});
await tx.teamMemberInvite.delete({
where: {
id: invite.id,
},
});
if (!IS_BILLING_ENABLED()) {
return;
}
const team = await tx.team.findFirstOrThrow({
where: {
id: invite.teamId,
},
include: {
members: {
select: {
id: true,
},
}, },
subscription: true,
},
});
if (team.subscription) {
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: team.members.length,
}); });
}
}) await tx.teamMemberInvite.delete({
where: {
id: invite.id,
},
});
if (!IS_BILLING_ENABLED()) {
return;
}
const team = await tx.team.findFirstOrThrow({
where: {
id: invite.teamId,
},
include: {
members: {
select: {
id: true,
},
},
subscription: true,
},
});
if (team.subscription) {
await updateSubscriptionItemQuantity({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: team.members.length,
});
}
},
{ timeout: 30_000 },
)
.catch(async () => { .catch(async () => {
await prisma.teamMemberInvite.update({ await prisma.teamMemberInvite.update({
where: { where: {