mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 01:01:49 +10:00
feat: wip
This commit is contained in:
@ -5,15 +5,37 @@ import { prisma } from '@documenso/prisma';
|
||||
export type CreateDocumentOptions = {
|
||||
title: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
documentDataId: string;
|
||||
};
|
||||
|
||||
export const createDocument = async ({ userId, title, documentDataId }: CreateDocumentOptions) => {
|
||||
return await prisma.document.create({
|
||||
data: {
|
||||
title,
|
||||
documentDataId,
|
||||
userId,
|
||||
},
|
||||
export const createDocument = async ({
|
||||
userId,
|
||||
title,
|
||||
documentDataId,
|
||||
teamId,
|
||||
}: CreateDocumentOptions) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
if (teamId !== undefined) {
|
||||
await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.document.create({
|
||||
data: {
|
||||
title,
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -2,7 +2,7 @@ import { DateTime } from 'luxon';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Document, Prisma } from '@documenso/prisma/client';
|
||||
import type { Document, Prisma, Team, TeamEmail, User } from '@documenso/prisma/client';
|
||||
import { SigningStatus } from '@documenso/prisma/client';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
@ -10,6 +10,7 @@ import type { FindResultSet } from '../../types/find-result-set';
|
||||
|
||||
export type FindDocumentsOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
term?: string;
|
||||
status?: ExtendedDocumentStatus;
|
||||
page?: number;
|
||||
@ -19,21 +20,51 @@ export type FindDocumentsOptions = {
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
period?: '' | '7d' | '14d' | '30d';
|
||||
senderIds?: number[];
|
||||
};
|
||||
|
||||
export const findDocuments = async ({
|
||||
userId,
|
||||
teamId,
|
||||
term,
|
||||
status = ExtendedDocumentStatus.ALL,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
period,
|
||||
senderIds,
|
||||
}: FindDocumentsOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
// Todo: Teams - deletedAt
|
||||
|
||||
const { user, team } = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
let team = null;
|
||||
|
||||
if (teamId !== undefined) {
|
||||
team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
teamEmail: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
team,
|
||||
};
|
||||
});
|
||||
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
@ -50,11 +81,79 @@ export const findDocuments = async ({
|
||||
})
|
||||
.otherwise(() => undefined);
|
||||
|
||||
const filters = match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
|
||||
const filters = team ? findTeamDocumentsFilter(status, team) : findDocumentsFilter(status, user);
|
||||
|
||||
if (filters === null) {
|
||||
return {
|
||||
data: [],
|
||||
count: 0,
|
||||
currentPage: 1,
|
||||
perPage,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const whereClause: Prisma.DocumentWhereInput = {
|
||||
...termFilters,
|
||||
...filters,
|
||||
};
|
||||
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
whereClause.createdAt = {
|
||||
gte: startOfPeriod.toJSDate(),
|
||||
};
|
||||
}
|
||||
|
||||
if (senderIds && senderIds.length > 0) {
|
||||
whereClause.userId = {
|
||||
in: senderIds,
|
||||
};
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.document.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
[orderByColumn]: orderByDirection,
|
||||
},
|
||||
include: {
|
||||
User: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
Recipient: true,
|
||||
},
|
||||
}),
|
||||
prisma.document.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultSet<typeof data>;
|
||||
};
|
||||
|
||||
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
@ -89,14 +188,16 @@ export const findDocuments = async ({
|
||||
deletedAt: null,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||
userId,
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.DRAFT,
|
||||
deletedAt: null,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.PENDING, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
deletedAt: null,
|
||||
},
|
||||
@ -115,7 +216,8 @@ export const findDocuments = async ({
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
deletedAt: null,
|
||||
},
|
||||
@ -130,54 +232,154 @@ export const findDocuments = async ({
|
||||
],
|
||||
}))
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
const whereClause = {
|
||||
...termFilters,
|
||||
...filters,
|
||||
};
|
||||
/**
|
||||
* Create a Prisma filter for the Document schema to find documents for a team.
|
||||
*
|
||||
* Status All:
|
||||
* - Documents that belong to the team
|
||||
* - Documents that have been sent by the team email
|
||||
* - Non draft documents that have been sent to the team email
|
||||
*
|
||||
* Status Inbox:
|
||||
* - Non draft documents that have been sent to the team email that have not been signed
|
||||
*
|
||||
* Status Draft:
|
||||
* - Documents that belong to the team that are draft
|
||||
*
|
||||
* Status Pending:
|
||||
* - Documents that belong to the team that are pending
|
||||
* - Documents that have been sent to the team email that is pending to be signed
|
||||
* - Documents that have been sent by the team email that is pending to be signed
|
||||
*
|
||||
* Status Completed:
|
||||
* - Documents that belong to the team that are completed
|
||||
* - Documents that have been sent to the team email that have been signed
|
||||
* - Documents that have been sent by the team email that have been signed
|
||||
*
|
||||
* @param status The status of the documents to find.
|
||||
* @param team The team to find the documents for.
|
||||
* @returns A filter which can be applied to the Prisma Document schema.
|
||||
*/
|
||||
const findTeamDocumentsFilter = (
|
||||
status: ExtendedDocumentStatus,
|
||||
team: Team & { teamEmail: TeamEmail | null },
|
||||
) => {
|
||||
const teamEmail = team.teamEmail?.email ?? null;
|
||||
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput | null>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
// Filter to display all documents that belong to the team.
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
if (teamEmail && filter.OR) {
|
||||
// Filter to display all documents received by the team email that are not draft.
|
||||
filter.OR.push({
|
||||
status: {
|
||||
not: ExtendedDocumentStatus.DRAFT,
|
||||
},
|
||||
Recipient: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
whereClause.createdAt = {
|
||||
gte: startOfPeriod.toJSDate(),
|
||||
};
|
||||
}
|
||||
// Filter to display all documents that have been sent by the team email.
|
||||
filter.OR.push({
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.document.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
[orderByColumn]: orderByDirection,
|
||||
},
|
||||
include: {
|
||||
User: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
return filter;
|
||||
})
|
||||
.with(ExtendedDocumentStatus.INBOX, () => {
|
||||
// Return a filter that will return nothing.
|
||||
// Todo: Teams - Should be a better way to do this.
|
||||
if (!teamEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
status: {
|
||||
not: ExtendedDocumentStatus.DRAFT,
|
||||
},
|
||||
Recipient: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
},
|
||||
Recipient: true,
|
||||
},
|
||||
}),
|
||||
prisma.document.count({
|
||||
where: {
|
||||
...termFilters,
|
||||
...filters,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
};
|
||||
})
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||
teamId: team.id,
|
||||
status: ExtendedDocumentStatus.DRAFT,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.PENDING, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultSet<typeof data>;
|
||||
if (teamEmail && filter.OR) {
|
||||
// Filter to display all documents received by the team email that are pending.
|
||||
filter.OR.push({
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Filter to display all documents that have been sent by the team email that are pending.
|
||||
filter.OR.push({
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return filter;
|
||||
})
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (teamEmail && filter.OR) {
|
||||
filter.OR.push({
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return filter;
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@ -10,6 +10,24 @@ export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) =>
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
team: {
|
||||
is: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
is: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
|
||||
@ -1,15 +1,63 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
import { SigningStatus } from '@documenso/prisma/client';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
export type GetStatsInput = {
|
||||
user: User;
|
||||
type TeamStatsOptions = {
|
||||
teamId: number;
|
||||
teamEmail?: string;
|
||||
senderIds?: number[];
|
||||
};
|
||||
|
||||
export const getStats = async ({ user }: GetStatsInput) => {
|
||||
const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([
|
||||
export type GetStatsInput = {
|
||||
user: User;
|
||||
team?: TeamStatsOptions;
|
||||
};
|
||||
|
||||
export const getStats = async ({ user, ...options }: GetStatsInput) => {
|
||||
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
|
||||
? getTeamCounts({ team: options.team })
|
||||
: getCounts(user));
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.INBOX]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
};
|
||||
|
||||
ownerCounts.forEach((stat) => {
|
||||
stats[stat.status] = stat._count._all;
|
||||
});
|
||||
|
||||
notSignedCounts.forEach((stat) => {
|
||||
stats[ExtendedDocumentStatus.INBOX] += stat._count._all;
|
||||
});
|
||||
|
||||
hasSignedCounts.forEach((stat) => {
|
||||
if (stat.status === ExtendedDocumentStatus.COMPLETED) {
|
||||
stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all;
|
||||
}
|
||||
|
||||
if (stat.status === ExtendedDocumentStatus.PENDING) {
|
||||
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(stats).forEach((key) => {
|
||||
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
|
||||
stats[ExtendedDocumentStatus.ALL] += stats[key];
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
const getCounts = async (user: User) => {
|
||||
return Promise.all([
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
@ -17,6 +65,7 @@ export const getStats = async ({ user }: GetStatsInput) => {
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
@ -66,38 +115,110 @@ export const getStats = async ({ user }: GetStatsInput) => {
|
||||
},
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.INBOX]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
const getTeamCounts = async ({ team }: { team: TeamStatsOptions }) => {
|
||||
const { teamId, teamEmail } = team;
|
||||
|
||||
const senderIds = team.senderIds ?? [];
|
||||
|
||||
const userIdWhereClause: Prisma.DocumentWhereInput['userId'] =
|
||||
senderIds.length > 0
|
||||
? {
|
||||
in: senderIds,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let ownerCountsWhereInput: Prisma.DocumentWhereInput = {
|
||||
userId: userIdWhereClause,
|
||||
teamId,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
ownerCounts.forEach((stat) => {
|
||||
stats[stat.status] = stat._count._all;
|
||||
});
|
||||
if (teamEmail && senderIds.length === 0) {
|
||||
ownerCountsWhereInput = {
|
||||
OR: [
|
||||
{
|
||||
teamId,
|
||||
},
|
||||
{
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
notSignedCounts.forEach((stat) => {
|
||||
stats[ExtendedDocumentStatus.INBOX] += stat._count._all;
|
||||
});
|
||||
if (teamEmail && senderIds.length > 0) {
|
||||
ownerCountsWhereInput = {
|
||||
userId: userIdWhereClause,
|
||||
OR: [
|
||||
{
|
||||
teamId,
|
||||
},
|
||||
{
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
],
|
||||
deletedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
hasSignedCounts.forEach((stat) => {
|
||||
if (stat.status === ExtendedDocumentStatus.COMPLETED) {
|
||||
stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all;
|
||||
}
|
||||
let notSignedCountsGroupByArgs = null;
|
||||
|
||||
if (stat.status === ExtendedDocumentStatus.PENDING) {
|
||||
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
|
||||
}
|
||||
});
|
||||
if (teamEmail) {
|
||||
notSignedCountsGroupByArgs = {
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
userId: userIdWhereClause,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
} satisfies Prisma.DocumentGroupByArgs;
|
||||
}
|
||||
|
||||
Object.keys(stats).forEach((key) => {
|
||||
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
|
||||
stats[ExtendedDocumentStatus.ALL] += stats[key];
|
||||
}
|
||||
});
|
||||
let hasSignedCountsGroupByArgs = null;
|
||||
|
||||
return stats;
|
||||
if (teamEmail) {
|
||||
hasSignedCountsGroupByArgs = {
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
} satisfies Prisma.DocumentGroupByArgs;
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: ownerCountsWhereInput,
|
||||
}),
|
||||
notSignedCountsGroupByArgs ? prisma.document.groupBy(notSignedCountsGroupByArgs) : [],
|
||||
hasSignedCountsGroupByArgs ? prisma.document.groupBy(hasSignedCountsGroupByArgs) : [],
|
||||
]);
|
||||
};
|
||||
|
||||
@ -26,6 +26,24 @@ export const setFieldsForDocument = async ({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
team: {
|
||||
is: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
is: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -22,6 +22,24 @@ export const setRecipientsForDocument = async ({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
team: {
|
||||
is: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
is: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
60
packages/lib/server-only/team/accept-team-invitation.ts
Normal file
60
packages/lib/server-only/team/accept-team-invitation.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import { getTeamSeatPriceId } from '../../utils/billing';
|
||||
|
||||
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,
|
||||
},
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { team } = teamMemberInvite;
|
||||
|
||||
await tx.teamMember.create({
|
||||
data: {
|
||||
teamId: teamMemberInvite.teamId,
|
||||
userId: user.id,
|
||||
role: teamMemberInvite.role,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMemberInvite.delete({
|
||||
where: {
|
||||
id: teamMemberInvite.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED && team.subscriptionId) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId: teamMemberInvite.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: getTeamSeatPriceId(),
|
||||
subscriptionId: team.subscriptionId,
|
||||
quantity: numberOfSeats,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
133
packages/lib/server-only/team/add-team-email-verification.ts
Normal file
133
packages/lib/server-only/team/add-team-email-verification.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
|
||||
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 { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { createTokenVerification } from '../../utils/token-verification';
|
||||
|
||||
export type AddTeamEmailVerificationOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
data: {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const addTeamEmailVerification = async ({
|
||||
userId,
|
||||
teamId,
|
||||
data,
|
||||
}: AddTeamEmailVerificationOptions) => {
|
||||
try {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
if (team.teamEmail || team.emailVerification) {
|
||||
throw new AppError(
|
||||
AppErrorCode.INVALID_REQUEST,
|
||||
'Team already has an email or existing email verification.',
|
||||
);
|
||||
}
|
||||
|
||||
const existingTeamEmail = await tx.teamEmail.findFirst({
|
||||
where: {
|
||||
email: data.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTeamEmail) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
|
||||
}
|
||||
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
|
||||
await tx.teamEmailVerification.create({
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
|
||||
});
|
||||
} 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('email')) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send an email to a user asking them to accept a team email request.
|
||||
*
|
||||
* @param email The email address to use for the team.
|
||||
* @param token The token used to authenticate that the user has granted access.
|
||||
* @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,
|
||||
teamName: string,
|
||||
teamUrl: string,
|
||||
) => {
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(ConfirmTeamEmailTemplate, {
|
||||
assetBaseUrl,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
teamName,
|
||||
teamUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `A request to use your email has been initiated by ${teamName} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,51 @@
|
||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||
import { getStripeCustomerIdByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { getTeamSeatPriceId } from '../../utils/billing';
|
||||
|
||||
export type CreateTeamPendingCheckoutSession = {
|
||||
userId: number;
|
||||
pendingTeamId: number;
|
||||
};
|
||||
|
||||
export const createTeamPendingCheckoutSession = async ({
|
||||
userId,
|
||||
pendingTeamId,
|
||||
}: CreateTeamPendingCheckoutSession) => {
|
||||
const teamPendingCreation = await prisma.teamPending.findFirstOrThrow({
|
||||
where: {
|
||||
id: pendingTeamId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
include: {
|
||||
owner: true,
|
||||
},
|
||||
});
|
||||
|
||||
const stripeCustomerId = await getStripeCustomerIdByUser(teamPendingCreation.owner);
|
||||
|
||||
try {
|
||||
const stripeCheckoutSession = await getCheckoutSession({
|
||||
customerId: stripeCustomerId,
|
||||
priceId: getTeamSeatPriceId(),
|
||||
returnUrl: `${WEBAPP_BASE_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, 'Something went wrong.');
|
||||
}
|
||||
};
|
||||
157
packages/lib/server-only/team/create-team-member-invites.ts
Normal file
157
packages/lib/server-only/team/create-team-member-invites.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import type { TeamInviteEmailProps } from '@documenso/email/templates/team-invite';
|
||||
import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
|
||||
import type { TCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '../../constants/app';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { getTeamById } from './get-teams';
|
||||
|
||||
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) => {
|
||||
const [team, currentTeamMemberEmails, currentTeamMemberInviteEmails] = await Promise.all([
|
||||
getTeamById({ userId, teamId }),
|
||||
getTeamMemberEmails(teamId),
|
||||
getTeamInvites(teamId),
|
||||
]);
|
||||
|
||||
const usersToInvite = invitations.filter((invitation) => {
|
||||
// Filter out users that are already members of the team.
|
||||
if (currentTeamMemberEmails.includes(invitation.email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out users that have already been invited to the team.
|
||||
if (currentTeamMemberInviteEmails.includes(invitation.email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
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,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
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',
|
||||
'Failed to send invite emails to one or more users.',
|
||||
`Failed to send invites to ${sendEmailResultErrorList.length}/${teamMemberInvites.length} users.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type SendTeamMemberInviteEmailOptions = Omit<TeamInviteEmailProps, 'baseUrl' | 'assetBaseUrl'> & {
|
||||
email: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send an email to a user inviting them to join a team.
|
||||
*/
|
||||
export const sendTeamMemberInviteEmail = async ({
|
||||
email,
|
||||
...emailTemplateOptions
|
||||
}: SendTeamMemberInviteEmailOptions) => {
|
||||
const template = createElement(TeamInviteEmailTemplate, {
|
||||
assetBaseUrl: WEBAPP_BASE_URL,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
...emailTemplateOptions,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `You have been invited to join ${emailTemplateOptions.teamName} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a list of emails of the team members for a given team.
|
||||
*
|
||||
* @param teamId The ID of the team.
|
||||
* @returns All team member emails for a given team.
|
||||
*/
|
||||
const getTeamMemberEmails = async (teamId: number) => {
|
||||
const teamMembers = await prisma.teamMember.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
return teamMembers.map((teamMember) => teamMember.user.email);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a list of emails that have been invited to join a team.
|
||||
*
|
||||
* This list will not include users who have accepted and created an account.
|
||||
*
|
||||
* @param teamId The ID of the team.
|
||||
* @returns All the emails of users that have been invited to join a team.
|
||||
*/
|
||||
const getTeamInvites = async (teamId: number) => {
|
||||
const teamMemberInvites = await prisma.teamMemberInvite.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
return teamMemberInvites.map((teamMemberInvite) => teamMemberInvite.email);
|
||||
};
|
||||
200
packages/lib/server-only/team/create-team.ts
Normal file
200
packages/lib/server-only/team/create-team.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||
import { getStripeCustomerIdByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import {
|
||||
getTeamSeatPriceId,
|
||||
isSomeSubscriptionsActiveAndCommunityPlan,
|
||||
} from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma, TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
import { IS_BILLING_ENABLED, WEBAPP_BASE_URL } from '../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { stripe } from '../stripe';
|
||||
|
||||
export type CreateTeamOptions = {
|
||||
/**
|
||||
* ID of the user creating the Team.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* Name of the team to display.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Unique URL of the team.
|
||||
*
|
||||
* Used as the URL path, example: https://documenso.com/t/{teamUrl}/settings
|
||||
*/
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
export type CreateTeamResponse =
|
||||
| {
|
||||
paymentRequired: false;
|
||||
}
|
||||
| {
|
||||
paymentRequired: true;
|
||||
checkoutUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a team or pending team depending on the user's subscription or application's billing settings.
|
||||
*/
|
||||
export const createTeam = async ({
|
||||
name,
|
||||
userId,
|
||||
teamUrl,
|
||||
}: CreateTeamOptions): Promise<CreateTeamResponse> => {
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
Subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isUserSubscriptionValidForTeams = isSomeSubscriptionsActiveAndCommunityPlan(
|
||||
user.Subscription,
|
||||
);
|
||||
|
||||
const isPaymentRequired = IS_BILLING_ENABLED && !isUserSubscriptionValidForTeams;
|
||||
|
||||
try {
|
||||
// Create the team directly if no payment is required.
|
||||
if (!isPaymentRequired) {
|
||||
await prisma.team.create({
|
||||
data: {
|
||||
name,
|
||||
url: teamUrl,
|
||||
ownerUserId: user.id,
|
||||
members: {
|
||||
create: [
|
||||
{
|
||||
userId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
paymentRequired: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Create a pending team if payment is required.
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const existingTeamWithUrl = await tx.team.findUnique({
|
||||
where: {
|
||||
url: teamUrl,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTeamWithUrl) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
|
||||
}
|
||||
|
||||
const pendingTeam = await tx.teamPending.create({
|
||||
data: {
|
||||
name,
|
||||
url: teamUrl,
|
||||
ownerUserId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const stripeCustomerId = await getStripeCustomerIdByUser(user);
|
||||
|
||||
const stripeCheckoutSession = await getCheckoutSession({
|
||||
customerId: stripeCustomerId,
|
||||
priceId: getTeamSeatPriceId(),
|
||||
returnUrl: `${WEBAPP_BASE_URL}/settings/teams`,
|
||||
subscriptionMetadata: {
|
||||
pendingTeamId: pendingTeam.id.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!stripeCheckoutSession) {
|
||||
throw new AppError('Unable to create checkout session');
|
||||
}
|
||||
|
||||
return {
|
||||
paymentRequired: true,
|
||||
checkoutUrl: stripeCheckoutSession,
|
||||
};
|
||||
});
|
||||
} 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, 'Team URL already exists.');
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export type CreateTeamFromPendingTeamOptions = {
|
||||
pendingTeamId: number;
|
||||
subscriptionId: string;
|
||||
};
|
||||
|
||||
export const createTeamFromPendingTeam = async ({
|
||||
pendingTeamId,
|
||||
subscriptionId,
|
||||
}: CreateTeamFromPendingTeamOptions) => {
|
||||
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,
|
||||
subscriptionId,
|
||||
members: {
|
||||
create: [
|
||||
{
|
||||
userId: pendingTeam.ownerUserId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Attach the team ID to the subscription metadata so we can keep track of it if the team changes ownership.
|
||||
await stripe.subscriptions
|
||||
.update(subscriptionId, {
|
||||
metadata: {
|
||||
teamId: team.id.toString(),
|
||||
},
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
// Non-critical error, but we want to log it so we can rectify it.
|
||||
// Todo: Teams - Send alert.
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type DeleteTeamEmailVerificationOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
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 tx.teamEmailVerification.delete({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
46
packages/lib/server-only/team/delete-team-email.ts
Normal file
46
packages/lib/server-only/team/delete-team-email.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type DeleteTeamEmailOptions = {
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a team email.
|
||||
*
|
||||
* 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) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
OR: [
|
||||
{
|
||||
teamEmail: {
|
||||
email: userEmail,
|
||||
},
|
||||
},
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamEmail.delete({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
47
packages/lib/server-only/team/delete-team-invitations.ts
Normal file
47
packages/lib/server-only/team/delete-team-invitations.ts
Normal file
@ -0,0 +1,47 @@
|
||||
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 team members 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['DELETE_INVITATIONS'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMemberInvite.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: invitationIds,
|
||||
},
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
73
packages/lib/server-only/team/delete-team-members.ts
Normal file
73
packages/lib/server-only/team/delete-team-members.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { getTeamSeatPriceId } from '../../utils/billing';
|
||||
|
||||
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['DELETE_TEAM_MEMBERS'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Remove the team members.
|
||||
await tx.teamMember.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: teamMemberIds,
|
||||
},
|
||||
teamId,
|
||||
userId: {
|
||||
not: team.ownerUserId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED && team.subscriptionId) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: getTeamSeatPriceId(),
|
||||
subscriptionId: team.subscriptionId,
|
||||
quantity: numberOfSeats,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
15
packages/lib/server-only/team/delete-team-pending.ts
Normal file
15
packages/lib/server-only/team/delete-team-pending.ts
Normal file
@ -0,0 +1,15 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
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 invitation 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
39
packages/lib/server-only/team/delete-team.ts
Normal file
39
packages/lib/server-only/team/delete-team.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { stripe } from '../stripe';
|
||||
|
||||
export type DeleteTeamOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (team.subscriptionId !== null) {
|
||||
await stripe.subscriptions
|
||||
.cancel(team.subscriptionId, {
|
||||
prorate: true,
|
||||
invoice_now: true,
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
throw AppError.parseError(err);
|
||||
});
|
||||
}
|
||||
|
||||
await tx.team.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
48
packages/lib/server-only/team/find-team-invoices.ts
Normal file
48
packages/lib/server-only/team/find-team-invoices.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { getTeamInvoices } from '@documenso/ee/server-only/stripe/get-team-invoices';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
|
||||
export interface FindTeamInvoicesOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
}
|
||||
|
||||
export const findTeamInvoices = async ({ userId, teamId }: FindTeamInvoicesOptions) => {
|
||||
await prisma.team.findUniqueOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const results = await getTeamInvoices({ teamId });
|
||||
|
||||
if (!results) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...results,
|
||||
data: results.data.map((invoice) => ({
|
||||
invoicePdf: invoice.invoice_pdf,
|
||||
hostedInvoicePdf: invoice.hosted_invoice_url,
|
||||
status: invoice.status,
|
||||
subtotal: invoice.subtotal,
|
||||
total: invoice.total,
|
||||
amountPaid: invoice.amount_paid,
|
||||
amountDue: invoice.amount_due,
|
||||
created: invoice.created,
|
||||
paid: invoice.paid,
|
||||
quantity: invoice.lines.data[0].quantity ?? 0,
|
||||
currency: invoice.currency,
|
||||
})),
|
||||
};
|
||||
};
|
||||
88
packages/lib/server-only/team/find-team-member-invites.ts
Normal file
88
packages/lib/server-only/team/find-team-member-invites.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TeamMemberInvite } from '@documenso/prisma/client';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import type { FindResultSet } from '../../types/find-result-set';
|
||||
|
||||
export interface FindTeamMemberInvitesOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
term?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof TeamMemberInvite;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export const findTeamMemberInvites = async ({
|
||||
userId,
|
||||
teamId,
|
||||
term,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
}: FindTeamMemberInvitesOptions) => {
|
||||
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,
|
||||
},
|
||||
},
|
||||
// Todo: Teams - Should only certain roles be able to find members?
|
||||
},
|
||||
});
|
||||
|
||||
const termFilters: Prisma.TeamMemberInviteWhereInput | undefined = match(term)
|
||||
.with(P.string.minLength(1), () => ({
|
||||
email: {
|
||||
contains: term,
|
||||
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 FindResultSet<typeof data>;
|
||||
};
|
||||
99
packages/lib/server-only/team/find-team-members.ts
Normal file
99
packages/lib/server-only/team/find-team-members.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma, TeamMember } from '@documenso/prisma/client';
|
||||
|
||||
import { FindResultSet } from '../../types/find-result-set';
|
||||
|
||||
export interface FindTeamMembersOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
term?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof TeamMember | 'name';
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export const findTeamMembers = async ({
|
||||
userId,
|
||||
teamId,
|
||||
term,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
}: 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({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
// Todo: Teams - Should only certain roles be able to find members?
|
||||
},
|
||||
});
|
||||
|
||||
const termFilters: Prisma.TeamMemberWhereInput | undefined = match(term)
|
||||
.with(P.string.minLength(1), () => ({
|
||||
user: {
|
||||
name: {
|
||||
contains: term,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
},
|
||||
}))
|
||||
.otherwise(() => undefined);
|
||||
|
||||
const whereClause: Prisma.TeamMemberWhereInput = {
|
||||
...termFilters,
|
||||
teamId: userTeam.id,
|
||||
};
|
||||
|
||||
let orderByClause: Prisma.TeamMemberOrderByWithRelationInput = {
|
||||
[orderByColumn]: orderByDirection,
|
||||
};
|
||||
|
||||
if (orderByColumn === 'name') {
|
||||
orderByClause = {
|
||||
user: {
|
||||
name: orderByDirection,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.teamMember.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: orderByClause,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.teamMember.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultSet<typeof data>;
|
||||
};
|
||||
58
packages/lib/server-only/team/find-teams-pending.ts
Normal file
58
packages/lib/server-only/team/find-teams-pending.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
export interface FindTeamsPendingOptions {
|
||||
userId: number;
|
||||
term?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof Team;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export const findTeamsPending = async ({
|
||||
userId,
|
||||
term,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
}: FindTeamsPendingOptions) => {
|
||||
const orderByColumn = orderBy?.column ?? 'name';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
const whereClause: Prisma.TeamPendingWhereInput = {
|
||||
ownerUserId: userId,
|
||||
};
|
||||
|
||||
if (term && term.length > 0) {
|
||||
whereClause.name = {
|
||||
contains: term,
|
||||
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),
|
||||
};
|
||||
};
|
||||
77
packages/lib/server-only/team/find-teams.ts
Normal file
77
packages/lib/server-only/team/find-teams.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import type { FindResultSet } from '../../types/find-result-set';
|
||||
|
||||
export interface FindTeamsOptions {
|
||||
userId: number;
|
||||
term?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof Team;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export const findTeams = async ({
|
||||
userId,
|
||||
term,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
}: FindTeamsOptions) => {
|
||||
const orderByColumn = orderBy?.column ?? 'name';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
const whereClause: Prisma.TeamWhereInput = {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (term && term.length > 0) {
|
||||
whereClause.name = {
|
||||
contains: term,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
};
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.team.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
[orderByColumn]: orderByDirection,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.team.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
const maskedData = data.map((team) => ({
|
||||
...team,
|
||||
currentTeamMember: team.members[0],
|
||||
members: undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
data: maskedData,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultSet<typeof maskedData>;
|
||||
};
|
||||
22
packages/lib/server-only/team/get-team-email-by-email.ts
Normal file
22
packages/lib/server-only/team/get-team-email-by-email.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetTeamEmailByEmailOptions = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const getTeamEmailByEmail = async ({ email }: GetTeamEmailByEmailOptions) => {
|
||||
return await prisma.teamEmail.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
22
packages/lib/server-only/team/get-team-invitations.ts
Normal file
22
packages/lib/server-only/team/get-team-invitations.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetTeamInvitationsOptions = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const getTeamInvitations = async ({ email }: GetTeamInvitationsOptions) => {
|
||||
return await prisma.teamMemberInvite.findMany({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
52
packages/lib/server-only/team/get-team-members.ts
Normal file
52
packages/lib/server-only/team/get-team-members.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export type GetTeamMembersOptions = {
|
||||
/**
|
||||
* The optional ID of the user initiating the request.
|
||||
*
|
||||
* If provided, the user will be checked to ensure they are a member of the team.
|
||||
*/
|
||||
userId?: number;
|
||||
|
||||
/**
|
||||
* The ID of the team to retrieve members from.
|
||||
*/
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all team members for a given teamId.
|
||||
*
|
||||
* Provide an optional userId to check that the user is a member of the team.
|
||||
*/
|
||||
export const getTeamMembers = async ({ userId, teamId }: GetTeamMembersOptions) => {
|
||||
const teamMembers = await prisma.teamMember.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (userId !== undefined) {
|
||||
const teamMember = teamMembers.find((teamMember) => teamMember.userId === userId);
|
||||
|
||||
if (!teamMember) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
`User ${userId} is not a member of team ${teamId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return teamMembers;
|
||||
};
|
||||
113
packages/lib/server-only/team/get-teams.ts
Normal file
113
packages/lib/server-only/team/get-teams.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
export type GetTeamsOptions = {
|
||||
userId: number;
|
||||
};
|
||||
export type GetTeamsResponse = Awaited<ReturnType<typeof getTeams>>;
|
||||
|
||||
export const getTeams = async ({ userId }: GetTeamsOptions) => {
|
||||
const teams = await prisma.team.findMany({
|
||||
where: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return teams.map(({ members, ...team }) => ({
|
||||
...team,
|
||||
currentTeamMember: members[0],
|
||||
}));
|
||||
};
|
||||
|
||||
export type GetTeamByIdOptions = {
|
||||
userId?: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
const whereFilter: Prisma.TeamWhereUniqueInput = {
|
||||
id: teamId,
|
||||
};
|
||||
|
||||
if (userId !== undefined) {
|
||||
whereFilter['members'] = {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return await prisma.team.findUniqueOrThrow({
|
||||
where: whereFilter,
|
||||
include: {
|
||||
teamEmail: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export type GetTeamByUrlOptions = {
|
||||
userId?: number;
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a team given a teamId.
|
||||
*
|
||||
* Provide an optional userId to check that the user is a member of the team.
|
||||
*/
|
||||
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.findUniqueOrThrow({
|
||||
where: whereFilter,
|
||||
include: {
|
||||
teamEmail: true,
|
||||
emailVerification: true,
|
||||
transferVerification: true,
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { members, ...team } = result;
|
||||
|
||||
return {
|
||||
...team,
|
||||
currentTeamMember: members[0],
|
||||
};
|
||||
};
|
||||
27
packages/lib/server-only/team/leave-team.ts
Normal file
27
packages/lib/server-only/team/leave-team.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
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) => {
|
||||
await prisma.teamMember.deleteMany({
|
||||
where: {
|
||||
teamId,
|
||||
userId,
|
||||
team: {
|
||||
ownerUserId: {
|
||||
not: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,97 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { TeamTransferRequestTemplate } from '@documenso/email/templates/team-transfer-request';
|
||||
import { WEBAPP_BASE_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';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const requestTeamOwnershipTransfer = async ({
|
||||
userId,
|
||||
userName,
|
||||
teamId,
|
||||
newOwnerUserId,
|
||||
}: RequestTeamOwnershipTransferOptions) => {
|
||||
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,
|
||||
};
|
||||
|
||||
await tx.teamTransferVerification.upsert({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
create: teamVerificationPayload,
|
||||
update: teamVerificationPayload,
|
||||
});
|
||||
|
||||
const template = createElement(TeamTransferRequestTemplate, {
|
||||
assetBaseUrl: WEBAPP_BASE_URL,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
senderName: userName,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
token,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: newOwnerUser.email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,65 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { createTokenVerification } from '../../utils/token-verification';
|
||||
import { sendTeamEmailVerificationEmail } from './add-team-email-verification';
|
||||
|
||||
export type ResendTeamMemberInvitationOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resend a team email verification with a new token.
|
||||
*/
|
||||
export const resendTeamEmailVerification = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: ResendTeamMemberInvitationOptions) => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError('TeamNotFound', 'User is not a member of the team.');
|
||||
}
|
||||
|
||||
const { emailVerification } = team;
|
||||
|
||||
if (!emailVerification) {
|
||||
throw new AppError(
|
||||
'VerificationNotFound',
|
||||
'No team email verification exists for this team.',
|
||||
);
|
||||
}
|
||||
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
|
||||
await tx.teamEmailVerification.update({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,76 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
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 hte 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) => {
|
||||
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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError('TeamNotFound', 'User is not a member of the team.');
|
||||
}
|
||||
|
||||
const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({
|
||||
where: {
|
||||
id: invitationId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamMemberInvite) {
|
||||
throw new AppError('InviteNotFound', 'No invite exists for this user.');
|
||||
}
|
||||
|
||||
await sendTeamMemberInviteEmail({
|
||||
email: teamMemberInvite.email,
|
||||
token: teamMemberInvite.token,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
senderName: userName,
|
||||
});
|
||||
});
|
||||
};
|
||||
86
packages/lib/server-only/team/transfer-team-ownership.ts
Normal file
86
packages/lib/server-only/team/transfer-team-ownership.ts
Normal file
@ -0,0 +1,86 @@
|
||||
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';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
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: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { team, userId: newOwnerUserId } = teamTransferVerification;
|
||||
|
||||
await tx.teamTransferVerification.deleteMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const newOwnerUser = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: newOwnerUserId,
|
||||
},
|
||||
include: {
|
||||
Subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
let newTeamSubscription: Stripe.Subscription | null = null;
|
||||
|
||||
if (IS_BILLING_ENABLED) {
|
||||
newTeamSubscription = await transferTeamSubscription({
|
||||
user: newOwnerUser,
|
||||
team,
|
||||
});
|
||||
}
|
||||
|
||||
if (newTeamSubscription) {
|
||||
await tx.subscription.upsert(
|
||||
mapStripeSubscriptionToPrismaUpsertAction(newOwnerUser.id, newTeamSubscription),
|
||||
);
|
||||
}
|
||||
|
||||
// Todo: Teams - Add billing message in email indicating that billing will be passed on when transferring a team.
|
||||
|
||||
await tx.team.update({
|
||||
where: {
|
||||
id: team.id,
|
||||
members: {
|
||||
some: {
|
||||
userId: newOwnerUserId,
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
ownerUserId: newOwnerUserId,
|
||||
subscriptionId: newTeamSubscription?.id ?? null,
|
||||
members: {
|
||||
update: {
|
||||
where: {
|
||||
userId_teamId: {
|
||||
teamId: team.id,
|
||||
userId: newOwnerUserId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
41
packages/lib/server-only/team/update-team-email.ts
Normal file
41
packages/lib/server-only/team/update-team-email.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
|
||||
export type UpdateTeamEmailOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
data: {
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const updateTeamEmail = async ({ userId, teamId, data }: UpdateTeamEmailOptions) => {
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamEmail.update({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
50
packages/lib/server-only/team/update-team-member.ts
Normal file
50
packages/lib/server-only/team/update-team-member.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
|
||||
export type UpdateTeamMemberOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
teamMemberId: number;
|
||||
data: {
|
||||
role: TeamMemberRole;
|
||||
};
|
||||
};
|
||||
|
||||
export const updateTeamMember = async ({
|
||||
userId,
|
||||
teamId,
|
||||
teamMemberId,
|
||||
data,
|
||||
}: UpdateTeamMemberOptions) => {
|
||||
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['UPDATE_TEAM_MEMBERS'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return await tx.teamMember.update({
|
||||
where: {
|
||||
id: teamMemberId,
|
||||
teamId,
|
||||
userId: {
|
||||
not: team.ownerUserId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: data.role,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
51
packages/lib/server-only/team/update-team.ts
Normal file
51
packages/lib/server-only/team/update-team.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export type UpdateTeamOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
data: {
|
||||
name?: string;
|
||||
url?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions) => {
|
||||
try {
|
||||
return await prisma.team.update({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
} 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, 'Team URL already exists.');
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@ -1,11 +1,13 @@
|
||||
import { hash } from 'bcrypt';
|
||||
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { IdentityProvider } from '@documenso/prisma/client';
|
||||
import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import { SALT_ROUNDS } from '../../constants/auth';
|
||||
import { getFlag } from '../../universal/get-feature-flag';
|
||||
import { getTeamSeatPriceId } from '../../utils/billing';
|
||||
|
||||
export interface CreateUserOptions {
|
||||
name: string;
|
||||
@ -15,8 +17,6 @@ export interface CreateUserOptions {
|
||||
}
|
||||
|
||||
export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => {
|
||||
const isBillingEnabled = await getFlag('app_billing');
|
||||
|
||||
const hashedPassword = await hash(password, SALT_ROUNDS);
|
||||
|
||||
const userExists = await prisma.user.findFirst({
|
||||
@ -29,24 +29,77 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
let user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
password: hashedPassword,
|
||||
signature,
|
||||
identityProvider: IdentityProvider.DOCUMENSO,
|
||||
},
|
||||
});
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
password: hashedPassword,
|
||||
signature,
|
||||
identityProvider: IdentityProvider.DOCUMENSO,
|
||||
},
|
||||
});
|
||||
|
||||
if (isBillingEnabled) {
|
||||
try {
|
||||
const stripeSession = await getStripeCustomerByUser(user);
|
||||
user = stripeSession.user;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const acceptedTeamInvites = await tx.teamMemberInvite.findMany({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
status: TeamMemberInviteStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
// For each team invite, add the user to the team and delete the team invite.
|
||||
await Promise.all(
|
||||
acceptedTeamInvites.map(async (invite) => {
|
||||
await tx.teamMember.create({
|
||||
data: {
|
||||
teamId: invite.teamId,
|
||||
userId: user.id,
|
||||
role: invite.role,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMemberInvite.delete({
|
||||
where: {
|
||||
id: invite.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED) {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: invite.teamId,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (team.subscriptionId) {
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: getTeamSeatPriceId(),
|
||||
subscriptionId: team.subscriptionId,
|
||||
quantity: team.members.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (IS_BILLING_ENABLED) {
|
||||
try {
|
||||
return await getStripeCustomerByUser(user).then((session) => session.user);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
return user;
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user