feat: add organisations (#1820)

This commit is contained in:
David Nguyen
2025-06-10 11:49:52 +10:00
committed by GitHub
parent 0b37f19641
commit e6dc237ad2
631 changed files with 37616 additions and 25695 deletions

View File

@ -1,101 +0,0 @@
import { TeamMemberInviteStatus } from '@prisma/client';
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { jobs } from '../../jobs/client';
export type AcceptTeamInvitationOptions = {
userId: number;
teamId: number;
};
export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitationOptions) => {
await prisma.$transaction(
async (tx) => {
const user = await tx.user.findFirstOrThrow({
where: {
id: userId,
},
});
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
where: {
teamId,
email: user.email,
status: {
not: TeamMemberInviteStatus.DECLINED,
},
},
include: {
team: {
include: {
subscription: true,
members: {
include: {
user: true,
},
},
},
},
},
});
if (teamMemberInvite.status === TeamMemberInviteStatus.ACCEPTED) {
const memberExists = await tx.teamMember.findFirst({
where: {
teamId: teamMemberInvite.teamId,
userId: user.id,
},
});
if (memberExists) {
return;
}
}
const { team } = teamMemberInvite;
const teamMember = await tx.teamMember.create({
data: {
teamId: teamMemberInvite.teamId,
userId: user.id,
role: teamMemberInvite.role,
},
});
await tx.teamMemberInvite.update({
where: {
id: teamMemberInvite.id,
},
data: {
status: TeamMemberInviteStatus.ACCEPTED,
},
});
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,
});
}
await jobs.triggerJob({
name: 'send.team-member-joined.email',
payload: {
teamId: teamMember.teamId,
memberId: teamMember.id,
},
});
},
{ timeout: 30_000 },
);
};

View File

@ -1,47 +0,0 @@
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { prisma } from '@documenso/prisma';
export type CreateTeamBillingPortalOptions = {
userId: number;
teamId: number;
};
export const createTeamBillingPortal = async ({
userId,
teamId,
}: CreateTeamBillingPortalOptions) => {
if (!IS_BILLING_ENABLED()) {
throw new Error('Billing is not enabled');
}
const team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_BILLING'],
},
},
},
},
include: {
subscription: true,
},
});
if (!team.subscription) {
throw new Error('Team has no subscription');
}
if (!team.customerId) {
throw new Error('Team has no customerId');
}
return getPortalSession({
customerId: team.customerId,
});
};

View File

@ -1,54 +0,0 @@
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import { getTeamPrices } from '@documenso/ee/server-only/stripe/get-team-prices';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
export type CreateTeamPendingCheckoutSession = {
userId: number;
pendingTeamId: number;
interval: 'monthly' | 'yearly';
};
export const createTeamPendingCheckoutSession = async ({
userId,
pendingTeamId,
interval,
}: CreateTeamPendingCheckoutSession) => {
const teamPendingCreation = await prisma.teamPending.findFirstOrThrow({
where: {
id: pendingTeamId,
ownerUserId: userId,
},
include: {
owner: true,
},
});
const prices = await getTeamPrices();
const priceId = prices[interval].priceId;
try {
const stripeCheckoutSession = await getCheckoutSession({
customerId: teamPendingCreation.customerId,
priceId,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/teams`,
subscriptionMetadata: {
pendingTeamId: pendingTeamId.toString(),
},
});
if (!stripeCheckoutSession) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
return stripeCheckoutSession;
} catch (e) {
console.error(e);
// Absorb all the errors incase Stripe throws something sensitive.
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Something went wrong.',
});
}
};

View File

@ -1,7 +1,7 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import type { Team } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { z } from 'zod';
@ -15,9 +15,11 @@ import { createTokenVerification } from '@documenso/lib/utils/token-verification
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
export type CreateTeamEmailVerificationOptions = {
userId: number;
@ -34,33 +36,26 @@ export const createTeamEmailVerification = async ({
data,
}: CreateTeamEmailVerificationOptions): Promise<void> => {
try {
const team = await prisma.team.findFirstOrThrow({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
include: {
teamEmail: true,
emailVerification: true,
},
});
if (team.teamEmail || team.emailVerification) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Team already has an email or existing email verification.',
});
}
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
include: {
teamEmail: true,
emailVerification: true,
teamGlobalSettings: true,
},
});
if (team.teamEmail || team.emailVerification) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Team already has an email or existing email verification.',
});
}
const existingTeamEmail = await tx.teamEmail.findFirst({
where: {
email: data.email,
@ -116,13 +111,7 @@ export const createTeamEmailVerification = async ({
* @param teamName The name of the team the user is being invited to.
* @param teamUrl The url of the team the user is being invited to.
*/
export const sendTeamEmailVerificationEmail = async (
email: string,
token: string,
team: Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
},
) => {
export const sendTeamEmailVerificationEmail = async (email: string, token: string, team: Team) => {
const assetBaseUrl = env('NEXT_PUBLIC_WEBAPP_URL') || 'http://localhost:3000';
const template = createElement(ConfirmTeamEmailTemplate, {
@ -133,11 +122,15 @@ export const sendTeamEmailVerificationEmail = async (
token,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const { branding, settings } = await getEmailContext({
source: {
type: 'team',
teamId: team.id,
},
});
const lang = team.teamGlobalSettings?.documentLanguage;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const lang = settings.documentLanguage as SupportedLanguageCodes;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),

View File

@ -1,190 +0,0 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import { TeamMemberInviteStatus } from '@prisma/client';
import { nanoid } from 'nanoid';
import { mailer } from '@documenso/email/mailer';
import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import type { TCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export type CreateTeamMemberInvitesOptions = {
userId: number;
userName: string;
teamId: number;
invitations: TCreateTeamMemberInvitesMutationSchema['invitations'];
};
/**
* Invite team members via email to join a team.
*/
export const createTeamMemberInvites = async ({
userId,
userName,
teamId,
invitations,
}: CreateTeamMemberInvitesOptions): Promise<void> => {
const team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
include: {
members: {
select: {
role: true,
user: {
select: {
id: true,
email: true,
},
},
},
},
invites: true,
teamGlobalSettings: true,
},
});
const teamMemberEmails = team.members.map((member) => member.user.email);
const teamMemberInviteEmails = team.invites.map((invite) => invite.email);
const currentTeamMember = team.members.find((member) => member.user.id === userId);
if (!currentTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User not part of team.',
});
}
const usersToInvite = invitations.filter((invitation) => {
// Filter out users that are already members of the team.
if (teamMemberEmails.includes(invitation.email)) {
return false;
}
// Filter out users that have already been invited to the team.
if (teamMemberInviteEmails.includes(invitation.email)) {
return false;
}
return true;
});
const unauthorizedRoleAccess = usersToInvite.some(
({ role }) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, role),
);
if (unauthorizedRoleAccess) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User does not have permission to set high level roles',
});
}
const teamMemberInvites = usersToInvite.map(({ email, role }) => ({
email,
teamId,
role,
status: TeamMemberInviteStatus.PENDING,
token: nanoid(32),
}));
await prisma.teamMemberInvite.createMany({
data: teamMemberInvites,
});
const sendEmailResult = await Promise.allSettled(
teamMemberInvites.map(async ({ email, token }) =>
sendTeamMemberInviteEmail({
email,
token,
team,
senderName: userName,
}),
),
);
const sendEmailResultErrorList = sendEmailResult.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
if (sendEmailResultErrorList.length > 0) {
console.error(JSON.stringify(sendEmailResultErrorList));
throw new AppError('EmailDeliveryFailed', {
message: 'Failed to send invite emails to one or more users.',
userMessage: `Failed to send invites to ${sendEmailResultErrorList.length}/${teamMemberInvites.length} users.`,
});
}
};
type SendTeamMemberInviteEmailOptions = {
email: string;
senderName: string;
token: string;
team: Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
};
};
/**
* Send an email to a user inviting them to join a team.
*/
export const sendTeamMemberInviteEmail = async ({
email,
senderName,
token,
team,
}: SendTeamMemberInviteEmailOptions) => {
const template = createElement(TeamInviteEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
senderName,
token,
teamName: team.name,
teamUrl: team.url,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: team.teamGlobalSettings?.documentLanguage, branding }),
renderEmailWithI18N(template, {
lang: team.teamGlobalSettings?.documentLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(team.teamGlobalSettings?.documentLanguage);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`You have been invited to join ${team.name} on Documenso`),
html,
text,
});
};

View File

@ -1,17 +1,18 @@
import { Prisma, TeamMemberRole } from '@prisma/client';
import type Stripe from 'stripe';
import { z } from 'zod';
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
import { getTeamRelatedPrices } from '@documenso/ee/server-only/stripe/get-team-related-prices';
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
import { isDocumentPlatform as isUserPlatformPlan } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { stripe } from '../stripe';
import { IS_BILLING_ENABLED } from '../../constants/app';
import {
LOWEST_ORGANISATION_ROLE,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
} from '../../constants/organisations';
import { TEAM_INTERNAL_GROUPS } from '../../constants/teams';
import { generateDatabaseId } from '../../universal/id';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { generateDefaultTeamSettings } from '../../utils/teams';
export type CreateTeamOptions = {
/**
@ -30,264 +31,169 @@ export type CreateTeamOptions = {
* Used as the URL path, example: https://documenso.com/t/{teamUrl}/settings
*/
teamUrl: string;
/**
* ID of the organisation the team belongs to.
*/
organisationId: string;
/**
* Whether to inherit all members from the organisation.
*/
inheritMembers: boolean;
/**
* List of additional groups to attach to the team.
*/
groups?: {
id: string;
role: TeamMemberRole;
}[];
};
export const ZCreateTeamResponseSchema = z.union([
z.object({
paymentRequired: z.literal(false),
}),
z.object({
paymentRequired: z.literal(true),
pendingTeamId: z.number(),
}),
]);
export type TCreateTeamResponse = z.infer<typeof ZCreateTeamResponseSchema>;
/**
* Create a team or pending team depending on the user's subscription or application's billing settings.
*/
export const createTeam = async ({
userId,
teamName,
teamUrl,
}: CreateTeamOptions): Promise<TCreateTeamResponse> => {
const user = await prisma.user.findUniqueOrThrow({
where: {
id: userId,
},
organisationId,
inheritMembers,
}: CreateTeamOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
subscriptions: true,
groups: true,
subscription: true,
organisationClaim: true,
owner: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
const isPlatformPlan = await isUserPlatformPlan({
userId: user.id,
teamId: null,
});
let isPaymentRequired = IS_BILLING_ENABLED();
let customerId: string | null = null;
if (IS_BILLING_ENABLED()) {
const teamRelatedPriceIds = await getTeamRelatedPrices().then((prices) =>
prices.map((price) => price.id),
);
const hasTeamRelatedSubscription = subscriptionsContainsActivePlan(
user.subscriptions,
teamRelatedPriceIds,
);
if (isPlatformPlan) {
// For platform users, check if they already have any teams
const existingTeams = await prisma.team.findMany({
where: {
ownerUserId: userId,
},
});
// Payment is required if they already have any team
isPaymentRequired = existingTeams.length > 0;
} else {
// For non-platform users, payment is required if they don't have a team-related subscription
isPaymentRequired = !hasTeamRelatedSubscription;
}
customerId = await createTeamCustomer({
name: user.name ?? teamName,
email: user.email,
}).then((customer) => customer.id);
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found.',
});
}
try {
// Create the team directly if no payment is required.
if (!isPaymentRequired) {
await prisma.$transaction(async (tx) => {
const existingUserProfileWithUrl = await tx.user.findUnique({
where: {
url: teamUrl,
},
select: {
id: true,
// Validate they have enough team slots. 0 means they can create unlimited teams.
if (organisation.organisationClaim.teamCount !== 0 && IS_BILLING_ENABLED()) {
const teamCount = await prisma.team.count({
where: {
organisationId,
},
});
if (teamCount >= organisation.organisationClaim.teamCount) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'You have reached the maximum number of teams for your plan.',
});
}
}
// Inherit internal organisation groups to the team.
// Organisation Admins/Mangers get assigned as team admins, members get assigned as team members.
const internalOrganisationGroups = organisation.groups
.filter((group) => {
if (group.type !== OrganisationGroupType.INTERNAL_ORGANISATION) {
return false;
}
// If we're inheriting members, allow all internal organisation groups.
if (inheritMembers) {
return true;
}
// Otherwise, only inherit organisation admins/managers.
return (
group.organisationRole === OrganisationMemberRole.ADMIN ||
group.organisationRole === OrganisationMemberRole.MANAGER
);
})
.map((group) =>
match(group.organisationRole)
.with(OrganisationMemberRole.ADMIN, OrganisationMemberRole.MANAGER, () => ({
organisationGroupId: group.id,
teamRole: TeamMemberRole.ADMIN,
}))
.with(OrganisationMemberRole.MEMBER, () => ({
organisationGroupId: group.id,
teamRole: TeamMemberRole.MEMBER,
}))
.exhaustive(),
);
await prisma
.$transaction(
async (tx) => {
const teamSettings = await tx.teamGlobalSettings.create({
data: {
...generateDefaultTeamSettings(),
id: generateDatabaseId('team_setting'),
},
});
if (existingUserProfileWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'URL already taken.',
});
}
const team = await tx.team.create({
data: {
name: teamName,
url: teamUrl,
ownerUserId: user.id,
customerId,
members: {
create: [
{
userId: user.id,
role: TeamMemberRole.ADMIN,
organisationId,
teamGlobalSettingsId: teamSettings.id,
teamGroups: {
createMany: {
// Attach the internal organisation groups to the team.
data: internalOrganisationGroups.map((group) => ({
...group,
id: generateDatabaseId('team_group'),
})),
},
},
},
include: {
teamGroups: true,
},
});
// Create the internal team groups.
await Promise.all(
TEAM_INTERNAL_GROUPS.map(async (teamGroup) =>
tx.organisationGroup.create({
data: {
id: generateDatabaseId('org_group'),
type: teamGroup.type,
organisationRole: LOWEST_ORGANISATION_ROLE,
organisationId,
teamGroups: {
create: {
id: generateDatabaseId('team_group'),
teamId: team.id,
teamRole: teamGroup.teamRole,
},
},
],
},
},
});
await tx.teamGlobalSettings.upsert({
where: {
teamId: team.id,
},
update: {},
create: {
teamId: team.id,
},
});
});
return {
paymentRequired: false,
};
}
// Create a pending team if payment is required.
const pendingTeam = await prisma.$transaction(async (tx) => {
const existingTeamWithUrl = await tx.team.findUnique({
where: {
url: teamUrl,
},
});
const existingUserProfileWithUrl = await tx.user.findUnique({
where: {
url: teamUrl,
},
select: {
id: true,
},
});
if (existingUserProfileWithUrl) {
},
}),
),
);
},
{
timeout: 7500,
},
)
.catch((err) => {
if (err.code === 'P2002') {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'URL already taken.',
message: 'Team URL already exists',
});
}
if (existingTeamWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Team URL already exists.',
});
}
if (!customerId) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Missing customer ID for pending teams.',
});
}
return await tx.teamPending.create({
data: {
name: teamName,
url: teamUrl,
ownerUserId: user.id,
customerId,
},
});
});
return {
paymentRequired: true,
pendingTeamId: pendingTeam.id,
};
} catch (err) {
console.error(err);
if (!(err instanceof Prisma.PrismaClientKnownRequestError)) {
throw err;
}
const target = z.array(z.string()).safeParse(err.meta?.target);
if (err.code === 'P2002' && target.success && target.data.includes('url')) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Team URL already exists.',
});
}
throw err;
}
};
export type CreateTeamFromPendingTeamOptions = {
pendingTeamId: number;
subscription: Stripe.Subscription;
};
export const createTeamFromPendingTeam = async ({
pendingTeamId,
subscription,
}: CreateTeamFromPendingTeamOptions) => {
const createdTeam = await prisma.$transaction(async (tx) => {
const pendingTeam = await tx.teamPending.findUniqueOrThrow({
where: {
id: pendingTeamId,
},
});
await tx.teamPending.delete({
where: {
id: pendingTeamId,
},
});
const team = await tx.team.create({
data: {
name: pendingTeam.name,
url: pendingTeam.url,
ownerUserId: pendingTeam.ownerUserId,
customerId: pendingTeam.customerId,
members: {
create: [
{
userId: pendingTeam.ownerUserId,
role: TeamMemberRole.ADMIN,
},
],
},
},
});
await tx.teamGlobalSettings.upsert({
where: {
teamId: team.id,
},
update: {},
create: {
teamId: team.id,
},
});
await tx.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(subscription, undefined, team.id),
);
return team;
});
// Attach the team ID to the subscription metadata for sanity reasons.
await stripe.subscriptions
.update(subscription.id, {
metadata: {
teamId: createdTeam.id.toString(),
},
})
.catch((e) => {
console.error(e);
// Non-critical error, but we want to log it so we can rectify it.
// Todo: Teams - Alert us.
});
return createdTeam;
});
};

View File

@ -1,34 +0,0 @@
import { prisma } from '@documenso/prisma';
export type DeclineTeamInvitationOptions = {
userId: number;
teamId: number;
};
export const declineTeamInvitation = async ({ userId, teamId }: DeclineTeamInvitationOptions) => {
await prisma.$transaction(
async (tx) => {
const user = await tx.user.findFirstOrThrow({
where: {
id: userId,
},
});
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
where: {
teamId,
email: user.email,
},
});
await tx.teamMemberInvite.delete({
where: {
id: teamMemberInvite.id,
},
});
// TODO: notify the team owner
},
{ timeout: 30_000 },
);
};

View File

@ -1,6 +1,8 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type DeleteTeamEmailVerificationOptions = {
userId: number;
teamId: number;
@ -10,25 +12,17 @@ export const deleteTeamEmailVerification = async ({
userId,
teamId,
}: DeleteTeamEmailVerificationOptions) => {
await prisma.$transaction(async (tx) => {
await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
});
await prisma.team.findFirstOrThrow({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
});
await tx.teamEmailVerification.delete({
where: {
teamId,
},
});
await prisma.teamEmailVerification.delete({
where: {
teamId,
},
});
};

View File

@ -12,7 +12,8 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
export type DeleteTeamEmailOptions = {
userId: number;
@ -26,47 +27,48 @@ export type DeleteTeamEmailOptions = {
* The user must either be part of the team with the required permissions, or the owner of the email.
*/
export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamEmailOptions) => {
const team = await prisma.$transaction(async (tx) => {
const foundTeam = await tx.team.findFirstOrThrow({
where: {
id: teamId,
OR: [
{
teamEmail: {
email: userEmail,
},
},
{
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
],
},
include: {
teamEmail: true,
owner: {
select: {
name: true,
email: true,
const { branding, settings } = await getEmailContext({
source: {
type: 'team',
teamId,
},
});
const team = await prisma.team.findFirstOrThrow({
where: {
OR: [
buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
{
id: teamId,
teamEmail: {
email: userEmail,
},
},
],
},
include: {
teamEmail: true,
organisation: {
select: {
owner: {
select: {
name: true,
email: true,
},
},
},
teamGlobalSettings: true,
},
});
},
});
await tx.teamEmail.delete({
where: {
teamId,
},
});
return foundTeam;
await prisma.teamEmail.delete({
where: {
teamId,
},
});
try {
@ -80,11 +82,7 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
teamUrl: team.url,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = team.teamGlobalSettings?.documentLanguage;
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
@ -95,8 +93,8 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
await mailer.sendMail({
to: {
address: team.owner.email,
name: team.owner.name ?? '',
address: team.organisation.owner.email,
name: team.organisation.owner.name ?? '',
},
from: {
name: FROM_NAME,

View File

@ -1,47 +0,0 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
export type DeleteTeamMemberInvitationsOptions = {
/**
* The ID of the user who is initiating this action.
*/
userId: number;
/**
* The ID of the team to remove members from.
*/
teamId: number;
/**
* The IDs of the invitations to remove.
*/
invitationIds: number[];
};
export const deleteTeamMemberInvitations = async ({
userId,
teamId,
invitationIds,
}: DeleteTeamMemberInvitationsOptions) => {
await prisma.$transaction(async (tx) => {
await tx.teamMember.findFirstOrThrow({
where: {
userId,
teamId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
});
await tx.teamMemberInvite.deleteMany({
where: {
id: {
in: invitationIds,
},
teamId,
},
});
});
};

View File

@ -1,111 +0,0 @@
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
export type DeleteTeamMembersOptions = {
/**
* The ID of the user who is initiating this action.
*/
userId: number;
/**
* The ID of the team to remove members from.
*/
teamId: number;
/**
* The IDs of the team members to remove.
*/
teamMemberIds: number[];
};
export const deleteTeamMembers = async ({
userId,
teamId,
teamMemberIds,
}: DeleteTeamMembersOptions) => {
await prisma.$transaction(
async (tx) => {
// Find the team and validate that the user is allowed to remove members.
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
include: {
members: {
select: {
id: true,
userId: 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, {
message: 'Team member record does not exist',
});
}
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Cannot remove the team owner' });
}
const isMemberToRemoveHigherRole = teamMembersToRemove.some(
(member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role),
);
if (isMemberToRemoveHigherRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: '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({
priceId: team.subscription.priceId,
subscriptionId: team.subscription.planId,
quantity: numberOfSeats,
});
}
},
{ timeout: 30_000 },
);
};

View File

@ -1,15 +0,0 @@
import { prisma } from '@documenso/prisma';
export type DeleteTeamPendingOptions = {
userId: number;
pendingTeamId: number;
};
export const deleteTeamPending = async ({ userId, pendingTeamId }: DeleteTeamPendingOptions) => {
await prisma.teamPending.delete({
where: {
id: pendingTeamId,
ownerUserId: userId,
},
});
};

View File

@ -1,42 +0,0 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
export type DeleteTeamTransferRequestOptions = {
/**
* The ID of the user deleting the transfer.
*/
userId: number;
/**
* The ID of the team whose team transfer request should be deleted.
*/
teamId: number;
};
export const deleteTeamTransferRequest = async ({
userId,
teamId,
}: DeleteTeamTransferRequestOptions) => {
await prisma.$transaction(async (tx) => {
await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_TEAM_TRANSFER_REQUEST'],
},
},
},
},
});
await tx.teamTransferVerification.delete({
where: {
teamId,
},
});
});
};

View File

@ -1,20 +1,22 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import { OrganisationGroupType, type Team } from '@prisma/client';
import { uniqueBy } from 'remeda';
import { mailer } from '@documenso/email/mailer';
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { jobs } from '../../jobs/client';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
export type DeleteTeamOptions = {
userId: number;
@ -22,41 +24,78 @@ export type DeleteTeamOptions = {
};
export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: userId,
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_TEAM'],
}),
include: {
organisation: {
select: {
organisationGlobalSettings: true,
},
},
teamGroups: {
include: {
subscription: true,
members: {
organisationGroup: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
organisationGroupMembers: {
include: {
organisationMember: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
},
},
},
},
teamGlobalSettings: true,
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not authorized to delete this team',
});
}
const membersToNotify = uniqueBy(
team.teamGroups.flatMap((group) =>
group.organisationGroup.organisationGroupMembers.map((member) => ({
id: member.organisationMember.user.id,
name: member.organisationMember.user.name || '',
email: member.organisationMember.user.email,
})),
),
(member) => member.id,
);
await prisma.$transaction(
async (tx) => {
await tx.team.delete({
where: {
id: teamId,
},
});
if (team.subscription) {
await stripe.subscriptions
.cancel(team.subscription.planId, {
prorate: false,
invoice_now: true,
})
.catch((err) => {
console.error(err);
throw AppError.parseError(err);
});
}
// 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',
@ -64,21 +103,9 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
team: {
name: team.name,
url: team.url,
ownerUserId: team.ownerUserId,
teamGlobalSettings: team.teamGlobalSettings,
},
members: team.members.map((member) => ({
id: member.user.id,
name: member.user.name || '',
email: member.user.email,
})),
},
});
await tx.team.delete({
where: {
id: teamId,
ownerUserId: userId,
members: membersToNotify,
organisationId: team.organisationId,
},
});
},
@ -88,25 +115,29 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
type SendTeamDeleteEmailOptions = {
email: string;
team: Pick<Team, 'url' | 'name'> & {
teamGlobalSettings?: TeamGlobalSettings | null;
};
isOwner: boolean;
team: Pick<Team, 'url' | 'name'>;
organisationId: string;
};
export const sendTeamDeleteEmail = async ({ email, isOwner, team }: SendTeamDeleteEmailOptions) => {
export const sendTeamDeleteEmail = async ({
email,
team,
organisationId,
}: SendTeamDeleteEmailOptions) => {
const template = createElement(TeamDeleteEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
teamUrl: team.url,
isOwner,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const { branding, settings } = await getEmailContext({
source: {
type: 'organisation',
organisationId,
},
});
const lang = team.teamGlobalSettings?.documentLanguage;
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),

View File

@ -8,7 +8,7 @@ export interface FindTeamInvoicesOptions {
teamId: number;
}
export const findTeamInvoices = async ({ userId, teamId }: FindTeamInvoicesOptions) => {
export const findOrganisationInvoices = async ({ userId, teamId }: FindTeamInvoicesOptions) => {
const team = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,

View File

@ -1,105 +0,0 @@
import type { TeamMemberInvite } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { P, match } from 'ts-pattern';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TeamMemberInviteSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamMemberInviteSchema';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { type FindResultResponse, ZFindResultResponse } from '../../types/search-params';
export interface FindTeamMemberInvitesOptions {
userId: number;
teamId: number;
query?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof TeamMemberInvite;
direction: 'asc' | 'desc';
};
}
export const ZFindTeamMemberInvitesResponseSchema = ZFindResultResponse.extend({
data: TeamMemberInviteSchema.pick({
id: true,
teamId: true,
email: true,
role: true,
createdAt: true,
}).array(),
});
export type TFindTeamMemberInvitesResponse = z.infer<typeof ZFindTeamMemberInvitesResponseSchema>;
export const findTeamMemberInvites = async ({
userId,
teamId,
query,
page = 1,
perPage = 10,
orderBy,
}: FindTeamMemberInvitesOptions): Promise<TFindTeamMemberInvitesResponse> => {
const orderByColumn = orderBy?.column ?? 'email';
const orderByDirection = orderBy?.direction ?? 'desc';
// Check that the user belongs to the team they are trying to find invites in.
const userTeam = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
});
const termFilters: Prisma.TeamMemberInviteWhereInput | undefined = match(query)
.with(P.string.minLength(1), () => ({
email: {
contains: query,
mode: Prisma.QueryMode.insensitive,
},
}))
.otherwise(() => undefined);
const whereClause: Prisma.TeamMemberInviteWhereInput = {
...termFilters,
teamId: userTeam.id,
};
const [data, count] = await Promise.all([
prisma.teamMemberInvite.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
// Exclude token attribute.
select: {
id: true,
teamId: true,
email: true,
role: true,
createdAt: true,
},
}),
prisma.teamMemberInvite.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};

View File

@ -1,14 +1,13 @@
import type { TeamMember } from '@prisma/client';
import type { OrganisationMember } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { P, match } from 'ts-pattern';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TeamMemberSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamMemberSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { FindResultResponse } from '../../types/search-params';
import { ZFindResultResponse } from '../../types/search-params';
import { getHighestOrganisationRoleInGroup } from '../../utils/organisations';
import { getHighestTeamRoleInGroup } from '../../utils/teams';
export interface FindTeamMembersOptions {
userId: number;
@ -17,22 +16,11 @@ export interface FindTeamMembersOptions {
page?: number;
perPage?: number;
orderBy?: {
column: keyof TeamMember | 'name';
column: keyof OrganisationMember | 'name';
direction: 'asc' | 'desc';
};
}
export const ZFindTeamMembersResponseSchema = ZFindResultResponse.extend({
data: TeamMemberSchema.extend({
user: UserSchema.pick({
name: true,
email: true,
}),
}).array(),
});
export type TFindTeamMembersResponse = z.infer<typeof ZFindTeamMembersResponseSchema>;
export const findTeamMembers = async ({
userId,
teamId,
@ -40,39 +28,69 @@ export const findTeamMembers = async ({
page = 1,
perPage = 10,
orderBy,
}: FindTeamMembersOptions): Promise<TFindTeamMembersResponse> => {
}: FindTeamMembersOptions) => {
const orderByColumn = orderBy?.column ?? 'name';
const orderByDirection = orderBy?.direction ?? 'desc';
// Check that the user belongs to the team they are trying to find members in.
const userTeam = await prisma.team.findUniqueOrThrow({
const userTeam = await prisma.organisationMember.findFirst({
where: {
id: teamId,
members: {
userId,
organisationGroupMembers: {
some: {
userId,
group: {
teamGroups: {
some: {
teamId,
},
},
},
},
},
},
});
const termFilters: Prisma.TeamMemberWhereInput | undefined = match(query)
if (!userTeam) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
const termFilters: Prisma.OrganisationMemberWhereInput | undefined = match(query)
.with(P.string.minLength(1), () => ({
user: {
name: {
contains: query,
mode: Prisma.QueryMode.insensitive,
},
OR: [
{
name: {
contains: query,
mode: Prisma.QueryMode.insensitive,
},
},
{
email: {
contains: query,
mode: Prisma.QueryMode.insensitive,
},
},
],
},
}))
.otherwise(() => undefined);
const whereClause: Prisma.TeamMemberWhereInput = {
const whereClause: Prisma.OrganisationMemberWhereInput = {
...termFilters,
teamId: userTeam.id,
organisationGroupMembers: {
some: {
group: {
teamGroups: {
some: {
teamId,
},
},
},
},
},
};
let orderByClause: Prisma.TeamMemberOrderByWithRelationInput = {
let orderByClause: Prisma.OrganisationMemberOrderByWithRelationInput = {
[orderByColumn]: orderByDirection,
};
@ -86,7 +104,7 @@ export const findTeamMembers = async ({
}
const [data, count] = await Promise.all([
prisma.teamMember.findMany({
prisma.organisationMember.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
@ -94,22 +112,53 @@ export const findTeamMembers = async ({
include: {
user: {
select: {
name: true,
id: true,
email: true,
name: true,
avatarImageId: true,
},
},
organisationGroupMembers: {
include: {
group: {
include: {
teamGroups: true,
},
},
},
},
},
}),
prisma.teamMember.count({
prisma.organisationMember.count({
where: whereClause,
}),
]);
// same as get-team-members.
const mappedData = data.map((member) => ({
id: member.id,
userId: member.userId,
createdAt: member.createdAt,
email: member.user.email,
name: member.user.name,
avatarImageId: member.user.avatarImageId,
// Filter teamGroups to only include the current team
teamRole: getHighestTeamRoleInGroup(
member.organisationGroupMembers.flatMap(({ group }) =>
group.teamGroups.filter((tg) => tg.teamId === teamId),
),
),
teamRoleGroupType: member.organisationGroupMembers[0].group.type,
organisationRole: getHighestOrganisationRoleInGroup(
member.organisationGroupMembers.flatMap(({ group }) => group),
),
}));
return {
data,
data: mappedData,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
} satisfies FindResultResponse<typeof mappedData>;
};

View File

@ -1,69 +0,0 @@
import type { Team } from '@prisma/client';
import { Prisma } from '@prisma/client';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TeamPendingSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamPendingSchema';
import { type FindResultResponse, ZFindResultResponse } from '../../types/search-params';
export interface FindTeamsPendingOptions {
userId: number;
query?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof Team;
direction: 'asc' | 'desc';
};
}
export const ZFindTeamsPendingResponseSchema = ZFindResultResponse.extend({
data: TeamPendingSchema.array(),
});
export type TFindTeamsPendingResponse = z.infer<typeof ZFindTeamsPendingResponseSchema>;
export const findTeamsPending = async ({
userId,
query,
page = 1,
perPage = 10,
orderBy,
}: FindTeamsPendingOptions): Promise<TFindTeamsPendingResponse> => {
const orderByColumn = orderBy?.column ?? 'name';
const orderByDirection = orderBy?.direction ?? 'desc';
const whereClause: Prisma.TeamPendingWhereInput = {
ownerUserId: userId,
};
if (query && query.length > 0) {
whereClause.name = {
contains: query,
mode: Prisma.QueryMode.insensitive,
};
}
const [data, count] = await Promise.all([
prisma.teamPending.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
}),
prisma.teamPending.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};

View File

@ -4,9 +4,11 @@ import { Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { FindResultResponse } from '../../types/search-params';
import { getHighestTeamRoleInGroup } from '../../utils/teams';
export interface FindTeamsOptions {
userId: number;
organisationId: string;
query?: string;
page?: number;
perPage?: number;
@ -18,6 +20,7 @@ export interface FindTeamsOptions {
export const findTeams = async ({
userId,
organisationId,
query,
page = 1,
perPage = 10,
@ -27,9 +30,20 @@ export const findTeams = async ({
const orderByDirection = orderBy?.direction ?? 'desc';
const whereClause: Prisma.TeamWhereInput = {
members: {
organisation: {
id: organisationId,
},
teamGroups: {
some: {
userId,
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
};
@ -50,9 +64,17 @@ export const findTeams = async ({
[orderByColumn]: orderByDirection,
},
include: {
members: {
teamGroups: {
where: {
userId,
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
@ -64,8 +86,7 @@ export const findTeams = async ({
const maskedData = data.map((team) => ({
...team,
currentTeamMember: team.members[0],
members: undefined,
currentTeamRole: getHighestTeamRoleInGroup(team.teamGroups),
}));
return {

View File

@ -0,0 +1,114 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getHighestOrganisationRoleInGroup } from '../../utils/organisations';
import { getHighestTeamRoleInGroup } from '../../utils/teams';
type GetMemberRolesOptions = {
teamId: number;
reference:
| {
type: 'User';
id: number;
}
| {
type: 'Member';
id: string;
};
};
/**
* Returns the highest Team role of a given member or user of a team
*/
export const getMemberRoles = async ({ teamId, reference }: GetMemberRolesOptions) => {
const team = await prisma.team.findFirst({
where: {
id: teamId,
},
include: {
teamGroups: {
where: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember:
reference.type === 'User'
? {
userId: reference.id,
}
: {
id: reference.id,
},
},
},
},
},
},
},
});
if (!team || team.teamGroups.length === 0) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Roles not found',
});
}
return {
// Todo: Would be nice bonus to have. If implemented make sure to test hard.
// organisationRole: getHighestOrganisationRoleInGroup(),
teamRole: getHighestTeamRoleInGroup(team.teamGroups),
};
};
type GetMemberOrganisationRoleOptions = {
organisationId: string;
reference:
| {
type: 'User';
id: number;
}
| {
type: 'Member';
id: string;
};
};
/**
* Returns the highest Organisation of a given organisation member
*/
export const getMemberOrganisationRole = async ({
organisationId,
reference,
}: GetMemberOrganisationRoleOptions) => {
const organisation = await prisma.organisation.findFirst({
where: {
id: organisationId,
},
include: {
groups: {
where: {
organisationGroupMembers: {
some: {
organisationMember:
reference.type === 'User'
? {
userId: reference.id,
}
: {
id: reference.id,
},
},
},
},
},
},
});
if (!organisation || organisation.groups.length === 0) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Roles not found',
});
}
return getHighestOrganisationRoleInGroup(organisation.groups);
};

View File

@ -1,5 +1,7 @@
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type GetTeamEmailByEmailOptions = {
email: string;
};
@ -20,3 +22,22 @@ export const getTeamEmailByEmail = async ({ email }: GetTeamEmailByEmailOptions)
},
});
};
export const getTeamWithEmail = async ({
userId,
teamUrl,
}: {
userId: number;
teamUrl: string;
}) => {
return await prisma.team.findFirstOrThrow({
where: {
...buildTeamWhereQuery({ teamId: undefined, userId }),
url: teamUrl,
},
include: {
teamEmail: true,
emailVerification: true,
},
});
};

View File

@ -1,40 +0,0 @@
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TeamMemberInviteSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamMemberInviteSchema';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
export type GetTeamInvitationsOptions = {
email: string;
};
export const ZGetTeamInvitationsResponseSchema = TeamMemberInviteSchema.extend({
team: TeamSchema.pick({
id: true,
name: true,
url: true,
avatarImageId: true,
}),
}).array();
export type TGetTeamInvitationsResponse = z.infer<typeof ZGetTeamInvitationsResponseSchema>;
export const getTeamInvitations = async ({
email,
}: GetTeamInvitationsOptions): Promise<TGetTeamInvitationsResponse> => {
return await prisma.teamMemberInvite.findMany({
where: {
email,
},
include: {
team: {
select: {
id: true,
name: true,
url: true,
avatarImageId: true,
},
},
},
});
};

View File

@ -1,38 +1,32 @@
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TeamMemberSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamMemberSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import type { TGetTeamMembersResponse } from '@documenso/trpc/server/team-router/get-team-members.types';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getHighestOrganisationRoleInGroup } from '../../utils/organisations';
import { getHighestTeamRoleInGroup } from '../../utils/teams';
export type GetTeamMembersOptions = {
userId: number;
teamId: number;
};
export const ZGetTeamMembersResponseSchema = TeamMemberSchema.extend({
user: UserSchema.pick({
id: true,
name: true,
email: true,
}),
}).array();
export type TGetTeamMembersResponseSchema = z.infer<typeof ZGetTeamMembersResponseSchema>;
/**
* Get all team members for a given team.
*/
export const getTeamMembers = async ({
userId,
teamId,
}: GetTeamMembersOptions): Promise<TGetTeamMembersResponseSchema> => {
return await prisma.teamMember.findMany({
}: GetTeamMembersOptions): Promise<TGetTeamMembersResponse> => {
const teamMembers = await prisma.organisationMember.findMany({
where: {
team: {
id: teamId,
members: {
some: {
userId: userId,
organisationGroupMembers: {
some: {
group: {
teamGroups: {
some: {
teamId,
},
},
},
},
},
@ -43,8 +37,43 @@ export const getTeamMembers = async ({
id: true,
email: true,
name: true,
avatarImageId: true,
},
},
organisationGroupMembers: {
include: {
group: {
include: {
teamGroups: true,
},
},
},
},
},
});
const isAuthorized = teamMembers.some((member) => member.userId === userId);
// Checks that the user is part of the organisation/team.
if (!isAuthorized) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
return teamMembers.map((member) => {
const memberGroups = member.organisationGroupMembers.map((group) => group.group);
return {
id: member.id,
userId: member.userId,
createdAt: member.createdAt,
email: member.user.email,
name: member.user.name,
avatarImageId: member.user.avatarImageId,
// Filter teamGroups to only include the current team
teamRole: getHighestTeamRoleInGroup(
memberGroups.flatMap((group) => group.teamGroups.filter((tg) => tg.teamId === teamId)),
),
organisationRole: getHighestOrganisationRoleInGroup(memberGroups),
};
});
};

View File

@ -3,6 +3,7 @@ import type { TeamProfile } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
import { updateTeamPublicProfile } from './update-team-public-profile';
export type GetTeamPublicProfileOptions = {
@ -20,14 +21,7 @@ export const getTeamPublicProfile = async ({
teamId,
}: GetTeamPublicProfileOptions): Promise<GetTeamPublicProfileResponse> => {
const team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
where: buildTeamWhereQuery({ teamId, userId }),
include: {
profile: true,
},

View File

@ -0,0 +1,37 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery, extractDerivedTeamSettings } from '../../utils/teams';
export type GetTeamSettingsOptions = {
userId?: number;
teamId: number;
};
/**
* You must provide userId if you want to validate whether the user can access the team settings.
*/
export const getTeamSettings = async ({ userId, teamId }: GetTeamSettingsOptions) => {
const team = await prisma.team.findFirst({
where: userId !== undefined ? buildTeamWhereQuery({ teamId, userId }) : { id: teamId },
include: {
organisation: {
include: {
organisationGlobalSettings: true,
},
},
teamGlobalSettings: true,
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
const organisationSettings = team.organisation.organisationGlobalSettings;
const teamSettings = team.teamGlobalSettings;
return extractDerivedTeamSettings(organisationSettings, teamSettings);
};

View File

@ -1,72 +1,22 @@
import type { Prisma } from '@prisma/client';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TeamEmailSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamEmailSchema';
import { TeamGlobalSettingsSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamGlobalSettingsSchema';
import { TeamMemberSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamMemberSchema';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
buildTeamWhereQuery,
extractDerivedTeamSettings,
getHighestTeamRoleInGroup,
} from '../../utils/teams';
export type GetTeamByIdOptions = {
userId?: number;
userId: number;
teamId: number;
};
export const ZGetTeamByIdResponseSchema = TeamSchema.extend({
teamEmail: TeamEmailSchema.nullable(),
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),
currentTeamMember: TeamMemberSchema.pick({
role: true,
}).nullable(),
});
export type TGetTeamByIdResponse = z.infer<typeof ZGetTeamByIdResponseSchema>;
/**
* Get a team given a teamId.
*
* Provide an optional userId to check that the user is a member of the team.
*/
export const getTeamById = async ({
userId,
teamId,
}: GetTeamByIdOptions): Promise<TGetTeamByIdResponse> => {
const whereFilter: Prisma.TeamWhereUniqueInput = {
id: teamId,
};
if (userId !== undefined) {
whereFilter['members'] = {
some: {
userId,
},
};
}
const result = await prisma.team.findUniqueOrThrow({
where: whereFilter,
include: {
teamEmail: true,
teamGlobalSettings: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
export const getTeamById = async ({ userId, teamId }: GetTeamByIdOptions) => {
return await getTeam({
teamReference: teamId,
userId,
});
const { members, ...team } = result;
return {
...team,
currentTeamMember: userId !== undefined ? members[0] : null,
};
};
export type GetTeamByUrlOptions = {
@ -80,57 +30,65 @@ export type TGetTeamByUrlResponse = Awaited<ReturnType<typeof getTeamByUrl>>;
* Get a team given a team URL.
*/
export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) => {
const whereFilter: Prisma.TeamWhereUniqueInput = {
url: teamUrl,
};
return await getTeam({
teamReference: teamUrl,
userId,
});
};
if (userId !== undefined) {
whereFilter['members'] = {
some: {
userId,
},
};
}
const result = await prisma.team.findFirst({
where: whereFilter,
/**
* Get a team by its ID or URL.
*/
export const getTeam = async ({
teamReference,
userId,
}: {
teamReference: number | string;
userId: number;
}) => {
const team = await prisma.team.findFirst({
where: {
...buildTeamWhereQuery({ teamId: undefined, userId }),
id: typeof teamReference === 'number' ? teamReference : undefined,
url: typeof teamReference === 'string' ? teamReference : undefined,
},
include: {
teamEmail: true,
emailVerification: {
select: {
expiresAt: true,
name: true,
email: true,
},
},
transferVerification: {
select: {
expiresAt: true,
name: true,
email: true,
},
},
subscription: true,
teamGlobalSettings: true,
members: {
teamGroups: {
where: {
userId,
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
select: {
role: true,
},
teamGlobalSettings: true,
organisation: {
include: {
organisationGlobalSettings: true,
},
},
},
});
if (!result) {
throw new AppError(AppErrorCode.NOT_FOUND);
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
const { members, ...team } = result;
const organisationSettings = team.organisation.organisationGlobalSettings;
const teamSettings = team.teamGlobalSettings;
return {
...team,
currentTeamMember: members[0],
currentTeamRole: getHighestTeamRoleInGroup(team.teamGroups),
teamSettings,
derivedSettings: extractDerivedTeamSettings(organisationSettings, teamSettings),
};
};

View File

@ -1,53 +1,48 @@
import type { z } from 'zod';
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { SubscriptionSchema } from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import { TeamMemberSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamMemberSchema';
import { TeamMemberRole } from '@documenso/prisma/generated/types';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { buildTeamWhereQuery, getHighestTeamRoleInGroup } from '../../utils/teams';
export type GetTeamsOptions = {
userId: number;
/**
* If teamId is undefined we get all teams the user belongs to.
*/
teamId?: number;
};
export const ZGetTeamsResponseSchema = TeamSchema.extend({
currentTeamMember: TeamMemberSchema.pick({
role: true,
}),
subscription: SubscriptionSchema.pick({
status: true,
}).nullable(),
teamRole: z.nativeEnum(TeamMemberRole),
}).array();
export type TGetTeamsResponse = z.infer<typeof ZGetTeamsResponseSchema>;
export const getTeams = async ({ userId }: GetTeamsOptions): Promise<TGetTeamsResponse> => {
export const getTeams = async ({ userId, teamId }: GetTeamsOptions) => {
const teams = await prisma.team.findMany({
where: {
members: {
some: {
userId,
},
},
},
where: buildTeamWhereQuery({ teamId, userId }),
include: {
subscription: {
select: {
status: true,
},
},
members: {
teamGroups: {
where: {
userId,
},
select: {
role: true,
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
});
return teams.map(({ members, ...team }) => ({
return teams.map((team) => ({
...team,
currentTeamMember: members[0],
currentTeamRole: getHighestTeamRoleInGroup(team.teamGroups),
}));
};

View File

@ -1,81 +0,0 @@
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { jobs } from '../../jobs/client';
export type LeaveTeamOptions = {
/**
* The ID of the user who is leaving the team.
*/
userId: number;
/**
* The ID of the team the user is leaving.
*/
teamId: number;
};
export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions): Promise<void> => {
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: {
not: userId,
},
members: {
some: {
userId,
},
},
},
include: {
subscription: true,
},
});
const leavingUser = await tx.user.findUniqueOrThrow({
where: { id: userId },
});
await tx.teamMember.delete({
where: {
userId_teamId: {
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,
});
}
await jobs.triggerJob({
name: 'send.team-member-left.email',
payload: {
teamId,
memberUserId: leavingUser.id,
},
});
},
{ timeout: 30_000 },
);
};

View File

@ -1,122 +0,0 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { TeamTransferRequestTemplate } from '@documenso/email/templates/team-transfer-request';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export type RequestTeamOwnershipTransferOptions = {
/**
* The ID of the user initiating the transfer.
*/
userId: number;
/**
* The name of the user initiating the transfer.
*/
userName: string;
/**
* The ID of the team whose ownership is being transferred.
*/
teamId: number;
/**
* The user ID of the new owner.
*/
newOwnerUserId: number;
/**
* Whether to clear any current payment methods attached to the team.
*/
clearPaymentMethods: boolean;
};
export const requestTeamOwnershipTransfer = async ({
userId,
userName,
teamId,
newOwnerUserId,
}: RequestTeamOwnershipTransferOptions): Promise<void> => {
// Todo: Clear payment methods disabled for now.
const clearPaymentMethods = false;
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: userId,
members: {
some: {
userId: newOwnerUserId,
},
},
},
});
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
},
});
const { token, expiresAt } = createTokenVerification({ minute: 10 });
const teamVerificationPayload = {
teamId,
token,
expiresAt,
userId: newOwnerUserId,
name: newOwnerUser.name ?? '',
email: newOwnerUser.email,
clearPaymentMethods,
};
await tx.teamTransferVerification.upsert({
where: {
teamId,
},
create: teamVerificationPayload,
update: teamVerificationPayload,
});
const template = createElement(TeamTransferRequestTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
senderName: userName,
teamName: team.name,
teamUrl: team.url,
token,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
const i18n = await getI18nInstance();
await mailer.sendMail({
to: newOwnerUser.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(
msg`You have been requested to take ownership of team ${team.name} on Documenso`,
),
html,
text,
});
},
{ timeout: 30_000 },
);
};

View File

@ -3,6 +3,7 @@ import { AppError } from '@documenso/lib/errors/app-error';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
import { sendTeamEmailVerificationEmail } from './create-team-email-verification';
export type ResendTeamMemberInvitationOptions = {
@ -17,32 +18,25 @@ export const resendTeamEmailVerification = async ({
userId,
teamId,
}: ResendTeamMemberInvitationOptions): Promise<void> => {
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
include: {
emailVerification: true,
},
});
if (!team) {
throw new AppError('TeamNotFound', {
message: 'User is not a member of the team.',
});
}
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
include: {
emailVerification: true,
teamGlobalSettings: true,
},
});
if (!team) {
throw new AppError('TeamNotFound', {
message: 'User is not a member of the team.',
});
}
const { emailVerification } = team;
if (!emailVerification) {

View File

@ -1,81 +0,0 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { sendTeamMemberInviteEmail } from './create-team-member-invites';
export type ResendTeamMemberInvitationOptions = {
/**
* The ID of the user who is initiating this action.
*/
userId: number;
/**
* The name of the user who is initiating this action.
*/
userName: string;
/**
* The ID of the team.
*/
teamId: number;
/**
* The IDs of the invitations to resend.
*/
invitationId: number;
};
/**
* Resend an email for a given team member invite.
*/
export const resendTeamMemberInvitation = async ({
userId,
userName,
teamId,
invitationId,
}: ResendTeamMemberInvitationOptions): Promise<void> => {
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
include: {
teamGlobalSettings: true,
},
});
if (!team) {
throw new AppError('TeamNotFound', { message: 'User is not a valid member of the team.' });
}
const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({
where: {
id: invitationId,
teamId,
},
});
if (!teamMemberInvite) {
throw new AppError('InviteNotFound', { message: 'No invite exists for this user.' });
}
await sendTeamMemberInviteEmail({
email: teamMemberInvite.email,
token: teamMemberInvite.token,
senderName: userName,
team,
});
},
{ timeout: 30_000 },
);
};

View File

@ -1,104 +0,0 @@
import { TeamMemberRole } from '@prisma/client';
import type Stripe from 'stripe';
import { transferTeamSubscription } from '@documenso/ee/server-only/stripe/transfer-team-subscription';
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
export type TransferTeamOwnershipOptions = {
token: string;
};
export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOptions) => {
await prisma.$transaction(
async (tx) => {
const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({
where: {
token,
},
include: {
team: {
include: {
subscription: true,
},
},
},
});
const { team, userId: newOwnerUserId } = teamTransferVerification;
await Promise.all([
tx.teamTransferVerification.updateMany({
where: {
teamId: team.id,
},
data: {
completed: true,
},
}),
tx.teamTransferVerification.deleteMany({
where: {
teamId: team.id,
expiresAt: {
lt: new Date(),
},
},
}),
]);
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
teamMembers: {
some: {
teamId: team.id,
},
},
},
include: {
subscriptions: 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,
},
},
},
},
});
},
{ timeout: 30_000 },
);
};

View File

@ -1,61 +0,0 @@
import { TeamMemberRole } from '@prisma/client';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TeamGlobalSettingsSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamGlobalSettingsSchema';
export type UpdateTeamBrandingSettingsOptions = {
userId: number;
teamId: number;
settings: {
brandingEnabled: boolean;
brandingLogo: string;
brandingUrl: string;
brandingCompanyDetails: string;
};
};
export const ZUpdateTeamBrandingSettingsResponseSchema = TeamGlobalSettingsSchema;
export type TUpdateTeamBrandingSettingsResponse = z.infer<
typeof ZUpdateTeamBrandingSettingsResponseSchema
>;
export const updateTeamBrandingSettings = async ({
userId,
teamId,
settings,
}: UpdateTeamBrandingSettingsOptions): Promise<TUpdateTeamBrandingSettingsResponse> => {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = settings;
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
},
});
if (!member || member.role !== TeamMemberRole.ADMIN) {
throw new Error('You do not have permission to update this team.');
}
return await prisma.teamGlobalSettings.upsert({
where: {
teamId,
},
create: {
teamId,
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
},
update: {
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
},
});
};

View File

@ -1,6 +1,8 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
export type UpdateTeamEmailOptions = {
userId: number;
@ -15,32 +17,34 @@ export const updateTeamEmail = async ({
teamId,
data,
}: UpdateTeamEmailOptions): Promise<void> => {
await prisma.$transaction(async (tx) => {
await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
teamEmail: {
isNot: null,
},
},
});
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
include: {
teamEmail: true,
},
});
await tx.teamEmail.update({
where: {
teamId,
},
data: {
// Note: Never allow the email to be updated without re-verifying via email.
name: data.name,
},
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
if (!team.teamEmail) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team email does not exist',
});
}
await prisma.teamEmail.update({
where: {
teamId,
},
data: {
// Note: Never allow the email to be updated without re-verifying via email.
name: data.name,
},
});
};

View File

@ -1,94 +0,0 @@
import type { TeamMemberRole } from '@prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
export type UpdateTeamMemberOptions = {
userId: number;
teamId: number;
teamMemberId: number;
data: {
role: TeamMemberRole;
};
};
export const updateTeamMember = async ({
userId,
teamId,
teamMemberId,
data,
}: UpdateTeamMemberOptions): Promise<void> => {
await prisma.$transaction(async (tx) => {
// Find the team and validate that the user is allowed to update members.
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
include: {
members: {
select: {
id: true,
userId: true,
role: true,
},
},
},
});
const currentTeamMember = team.members.find((member) => member.userId === userId);
const teamMemberToUpdate = team.members.find((member) => member.id === teamMemberId);
if (!teamMemberToUpdate || !currentTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Team member does not exist' });
}
if (teamMemberToUpdate.userId === team.ownerUserId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Cannot update the owner' });
}
const isMemberToUpdateHigherRole = !isTeamRoleWithinUserHierarchy(
currentTeamMember.role,
teamMemberToUpdate.role,
);
if (isMemberToUpdateHigherRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Cannot update a member with a higher role',
});
}
const isNewMemberRoleHigherThanCurrentRole = !isTeamRoleWithinUserHierarchy(
currentTeamMember.role,
data.role,
);
if (isNewMemberRoleHigherThanCurrentRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Cannot give a member a role higher than the user initating the update',
});
}
return await tx.teamMember.update({
where: {
id: teamMemberId,
teamId,
userId: {
not: team.ownerUserId,
},
},
data: {
role: data.role,
},
});
});
};

View File

@ -1,5 +1,7 @@
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type UpdatePublicProfileOptions = {
userId: number;
teamId: number;
@ -15,14 +17,7 @@ export const updateTeamPublicProfile = async ({
data,
}: UpdatePublicProfileOptions) => {
return await prisma.team.update({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
where: buildTeamWhereQuery({ teamId, userId }),
data: {
profile: {
upsert: {

View File

@ -5,6 +5,8 @@ import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type UpdateTeamOptions = {
userId: number;
teamId: number;
@ -16,38 +18,37 @@ export type UpdateTeamOptions = {
export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions): Promise<void> => {
try {
await prisma.$transaction(async (tx) => {
const foundPendingTeamWithUrl = await tx.teamPending.findFirst({
where: {
url: data.url,
const foundTeamWithUrl = await prisma.team.findFirst({
where: {
url: data.url,
id: {
not: teamId,
},
},
});
const foundOrganisationWithUrl = await prisma.organisation.findFirst({
where: {
url: data.url,
},
});
if (foundTeamWithUrl || foundOrganisationWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Team URL already exists.',
});
}
if (foundPendingTeamWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Team URL already exists.',
});
}
const team = await tx.team.update({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
data: {
url: data.url,
name: data.name,
},
});
return team;
await prisma.team.update({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
data: {
url: data.url,
name: data.name,
},
});
} catch (err) {
console.error(err);