This commit is contained in:
David Nguyen
2025-05-07 15:03:20 +10:00
parent 419bc02171
commit 7abfc9e271
390 changed files with 21254 additions and 12607 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,7 +1,7 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import type { OrganisationGlobalSettings, Team } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { z } from 'zod';
@ -15,9 +15,12 @@ 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 { getTeamSettings } from './get-team-settings';
export type CreateTeamEmailVerificationOptions = {
userId: number;
@ -34,33 +37,27 @@ export const createTeamEmailVerification = async ({
data,
}: CreateTeamEmailVerificationOptions): Promise<void> => {
try {
const settings = await getTeamSettings({
userId,
teamId,
});
const team = await prisma.team.findFirstOrThrow({
where: buildTeamWhereQuery(teamId, userId, 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,
@ -85,7 +82,7 @@ export const createTeamEmailVerification = async ({
},
});
await sendTeamEmailVerificationEmail(data.email, token, team);
await sendTeamEmailVerificationEmail(data.email, token, team, settings);
},
{ timeout: 30_000 },
);
@ -119,9 +116,8 @@ export const createTeamEmailVerification = async ({
export const sendTeamEmailVerificationEmail = async (
email: string,
token: string,
team: Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
},
team: Team,
settings: Omit<OrganisationGlobalSettings, 'id'>,
) => {
const assetBaseUrl = env('NEXT_PUBLIC_WEBAPP_URL') || 'http://localhost:3000';
@ -133,11 +129,10 @@ export const sendTeamEmailVerificationEmail = async (
token,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const branding = teamGlobalSettingsToBranding(settings, 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,8 +1,14 @@
import { Prisma, TeamMemberRole } from '@prisma/client';
import {
OrganisationGroupType,
OrganisationMemberRole,
Prisma,
TeamMemberRole,
} from '@prisma/client';
import type Stripe from 'stripe';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
import { createOrganisationCustomer } 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 { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@ -10,6 +16,13 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import {
LOWEST_ORGANISATION_ROLE,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
} from '../../constants/organisations';
import { TEAM_INTERNAL_GROUPS } from '../../constants/teams';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { generateDefaultTeamSettings } from '../../utils/teams';
import { stripe } from '../stripe';
export type CreateTeamOptions = {
@ -29,6 +42,24 @@ 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([
@ -50,16 +81,123 @@ export const createTeam = async ({
userId,
teamName,
teamUrl,
organisationId,
inheritMembers,
}: CreateTeamOptions): Promise<TCreateTeamResponse> => {
const user = await prisma.user.findUniqueOrThrow({
where: {
id: userId,
},
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery(
organisationId,
userId,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
),
include: {
groups: true, // Todo: (orgs)
subscriptions: true,
owner: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found.',
});
}
// 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(),
);
console.log({
internalOrganisationGroups,
});
if (Date.now() > 0) {
await prisma.$transaction(async (tx) => {
const teamSettings = await tx.teamGlobalSettings.create({
data: generateDefaultTeamSettings(),
});
const team = await tx.team.create({
data: {
name: teamName,
url: teamUrl,
organisationId,
teamGlobalSettingsId: teamSettings.id,
teamGroups: {
createMany: {
// Attach the internal organisation groups to the team.
data: internalOrganisationGroups,
},
},
},
include: {
teamGroups: true,
},
});
// Create the internal team groups.
await Promise.all(
TEAM_INTERNAL_GROUPS.map(async (teamGroup) =>
tx.organisationGroup.create({
data: {
type: teamGroup.type,
organisationRole: LOWEST_ORGANISATION_ROLE,
organisationId,
teamGroups: {
create: {
teamId: team.id,
teamRole: teamGroup.teamRole,
},
},
},
}),
),
);
});
return {
paymentRequired: false,
};
}
if (Date.now() > 0) {
throw new Error('Todo: Orgs');
}
let isPaymentRequired = IS_BILLING_ENABLED();
let customerId: string | null = null;
@ -68,59 +206,46 @@ export const createTeam = async ({
prices.map((price) => price.id),
);
isPaymentRequired = !subscriptionsContainsActivePlan(user.subscriptions, teamRelatedPriceIds);
isPaymentRequired = !subscriptionsContainsActivePlan(
organisation.subscriptions,
teamRelatedPriceIds, // Todo: (orgs)
);
customerId = await createTeamCustomer({
name: user.name ?? teamName,
email: user.email,
customerId = await createOrganisationCustomer({
name: organisation.owner.name ?? teamName,
email: organisation.owner.email,
}).then((customer) => customer.id);
await prisma.organisation.update({
where: {
id: organisationId,
},
data: {
customerId,
},
});
}
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,
await prisma.team.create({
data: {
name: teamName,
url: teamUrl,
organisationId,
members: {
create: [
{
userId,
role: TeamMemberRole.ADMIN, // Todo: (orgs)
},
],
},
select: {
id: true,
teamGlobalSettings: {
create: {},
},
});
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,
},
],
},
},
});
await tx.teamGlobalSettings.upsert({
where: {
teamId: team.id,
},
update: {},
create: {
teamId: team.id,
},
});
},
});
return {

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,13 @@ 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, TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM']),
});
await tx.teamEmailVerification.delete({
where: {
teamId,
},
});
await prisma.teamEmailVerification.delete({
where: {
teamId,
},
});
};

View File

@ -13,6 +13,8 @@ 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 { getTeamSettings } from './get-team-settings';
export type DeleteTeamEmailOptions = {
userId: number;
@ -26,47 +28,42 @@ 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 settings = await getTeamSettings({
userId,
teamId,
});
const team = await prisma.team.findFirstOrThrow({
where: {
OR: [
buildTeamWhereQuery(teamId, userId, 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 +77,8 @@ 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 branding = teamGlobalSettingsToBranding(settings, team.id);
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
@ -95,8 +89,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

@ -22,6 +22,7 @@ export type DeleteTeamMembersOptions = {
teamMemberIds: number[];
};
// Todo: orgs (we curretnly have an implementation already, need to make it backwards compatible)
export const deleteTeamMembers = async ({
userId,
teamId,
@ -50,7 +51,6 @@ export const deleteTeamMembers = async ({
role: true,
},
},
subscription: true,
},
});

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 type { OrganisationGlobalSettings } from '@prisma/client';
import { OrganisationGroupType, type Team } from '@prisma/client';
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 { jobs } from '../../jobs/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from './get-team-settings';
export type DeleteTeamOptions = {
userId: number;
@ -22,65 +24,97 @@ 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,
},
// Todo: orgs double check this.
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery(teamId, userId, TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_TEAM']),
include: {
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.subscription) {
await stripe.subscriptions
.cancel(team.subscription.planId, {
prorate: false,
invoice_now: true,
})
.catch((err) => {
console.error(err);
throw AppError.parseError(err);
});
}
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not authorized to delete this team',
});
}
await jobs.triggerJob({
name: 'send.team-deleted.email',
payload: {
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,
})),
},
});
const settings = await getTeamSettings({
userId,
teamId,
});
await prisma.$transaction(
async (tx) => {
// Todo: orgs handle any subs?
// if (team.subscription) {
// await stripe.subscriptions
// .cancel(team.subscription.planId, {
// prorate: false,
// invoice_now: true,
// })
// .catch((err) => {
// console.error(err);
// throw AppError.parseError(err);
// });
// }
await tx.team.delete({
where: {
id: teamId,
ownerUserId: userId,
},
});
// Purge all internal organisation groups that have no teams.
await tx.organisationGroup.deleteMany({
where: {
type: OrganisationGroupType.INTERNAL_TEAM,
teamGroups: {
none: {},
},
},
});
// const members = team.teamGroups.flatMap((group) =>
// group.organisationGroup.organisationMembers.map((member) => ({
// id: member.user.id,
// name: member.user.name || '',
// email: member.user.email,
// })),
// );
// await jobs.triggerJob({
// name: 'send.team-deleted.email',
// payload: {
// team: {
// name: team.name,
// url: team.url,
// teamGlobalSettings: team.teamGlobalSettings, // Todo: orgs
// },
// members,
// },
// });
},
{ timeout: 30_000 },
);
@ -88,25 +122,24 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
type SendTeamDeleteEmailOptions = {
email: string;
team: Pick<Team, 'url' | 'name'> & {
teamGlobalSettings?: TeamGlobalSettings | null;
};
isOwner: boolean;
team: Pick<Team, 'id' | 'url' | 'name'>;
settings: Omit<OrganisationGlobalSettings, 'id'>;
};
export const sendTeamDeleteEmail = async ({ email, isOwner, team }: SendTeamDeleteEmailOptions) => {
export const sendTeamDeleteEmail = async ({
email,
team,
settings,
}: 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 = teamGlobalSettingsToBranding(settings, team.id);
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,51 @@ 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,
// Todo: orgs test this
teamRole: getHighestTeamRoleInGroup(
member.organisationGroupMembers.flatMap(({ group }) => group.teamGroups),
),
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

@ -7,6 +7,7 @@ import type { FindResultResponse } from '../../types/search-params';
export interface FindTeamsOptions {
userId: number;
organisationId: string;
query?: string;
page?: number;
perPage?: number;
@ -18,6 +19,7 @@ export interface FindTeamsOptions {
export const findTeams = async ({
userId,
organisationId,
query,
page = 1,
perPage = 10,
@ -27,9 +29,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 +63,27 @@ export const findTeams = async ({
[orderByColumn]: orderByDirection,
},
include: {
members: {
where: {
userId,
teamGroups: {
include: {
organisationGroup: {
include: {
organisationGroupMembers: {
include: {
organisationMember: {
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
},
},
},
},
},
},
},
},
@ -62,10 +93,9 @@ export const findTeams = async ({
}),
]);
// Todo: Orgs nested group membesr thing
const maskedData = data.map((team) => ({
...team,
currentTeamMember: team.members[0],
members: undefined,
}));
return {

View File

@ -0,0 +1,128 @@
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 Organisation and 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,
},
},
},
},
},
include: {
organisationGroup: true,
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Roles not found',
});
}
console.log({
groups: team.teamGroups,
roles: {
organisationRole: getHighestOrganisationRoleInGroup(
team.teamGroups.flatMap((group) => group.organisationGroup),
),
teamRole: getHighestTeamRoleInGroup(team.teamGroups),
},
});
return {
organisationRole: getHighestOrganisationRoleInGroup(
team.teamGroups.flatMap((group) => group.organisationGroup),
),
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) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Roles not found',
});
}
return getHighestOrganisationRoleInGroup(organisation.groups);
};

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,41 @@ 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,
// Todo: orgs test this
teamRole: getHighestTeamRoleInGroup(memberGroups.flatMap((group) => group.teamGroups)),
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,71 +1,61 @@
import type { Prisma } from '@prisma/client';
import type { z } from 'zod';
import { TeamMemberRole } from '@prisma/client';
import { 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, 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(),
currentTeamRole: z.nativeEnum(TeamMemberRole),
});
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,
// Todo: orgs test
const result = await prisma.team.findFirst({
where: buildTeamWhereQuery(teamId, userId),
include: {
teamEmail: true,
teamGlobalSettings: true,
members: {
teamGroups: {
where: {
userId,
},
select: {
role: true,
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
});
const { members, ...team } = result;
if (!result) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
const { teamGroups, ...team } = result;
return {
...team,
currentTeamMember: userId !== undefined ? members[0] : null,
currentTeamRole: getHighestTeamRoleInGroup(teamGroups),
};
};
@ -80,20 +70,23 @@ 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,
};
if (userId !== undefined) {
whereFilter['members'] = {
some: {
userId,
},
};
}
const result = await prisma.team.findFirst({
where: whereFilter,
where: {
url: teamUrl,
teamGroups: {
some: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
include: {
teamEmail: true,
emailVerification: {
@ -103,21 +96,17 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
email: true,
},
},
transferVerification: {
select: {
expiresAt: true,
name: true,
email: true,
},
},
subscription: true,
teamGlobalSettings: true,
members: {
teamGroups: {
where: {
userId,
},
select: {
role: true,
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
@ -127,10 +116,10 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { members, ...team } = result;
const { teamGroups, ...team } = result;
return {
...team,
currentTeamMember: members[0],
currentTeamRole: getHighestTeamRoleInGroup(teamGroups),
};
};

View File

@ -1,53 +1,84 @@
import type { z } from 'zod';
import type { Prisma } from '@prisma/client';
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 { getHighestTeamRoleInGroup } from '../../utils/teams';
export type GetTeamsOptions = {
userId: number;
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> => {
const teams = await prisma.team.findMany({
where: {
members: {
some: {
userId,
export const getTeams = async ({ userId, teamId }: GetTeamsOptions) => {
let whereQuery: Prisma.TeamWhereInput = {
teamGroups: {
some: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
include: {
subscription: {
select: {
status: true,
};
if (teamId) {
whereQuery = {
teamGroups: {
some: {
teamId,
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
members: {
};
}
const teams = await prisma.team.findMany({
where: whereQuery,
include: {
teamGroups: {
where: {
userId,
},
select: {
role: true,
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
});
return teams.map(({ members, ...team }) => ({
...team,
currentTeamMember: members[0],
}));
return teams.map((team) => {
const teamRole = getHighestTeamRoleInGroup(team.teamGroups);
return {
...team,
teamRole,
};
});
};

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,7 +3,9 @@ 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';
import { getTeamSettings } from './get-team-settings';
export type ResendTeamMemberInvitationOptions = {
userId: number;
@ -17,32 +19,26 @@ export const resendTeamEmailVerification = async ({
userId,
teamId,
}: ResendTeamMemberInvitationOptions): Promise<void> => {
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery(teamId, userId, 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.',
});
}
const settings = await getTeamSettings({
userId,
teamId,
});
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) {
@ -63,7 +59,7 @@ export const resendTeamEmailVerification = async ({
},
});
await sendTeamEmailVerificationEmail(emailVerification.email, token, team);
await sendTeamEmailVerificationEmail(emailVerification.email, token, team, settings);
},
{ timeout: 30_000 },
);

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,30 @@ 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, 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,5 +1,6 @@
import type { TeamMemberRole } from '@prisma/client';
// Todo: orgs
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';

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;
@ -23,24 +25,20 @@ export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions): P
},
});
if (foundPendingTeamWithUrl) {
const foundOrganisationWithUrl = await tx.organisation.findFirst({
where: {
url: data.url,
},
});
if (foundPendingTeamWithUrl || foundOrganisationWithUrl) {
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'],
},
},
},
},
where: buildTeamWhereQuery(teamId, userId, TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM']),
data: {
url: data.url,
name: data.name,