feat: add organisations (#1820)

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

View File

@ -1,13 +0,0 @@
import { prisma } from '@documenso/prisma';
export const findSubscriptions = async () => {
return prisma.subscription.findMany({
select: {
id: true,
status: true,
createdAt: true,
periodEnd: true,
userId: true,
},
});
};

View File

@ -1,7 +1,16 @@
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
import { DocumentStatus, SubscriptionStatus } from '@prisma/client';
type GetSigningVolumeOptions = {
import { kyselyPrisma, sql } from '@documenso/prisma';
export type SigningVolume = {
id: number;
name: string;
signingVolume: number;
createdAt: Date;
planId: string;
};
export type GetSigningVolumeOptions = {
search?: string;
page?: number;
perPage?: number;
@ -9,187 +18,68 @@ type GetSigningVolumeOptions = {
sortOrder?: 'asc' | 'desc';
};
export const getSigningVolume = async ({
export async function getSigningVolume({
search = '',
page = 1,
perPage = 10,
sortBy = 'signingVolume',
sortOrder = 'desc',
}: GetSigningVolumeOptions) => {
const validPage = Math.max(1, page);
const validPerPage = Math.max(1, perPage);
const skip = (validPage - 1) * validPerPage;
}: GetSigningVolumeOptions) {
const offset = Math.max(page - 1, 0) * perPage;
const activeSubscriptions = await prisma.subscription.findMany({
where: {
status: SubscriptionStatus.ACTIVE,
},
select: {
id: true,
planId: true,
userId: true,
teamId: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
},
team: {
select: {
id: true,
name: true,
teamEmail: {
select: {
email: true,
},
},
createdAt: true,
},
},
},
});
let findQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.leftJoin('Document as d', (join) =>
join
.onRef('t.id', '=', 'd.teamId')
.on('d.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('d.deletedAt', 'is', null),
)
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select([
's.id as id',
's.createdAt as createdAt',
's.planId as planId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT d.id)`.as('signingVolume'),
])
.groupBy(['s.id', 'o.name']);
const userSubscriptionsMap = new Map();
const teamSubscriptionsMap = new Map();
switch (sortBy) {
case 'name':
findQuery = findQuery.orderBy('name', sortOrder);
break;
case 'createdAt':
findQuery = findQuery.orderBy('createdAt', sortOrder);
break;
case 'signingVolume':
findQuery = findQuery.orderBy('signingVolume', sortOrder);
break;
default:
findQuery = findQuery.orderBy('signingVolume', 'desc');
}
activeSubscriptions.forEach((subscription) => {
const isTeam = !!subscription.teamId;
findQuery = findQuery.limit(perPage).offset(offset);
if (isTeam && subscription.teamId) {
if (!teamSubscriptionsMap.has(subscription.teamId)) {
teamSubscriptionsMap.set(subscription.teamId, {
id: subscription.id,
planId: subscription.planId,
teamId: subscription.teamId,
name: subscription.team?.name || '',
email: subscription.team?.teamEmail?.email || `Team ${subscription.team?.id}`,
createdAt: subscription.team?.createdAt,
isTeam: true,
subscriptionIds: [subscription.id],
});
} else {
const existingTeam = teamSubscriptionsMap.get(subscription.teamId);
existingTeam.subscriptionIds.push(subscription.id);
}
} else if (subscription.userId) {
if (!userSubscriptionsMap.has(subscription.userId)) {
userSubscriptionsMap.set(subscription.userId, {
id: subscription.id,
planId: subscription.planId,
userId: subscription.userId,
name: subscription.user?.name || '',
email: subscription.user?.email || '',
createdAt: subscription.user?.createdAt,
isTeam: false,
subscriptionIds: [subscription.id],
});
} else {
const existingUser = userSubscriptionsMap.get(subscription.userId);
existingUser.subscriptionIds.push(subscription.id);
}
}
});
const countQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select(({ fn }) => [fn.countAll().as('count')]);
const subscriptions = [
...Array.from(userSubscriptionsMap.values()),
...Array.from(teamSubscriptionsMap.values()),
];
const filteredSubscriptions = search
? subscriptions.filter((sub) => {
const searchLower = search.toLowerCase();
return (
sub.name?.toLowerCase().includes(searchLower) ||
sub.email?.toLowerCase().includes(searchLower)
);
})
: subscriptions;
const signingVolume = await Promise.all(
filteredSubscriptions.map(async (subscription) => {
let signingVolume = 0;
if (subscription.userId && !subscription.isTeam) {
const personalCount = await prisma.document.count({
where: {
userId: subscription.userId,
status: DocumentStatus.COMPLETED,
teamId: null,
},
});
signingVolume += personalCount;
const userTeams = await prisma.teamMember.findMany({
where: {
userId: subscription.userId,
},
select: {
teamId: true,
},
});
if (userTeams.length > 0) {
const teamIds = userTeams.map((team) => team.teamId);
const teamCount = await prisma.document.count({
where: {
teamId: {
in: teamIds,
},
status: DocumentStatus.COMPLETED,
},
});
signingVolume += teamCount;
}
}
if (subscription.teamId) {
const teamCount = await prisma.document.count({
where: {
teamId: subscription.teamId,
status: DocumentStatus.COMPLETED,
},
});
signingVolume += teamCount;
}
return {
...subscription,
signingVolume,
};
}),
);
const sortedResults = [...signingVolume].sort((a, b) => {
if (sortBy === 'name') {
return sortOrder === 'asc'
? (a.name || '').localeCompare(b.name || '')
: (b.name || '').localeCompare(a.name || '');
}
if (sortBy === 'createdAt') {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
}
return sortOrder === 'asc'
? a.signingVolume - b.signingVolume
: b.signingVolume - a.signingVolume;
});
const paginatedResults = sortedResults.slice(skip, skip + validPerPage);
const totalPages = Math.ceil(sortedResults.length / validPerPage);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return {
leaderboard: paginatedResults,
totalPages,
leaderboard: results,
totalPages: Math.ceil(Number(count) / perPage),
};
};
}

View File

@ -7,13 +7,11 @@ export const getUsersCount = async () => {
return await prisma.user.count();
};
export const getUsersWithSubscriptionsCount = async () => {
return await prisma.user.count({
export const getOrganisationsWithSubscriptionsCount = async () => {
return await prisma.organisation.count({
where: {
subscriptions: {
some: {
status: SubscriptionStatus.ACTIVE,
},
subscription: {
status: SubscriptionStatus.ACTIVE,
},
},
});

View File

@ -11,10 +11,11 @@ import { prisma } from '@documenso/prisma';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentEmailSettings } from '../../types/document-email';
import { getDocumentWhereInput } from '../document/get-document-by-id';
export type CreateDocumentMetaOptions = {
userId: number;
teamId?: number;
teamId: number;
documentId: number;
subject?: string;
message?: string;
@ -53,25 +54,14 @@ export const upsertDocumentMeta = async ({
language,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
include: {
documentMeta: true,
},

View File

@ -6,9 +6,7 @@ import {
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -28,11 +26,14 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
userId: number;
teamId?: number;
teamId: number;
documentDataId: string;
normalizePdf?: boolean;
data: {
@ -59,36 +60,28 @@ export const createDocumentV2 = async ({
}: CreateDocumentOptions) => {
const { title, formValues } = data;
const team = teamId
? await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
include: {
organisation: {
select: {
organisationClaim: true,
},
include: {
teamGlobalSettings: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
})
: null;
},
},
});
if (teamId !== undefined && !team) {
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
const settings = await getTeamSettings({
userId,
teamId,
});
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
@ -122,23 +115,24 @@ export const createDocumentV2 = async ({
);
// Check if user has permission to set the global action auth.
if (authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
if (
(authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) &&
!team.organisation.organisationClaim.flags.cfr21
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const visibility = determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
team?.members[0].role ?? TeamMemberRole.MEMBER,
);
const { teamRole } = await getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
});
const visibility = determineDocumentVisibility(settings.documentVisibility, teamRole);
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
@ -158,13 +152,10 @@ export const createDocumentV2 = async ({
...meta,
signingOrder: meta?.signingOrder || undefined,
emailSettings: meta?.emailSettings || undefined,
language: meta?.language || team?.teamGlobalSettings?.documentLanguage,
typedSignatureEnabled:
meta?.typedSignatureEnabled ?? team?.teamGlobalSettings?.typedSignatureEnabled,
uploadSignatureEnabled:
meta?.uploadSignatureEnabled ?? team?.teamGlobalSettings?.uploadSignatureEnabled,
drawSignatureEnabled:
meta?.drawSignatureEnabled ?? team?.teamGlobalSettings?.drawSignatureEnabled,
language: meta?.language || settings.documentLanguage,
typedSignatureEnabled: meta?.typedSignatureEnabled ?? settings.typedSignatureEnabled,
uploadSignatureEnabled: meta?.uploadSignatureEnabled ?? settings.uploadSignatureEnabled,
drawSignatureEnabled: meta?.drawSignatureEnabled ?? settings.drawSignatureEnabled,
},
},
},

View File

@ -1,14 +1,13 @@
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
import type { DocumentVisibility, Team, TeamGlobalSettings } from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import type { DocumentVisibility } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
@ -17,13 +16,15 @@ import { prefixedId } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { getTeamById } from '../team/get-team';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
title: string;
externalId?: string | null;
userId: number;
teamId?: number;
teamId: number;
documentDataId: string;
formValues?: Record<string, string | number | boolean>;
normalizePdf?: boolean;
@ -44,53 +45,13 @@ export const createDocument = async ({
timezone,
folderId,
}: CreateDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
teamMembers: {
select: {
teamId: true,
},
},
},
const team = await getTeamById({ userId, teamId });
const settings = await getTeamSettings({
userId,
teamId,
});
if (
teamId !== undefined &&
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;
let userTeamRole: TeamMemberRole | undefined;
if (teamId) {
const teamWithUserRole = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
},
include: {
teamGlobalSettings: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
});
team = teamWithUserRole;
userTeamRole = teamWithUserRole.members[0]?.role;
}
let folderVisibility: DocumentVisibility | undefined;
if (folderId) {
@ -149,19 +110,16 @@ export const createDocument = async ({
folderId,
visibility:
folderVisibility ??
determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
determineDocumentVisibility(settings.documentVisibility, team.currentTeamRole),
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: {
language: team?.teamGlobalSettings?.documentLanguage,
language: settings.documentLanguage,
timezone: timezone,
typedSignatureEnabled: team?.teamGlobalSettings?.typedSignatureEnabled ?? true,
uploadSignatureEnabled: team?.teamGlobalSettings?.uploadSignatureEnabled ?? true,
drawSignatureEnabled: team?.teamGlobalSettings?.drawSignatureEnabled ?? true,
typedSignatureEnabled: settings.typedSignatureEnabled,
uploadSignatureEnabled: settings.uploadSignatureEnabled,
drawSignatureEnabled: settings.drawSignatureEnabled,
},
},
},

View File

@ -1,14 +1,7 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type {
Document,
DocumentMeta,
Recipient,
Team,
TeamGlobalSettings,
User,
} from '@prisma/client';
import type { Document, DocumentMeta, Recipient, User } from '@prisma/client';
import { DocumentStatus, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
@ -29,13 +22,14 @@ import type { ApiRequestMetadata } from '../../universal/extract-request-metadat
import { isDocumentCompleted } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getEmailContext } from '../email/get-email-context';
import { getMemberRoles } from '../team/get-member-roles';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteDocumentOptions = {
id: number;
userId: number;
teamId?: number;
teamId: number;
requestMetadata: ApiRequestMetadata;
};
@ -64,23 +58,26 @@ export const deleteDocument = async ({
include: {
recipients: true,
documentMeta: true,
team: {
include: {
members: true,
teamGlobalSettings: true,
},
},
},
});
if (!document || (teamId !== undefined && teamId !== document.teamId)) {
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isUserTeamMember = await getMemberRoles({
teamId: document.teamId,
reference: {
type: 'User',
id: userId,
},
})
.then(() => true)
.catch(() => false);
const isUserOwner = document.userId === userId;
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
const userRecipient = document.recipients.find((recipient) => recipient.email === user.email);
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
@ -94,7 +91,6 @@ export const deleteDocument = async ({
await handleDocumentOwnerDelete({
document,
user,
team: document.team,
requestMetadata,
});
}
@ -142,11 +138,6 @@ type HandleDocumentOwnerDeleteOptions = {
recipients: Recipient[];
documentMeta: DocumentMeta | null;
};
team?:
| (Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
})
| null;
user: User;
requestMetadata: ApiRequestMetadata;
};
@ -154,13 +145,19 @@ type HandleDocumentOwnerDeleteOptions = {
const handleDocumentOwnerDelete = async ({
document,
user,
team,
requestMetadata,
}: HandleDocumentOwnerDeleteOptions) => {
if (document.deletedAt) {
return;
}
const { branding, settings } = await getEmailContext({
source: {
type: 'team',
teamId: document.teamId,
},
});
// Soft delete completed documents.
if (isDocumentCompleted(document.status)) {
return await prisma.$transaction(async (tx) => {
@ -235,20 +232,18 @@ const handleDocumentOwnerDelete = async ({
assetBaseUrl,
});
const branding = team?.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: {

View File

@ -14,7 +14,7 @@ import { getDocumentWhereInput } from './get-document-by-id';
export interface DuplicateDocumentOptions {
documentId: number;
userId: number;
teamId?: number;
teamId: number;
}
export const duplicateDocument = async ({
@ -22,7 +22,7 @@ export const duplicateDocument = async ({
userId,
teamId,
}: DuplicateDocumentOptions) => {
const documentWhereInput = await getDocumentWhereInput({
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
@ -68,6 +68,11 @@ export const duplicateDocument = async ({
id: document.userId,
},
},
team: {
connect: {
id: teamId,
},
},
documentData: {
create: {
...document.documentData,

View File

@ -6,10 +6,11 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { FindResultResponse } from '../../types/search-params';
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
import { getDocumentWhereInput } from './get-document-by-id';
export interface FindDocumentAuditLogsOptions {
userId: number;
teamId?: number;
teamId: number;
documentId: number;
page?: number;
perPage?: number;
@ -34,25 +35,14 @@ export const findDocumentAuditLogs = async ({
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
});
if (!document) {

View File

@ -9,6 +9,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
import { DocumentVisibility } from '../../types/document-visibility';
import { type FindResultResponse } from '../../types/search-params';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
import { getTeamById } from '../team/get-team';
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
@ -53,32 +54,15 @@ export const findDocuments = async ({
let team = null;
if (teamId !== undefined) {
team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
teamEmail: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
team = await getTeamById({
userId,
teamId,
});
}
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const teamMemberRole = team?.members[0].role ?? null;
const teamMemberRole = team?.currentTeamRole ?? null;
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
@ -291,7 +275,6 @@ const findDocumentsFilter = (
OR: [
{
userId: user.id,
teamId: null,
folderId: folderId,
},
{
@ -330,14 +313,12 @@ const findDocumentsFilter = (
}))
.with(ExtendedDocumentStatus.DRAFT, () => ({
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.DRAFT,
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
OR: [
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.PENDING,
folderId: folderId,
},
@ -360,7 +341,6 @@ const findDocumentsFilter = (
OR: [
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.COMPLETED,
folderId: folderId,
},
@ -379,7 +359,6 @@ const findDocumentsFilter = (
OR: [
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.REJECTED,
folderId: folderId,
},

View File

@ -11,7 +11,7 @@ import { getTeamById } from '../team/get-team';
export type GetDocumentByIdOptions = {
documentId: number;
userId: number;
teamId?: number;
teamId: number;
folderId?: string;
};
@ -21,7 +21,7 @@ export const getDocumentById = async ({
teamId,
folderId,
}: GetDocumentByIdOptions) => {
const documentWhereInput = await getDocumentWhereInput({
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
@ -68,18 +68,7 @@ export const getDocumentById = async ({
export type GetDocumentWhereInputOptions = {
documentId: number;
userId: number;
teamId?: number;
/**
* Whether to return a filter that allows access to both the user and team documents.
* This only applies if `teamId` is passed in.
*
* If true, and `teamId` is passed in, the filter will allow both team and user documents.
* If false, and `teamId` is passed in, the filter will only allow team documents.
*
* Defaults to false.
*/
overlapUserTeamScope?: boolean;
teamId: number;
};
/**
@ -91,42 +80,18 @@ export const getDocumentWhereInput = async ({
documentId,
userId,
teamId,
overlapUserTeamScope = false,
}: GetDocumentWhereInputOptions) => {
const documentWhereInput: Prisma.DocumentWhereUniqueInput = {
id: documentId,
OR: [
{
userId,
},
],
};
if (teamId === undefined || !documentWhereInput.OR) {
return documentWhereInput;
}
const team = await getTeamById({ teamId, userId });
// Allow access to team and user documents.
if (overlapUserTeamScope) {
documentWhereInput.OR.push({
const documentOrInput: Prisma.DocumentWhereInput[] = [
{
teamId: team.id,
});
}
// Allow access to only team documents.
if (!overlapUserTeamScope) {
documentWhereInput.OR = [
{
teamId: team.id,
},
];
}
},
];
// Allow access to documents sent to or from the team email.
if (team.teamEmail) {
documentWhereInput.OR.push(
documentOrInput.push(
{
recipients: {
some: {
@ -142,6 +107,11 @@ export const getDocumentWhereInput = async ({
);
}
const documentWhereInput: Prisma.DocumentWhereUniqueInput = {
id: documentId,
OR: documentOrInput,
};
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@ -149,7 +119,7 @@ export const getDocumentWhereInput = async ({
});
const visibilityFilters = [
...match(team.currentTeamMember?.role)
...match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => [
{ visibility: DocumentVisibility.EVERYONE },
{ visibility: DocumentVisibility.MANAGER_AND_ABOVE },
@ -177,7 +147,10 @@ export const getDocumentWhereInput = async ({
];
return {
...documentWhereInput,
OR: [...visibilityFilters],
documentWhereInput: {
...documentWhereInput,
OR: [...visibilityFilters],
},
team,
};
};

View File

@ -85,11 +85,6 @@ export const getDocumentAndSenderByToken = async ({
select: {
name: true,
teamEmail: true,
teamGlobalSettings: {
select: {
includeSenderDetails: true,
},
},
},
},
},

View File

@ -6,7 +6,7 @@ import { getDocumentWhereInput } from './get-document-by-id';
export type GetDocumentWithDetailsByIdOptions = {
documentId: number;
userId: number;
teamId?: number;
teamId: number;
folderId?: string;
};
@ -16,7 +16,7 @@ export const getDocumentWithDetailsById = async ({
teamId,
folderId,
}: GetDocumentWithDetailsByIdOptions) => {
const documentWhereInput = await getDocumentWhereInput({
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
@ -31,8 +31,32 @@ export const getDocumentWithDetailsById = async ({
documentData: true,
documentMeta: true,
recipients: true,
fields: true,
folder: true,
fields: {
include: {
signature: true,
recipient: {
select: {
name: true,
email: true,
signingStatus: true,
},
},
},
},
team: {
select: {
id: true,
url: true,
},
},
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});

View File

@ -11,7 +11,7 @@ import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-d
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
export type GetStatsInput = {
user: User;
user: Pick<User, 'id' | 'email'>;
team?: Omit<GetTeamCountsOption, 'createdAt'>;
period?: PeriodSelectorValue;
search?: string;
@ -89,7 +89,7 @@ export const getStats = async ({
};
type GetCountsOption = {
user: User;
user: Pick<User, 'id' | 'email'>;
createdAt: Prisma.DocumentWhereInput['createdAt'];
search?: string;
folderId?: string | null;
@ -116,7 +116,6 @@ const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption)
where: {
userId: user.id,
createdAt,
teamId: null,
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
@ -334,7 +333,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
documentDeletedAt: null,
},
},
deletedAt: null,
},
],
},

View File

@ -1,73 +0,0 @@
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type MoveDocumentToTeamOptions = {
documentId: number;
teamId: number;
userId: number;
requestMetadata: ApiRequestMetadata;
};
export const moveDocumentToTeam = async ({
documentId,
teamId,
userId,
requestMetadata,
}: MoveDocumentToTeamOptions) => {
return await prisma.$transaction(async (tx) => {
const document = await tx.document.findFirst({
where: {
id: documentId,
userId,
teamId: null,
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found or already associated with a team.',
});
}
const team = await tx.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'This team does not exist, or you are not a member of this team.',
});
}
const updatedDocument = await tx.document.update({
where: { id: documentId },
data: { teamId },
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM,
documentId: updatedDocument.id,
metadata: requestMetadata,
data: {
movedByUserId: userId,
fromPersonalAccount: true,
toTeamId: teamId,
},
}),
});
return updatedDocument;
});
};

View File

@ -1,8 +1,7 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import type { Prisma } from '@prisma/client';
import { DocumentStatus, OrganisationType, RecipientRole, SigningStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
@ -22,14 +21,14 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { isDocumentCompleted } from '../../utils/document';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getEmailContext } from '../email/get-email-context';
import { getDocumentWhereInput } from './get-document-by-id';
export type ResendDocumentOptions = {
documentId: number;
userId: number;
recipients: number[];
teamId?: number;
teamId: number;
requestMetadata: ApiRequestMetadata;
};
@ -46,7 +45,7 @@ export const resendDocument = async ({
},
});
const documentWhereInput: Prisma.DocumentWhereUniqueInput = await getDocumentWhereInput({
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
@ -68,14 +67,12 @@ export const resendDocument = async ({
select: {
teamEmail: true,
name: true,
teamGlobalSettings: true,
},
},
},
});
const customEmail = document?.documentMeta;
const isTeamDocument = document?.team !== null;
if (!document) {
throw new Error('Document not found');
@ -101,13 +98,21 @@ export const resendDocument = async ({
return;
}
const { branding, settings, organisationType } = await getEmailContext({
source: {
type: 'team',
teamId: document.teamId,
},
});
await Promise.all(
document.recipients.map(async (recipient) => {
if (recipient.role === RecipientRole.CC) {
return;
}
const i18n = await getI18nInstance(document.documentMeta?.language);
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
@ -128,7 +133,7 @@ export const resendDocument = async ({
emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} your document`);
}
if (isTeamDocument && document.team) {
if (organisationType === OrganisationType.ORGANISATION) {
emailSubject = i18n._(
msg`Reminder: ${document.team.name} invited you to ${recipientActionVerb} a document`,
);
@ -151,27 +156,26 @@ export const resendDocument = async ({
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: isTeamDocument ? document.team?.teamEmail?.email || user.email : user.email,
inviterEmail:
organisationType === OrganisationType.ORGANISATION
? document.team?.teamEmail?.email || user.email
: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
role: recipient.role,
selfSigner,
isTeamInvite: isTeamDocument,
organisationType,
teamName: document.team?.name,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang,
branding,
}),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang,
branding,
plainText: true,
}),

View File

@ -24,6 +24,7 @@ import { flattenForm } from '../pdf/flatten-form';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { legacy_insertFieldInPDF } from '../pdf/legacy-insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendCompletedEmail } from './send-completed-email';
@ -48,15 +49,6 @@ export const sealDocument = async ({
documentData: true,
documentMeta: true,
recipients: true,
team: {
select: {
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
},
},
},
},
},
});
@ -66,6 +58,11 @@ export const sealDocument = async ({
throw new Error(`Document ${document.id} has no document data`);
}
const settings = await getTeamSettings({
userId: document.userId,
teamId: document.teamId,
});
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
@ -116,13 +113,12 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy
const pdfData = await getFileServerSide(documentData);
const certificateData =
(document.team?.teamGlobalSettings?.includeSigningCertificate ?? true)
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
: null;
const certificateData = settings.includeSigningCertificate
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
: null;
const doc = await PDFDocument.load(pdfData);

View File

@ -3,7 +3,11 @@ import type { Document, Recipient, User } from '@prisma/client';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import {
buildTeamWhereQuery,
formatDocumentsPath,
getHighestTeamRoleInGroup,
} from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
export type SearchDocumentsWithKeywordOptions = {
@ -84,16 +88,7 @@ export const searchDocumentsWithKeyword = async ({
contains: query,
mode: 'insensitive',
},
teamId: {
not: null,
},
team: {
members: {
some: {
userId: userId,
},
},
},
team: buildTeamWhereQuery({ teamId: undefined, userId }),
deletedAt: null,
},
{
@ -101,16 +96,7 @@ export const searchDocumentsWithKeyword = async ({
contains: query,
mode: 'insensitive',
},
teamId: {
not: null,
},
team: {
members: {
some: {
userId: userId,
},
},
},
team: buildTeamWhereQuery({ teamId: undefined, userId }),
deletedAt: null,
},
],
@ -120,12 +106,17 @@ export const searchDocumentsWithKeyword = async ({
team: {
select: {
url: true,
members: {
teamGroups: {
where: {
userId: userId,
},
select: {
role: true,
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
@ -147,7 +138,9 @@ export const searchDocumentsWithKeyword = async ({
return true;
}
const teamMemberRole = document.team?.members[0]?.role;
const teamMemberRole = getHighestTeamRoleInGroup(
document.team.teamGroups.filter((tg) => tg.teamId === document.teamId),
);
if (!teamMemberRole) {
return false;

View File

@ -17,8 +17,8 @@ import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { env } from '../../utils/env';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { formatDocumentsPath } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
export interface SendDocumentOptions {
documentId: number;
@ -39,7 +39,6 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
select: {
id: true,
url: true,
teamGlobalSettings: true,
},
},
},
@ -55,6 +54,13 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
throw new Error('Document has no recipients');
}
const { branding, settings } = await getEmailContext({
source: {
type: 'team',
teamId: document.teamId,
},
});
const { user: owner } = document;
const completedDocument = await getFileServerSide(document.documentData);
@ -71,8 +77,6 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
}`;
}
const i18n = await getI18nInstance(document.documentMeta?.language);
const emailSettings = extractDerivedDocumentEmailSettings(document.documentMeta);
const isDocumentCompletedEmailEnabled = emailSettings.documentCompleted;
const isOwnerDocumentCompletedEmailEnabled = emailSettings.ownerDocumentCompleted;
@ -93,19 +97,19 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
downloadLink: documentOwnerDownloadLink,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: [
{
@ -170,19 +174,19 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
: undefined,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: [
{

View File

@ -12,7 +12,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getEmailContext } from '../email/get-email-context';
export interface SendDeleteEmailOptions {
documentId: number;
@ -27,11 +27,6 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
include: {
user: true,
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -49,6 +44,13 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
return;
}
const { branding, settings } = await getEmailContext({
source: {
type: 'team',
teamId: document.teamId,
},
});
const { email, name } = document.user;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
@ -59,20 +61,18 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
assetBaseUrl,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance();
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: {

View File

@ -23,11 +23,12 @@ import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getDocumentWhereInput } from './get-document-by-id';
export type SendDocumentOptions = {
documentId: number;
userId: number;
teamId?: number;
teamId: number;
sendEmail?: boolean;
requestMetadata: ApiRequestMetadata;
};
@ -39,25 +40,14 @@ export const sendDocument = async ({
sendEmail,
requestMetadata,
}: SendDocumentOptions) => {
const document = await prisma.document.findUnique({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: documentWhereInput,
include: {
recipients: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],

View File

@ -11,7 +11,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getEmailContext } from '../email/get-email-context';
export interface SendPendingEmailOptions {
documentId: number;
@ -35,11 +35,6 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
},
},
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -51,6 +46,13 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
throw new Error('Document has no recipients');
}
const { branding, settings } = await getEmailContext({
source: {
type: 'team',
teamId: document.teamId,
},
});
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentPending;
@ -70,20 +72,18 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
assetBaseUrl,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: {

View File

@ -16,7 +16,7 @@ import { extractDerivedDocumentEmailSettings } from '../../types/document-email'
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getEmailContext } from '../email/get-email-context';
export type SuperDeleteDocumentOptions = {
id: number;
@ -32,11 +32,6 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
recipients: true,
documentMeta: true,
user: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -46,6 +41,13 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
});
}
const { branding, settings } = await getEmailContext({
source: {
type: 'team',
teamId: document.teamId,
},
});
const { status, user } = document;
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
@ -72,20 +74,18 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
assetBaseUrl,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: {

View File

@ -2,7 +2,6 @@ import { DocumentVisibility } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
@ -12,10 +11,11 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
import { getDocumentWhereInput } from './get-document-by-id';
export type UpdateDocumentOptions = {
userId: number;
teamId?: number;
teamId: number;
documentId: number;
data?: {
title?: string;
@ -35,34 +35,20 @@ export const updateDocument = async ({
data,
requestMetadata,
}: UpdateDocumentOptions) => {
const { documentWhereInput, team } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
include: {
team: {
select: {
members: {
where: {
userId,
},
organisation: {
select: {
role: true,
organisationClaim: true,
},
},
},
@ -76,45 +62,42 @@ export const updateDocument = async ({
});
}
if (teamId) {
const currentUserRole = document.team?.members[0]?.role;
const isDocumentOwner = document.userId === userId;
const requestedVisibility = data?.visibility;
const isDocumentOwner = document.userId === userId;
const requestedVisibility = data?.visibility;
if (!isDocumentOwner) {
match(currentUserRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => {
const allowedVisibilities: DocumentVisibility[] = [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
];
if (!isDocumentOwner) {
match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => {
const allowedVisibilities: DocumentVisibility[] = [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
];
if (
!allowedVisibilities.includes(document.visibility) ||
(requestedVisibility && !allowedVisibilities.includes(requestedVisibility))
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
});
}
})
.with(TeamMemberRole.MEMBER, () => {
if (
document.visibility !== DocumentVisibility.EVERYONE ||
(requestedVisibility && requestedVisibility !== DocumentVisibility.EVERYONE)
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
});
}
})
.otherwise(() => {
if (
!allowedVisibilities.includes(document.visibility) ||
(requestedVisibility && !allowedVisibilities.includes(requestedVisibility))
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document',
message: 'You do not have permission to update the document visibility',
});
}
})
.with(TeamMemberRole.MEMBER, () => {
if (
document.visibility !== DocumentVisibility.EVERYONE ||
(requestedVisibility && requestedVisibility !== DocumentVisibility.EVERYONE)
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
});
}
})
.otherwise(() => {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document',
});
}
});
}
// If no data just return the document since this function is normally chained after a meta update.
@ -136,17 +119,10 @@ export const updateDocument = async ({
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth && newGlobalActionAuth.length > 0) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
if (newGlobalActionAuth.length > 0 && !document.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const isTitleSame = data.title === undefined || data.title === document.title;

View File

@ -1,81 +0,0 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
export type UpdateTitleOptions = {
userId: number;
teamId?: number;
documentId: number;
title: string;
requestMetadata?: RequestMetadata;
};
export const updateTitle = async ({
userId,
teamId,
documentId,
title,
requestMetadata,
}: UpdateTitleOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
if (document.title === title) {
return document;
}
return await prisma.$transaction(async (tx) => {
// Instead of doing everything in a transaction we can use our knowledge
// of the current document title to ensure we aren't performing a conflicting
// update.
const updatedDocument = await tx.document.update({
where: {
id: documentId,
title: document.title,
},
data: {
title,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.title,
to: updatedDocument.title,
},
}),
});
return updatedDocument;
});
};

View File

@ -0,0 +1,90 @@
import type { BrandingSettings } from '@documenso/email/providers/branding';
import { prisma } from '@documenso/prisma';
import type { OrganisationType } from '@documenso/prisma/client';
import { type OrganisationClaim, type OrganisationGlobalSettings } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
organisationGlobalSettingsToBranding,
teamGlobalSettingsToBranding,
} from '../../utils/team-global-settings-to-branding';
import { getTeamSettings } from '../team/get-team-settings';
type GetEmailContextOptions = {
source:
| {
type: 'team';
teamId: number;
}
| {
type: 'organisation';
organisationId: string;
};
};
type EmailContextResponse = {
branding: BrandingSettings;
settings: Omit<OrganisationGlobalSettings, 'id'>;
claims: OrganisationClaim;
organisationType: OrganisationType;
};
export const getEmailContext = async (
options: GetEmailContextOptions,
): Promise<EmailContextResponse> => {
const { source } = options;
const organisation = await prisma.organisation.findFirst({
where:
source.type === 'organisation'
? {
id: source.organisationId,
}
: {
teams: {
some: {
id: source.teamId,
},
},
},
include: {
subscription: true,
organisationClaim: true,
organisationGlobalSettings: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const claims = organisation.organisationClaim;
if (source.type === 'organisation') {
return {
branding: organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
claims.flags.hidePoweredBy ?? false,
),
settings: organisation.organisationGlobalSettings,
claims,
organisationType: organisation.type,
};
}
const teamSettings = await getTeamSettings({
teamId: source.teamId,
});
return {
branding: teamGlobalSettingsToBranding(
teamSettings,
source.teamId,
claims.flags.hidePoweredBy ?? false,
),
settings: teamSettings,
claims,
organisationType: organisation.type,
};
};

View File

@ -6,10 +6,11 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getDocumentWhereInput } from '../document/get-document-by-id';
export interface CreateDocumentFieldsOptions {
userId: number;
teamId?: number;
teamId: number;
documentId: number;
fields: (TFieldAndMeta & {
recipientId: number;
@ -29,25 +30,14 @@ export const createDocumentFields = async ({
fields,
requestMetadata,
}: CreateDocumentFieldsOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
include: {
recipients: true,
fields: true,

View File

@ -1,4 +1,4 @@
import type { FieldType, Team } from '@prisma/client';
import type { FieldType } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
@ -13,11 +13,12 @@ import {
import type { TFieldMetaSchema as FieldMeta } from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { getDocumentWhereInput } from '../document/get-document-by-id';
export type CreateFieldOptions = {
documentId: number;
userId: number;
teamId?: number;
teamId: number;
recipientId: number;
type: FieldType;
pageNumber: number;
@ -43,60 +44,23 @@ export const createField = async ({
fieldMeta,
requestMetadata,
}: CreateFieldOptions) => {
const { documentWhereInput, team } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: documentWhereInput,
select: {
id: true,
},
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
if (!document) {
throw new Error('Document not found');
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(type);
if (advancedField && !fieldMeta) {
@ -154,9 +118,9 @@ export const createField = async ({
type: 'FIELD_CREATED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
id: team.id,
email: team.name,
name: '',
},
data: {
fieldId: field.secondaryId,

View File

@ -5,10 +5,11 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface CreateTemplateFieldsOptions {
userId: number;
teamId?: number;
teamId: number;
templateId: number;
fields: {
recipientId: number;
@ -31,21 +32,7 @@ export const createTemplateFields = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
team: buildTeamWhereQuery({ teamId, userId }),
},
include: {
recipients: true,

View File

@ -5,10 +5,11 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getDocumentWhereInput } from '../document/get-document-by-id';
export interface DeleteDocumentFieldOptions {
userId: number;
teamId?: number;
teamId: number;
fieldId: number;
requestMetadata: ApiRequestMetadata;
}
@ -39,25 +40,14 @@ export const deleteDocumentField = async ({
});
}
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
include: {
recipients: {
where: {

View File

@ -4,12 +4,13 @@ import { prisma } from '@documenso/prisma';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { buildTeamWhereQuery } from '../../utils/teams';
export type DeleteFieldOptions = {
fieldId: number;
documentId: number;
userId: number;
teamId?: number;
teamId: number;
requestMetadata?: RequestMetadata;
};
@ -25,21 +26,8 @@ export const deleteField = async ({
id: fieldId,
document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
userId,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {

View File

@ -1,10 +1,11 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface DeleteTemplateFieldOptions {
userId: number;
teamId?: number;
teamId: number;
fieldId: number;
}
@ -16,21 +17,9 @@ export const deleteTemplateField = async ({
const field = await prisma.field.findFirst({
where: {
id: fieldId,
template: teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
},
template: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
});

View File

@ -1,53 +1,49 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
export type GetFieldByIdOptions = {
userId: number;
teamId?: number;
teamId: number;
fieldId: number;
documentId?: number;
templateId?: number;
};
export const getFieldById = async ({
userId,
teamId,
fieldId,
documentId,
templateId,
}: GetFieldByIdOptions) => {
export const getFieldById = async ({ userId, teamId, fieldId }: GetFieldByIdOptions) => {
const field = await prisma.field.findFirst({
where: {
id: fieldId,
documentId,
templateId,
},
include: {
document: {
OR:
teamId === undefined
? [
{
userId,
teamId: null,
},
]
: [
{
teamId,
team: {
members: {
some: {
userId,
},
},
},
},
],
select: {
teamId: true,
},
},
template: {
select: {
teamId: true,
},
},
},
});
if (!field) {
const foundTeamId = field?.document?.teamId || field?.template?.teamId;
if (!field || !foundTeamId || foundTeamId !== teamId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Field not found',
});
}
const team = await prisma.team.findUnique({
where: buildTeamWhereQuery({
teamId: foundTeamId,
userId,
}),
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Field not found',
});

View File

@ -1,9 +1,11 @@
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface GetFieldsForDocumentOptions {
documentId: number;
userId: number;
teamId?: number;
teamId: number;
}
export type DocumentField = Awaited<ReturnType<typeof getFieldsForDocument>>[number];
@ -15,22 +17,10 @@ export const getFieldsForDocument = async ({
}: GetFieldsForDocumentOptions) => {
const fields = await prisma.field.findMany({
where: {
documentId,
document: teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
},
document: {
id: documentId,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
signature: true,

View File

@ -26,10 +26,11 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getDocumentWhereInput } from '../document/get-document-by-id';
export interface SetFieldsForDocumentOptions {
userId: number;
teamId?: number;
teamId: number;
documentId: number;
fields: FieldData[];
requestMetadata: ApiRequestMetadata;
@ -42,25 +43,14 @@ export const setFieldsForDocument = async ({
fields,
requestMetadata,
}: SetFieldsForDocumentOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
include: {
recipients: true,
},

View File

@ -16,9 +16,11 @@ import {
} from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type SetFieldsForTemplateOptions = {
userId: number;
teamId?: number;
teamId: number;
templateId: number;
fields: {
id?: number | null;
@ -42,21 +44,7 @@ export const setFieldsForTemplate = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
team: buildTeamWhereQuery({ teamId, userId }),
},
});

View File

@ -11,10 +11,11 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getDocumentWhereInput } from '../document/get-document-by-id';
export interface UpdateDocumentFieldsOptions {
userId: number;
teamId?: number;
teamId: number;
documentId: number;
fields: {
id: number;
@ -36,25 +37,14 @@ export const updateDocumentFields = async ({
fields,
requestMetadata,
}: UpdateDocumentFieldsOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
include: {
recipients: true,
fields: true,

View File

@ -6,12 +6,13 @@ import { prisma } from '@documenso/prisma';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData, diffFieldChanges } from '../../utils/document-audit-logs';
import { buildTeamWhereQuery } from '../../utils/teams';
export type UpdateFieldOptions = {
fieldId: number;
documentId: number;
userId: number;
teamId?: number;
teamId: number;
recipientId?: number;
type?: FieldType;
pageNumber?: number;
@ -47,21 +48,8 @@ export const updateField = async ({
id: fieldId,
document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
userId,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
});
@ -101,14 +89,7 @@ export const updateField = async ({
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
where: buildTeamWhereQuery({ teamId, userId }),
});
}

View File

@ -5,10 +5,11 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface UpdateTemplateFieldsOptions {
userId: number;
teamId?: number;
teamId: number;
templateId: number;
fields: {
id: number;
@ -31,21 +32,7 @@ export const updateTemplateFields = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
team: buildTeamWhereQuery({ teamId, userId }),
},
include: {
recipients: true,

View File

@ -1,16 +1,14 @@
import { TeamMemberRole } from '@prisma/client';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { getTeamById } from '../team/get-team';
import { getTeamSettings } from '../team/get-team-settings';
export interface CreateFolderOptions {
userId: number;
teamId?: number;
teamId: number;
name: string;
parentId?: string | null;
type?: TFolderType;
@ -23,52 +21,9 @@ export const createFolder = async ({
parentId,
type = FolderType.DOCUMENT,
}: CreateFolderOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
teamMembers: {
select: {
teamId: true,
},
},
},
});
const team = await getTeamById({ userId, teamId });
if (
teamId !== undefined &&
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;
let userTeamRole: TeamMemberRole | undefined;
if (teamId) {
const teamWithUserRole = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
},
include: {
teamGlobalSettings: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
});
team = teamWithUserRole;
userTeamRole = teamWithUserRole.members[0]?.role;
}
const settings = await getTeamSettings({ userId, teamId });
return await prisma.folder.create({
data: {
@ -77,10 +32,7 @@ export const createFolder = async ({
teamId,
parentId,
type,
visibility: determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
visibility: determineDocumentVisibility(settings.documentVisibility, team.currentTeamRole),
},
});
};

View File

@ -4,45 +4,16 @@ import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { getTeamById } from '../team/get-team';
export interface DeleteFolderOptions {
userId: number;
teamId?: number;
teamId: number;
folderId: string;
}
export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOptions) => {
let teamMemberRole: TeamMemberRole | null = null;
if (teamId) {
const team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
teamMemberRole = team.members[0]?.role ?? null;
}
const team = await getTeamById({ userId, teamId });
const folder = await prisma.folder.findFirst({
where: {
@ -63,18 +34,16 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
});
}
if (teamId && teamMemberRole) {
const hasPermission = match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => folder.visibility !== DocumentVisibility.ADMIN)
.with(TeamMemberRole.MEMBER, () => folder.visibility === DocumentVisibility.EVERYONE)
.otherwise(() => false);
const hasPermission = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => folder.visibility !== DocumentVisibility.ADMIN)
.with(TeamMemberRole.MEMBER, () => folder.visibility === DocumentVisibility.EVERYONE)
.otherwise(() => false);
if (!hasPermission) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to delete this folder',
});
}
if (!hasPermission) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to delete this folder',
});
}
return await prisma.folder.delete({

View File

@ -5,49 +5,19 @@ import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
import { getTeamById } from '../team/get-team';
export interface FindFoldersOptions {
userId: number;
teamId?: number;
teamId: number;
parentId?: string | null;
type?: TFolderType;
}
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => {
let team = null;
let teamMemberRole = null;
const team = await getTeamById({ userId, teamId });
if (teamId !== undefined) {
try {
team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
console.error('Error finding team:', error);
throw error;
}
}
const visibilityFilters = match(teamMemberRole)
const visibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
@ -67,14 +37,12 @@ export const findFolders = async ({ userId, teamId, parentId, type }: FindFolder
const whereClause = {
AND: [
{ parentId },
teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null },
{
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
},
],
};
@ -94,7 +62,8 @@ export const findFolders = async ({ userId, teamId, parentId, type }: FindFolder
prisma.folder.findMany({
where: {
parentId: folder.id,
...(teamId ? { teamId, ...visibilityFilters } : { userId, teamId: null }),
teamId,
...visibilityFilters,
},
orderBy: {
createdAt: 'desc',
@ -113,7 +82,8 @@ export const findFolders = async ({ userId, teamId, parentId, type }: FindFolder
prisma.folder.count({
where: {
parentId: folder.id,
...(teamId ? { teamId, ...visibilityFilters } : { userId, teamId: null }),
teamId,
...visibilityFilters,
},
}),
]);

View File

@ -5,10 +5,11 @@ import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
import { getTeamById } from '../team/get-team';
export interface GetFolderBreadcrumbsOptions {
userId: number;
teamId?: number;
teamId: number;
folderId: string;
type?: TFolderType;
}
@ -19,39 +20,9 @@ export const getFolderBreadcrumbs = async ({
folderId,
type,
}: GetFolderBreadcrumbsOptions) => {
let teamMemberRole = null;
const team = await getTeamById({ userId, teamId });
if (teamId !== undefined) {
try {
const team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
console.error('Error finding team:', error);
return [];
}
}
const visibilityFilters = match(teamMemberRole)
const visibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
@ -71,14 +42,10 @@ export const getFolderBreadcrumbs = async ({
const whereClause = (folderId: string) => ({
id: folderId,
...(type ? { type } : {}),
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
});
const breadcrumbs = [];

View File

@ -6,49 +6,19 @@ import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
import { getTeamById } from '../team/get-team';
export interface GetFolderByIdOptions {
userId: number;
teamId?: number;
teamId: number;
folderId?: string;
type?: TFolderType;
}
export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => {
let teamMemberRole = null;
const team = await getTeamById({ userId, teamId });
if (teamId !== undefined) {
try {
const team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
}
const visibilityFilters = match(teamMemberRole)
const visibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
@ -68,14 +38,10 @@ export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolde
const whereClause = {
id: folderId,
...(type ? { type } : {}),
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
};
const folder = await prisma.folder.findFirst({

View File

@ -7,9 +7,11 @@ import { FolderType } from '@documenso/lib/types/folder-type';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { getTeamById } from '../team/get-team';
export interface MoveDocumentToFolderOptions {
userId: number;
teamId?: number;
teamId: number;
documentId: number;
folderId?: string | null;
requestMetadata?: ApiRequestMetadata;
@ -21,41 +23,9 @@ export const moveDocumentToFolder = async ({
documentId,
folderId,
}: MoveDocumentToFolderOptions) => {
let teamMemberRole = null;
const team = await getTeamById({ userId, teamId });
if (teamId !== undefined) {
try {
const team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
console.error('Error finding team:', error);
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
}
const visibilityFilters = match(teamMemberRole)
const visibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
@ -74,14 +44,10 @@ export const moveDocumentToFolder = async ({
const documentWhereClause = {
id: documentId,
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
};
const document = await prisma.document.findFirst({
@ -98,14 +64,10 @@ export const moveDocumentToFolder = async ({
const folderWhereClause = {
id: folderId,
type: FolderType.DOCUMENT,
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
};
const folder = await prisma.folder.findFirst({

View File

@ -0,0 +1,105 @@
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { generateDatabaseId } from '../../universal/id';
export type AcceptOrganisationInvitationOptions = {
token: string;
};
export const acceptOrganisationInvitation = async ({
token,
}: AcceptOrganisationInvitationOptions) => {
const organisationMemberInvite = await prisma.organisationMemberInvite.findFirst({
where: {
token,
status: {
not: OrganisationMemberInviteStatus.DECLINED,
},
},
include: {
organisation: {
include: {
groups: {
include: {
teamGroups: true,
},
},
},
},
},
});
if (!organisationMemberInvite) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.ACCEPTED) {
return;
}
const user = await prisma.user.findFirst({
where: {
email: organisationMemberInvite.email,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User must exist to accept an organisation invitation',
});
}
const { organisation } = organisationMemberInvite;
const organisationGroupToUse = organisation.groups.find(
(group) =>
group.type === OrganisationGroupType.INTERNAL_ORGANISATION &&
group.organisationRole === organisationMemberInvite.organisationRole,
);
if (!organisationGroupToUse) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Organisation group not found',
});
}
await prisma.$transaction(
async (tx) => {
await tx.organisationMember.create({
data: {
id: generateDatabaseId('member'),
userId: user.id,
organisationId: organisation.id,
organisationGroupMembers: {
create: {
id: generateDatabaseId('group_member'),
groupId: organisationGroupToUse.id,
},
},
},
});
await tx.organisationMemberInvite.update({
where: {
id: organisationMemberInvite.id,
},
data: {
status: OrganisationMemberInviteStatus.ACCEPTED,
},
});
await jobs.triggerJob({
name: 'send.organisation-member-joined.email',
payload: {
organisationId: organisation.id,
memberUserId: user.id,
},
});
},
{ timeout: 30_000 },
);
};

View File

@ -0,0 +1,224 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Organisation, Prisma } from '@prisma/client';
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { nanoid } from 'nanoid';
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { mailer } from '@documenso/email/mailer';
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import type { TCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { generateDatabaseId } from '../../universal/id';
import { validateIfSubscriptionIsRequired } from '../../utils/billing';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getMemberOrganisationRole } from '../team/get-member-roles';
export type CreateOrganisationMemberInvitesOptions = {
userId: number;
userName: string;
organisationId: string;
invitations: TCreateOrganisationMemberInvitesRequestSchema['invitations'];
};
/**
* Invite organisation members via email to join a organisation.
*/
export const createOrganisationMemberInvites = async ({
userId,
userName,
organisationId,
invitations,
}: CreateOrganisationMemberInvitesOptions): Promise<void> => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
members: {
select: {
user: {
select: {
id: true,
email: true,
},
},
},
},
invites: {
where: {
status: OrganisationMemberInviteStatus.PENDING,
},
},
organisationGlobalSettings: true,
organisationClaim: true,
subscription: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { organisationClaim } = organisation;
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const currentOrganisationMemberRole = await getMemberOrganisationRole({
organisationId: organisation.id,
reference: {
type: 'User',
id: userId,
},
});
const organisationMemberEmails = organisation.members.map((member) => member.user.email);
const organisationMemberInviteEmails = organisation.invites.map((invite) => invite.email);
const usersToInvite = invitations.filter((invitation) => {
// Filter out users that are already members of the organisation.
if (organisationMemberEmails.includes(invitation.email)) {
return false;
}
// Filter out users that have already been invited to the organisation.
if (organisationMemberInviteEmails.includes(invitation.email)) {
return false;
}
return true;
});
const unauthorizedRoleAccess = usersToInvite.some(
({ organisationRole }) =>
!isOrganisationRoleWithinUserHierarchy(currentOrganisationMemberRole, organisationRole),
);
if (unauthorizedRoleAccess) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User does not have permission to set high level roles',
});
}
const organisationMemberInvites: Prisma.OrganisationMemberInviteCreateManyInput[] =
usersToInvite.map(({ email, organisationRole }) => ({
id: generateDatabaseId('member_invite'),
email,
organisationId,
organisationRole,
token: nanoid(32),
}));
const numberOfCurrentMembers = organisation.members.length;
const numberOfCurrentInvites = organisation.invites.length;
const numberOfNewInvites = organisationMemberInvites.length;
const totalMemberCountWithInvites =
numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites;
// Handle billing for seat based plans.
if (subscription) {
await syncMemberCountWithStripeSeatPlan(
subscription,
organisationClaim,
totalMemberCountWithInvites,
);
}
await prisma.organisationMemberInvite.createMany({
data: organisationMemberInvites,
});
const sendEmailResult = await Promise.allSettled(
organisationMemberInvites.map(async ({ email, token }) =>
sendOrganisationMemberInviteEmail({
email,
token,
organisation,
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}/${organisationMemberInvites.length} users.`,
});
}
};
type SendOrganisationMemberInviteEmailOptions = {
email: string;
senderName: string;
token: string;
organisation: Pick<Organisation, 'id' | 'name'>;
};
/**
* Send an email to a user inviting them to join a organisation.
*/
export const sendOrganisationMemberInviteEmail = async ({
email,
senderName,
token,
organisation,
}: SendOrganisationMemberInviteEmailOptions) => {
const template = createElement(OrganisationInviteEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
senderName,
token,
organisationName: organisation.name,
});
const { branding, settings } = await getEmailContext({
source: {
type: 'organisation',
organisationId: organisation.id,
},
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: settings.documentLanguage,
branding,
}),
renderEmailWithI18N(template, {
lang: settings.documentLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(settings.documentLanguage);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`You have been invited to join ${organisation.name} on Documenso`),
html,
text,
});
};

View File

@ -0,0 +1,175 @@
import type { Prisma } from '@prisma/client';
import { OrganisationType } from '@prisma/client';
import { OrganisationMemberRole } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations';
import { AppErrorCode } from '../../errors/app-error';
import { AppError } from '../../errors/app-error';
import type { InternalClaim } from '../../types/subscription';
import { INTERNAL_CLAIM_ID, internalClaims } from '../../types/subscription';
import { generateDatabaseId, prefixedId } from '../../universal/id';
import { generateDefaultOrganisationSettings } from '../../utils/organisations';
import { createTeam } from '../team/create-team';
type CreateOrganisationOptions = {
userId: number;
name: string;
type: OrganisationType;
url?: string;
customerId?: string;
claim: InternalClaim;
};
export const createOrganisation = async ({
name,
url,
type,
userId,
customerId,
claim,
}: CreateOrganisationOptions) => {
return await prisma.$transaction(async (tx) => {
const organisationSetting = await tx.organisationGlobalSettings.create({
data: {
...generateDefaultOrganisationSettings(),
id: generateDatabaseId('org_setting'),
},
});
const organisationClaim = await tx.organisationClaim.create({
data: {
id: generateDatabaseId('org_claim'),
originalSubscriptionClaimId: claim.id,
...createOrganisationClaimUpsertData(claim),
},
});
const orgIdAndUrl = prefixedId('org');
const organisation = await tx.organisation
.create({
data: {
id: orgIdAndUrl,
name,
type,
url: url || orgIdAndUrl,
ownerUserId: userId,
organisationGlobalSettingsId: organisationSetting.id,
organisationClaimId: organisationClaim.id,
groups: {
create: ORGANISATION_INTERNAL_GROUPS.map((group) => ({
...group,
id: generateDatabaseId('org_group'),
})),
},
customerId,
},
include: {
groups: true,
},
})
.catch((err) => {
if (err.code === 'P2002') {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Organisation URL already exists',
});
}
throw err;
});
const adminGroup = organisation.groups.find(
(group) => group.organisationRole === OrganisationMemberRole.ADMIN,
);
if (!adminGroup) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Admin group not found',
});
}
await tx.organisationMember.create({
data: {
id: generateDatabaseId('member'),
userId,
organisationId: organisation.id,
organisationGroupMembers: {
create: {
id: generateDatabaseId('group_member'),
groupId: adminGroup.id,
},
},
},
});
return organisation;
});
};
type CreatePersonalOrganisationOptions = {
userId: number;
orgUrl?: string;
throwErrorOnOrganisationCreationFailure?: boolean;
inheritMembers?: boolean;
type?: OrganisationType;
};
export const createPersonalOrganisation = async ({
userId,
orgUrl,
throwErrorOnOrganisationCreationFailure = false,
inheritMembers = true,
type = OrganisationType.PERSONAL,
}: CreatePersonalOrganisationOptions) => {
const organisation = await createOrganisation({
name: 'Personal Organisation',
userId,
url: orgUrl,
type,
claim: internalClaims[INTERNAL_CLAIM_ID.FREE],
}).catch((err) => {
console.error(err);
if (throwErrorOnOrganisationCreationFailure) {
throw err;
}
// Todo: (LOGS)
});
if (organisation) {
await createTeam({
userId,
teamName: 'Personal Team',
teamUrl: prefixedId('personal'),
organisationId: organisation.id,
inheritMembers,
}).catch((err) => {
console.error(err);
// Todo: (LOGS)
});
}
return organisation;
};
export const createOrganisationClaimUpsertData = (subscriptionClaim: InternalClaim) => {
// Done like this to ensure type errors are thrown if items are added.
const data: Omit<
Prisma.SubscriptionClaimCreateInput,
'id' | 'createdAt' | 'updatedAt' | 'locked' | 'name'
> = {
flags: {
...subscriptionClaim.flags,
},
teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount,
};
return {
...data,
};
};

View File

@ -0,0 +1,39 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export const getOrganisationClaim = async ({ organisationId }: { organisationId: string }) => {
const organisationClaim = await prisma.organisationClaim.findFirst({
where: {
organisation: {
id: organisationId,
},
},
});
if (!organisationClaim) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return organisationClaim;
};
export const getOrganisationClaimByTeamId = async ({ teamId }: { teamId: number }) => {
const organisationClaim = await prisma.organisationClaim.findFirst({
where: {
organisation: {
teams: {
some: {
id: teamId,
},
},
},
},
});
if (!organisationClaim) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return organisationClaim;
};

View File

@ -1,15 +1,8 @@
import type { Template, TemplateDirectLink } from '@prisma/client';
import {
SubscriptionStatus,
type TeamProfile,
TemplateType,
type UserProfile,
} from '@prisma/client';
import { type TeamProfile, TemplateType } from '@prisma/client';
import { getCommunityPlanPriceIds } from '@documenso/ee/server-only/stripe/get-community-plan-prices';
import { prisma } from '@documenso/prisma';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type GetPublicProfileByUrlOptions = {
@ -23,7 +16,7 @@ type PublicDirectLinkTemplate = Template & {
};
};
type BaseResponse = {
type GetPublicProfileByUrlResponse = {
url: string;
name: string;
avatarImageId?: string | null;
@ -32,155 +25,56 @@ type BaseResponse = {
since: Date;
};
templates: PublicDirectLinkTemplate[];
profile: TeamProfile;
};
type GetPublicProfileByUrlResponse = BaseResponse &
(
| {
type: 'User';
profile: UserProfile;
}
| {
type: 'Team';
profile: TeamProfile;
}
);
/**
* Get the user or team public profile by URL.
*/
export const getPublicProfileByUrl = async ({
profileUrl,
}: GetPublicProfileByUrlOptions): Promise<GetPublicProfileByUrlResponse> => {
const [user, team] = await Promise.all([
prisma.user.findFirst({
where: {
url: profileUrl,
profile: {
enabled: true,
const team = await prisma.team.findFirst({
where: {
url: profileUrl,
profile: {
enabled: true,
},
},
include: {
profile: true,
templates: {
where: {
directLink: {
enabled: true,
},
type: TemplateType.PUBLIC,
},
include: {
directLink: true,
},
},
include: {
profile: true,
templates: {
where: {
directLink: {
enabled: true,
},
type: TemplateType.PUBLIC,
},
include: {
directLink: true,
},
},
// Subscriptions and teamMembers are used to calculate the badges.
subscriptions: {
where: {
status: SubscriptionStatus.ACTIVE,
},
},
teamMembers: {
select: {
createdAt: true,
},
orderBy: {
createdAt: 'asc',
},
},
},
}),
prisma.team.findFirst({
where: {
url: profileUrl,
profile: {
enabled: true,
},
},
include: {
profile: true,
templates: {
where: {
directLink: {
enabled: true,
},
type: TemplateType.PUBLIC,
},
include: {
directLink: true,
},
},
},
}),
]);
},
});
// Log as critical error.
if (user?.profile && team?.profile) {
console.error('Profile URL is ambiguous', { profileUrl, userId: user.id, teamId: team.id });
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Profile URL is ambiguous',
if (!team?.profile?.enabled) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Profile not found',
});
}
if (user?.profile?.enabled) {
let badge: BaseResponse['badge'] = undefined;
if (user.teamMembers[0]) {
badge = {
type: 'Premium',
since: user.teamMembers[0]['createdAt'],
};
}
if (IS_BILLING_ENABLED()) {
const earlyAdopterPriceIds = await getCommunityPlanPriceIds();
const activeEarlyAdopterSub = user.subscriptions.find(
(subscription) =>
subscription.status === SubscriptionStatus.ACTIVE &&
earlyAdopterPriceIds.includes(subscription.priceId),
);
if (activeEarlyAdopterSub) {
badge = {
type: 'EarlySupporter',
since: activeEarlyAdopterSub.createdAt,
};
}
}
return {
type: 'User',
badge,
profile: user.profile,
url: profileUrl,
avatarImageId: user.avatarImageId,
name: user.name || '',
templates: user.templates.filter(
(template): template is PublicDirectLinkTemplate =>
template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
),
};
}
if (team?.profile?.enabled) {
return {
type: 'Team',
badge: {
type: 'Premium',
since: team.createdAt,
},
profile: team.profile,
url: profileUrl,
avatarImageId: team.avatarImageId,
name: team.name || '',
templates: team.templates.filter(
(template): template is PublicDirectLinkTemplate =>
template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
),
};
}
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Profile not found',
});
return {
badge: {
type: 'Premium',
since: team.createdAt,
},
profile: team.profile,
url: profileUrl,
avatarImageId: team.avatarImageId,
name: team.name || '',
templates: team.templates.filter(
(template): template is PublicDirectLinkTemplate =>
template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
),
};
};

View File

@ -2,51 +2,49 @@ import sharp from 'sharp';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/organisations';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { AppError } from '../../errors/app-error';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { buildTeamWhereQuery } from '../../utils/teams';
export type SetAvatarImageOptions = {
userId: number;
teamId?: number | null;
target:
| {
type: 'user';
}
| {
type: 'team';
teamId: number;
}
| {
type: 'organisation';
organisationId: string;
};
bytes?: string | null;
requestMetadata: ApiRequestMetadata;
};
/**
* Pretty nasty but will do for now.
*/
export const setAvatarImage = async ({
userId,
teamId,
target,
bytes,
requestMetadata,
}: SetAvatarImageOptions) => {
let oldAvatarImageId: string | null = null;
const user = await prisma.user.findUnique({
where: {
id: userId,
},
include: {
avatarImage: true,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
oldAvatarImageId = user.avatarImageId;
if (teamId) {
if (target.type === 'team') {
const team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
where: buildTeamWhereQuery({
teamId: target.teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
});
if (!team) {
@ -56,6 +54,39 @@ export const setAvatarImage = async ({
}
oldAvatarImageId = team.avatarImageId;
} else if (target.type === 'organisation') {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId: target.organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
});
if (!organisation) {
throw new AppError('ORGANISATION_NOT_FOUND', {
statusCode: 404,
});
}
oldAvatarImageId = organisation.avatarImageId;
} else {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
include: {
avatarImage: true,
},
});
if (!user) {
throw new AppError('USER_NOT_FOUND', {
statusCode: 404,
});
}
oldAvatarImageId = user.avatarImageId;
}
if (oldAvatarImageId) {
@ -83,17 +114,26 @@ export const setAvatarImage = async ({
newAvatarImageId = avatarImage.id;
}
if (teamId) {
// TODO: Audit Logs
if (target.type === 'team') {
await prisma.team.update({
where: {
id: teamId,
id: target.teamId,
},
data: {
avatarImageId: newAvatarImageId,
},
});
} else if (target.type === 'organisation') {
await prisma.organisation.update({
where: {
id: target.organisationId,
},
data: {
avatarImageId: newAvatarImageId,
},
});
// TODO: Audit Logs
} else {
await prisma.user.update({
where: {
@ -103,8 +143,6 @@ export const setAvatarImage = async ({
avatarImageId: newAvatarImageId,
},
});
// TODO: Audit Logs
}
return newAvatarImageId;

View File

@ -1,13 +1,14 @@
import { TeamMemberRole } from '@prisma/client';
import type { Duration } from 'luxon';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
// temporary choice for testing only
import * as timeConstants from '../../constants/time';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { alphaid } from '../../universal/id';
import { buildTeamWhereQuery } from '../../utils/teams';
import { hashString } from '../auth/hash';
type TimeConstants = typeof timeConstants & {
@ -16,7 +17,7 @@ type TimeConstants = typeof timeConstants & {
type CreateApiTokenInput = {
userId: number;
teamId?: number;
teamId: number;
tokenName: string;
expiresIn: string | null;
};
@ -33,20 +34,18 @@ export const createApiToken = async ({
const timeConstantsRecords: TimeConstants = timeConstants;
if (teamId) {
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
role: TeamMemberRole.ADMIN,
},
});
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
});
if (!member) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to create a token for this team',
});
}
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to create a token for this team',
});
}
const storedToken = await prisma.apiToken.create({

View File

@ -1,32 +1,34 @@
import { TeamMemberRole } from '@prisma/client';
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 DeleteTokenByIdOptions = {
id: number;
userId: number;
teamId?: number;
teamId: number;
};
export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOptions) => {
if (teamId) {
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
role: TeamMemberRole.ADMIN,
},
});
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
});
if (!member) {
throw new Error('You do not have permission to delete this token');
}
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to delete this token',
});
}
return await prisma.apiToken.delete({
where: {
id,
teamId: teamId ?? null,
teamId,
},
});
};

View File

@ -10,8 +10,30 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
token: hashedToken,
},
include: {
team: true,
user: true,
team: {
include: {
organisation: {
include: {
owner: {
select: {
id: true,
name: true,
email: true,
disabled: true,
},
},
},
},
},
},
user: {
select: {
id: true,
name: true,
email: true,
disabled: true,
},
},
},
});
@ -25,11 +47,7 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
// Handle a silly choice from many moons ago
if (apiToken.team && !apiToken.user) {
apiToken.user = await prisma.user.findFirst({
where: {
id: apiToken.team.ownerUserId,
},
});
apiToken.user = apiToken.team.organisation.owner;
}
const { user } = apiToken;

View File

@ -1,31 +1,22 @@
import { TeamMemberRole } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { buildTeamWhereQuery } from '../../utils/teams';
export type GetApiTokensOptions = {
userId: number;
teamId?: number;
teamId: number;
};
export const getApiTokens = async ({ userId, teamId }: GetApiTokensOptions) => {
return await prisma.apiToken.findMany({
where: {
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
role: TeamMemberRole.ADMIN,
},
},
},
}
: {
userId,
teamId: null,
}),
userId,
team: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
},
select: {
id: true,

View File

@ -1,7 +1,6 @@
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
@ -12,10 +11,11 @@ import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getDocumentWhereInput } from '../document/get-document-by-id';
export interface CreateDocumentRecipientsOptions {
userId: number;
teamId?: number;
teamId: number;
documentId: number;
recipients: {
email: string;
@ -35,27 +35,25 @@ export const createDocumentRecipients = async ({
recipients: recipientsToCreate,
requestMetadata,
}: CreateDocumentRecipientsOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -76,17 +74,10 @@ export const createDocumentRecipients = async ({
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({

View File

@ -1,7 +1,6 @@
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
@ -9,10 +8,11 @@ import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface CreateTemplateRecipientsOptions {
userId: number;
teamId?: number;
teamId: number;
templateId: number;
recipients: {
email: string;
@ -33,24 +33,19 @@ export const createTemplateRecipients = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
team: buildTeamWhereQuery({ teamId, userId }),
},
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -65,17 +60,10 @@ export const createTemplateRecipients = async ({
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({

View File

@ -16,10 +16,12 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
export interface DeleteDocumentRecipientOptions {
userId: number;
teamId?: number;
teamId: number;
recipientId: number;
requestMetadata: ApiRequestMetadata;
}
@ -37,21 +39,7 @@ export const deleteDocumentRecipient = async ({
id: recipientId,
},
},
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
team: buildTeamWhereQuery({ teamId, userId }),
},
include: {
documentMeta: true,
@ -137,12 +125,21 @@ export const deleteDocumentRecipient = async ({
assetBaseUrl,
});
const { branding, settings } = await getEmailContext({
source: {
type: 'team',
teamId: document.teamId,
},
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: {

View File

@ -1,16 +1,17 @@
import type { Team } from '@prisma/client';
import { SendStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { buildTeamWhereQuery } from '../../utils/teams';
export type DeleteRecipientOptions = {
documentId: number;
recipientId: number;
userId: number;
teamId?: number;
teamId: number;
requestMetadata?: RequestMetadata;
};
@ -26,21 +27,8 @@ export const deleteRecipient = async ({
id: recipientId,
document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
userId,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
});
@ -59,19 +47,12 @@ export const deleteRecipient = async ({
},
});
let team: Team | null = null;
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
});
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const deletedRecipient = await prisma.$transaction(async (tx) => {

View File

@ -1,10 +1,11 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface DeleteTemplateRecipientOptions {
userId: number;
teamId?: number;
teamId: number;
recipientId: number;
}
@ -20,21 +21,7 @@ export const deleteTemplateRecipient = async ({
id: recipientId,
},
},
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
team: buildTeamWhereQuery({ teamId, userId }),
},
include: {
recipients: {

View File

@ -1,11 +1,12 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
export type GetRecipientByIdOptions = {
recipientId: number;
userId: number;
teamId?: number;
teamId: number;
};
/**
@ -20,21 +21,9 @@ export const getRecipientById = async ({
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
document: teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
},
document: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
fields: true,

View File

@ -1,9 +1,11 @@
import { prisma } from '@documenso/prisma';
import { getDocumentWhereInput } from '../document/get-document-by-id';
export interface GetRecipientsForDocumentOptions {
documentId: number;
userId: number;
teamId?: number;
teamId: number;
}
export const getRecipientsForDocument = async ({
@ -11,24 +13,15 @@ export const getRecipientsForDocument = async ({
userId,
teamId,
}: GetRecipientsForDocumentOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const recipients = await prisma.recipient.findMany({
where: {
documentId,
document: teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
},
document: documentWhereInput,
},
orderBy: {
id: 'asc',

View File

@ -3,7 +3,7 @@ import { prisma } from '@documenso/prisma';
export interface GetRecipientsForTemplateOptions {
templateId: number;
userId: number;
teamId?: number;
teamId: number;
}
export const getRecipientsForTemplate = async ({

View File

@ -6,7 +6,6 @@ import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { mailer } from '@documenso/email/mailer';
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -31,11 +30,12 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { canRecipientBeModified } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getDocumentWhereInput } from '../document/get-document-by-id';
import { getEmailContext } from '../email/get-email-context';
export interface SetDocumentRecipientsOptions {
userId: number;
teamId?: number;
teamId: number;
documentId: number;
recipients: RecipientData[];
requestMetadata: ApiRequestMetadata;
@ -48,36 +48,36 @@ export const setDocumentRecipients = async ({
recipients,
requestMetadata,
}: SetDocumentRecipientsOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
include: {
fields: true,
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
const { branding, settings } = await getEmailContext({
source: {
type: 'team',
teamId,
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@ -102,17 +102,10 @@ export const setDocumentRecipients = async ({
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const normalizedRecipients = recipients.map((recipient) => ({
@ -309,16 +302,14 @@ export const setDocumentRecipients = async ({
assetBaseUrl,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: {

View File

@ -1,7 +1,6 @@
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
DIRECT_TEMPLATE_RECIPIENT_NAME,
@ -15,10 +14,11 @@ import {
} from '../../types/document-auth';
import { nanoid } from '../../universal/id';
import { createRecipientAuthOptions } from '../../utils/document-auth';
import { buildTeamWhereQuery } from '../../utils/teams';
export type SetTemplateRecipientsOptions = {
userId: number;
teamId?: number;
teamId: number;
templateId: number;
recipients: {
id?: number;
@ -39,24 +39,19 @@ export const setTemplateRecipients = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
team: buildTeamWhereQuery({ teamId, userId }),
},
include: {
directLink: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -69,17 +64,10 @@ export const setTemplateRecipients = async ({
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const normalizedRecipients = recipients.map((recipient) => {

View File

@ -3,7 +3,6 @@ import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import {
@ -20,10 +19,11 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientBeModified } from '../../utils/recipients';
import { getDocumentWhereInput } from '../document/get-document-by-id';
export interface UpdateDocumentRecipientsOptions {
userId: number;
teamId?: number;
teamId: number;
documentId: number;
recipients: RecipientData[];
requestMetadata: ApiRequestMetadata;
@ -36,28 +36,26 @@ export const updateDocumentRecipients = async ({
recipients,
requestMetadata,
}: UpdateDocumentRecipientsOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
include: {
fields: true,
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -78,17 +76,10 @@ export const updateDocumentRecipients = async ({
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const recipientsToUpdate = recipients.map((recipient) => {

View File

@ -1,153 +0,0 @@
import type { RecipientRole, Team } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '../../types/document-auth';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
import { createRecipientAuthOptions } from '../../utils/document-auth';
export type UpdateRecipientOptions = {
documentId: number;
recipientId: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes[];
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const updateRecipient = async ({
documentId,
recipientId,
email,
name,
role,
signingOrder,
actionAuth,
userId,
teamId,
requestMetadata,
}: UpdateRecipientOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
},
include: {
document: true,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
if (!recipient) {
throw new Error('Recipient not found');
}
if (actionAuth && actionAuth.length > 0) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
const updatedRecipient = await prisma.$transaction(async (tx) => {
const persisted = await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
email: email?.toLowerCase() ?? recipient.email,
name: name ?? recipient.name,
role: role ?? recipient.role,
signingOrder,
authOptions: createRecipientAuthOptions({
accessAuth: recipientAuthOptions.accessAuth,
actionAuth: actionAuth ?? [],
}),
},
});
const changes = diffRecipientChanges(recipient, persisted);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: documentId,
user: {
id: team?.id ?? user.id,
name: team?.name ?? user.name,
email: team ? '' : user.email,
},
requestMetadata,
data: {
changes,
recipientId,
recipientEmail: persisted.email,
recipientName: persisted.name,
recipientRole: persisted.role,
},
}),
});
return persisted;
}
});
return updatedRecipient;
};

View File

@ -1,7 +1,6 @@
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import {
type TRecipientActionAuthTypes,
@ -11,10 +10,11 @@ import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface UpdateTemplateRecipientsOptions {
userId: number;
teamId?: number;
teamId: number;
templateId: number;
recipients: {
id: number;
@ -36,24 +36,19 @@ export const updateTemplateRecipients = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
team: buildTeamWhereQuery({ teamId, userId }),
},
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -68,17 +63,10 @@ export const updateTemplateRecipients = async ({
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const recipientsToUpdate = recipients.map((recipient) => {

View File

@ -1,13 +0,0 @@
import { prisma } from '@documenso/prisma';
export type GetSubscriptionsByUserIdOptions = {
userId: number;
};
export const getSubscriptionsByUserId = async ({ userId }: GetSubscriptionsByUserIdOptions) => {
return await prisma.subscription.findMany({
where: {
userId,
},
});
};

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More