This commit is contained in:
David Nguyen
2025-05-07 15:03:20 +10:00
parent 419bc02171
commit 7abfc9e271
390 changed files with 21254 additions and 12607 deletions

View File

@ -6,13 +6,13 @@ import { useLocation } from 'react-router';
import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { type TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { trpc } from '@documenso/trpc/client';
import type { TGetOrganisationSessionResponse } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
export type AppSession = {
session: Session;
user: SessionUser;
teams: TGetTeamsResponse;
organisations: TGetOrganisationSessionResponse;
};
interface SessionProviderProps {
@ -67,15 +67,17 @@ export const SessionProvider = ({ children, initialSession }: SessionProviderPro
return;
}
const teams = await trpc.team.getTeams.query().catch(() => {
// Todo: (RR7) Log
return [];
});
const organisations = await trpc.organisation.internal.getOrganisationSession
.query()
.catch(() => {
// Todo: (RR7) Log
return [];
});
setSession({
session: newSession.session,
user: newSession.user,
teams,
organisations,
});
}, []);

View File

@ -1,6 +1,6 @@
export enum STRIPE_CUSTOMER_TYPE {
INDIVIDUAL = 'individual',
TEAM = 'team',
ORGANISATION = 'organisation',
}
export enum STRIPE_PLAN_TYPE {

View File

@ -0,0 +1,159 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
export const ORGANISATION_URL_ROOT_REGEX = new RegExp('^/t/[^/]+/?$');
export const ORGANISATION_URL_REGEX = new RegExp('^/t/[^/]+');
export const ORGANISATION_INTERNAL_GROUPS: {
organisationRole: OrganisationMemberRole;
type: OrganisationGroupType;
}[] = [
{
organisationRole: OrganisationMemberRole.ADMIN,
type: OrganisationGroupType.INTERNAL_ORGANISATION,
},
{
organisationRole: OrganisationMemberRole.MANAGER,
type: OrganisationGroupType.INTERNAL_ORGANISATION,
},
{
organisationRole: OrganisationMemberRole.MEMBER,
type: OrganisationGroupType.INTERNAL_ORGANISATION,
},
] as const;
export const ORGANISATION_MEMBER_ROLE_MAP: Record<
keyof typeof OrganisationMemberRole,
MessageDescriptor
> = {
ADMIN: msg`Admin`,
MANAGER: msg`Manager`,
MEMBER: msg`Member`,
};
export const EXTENDED_ORGANISATION_MEMBER_ROLE_MAP: Record<
keyof typeof OrganisationMemberRole,
MessageDescriptor
> = {
ADMIN: msg`Organisation Admin`,
MANAGER: msg`Organisation Manager`,
MEMBER: msg`Organisation Member`,
};
export const ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP = {
/**
* Includes permissions to:
* - Manage organisation members
* - Manage organisation settings, changing name, url, etc.
*/
DELETE_ORGANISATION: [OrganisationMemberRole.ADMIN],
MANAGE_BILLING: [OrganisationMemberRole.ADMIN],
DELETE_ORGANISATION_TRANSFER_REQUEST: [OrganisationMemberRole.ADMIN],
MANAGE_ORGANISATION: [OrganisationMemberRole.ADMIN, OrganisationMemberRole.MANAGER],
} satisfies Record<string, OrganisationMemberRole[]>;
/**
* A hierarchy of organisation member roles to determine which role has higher permission than another.
*
* Warning: The length of the array is used to determine the priority of the role.
* See `getHighestOrganisationRoleInGroup`
*/
export const ORGANISATION_MEMBER_ROLE_HIERARCHY = {
[OrganisationMemberRole.ADMIN]: [
OrganisationMemberRole.ADMIN,
OrganisationMemberRole.MANAGER,
OrganisationMemberRole.MEMBER,
],
[OrganisationMemberRole.MANAGER]: [OrganisationMemberRole.MANAGER, OrganisationMemberRole.MEMBER],
[OrganisationMemberRole.MEMBER]: [OrganisationMemberRole.MEMBER],
} satisfies Record<OrganisationMemberRole, OrganisationMemberRole[]>;
/**
* A hierarchy of organisation member roles to determine which role has higher permission than another.
*
* This is used to determine the highest role in a group.
*/
export const ORGANISATION_MEMBER_ROLE_HIERARCHY_ORDER = {
[OrganisationMemberRole.ADMIN]: 0,
[OrganisationMemberRole.MANAGER]: 1,
[OrganisationMemberRole.MEMBER]: 2,
} satisfies Record<OrganisationMemberRole, number>;
export const LOWEST_ORGANISATION_ROLE = OrganisationMemberRole.MEMBER;
export const PROTECTED_ORGANISATION_URLS = [
'403',
'404',
'500',
'502',
'503',
'504',
'about',
'account',
'admin',
'administrator',
'api',
'app',
'archive',
'auth',
'backup',
'config',
'configure',
'contact',
'contact-us',
'copyright',
'crime',
'criminal',
'dashboard',
'docs',
'documentation',
'document',
'documents',
'error',
'exploit',
'exploitation',
'exploiter',
'feedback',
'finance',
'forgot-password',
'fraud',
'fraudulent',
'hack',
'hacker',
'harassment',
'help',
'helpdesk',
'illegal',
'internal',
'legal',
'login',
'logout',
'maintenance',
'malware',
'newsletter',
'policy',
'privacy',
'profile',
'public',
'reset-password',
'scam',
'scammer',
'settings',
'setup',
'sign',
'signin',
'signout',
'signup',
'spam',
'support',
'system',
'organisation',
'terms',
'virus',
'webhook',
];
export const isOrganisationUrlProtected = (url: string) => {
return PROTECTED_ORGANISATION_URLS.some((protectedUrl) => url.startsWith(`/${protectedUrl}`));
};

View File

@ -1,29 +1,58 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { TeamMemberRole } from '@prisma/client';
import { OrganisationGroupType, TeamMemberRole } from '@prisma/client';
export const TEAM_URL_ROOT_REGEX = new RegExp('^/t/[^/]+/?$');
export const TEAM_URL_REGEX = new RegExp('^/t/[^/]+');
export const LOWEST_TEAM_ROLE = TeamMemberRole.MEMBER;
export const ALLOWED_TEAM_GROUP_TYPES: OrganisationGroupType[] = [
OrganisationGroupType.CUSTOM,
OrganisationGroupType.INTERNAL_ORGANISATION,
];
export const TEAM_INTERNAL_GROUPS: {
teamRole: TeamMemberRole;
type: OrganisationGroupType;
}[] = [
{
teamRole: TeamMemberRole.ADMIN,
type: OrganisationGroupType.INTERNAL_TEAM,
},
{
teamRole: TeamMemberRole.MANAGER,
type: OrganisationGroupType.INTERNAL_TEAM,
},
{
teamRole: TeamMemberRole.MEMBER,
type: OrganisationGroupType.INTERNAL_TEAM,
},
] as const;
export const TEAM_MEMBER_ROLE_MAP: Record<keyof typeof TeamMemberRole, MessageDescriptor> = {
ADMIN: msg`Admin`,
MANAGER: msg`Manager`,
MEMBER: msg`Member`,
};
export const EXTENDED_TEAM_MEMBER_ROLE_MAP: Record<keyof typeof TeamMemberRole, MessageDescriptor> =
{
ADMIN: msg`Team Admin`,
MANAGER: msg`Team Manager`,
MEMBER: msg`Team Member`,
};
export const TEAM_MEMBER_ROLE_PERMISSIONS_MAP = {
/**
* Includes permissions to:
* - Manage team members
* - Manage team settings, changing name, url, etc.
*/
DELETE_TEAM: [TeamMemberRole.ADMIN],
MANAGE_TEAM: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
MANAGE_BILLING: [TeamMemberRole.ADMIN],
DELETE_TEAM_TRANSFER_REQUEST: [TeamMemberRole.ADMIN],
} satisfies Record<string, TeamMemberRole[]>;
/**
* A hierarchy of team member roles to determine which role has higher permission than another.
*
* Warning: The length of the array is used to determine the priority of the role.
* See `getHighestTeamRoleInGroup`
*/
export const TEAM_MEMBER_ROLE_HIERARCHY = {
[TeamMemberRole.ADMIN]: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER, TeamMemberRole.MEMBER],

View File

@ -10,6 +10,7 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
@ -38,12 +39,15 @@ export const run = async ({
teamEmail: true,
name: true,
url: true,
teamGlobalSettings: true,
},
},
},
});
const teamSettings = await getTeamSettings({
teamId: document.teamId,
});
const { documentMeta, user: documentOwner } = document;
// Check if document cancellation emails are enabled
@ -53,7 +57,10 @@ export const run = async ({
return;
}
const i18n = await getI18nInstance(documentMeta?.language);
const branding = teamGlobalSettingsToBranding(teamSettings, document.teamId);
const lang = documentMeta?.language ?? teamSettings.documentLanguage;
const i18n = await getI18nInstance(lang);
// Send cancellation emails to all recipients who have been sent the document or viewed it
const recipientsToNotify = document.recipients.filter(
@ -73,14 +80,10 @@ export const run = async ({
cancellationReason: cancellationReason || 'The document has been cancelled.',
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, {
lang: documentMeta?.language,
lang,
branding,
plainText: true,
}),

View File

@ -9,6 +9,7 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
@ -41,11 +42,6 @@ export const run = async ({
},
user: true,
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -76,8 +72,16 @@ export const run = async ({
return;
}
const settings = await getTeamSettings({
userId: owner.id,
teamId: document.teamId,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const i18n = await getI18nInstance(document.documentMeta?.language);
const branding = teamGlobalSettingsToBranding(settings, document.teamId);
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const template = createElement(DocumentRecipientSignedEmailTemplate, {
documentName: document.title,
@ -87,14 +91,10 @@ export const run = async ({
});
await io.runTask('send-recipient-signed-email', async () => {
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
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,
}),

View File

@ -11,6 +11,7 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
@ -40,7 +41,6 @@ export const run = async ({
teamEmail: true,
name: true,
url: true,
teamGlobalSettings: true,
},
},
},
@ -63,7 +63,15 @@ export const run = async ({
return;
}
const i18n = await getI18nInstance(documentMeta?.language);
const settings = await getTeamSettings({
userId: documentOwner.id,
teamId: document.teamId,
});
const branding = teamGlobalSettingsToBranding(settings, document.teamId);
const lang = documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
// Send confirmation email to the recipient who rejected
await io.runTask('send-rejection-confirmation-email', async () => {
@ -75,14 +83,10 @@ export const run = async ({
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(recipientTemplate, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(recipientTemplate, { lang, branding }),
renderEmailWithI18N(recipientTemplate, {
lang: documentMeta?.language,
lang,
branding,
plainText: true,
}),
@ -115,14 +119,10 @@ export const run = async ({
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(ownerTemplate, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(ownerTemplate, { lang, branding }),
renderEmailWithI18N(ownerTemplate, {
lang: documentMeta?.language,
lang,
branding,
plainText: true,
}),

View File

@ -14,6 +14,7 @@ import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../../constants/recipient-roles';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
@ -49,7 +50,6 @@ export const run = async ({
select: {
teamEmail: true,
name: true,
teamGlobalSettings: true,
},
},
},
@ -75,6 +75,11 @@ export const run = async ({
return;
}
const settings = await getTeamSettings({
userId,
teamId: document.teamId,
});
const customEmail = document?.documentMeta;
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
const isTeamDocument = document.teamId !== null;
@ -84,7 +89,10 @@ export const run = async ({
const { email, name } = recipient;
const selfSigner = email === user.email;
const i18n = await getI18nInstance(documentMeta?.language);
const branding = teamGlobalSettingsToBranding(settings, document.teamId);
const lang = documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
@ -117,7 +125,7 @@ export const run = async ({
const inviterName = user.name || '';
emailMessage = i18n._(
team.teamGlobalSettings?.includeSenderDetails
settings.includeSenderDetails
? msg`${inviterName} on behalf of "${team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".`
: msg`${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
);
@ -145,18 +153,14 @@ export const run = async ({
isTeamInvite: isTeamDocument,
teamName: team?.name,
teamEmail: team?.teamEmail?.email,
includeSenderDetails: team?.teamGlobalSettings?.includeSenderDetails,
includeSenderDetails: settings.includeSenderDetails,
});
await io.runTask('send-signing-email', async () => {
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, {
lang: documentMeta?.language,
lang,
branding,
plainText: true,
}),

View File

@ -16,7 +16,6 @@ export const run = async ({
await sendTeamDeleteEmail({
email: member.email,
team,
isOwner: member.id === team.ownerUserId,
});
});
}

View File

@ -9,7 +9,6 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
team: z.object({
name: z.string(),
url: z.string(),
ownerUserId: z.number(),
teamGlobalSettings: z
.object({
documentVisibility: z.nativeEnum(DocumentVisibility),

View File

@ -10,6 +10,7 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
@ -37,10 +38,14 @@ export const run = async ({
user: true,
},
},
teamGlobalSettings: true,
},
});
const settings = await getTeamSettings({
userId: payload.userId,
teamId: payload.teamId,
});
const invitedMember = await prisma.teamMember.findFirstOrThrow({
where: {
id: payload.memberId,
@ -68,11 +73,8 @@ export const run = async ({
teamUrl: team.url,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = team.teamGlobalSettings?.documentLanguage;
const branding = teamGlobalSettingsToBranding(settings, team.id);
const lang = settings.documentLanguage;
// !: Replace with the actual language of the recipient later
const [html, text] = await Promise.all([

View File

@ -10,6 +10,7 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
@ -37,10 +38,13 @@ export const run = async ({
user: true,
},
},
teamGlobalSettings: true,
},
});
const settings = await getTeamSettings({
teamId: payload.teamId,
});
const oldMember = await prisma.user.findFirstOrThrow({
where: {
id: payload.memberUserId,
@ -58,11 +62,8 @@ export const run = async ({
teamUrl: team.url,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = team.teamGlobalSettings?.documentLanguage;
const branding = teamGlobalSettingsToBranding(settings, team.id);
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {

View File

@ -1,7 +1,6 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { parse } from 'csv-parse/sync';
import { z } from 'zod';
@ -16,6 +15,7 @@ import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { AppError } from '../../../errors/app-error';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
@ -163,29 +163,23 @@ export const run = async ({
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
let teamGlobalSettings: TeamGlobalSettings | undefined | null;
const settings = await getTeamSettings({
userId,
teamId,
});
if (template.teamId) {
teamGlobalSettings = await prisma.teamGlobalSettings.findUnique({
where: {
teamId: template.teamId,
},
});
}
const branding = teamGlobalSettingsToBranding(settings, template.teamId);
const lang = template.templateMeta?.language ?? settings.documentLanguage;
const branding = teamGlobalSettings
? teamGlobalSettingsToBranding(teamGlobalSettings)
: undefined;
const i18n = await getI18nInstance(teamGlobalSettings?.documentLanguage);
const i18n = await getI18nInstance(lang);
const [html, text] = await Promise.all([
renderEmailWithI18N(completionTemplate, {
lang: teamGlobalSettings?.documentLanguage,
lang,
branding,
}),
renderEmailWithI18N(completionTemplate, {
lang: teamGlobalSettings?.documentLanguage,
lang,
branding,
plainText: true,
}),

View File

@ -7,7 +7,7 @@ const BULK_SEND_TEMPLATE_JOB_DEFINITION_ID = 'internal.bulk-send-template';
const BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA = z.object({
userId: z.number(),
teamId: z.number().optional(),
teamId: z.number(),
templateId: z.number(),
csvContent: z.string(),
sendImmediately: z.boolean(),

View File

@ -15,6 +15,7 @@ import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations
import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import {
@ -45,18 +46,14 @@ export const run = async ({
include: {
documentMeta: true,
recipients: true,
team: {
select: {
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
},
},
},
},
},
});
const settings = await getTeamSettings({
userId: document.userId,
teamId: document.teamId,
});
const isComplete =
document.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
document.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
@ -131,13 +128,12 @@ export const run = async ({
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 newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
const pdfDoc = await PDFDocument.load(pdfData);

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,7 +6,6 @@ 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';
@ -28,11 +27,13 @@ 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 { 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,35 +60,10 @@ export const createDocumentV2 = async ({
}: CreateDocumentOptions) => {
const { title, formValues } = data;
const team = teamId
? await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
teamGlobalSettings: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
})
: null;
if (teamId !== undefined && !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({
@ -133,10 +109,15 @@ export const createDocumentV2 = async ({
}
}
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({
@ -155,13 +136,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,8 +1,5 @@
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import { TeamMemberRole } 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';
@ -16,13 +13,15 @@ import {
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;
@ -41,53 +40,13 @@ export const createDocument = async ({
requestMetadata,
timezone,
}: 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;
}
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
@ -119,19 +78,16 @@ export const createDocument = async ({
documentDataId,
userId,
teamId,
visibility: determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
visibility: 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

@ -4,9 +4,8 @@ import { msg } from '@lingui/core/macro';
import type {
Document,
DocumentMeta,
OrganisationGlobalSettings,
Recipient,
Team,
TeamGlobalSettings,
User,
} from '@prisma/client';
import { DocumentStatus, SendStatus, WebhookTriggerEvents } from '@prisma/client';
@ -30,12 +29,14 @@ 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 { getMemberRoles } from '../team/get-member-roles';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteDocumentOptions = {
id: number;
userId: number;
teamId?: number;
teamId: number;
requestMetadata: ApiRequestMetadata;
};
@ -64,12 +65,6 @@ export const deleteDocument = async ({
include: {
recipients: true,
documentMeta: true,
team: {
include: {
members: true,
teamGlobalSettings: true,
},
},
},
});
@ -79,8 +74,22 @@ export const deleteDocument = async ({
});
}
const settings = await getTeamSettings({
userId: document.userId,
teamId: document.teamId,
});
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 +103,7 @@ export const deleteDocument = async ({
await handleDocumentOwnerDelete({
document,
user,
team: document.team,
settings,
requestMetadata,
});
}
@ -142,11 +151,7 @@ type HandleDocumentOwnerDeleteOptions = {
recipients: Recipient[];
documentMeta: DocumentMeta | null;
};
team?:
| (Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
})
| null;
settings: Omit<OrganisationGlobalSettings, 'id'>;
user: User;
requestMetadata: ApiRequestMetadata;
};
@ -154,7 +159,7 @@ type HandleDocumentOwnerDeleteOptions = {
const handleDocumentOwnerDelete = async ({
document,
user,
team,
settings,
requestMetadata,
}: HandleDocumentOwnerDeleteOptions) => {
if (document.deletedAt) {
@ -235,20 +240,19 @@ const handleDocumentOwnerDelete = async ({
assetBaseUrl,
});
const branding = team?.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const branding = teamGlobalSettingsToBranding(settings, document.teamId);
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

@ -8,7 +8,7 @@ import { getDocumentWhereInput } from './get-document-by-id';
export interface DuplicateDocumentOptions {
documentId: number;
userId: number;
teamId?: number;
teamId: number;
}
export const duplicateDocument = async ({
@ -16,7 +16,7 @@ export const duplicateDocument = async ({
userId,
teamId,
}: DuplicateDocumentOptions) => {
const documentWhereInput = await getDocumentWhereInput({
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
@ -61,6 +61,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';
@ -51,32 +52,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: [
@ -273,108 +257,29 @@ export const findDocuments = async ({
} satisfies FindResultResponse<typeof data>;
};
/**
* For non team searches, only inbox documents are supported since user level documents no longer
* exist.
*/
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
.with(ExtendedDocumentStatus.ALL, () => ({
OR: [
{
userId: user.id,
teamId: null,
},
{
status: ExtendedDocumentStatus.COMPLETED,
recipients: {
some: {
email: user.email,
},
},
},
{
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
email: user.email,
},
},
},
],
}))
.with(ExtendedDocumentStatus.INBOX, () => ({
status: {
not: ExtendedDocumentStatus.DRAFT,
},
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.NOT_SIGNED,
role: {
not: RecipientRole.CC,
},
if (status !== ExtendedDocumentStatus.INBOX) {
return null;
}
return {
status: {
not: ExtendedDocumentStatus.DRAFT,
},
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.NOT_SIGNED,
role: {
not: RecipientRole.CC,
},
},
}))
.with(ExtendedDocumentStatus.DRAFT, () => ({
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.DRAFT,
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
OR: [
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.PENDING,
},
{
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
role: {
not: RecipientRole.CC,
},
},
},
},
],
}))
.with(ExtendedDocumentStatus.COMPLETED, () => ({
OR: [
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.COMPLETED,
},
{
status: ExtendedDocumentStatus.COMPLETED,
recipients: {
some: {
email: user.email,
},
},
},
],
}))
.with(ExtendedDocumentStatus.REJECTED, () => ({
OR: [
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.REJECTED,
},
{
status: ExtendedDocumentStatus.REJECTED,
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.REJECTED,
},
},
},
],
}))
.exhaustive();
},
};
};
/**

View File

@ -11,11 +11,11 @@ import { getTeamById } from '../team/get-team';
export type GetDocumentByIdOptions = {
documentId: number;
userId: number;
teamId?: number;
teamId: number;
};
export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumentByIdOptions) => {
const documentWhereInput = await getDocumentWhereInput({
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
@ -59,18 +59,7 @@ export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumen
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;
};
/**
@ -82,42 +71,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: {
@ -133,15 +98,22 @@ export const getDocumentWhereInput = async ({
);
}
const documentWhereInput: Prisma.DocumentWhereUniqueInput = {
id: documentId,
OR: documentOrInput,
};
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
// Todo: orgs test this
const visibilityFilters = [
...match(team.currentTeamMember?.role)
...match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => [
// Is this even needed?
{ visibility: DocumentVisibility.EVERYONE },
{ visibility: DocumentVisibility.MANAGER_AND_ABOVE },
{ visibility: DocumentVisibility.ADMIN },
@ -168,7 +140,10 @@ export const getDocumentWhereInput = async ({
];
return {
...documentWhereInput,
OR: [...visibilityFilters],
documentWhereInput: {
...documentWhereInput,
OR: [...visibilityFilters],
},
team,
};
};

View File

@ -85,6 +85,7 @@ export const getDocumentAndSenderByToken = async ({
select: {
name: true,
teamEmail: true,
// Todo: orgs, where does this lead to?
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;
};
export const getDocumentWithDetailsById = async ({
@ -14,7 +14,7 @@ export const getDocumentWithDetailsById = async ({
userId,
teamId,
}: GetDocumentWithDetailsByIdOptions) => {
const documentWhereInput = await getDocumentWhereInput({
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
@ -26,7 +26,31 @@ export const getDocumentWithDetailsById = async ({
documentData: true,
documentMeta: true,
recipients: true,
fields: 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

@ -105,7 +105,6 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
where: {
userId: user.id,
createdAt,
teamId: null,
deletedAt: null,
AND: [searchFilter],
},

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

@ -2,7 +2,6 @@ import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import type { Prisma } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
@ -23,13 +22,14 @@ 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 { getTeamSettings } from '../team/get-team-settings';
import { getDocumentWhereInput } from './get-document-by-id';
export type ResendDocumentOptions = {
documentId: number;
userId: number;
recipients: number[];
teamId?: number;
teamId: number;
requestMetadata: ApiRequestMetadata;
};
@ -46,7 +46,7 @@ export const resendDocument = async ({
},
});
const documentWhereInput: Prisma.DocumentWhereUniqueInput = await getDocumentWhereInput({
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
@ -68,7 +68,6 @@ export const resendDocument = async ({
select: {
teamEmail: true,
name: true,
teamGlobalSettings: true,
},
},
},
@ -101,13 +100,20 @@ export const resendDocument = async ({
return;
}
const settings = await getTeamSettings({
userId: document.userId,
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 branding = teamGlobalSettingsToBranding(settings, document.teamId);
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
@ -161,17 +167,13 @@ export const resendDocument = async ({
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

@ -23,6 +23,7 @@ import { flattenAnnotations } from '../pdf/flatten-annotations';
import { flattenForm } from '../pdf/flatten-form';
import { insertFieldInPDF } from '../pdf/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';
@ -47,15 +48,6 @@ export const sealDocument = async ({
documentData: true,
documentMeta: true,
recipients: true,
team: {
select: {
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
},
},
},
},
},
});
@ -65,6 +57,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,
@ -115,13 +112,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(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(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,8 @@ export const searchDocumentsWithKeyword = async ({
return true;
}
const teamMemberRole = document.team?.members[0]?.role;
// Todo: Orgs test.
const teamMemberRole = getHighestTeamRoleInGroup(document.team.teamGroups);
if (!teamMemberRole) {
return false;

View File

@ -19,6 +19,7 @@ import { renderCustomEmailTemplate } from '../../utils/render-custom-email-templ
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { formatDocumentsPath } from '../../utils/teams';
import { getTeamSettings } from '../team/get-team-settings';
export interface SendDocumentOptions {
documentId: number;
@ -39,7 +40,6 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
select: {
id: true,
url: true,
teamGlobalSettings: true,
},
},
},
@ -55,6 +55,11 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
throw new Error('Document has no recipients');
}
const settings = await getTeamSettings({
userId: document.userId,
teamId: document.teamId,
});
const { user: owner } = document;
const completedDocument = await getFileServerSide(document.documentData);
@ -71,8 +76,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 +96,20 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
downloadLink: documentOwnerDownloadLink,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const branding = teamGlobalSettingsToBranding(settings, document.teamId);
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,20 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
: undefined,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const branding = teamGlobalSettingsToBranding(settings, document.teamId);
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

@ -13,6 +13,7 @@ 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 { getTeamSettings } from '../team/get-team-settings';
export interface SendDeleteEmailOptions {
documentId: number;
@ -27,11 +28,6 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
include: {
user: true,
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -49,6 +45,11 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
return;
}
const settings = await getTeamSettings({
userId: document.userId,
teamId: document.teamId,
});
const { email, name } = document.user;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
@ -59,20 +60,19 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
assetBaseUrl,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const branding = teamGlobalSettingsToBranding(settings, document.teamId);
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

@ -12,6 +12,7 @@ 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 { getTeamSettings } from '../team/get-team-settings';
export interface SendPendingEmailOptions {
documentId: number;
@ -35,11 +36,6 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
},
},
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -51,6 +47,11 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
throw new Error('Document has no recipients');
}
const settings = await getTeamSettings({
userId: document.userId,
teamId: document.teamId,
});
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentPending;
@ -70,20 +71,19 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
assetBaseUrl,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const branding = teamGlobalSettingsToBranding(settings, document.teamId);
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

@ -17,6 +17,7 @@ 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 { getTeamSettings } from '../team/get-team-settings';
export type SuperDeleteDocumentOptions = {
id: number;
@ -32,11 +33,6 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
recipients: true,
documentMeta: true,
user: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -46,6 +42,11 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
});
}
const settings = await getTeamSettings({
userId: document.userId,
teamId: document.teamId,
});
const { status, user } = document;
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
@ -72,20 +73,19 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
assetBaseUrl,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const branding = teamGlobalSettingsToBranding(settings, document.teamId);
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

@ -12,10 +12,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;
@ -34,39 +35,14 @@ 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,
}),
},
include: {
team: {
select: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
},
},
where: documentWhereInput,
});
if (!document) {
@ -75,45 +51,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.

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

@ -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

@ -9,7 +9,7 @@ export type DeleteFieldOptions = {
fieldId: number;
documentId: number;
userId: number;
teamId?: number;
teamId: number;
requestMetadata?: RequestMetadata;
};
@ -25,21 +25,15 @@ export const deleteField = async ({
id: fieldId,
document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
team: {
id: teamId,
members: {
some: {
userId,
teamId: null,
}),
},
},
},
},
},
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,6 +1,9 @@
import type { Field } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
export type GetFieldByIdOptions = {
userId: number;
@ -17,35 +20,31 @@ export const getFieldById = async ({
documentId,
templateId,
}: GetFieldByIdOptions) => {
const field = await prisma.field.findFirst({
where: {
id: fieldId,
documentId,
templateId,
document: {
OR:
teamId === undefined
? [
{
userId,
teamId: null,
},
]
: [
{
teamId,
team: {
members: {
some: {
userId,
},
},
},
},
],
let field: Field | null = null;
if (documentId) {
field = await prisma.field.findFirst({
where: {
id: fieldId,
document: {
id: documentId,
team: buildTeamWhereQuery(teamId, userId),
},
},
},
});
});
}
if (templateId) {
field = await prisma.field.findFirst({
where: {
id: fieldId,
template: {
id: templateId,
team: buildTeamWhereQuery(teamId, userId),
},
},
});
}
if (!field) {
throw new AppError(AppErrorCode.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

@ -11,7 +11,7 @@ export type UpdateFieldOptions = {
fieldId: number;
documentId: number;
userId: number;
teamId?: number;
teamId: number;
recipientId?: number;
type?: FieldType;
pageNumber?: number;
@ -47,21 +47,15 @@ export const updateField = async ({
id: fieldId,
document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
team: {
id: teamId,
members: {
some: {
userId,
teamId: null,
}),
},
},
},
},
},
});

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

@ -0,0 +1,126 @@
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
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: {
subscriptions: true,
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 no user exists for the invitation, accept the invitation and create the organisation
// user when the user signs up.
if (!user) {
await prisma.organisationMemberInvite.update({
where: {
id: organisationMemberInvite.id,
},
data: {
status: OrganisationMemberInviteStatus.ACCEPTED,
},
});
return;
}
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: {
userId: user.id,
organisationId: organisation.id,
organisationGroupMembers: {
create: {
groupId: organisationGroupToUse.id,
},
},
},
});
await tx.organisationMemberInvite.update({
where: {
id: organisationMemberInvite.id,
},
data: {
status: OrganisationMemberInviteStatus.ACCEPTED,
},
});
// Todo: Orgs
// if (IS_BILLING_ENABLED() && team.subscription) {
// const numberOfSeats = await tx.teamMember.count({
// where: {
// teamId: organisationMemberInvite.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

@ -0,0 +1,223 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Organisation, OrganisationGlobalSettings, Prisma } from '@prisma/client';
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { nanoid } from 'nanoid';
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 {
buildOrganisationWhereQuery,
getHighestOrganisationRoleInGroup,
} from '../../utils/organisations';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { organisationGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
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,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
),
include: {
members: {
select: {
user: {
select: {
id: true,
email: true,
},
},
},
},
invites: true,
organisationGlobalSettings: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const currentOrganisationMember = await prisma.organisationMember.findFirst({
where: {
userId,
organisationId,
},
include: {
organisationGroupMembers: {
include: {
group: true,
},
},
},
});
if (!currentOrganisationMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
const currentOrganisationMemberRole = getHighestOrganisationRoleInGroup(
currentOrganisationMember.organisationGroupMembers.map((member) => member.group),
);
const organisationMemberEmails = organisation.members.map((member) => member.user.email);
const organisationMemberInviteEmails = organisation.invites
.filter((invite) => invite.status === OrganisationMemberInviteStatus.PENDING)
.map((invite) => invite.email);
if (!currentOrganisationMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User not part of organisation.',
});
}
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',
});
}
// Todo: (orgs)
const organisationMemberInvites: Prisma.OrganisationMemberInviteCreateManyInput[] =
usersToInvite.map(({ email, organisationRole }) => ({
email,
organisationId,
organisationRole,
token: nanoid(32),
}));
console.log({
organisationMemberInvites,
});
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: Organisation & {
organisationGlobalSettings: OrganisationGlobalSettings;
};
};
/**
* 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 = organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
);
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: organisation.organisationGlobalSettings.documentLanguage,
branding,
}),
renderEmailWithI18N(template, {
lang: organisation.organisationGlobalSettings.documentLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(organisation.organisationGlobalSettings.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,114 @@
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 { alphaid } from '../../universal/id';
import { generateDefaultOrganisationSettings } from '../../utils/organisations';
import { createTeam } from '../team/create-team';
type CreateOrganisationOptions = {
userId: number;
name: string;
url: string;
};
export const createOrganisation = async ({ name, url, userId }: CreateOrganisationOptions) => {
return await prisma.$transaction(async (tx) => {
const organisationSetting = await tx.organisationGlobalSettings.create({
data: generateDefaultOrganisationSettings(),
});
const organisation = await tx.organisation
.create({
data: {
name,
url, // Todo: orgs constraint this
ownerUserId: userId,
organisationGlobalSettingsId: organisationSetting.id,
groups: {
create: ORGANISATION_INTERNAL_GROUPS,
},
},
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: {
userId,
organisationId: organisation.id,
organisationGroupMembers: {
create: {
groupId: adminGroup.id,
},
},
},
});
return organisation;
});
};
type CreatePersonalOrganisationOptions = {
userId: number;
orgUrl?: string;
throwErrorOnOrganisationCreationFailure?: boolean;
};
export const createPersonalOrganisation = async ({
userId,
orgUrl,
throwErrorOnOrganisationCreationFailure = false,
}: CreatePersonalOrganisationOptions) => {
const organisation = await createOrganisation({
name: 'Personal Organisation',
userId,
url: orgUrl || `org_${alphaid(8)}`,
}).catch((err) => {
console.error(err);
if (throwErrorOnOrganisationCreationFailure) {
throw err;
}
// Todo: (orgs) Add logging.
});
if (organisation) {
await createTeam({
userId,
teamName: 'Personal Team',
teamUrl: `personal_${alphaid(8)}`,
organisationId: organisation.id,
inheritMembers: true,
}).catch((err) => {
console.error(err);
// Todo: (orgs) Add logging.
});
}
return organisation;
};

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,12 +2,14 @@ import sharp from 'sharp';
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { buildTeamWhereQuery } from '../../utils/teams';
export type SetAvatarImageOptions = {
userId: number;
teamId?: number | null;
teamId: number | null;
bytes?: string | null;
requestMetadata: ApiRequestMetadata;
};
@ -39,14 +41,7 @@ export const setAvatarImage = async ({
if (teamId) {
const team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
where: buildTeamWhereQuery(teamId, userId, TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM']),
});
if (!team) {

View File

@ -16,7 +16,7 @@ type TimeConstants = typeof timeConstants & {
type CreateApiTokenInput = {
userId: number;
teamId?: number;
teamId: number;
tokenName: string;
expiresIn: string | null;
};

View File

@ -5,7 +5,7 @@ import { prisma } from '@documenso/prisma';
export type DeleteTokenByIdOptions = {
id: number;
userId: number;
teamId?: number;
teamId: number;
};
export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOptions) => {

View File

@ -2,30 +2,19 @@ import { TeamMemberRole } from '@prisma/client';
import { prisma } from '@documenso/prisma';
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,
// Todo: Orgs check that this was how it originally works (admin required)
team: buildTeamWhereQuery(teamId, userId, [TeamMemberRole.ADMIN]),
},
select: {
id: true,

View File

@ -12,10 +12,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,25 +36,14 @@ 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,
},

View File

@ -9,10 +9,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,21 +34,7 @@ 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,

View File

@ -16,10 +16,11 @@ 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';
export interface DeleteDocumentRecipientOptions {
userId: number;
teamId?: number;
teamId: number;
recipientId: number;
requestMetadata: ApiRequestMetadata;
}
@ -37,21 +38,7 @@ export const deleteDocumentRecipient = async ({
id: recipientId,
},
},
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
team: buildTeamWhereQuery(teamId, userId),
},
include: {
documentMeta: true,

View File

@ -10,7 +10,7 @@ export type DeleteRecipientOptions = {
documentId: number;
recipientId: number;
userId: number;
teamId?: number;
teamId: number;
requestMetadata?: RequestMetadata;
};
@ -26,21 +26,15 @@ export const deleteRecipient = async ({
id: recipientId,
document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
team: {
id: teamId,
members: {
some: {
userId,
teamId: null,
}),
},
},
},
},
},
});

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

@ -31,10 +31,12 @@ 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 { getTeamSettings } from '../team/get-team-settings';
export interface SetDocumentRecipientsOptions {
userId: number;
teamId?: number;
teamId: number;
documentId: number;
recipients: RecipientData[];
requestMetadata: ApiRequestMetadata;
@ -47,36 +49,25 @@ 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,
},
},
},
});
const settings = await getTeamSettings({
userId,
teamId,
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@ -303,16 +294,15 @@ export const setDocumentRecipients = async ({
assetBaseUrl,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const branding = teamGlobalSettingsToBranding(settings, 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

@ -15,10 +15,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,21 +40,7 @@ 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,

View File

@ -19,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;
@ -35,25 +36,14 @@ 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,

View File

@ -22,7 +22,7 @@ export type UpdateRecipientOptions = {
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes | null;
userId: number;
teamId?: number;
teamId: number;
requestMetadata?: RequestMetadata;
};
@ -43,21 +43,15 @@ export const updateRecipient = async ({
id: recipientId,
document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
team: {
id: teamId,
members: {
some: {
userId,
teamId: null,
}),
},
},
},
},
},
include: {

View File

@ -11,10 +11,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,21 +37,7 @@ 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,

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,7 +1,7 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import type { OrganisationGlobalSettings, Team } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { z } from 'zod';
@ -15,9 +15,12 @@ import { createTokenVerification } from '@documenso/lib/utils/token-verification
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from './get-team-settings';
export type CreateTeamEmailVerificationOptions = {
userId: number;
@ -34,33 +37,27 @@ export const createTeamEmailVerification = async ({
data,
}: CreateTeamEmailVerificationOptions): Promise<void> => {
try {
const settings = await getTeamSettings({
userId,
teamId,
});
const team = await prisma.team.findFirstOrThrow({
where: buildTeamWhereQuery(teamId, userId, TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM']),
include: {
teamEmail: true,
emailVerification: true,
},
});
if (team.teamEmail || team.emailVerification) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Team already has an email or existing email verification.',
});
}
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
include: {
teamEmail: true,
emailVerification: true,
teamGlobalSettings: true,
},
});
if (team.teamEmail || team.emailVerification) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Team already has an email or existing email verification.',
});
}
const existingTeamEmail = await tx.teamEmail.findFirst({
where: {
email: data.email,
@ -85,7 +82,7 @@ export const createTeamEmailVerification = async ({
},
});
await sendTeamEmailVerificationEmail(data.email, token, team);
await sendTeamEmailVerificationEmail(data.email, token, team, settings);
},
{ timeout: 30_000 },
);
@ -119,9 +116,8 @@ export const createTeamEmailVerification = async ({
export const sendTeamEmailVerificationEmail = async (
email: string,
token: string,
team: Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
},
team: Team,
settings: Omit<OrganisationGlobalSettings, 'id'>,
) => {
const assetBaseUrl = env('NEXT_PUBLIC_WEBAPP_URL') || 'http://localhost:3000';
@ -133,11 +129,10 @@ export const sendTeamEmailVerificationEmail = async (
token,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const branding = teamGlobalSettingsToBranding(settings, team.id);
const lang = team.teamGlobalSettings?.documentLanguage;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const lang = settings.documentLanguage as SupportedLanguageCodes;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),

View File

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

View File

@ -1,8 +1,14 @@
import { Prisma, TeamMemberRole } from '@prisma/client';
import {
OrganisationGroupType,
OrganisationMemberRole,
Prisma,
TeamMemberRole,
} from '@prisma/client';
import type Stripe from 'stripe';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
import { createOrganisationCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
import { getTeamRelatedPrices } from '@documenso/ee/server-only/stripe/get-team-related-prices';
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@ -10,6 +16,13 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import {
LOWEST_ORGANISATION_ROLE,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
} from '../../constants/organisations';
import { TEAM_INTERNAL_GROUPS } from '../../constants/teams';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { generateDefaultTeamSettings } from '../../utils/teams';
import { stripe } from '../stripe';
export type CreateTeamOptions = {
@ -29,6 +42,24 @@ export type CreateTeamOptions = {
* Used as the URL path, example: https://documenso.com/t/{teamUrl}/settings
*/
teamUrl: string;
/**
* ID of the organisation the team belongs to.
*/
organisationId: string;
/**
* Whether to inherit all members from the organisation.
*/
inheritMembers: boolean;
/**
* List of additional groups to attach to the team.
*/
groups?: {
id: string;
role: TeamMemberRole;
}[];
};
export const ZCreateTeamResponseSchema = z.union([
@ -50,16 +81,123 @@ export const createTeam = async ({
userId,
teamName,
teamUrl,
organisationId,
inheritMembers,
}: CreateTeamOptions): Promise<TCreateTeamResponse> => {
const user = await prisma.user.findUniqueOrThrow({
where: {
id: userId,
},
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery(
organisationId,
userId,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
),
include: {
groups: true, // Todo: (orgs)
subscriptions: true,
owner: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found.',
});
}
// Inherit internal organisation groups to the team.
// Organisation Admins/Mangers get assigned as team admins, members get assigned as team members.
const internalOrganisationGroups = organisation.groups
.filter((group) => {
if (group.type !== OrganisationGroupType.INTERNAL_ORGANISATION) {
return false;
}
// If we're inheriting members, allow all internal organisation groups.
if (inheritMembers) {
return true;
}
// Otherwise, only inherit organisation admins/managers.
return (
group.organisationRole === OrganisationMemberRole.ADMIN ||
group.organisationRole === OrganisationMemberRole.MANAGER
);
})
.map((group) =>
match(group.organisationRole)
.with(OrganisationMemberRole.ADMIN, OrganisationMemberRole.MANAGER, () => ({
organisationGroupId: group.id,
teamRole: TeamMemberRole.ADMIN,
}))
.with(OrganisationMemberRole.MEMBER, () => ({
organisationGroupId: group.id,
teamRole: TeamMemberRole.MEMBER,
}))
.exhaustive(),
);
console.log({
internalOrganisationGroups,
});
if (Date.now() > 0) {
await prisma.$transaction(async (tx) => {
const teamSettings = await tx.teamGlobalSettings.create({
data: generateDefaultTeamSettings(),
});
const team = await tx.team.create({
data: {
name: teamName,
url: teamUrl,
organisationId,
teamGlobalSettingsId: teamSettings.id,
teamGroups: {
createMany: {
// Attach the internal organisation groups to the team.
data: internalOrganisationGroups,
},
},
},
include: {
teamGroups: true,
},
});
// Create the internal team groups.
await Promise.all(
TEAM_INTERNAL_GROUPS.map(async (teamGroup) =>
tx.organisationGroup.create({
data: {
type: teamGroup.type,
organisationRole: LOWEST_ORGANISATION_ROLE,
organisationId,
teamGroups: {
create: {
teamId: team.id,
teamRole: teamGroup.teamRole,
},
},
},
}),
),
);
});
return {
paymentRequired: false,
};
}
if (Date.now() > 0) {
throw new Error('Todo: Orgs');
}
let isPaymentRequired = IS_BILLING_ENABLED();
let customerId: string | null = null;
@ -68,59 +206,46 @@ export const createTeam = async ({
prices.map((price) => price.id),
);
isPaymentRequired = !subscriptionsContainsActivePlan(user.subscriptions, teamRelatedPriceIds);
isPaymentRequired = !subscriptionsContainsActivePlan(
organisation.subscriptions,
teamRelatedPriceIds, // Todo: (orgs)
);
customerId = await createTeamCustomer({
name: user.name ?? teamName,
email: user.email,
customerId = await createOrganisationCustomer({
name: organisation.owner.name ?? teamName,
email: organisation.owner.email,
}).then((customer) => customer.id);
await prisma.organisation.update({
where: {
id: organisationId,
},
data: {
customerId,
},
});
}
try {
// Create the team directly if no payment is required.
if (!isPaymentRequired) {
await prisma.$transaction(async (tx) => {
const existingUserProfileWithUrl = await tx.user.findUnique({
where: {
url: teamUrl,
await prisma.team.create({
data: {
name: teamName,
url: teamUrl,
organisationId,
members: {
create: [
{
userId,
role: TeamMemberRole.ADMIN, // Todo: (orgs)
},
],
},
select: {
id: true,
teamGlobalSettings: {
create: {},
},
});
if (existingUserProfileWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'URL already taken.',
});
}
const team = await tx.team.create({
data: {
name: teamName,
url: teamUrl,
ownerUserId: user.id,
customerId,
members: {
create: [
{
userId: user.id,
role: TeamMemberRole.ADMIN,
},
],
},
},
});
await tx.teamGlobalSettings.upsert({
where: {
teamId: team.id,
},
update: {},
create: {
teamId: team.id,
},
});
},
});
return {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,128 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getHighestOrganisationRoleInGroup } from '../../utils/organisations';
import { getHighestTeamRoleInGroup } from '../../utils/teams';
type GetMemberRolesOptions = {
teamId: number;
reference:
| {
type: 'User';
id: number;
}
| {
type: 'Member';
id: string;
};
};
/**
* Returns the highest Organisation and Team role of a given member or user of a team
*/
export const getMemberRoles = async ({ teamId, reference }: GetMemberRolesOptions) => {
const team = await prisma.team.findFirst({
where: {
id: teamId,
},
include: {
teamGroups: {
where: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember:
reference.type === 'User'
? {
userId: reference.id,
}
: {
id: reference.id,
},
},
},
},
},
include: {
organisationGroup: true,
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Roles not found',
});
}
console.log({
groups: team.teamGroups,
roles: {
organisationRole: getHighestOrganisationRoleInGroup(
team.teamGroups.flatMap((group) => group.organisationGroup),
),
teamRole: getHighestTeamRoleInGroup(team.teamGroups),
},
});
return {
organisationRole: getHighestOrganisationRoleInGroup(
team.teamGroups.flatMap((group) => group.organisationGroup),
),
teamRole: getHighestTeamRoleInGroup(team.teamGroups),
};
};
type GetMemberOrganisationRoleOptions = {
organisationId: string;
reference:
| {
type: 'User';
id: number;
}
| {
type: 'Member';
id: string;
};
};
/**
* Returns the highest Organisation of a given organisation member
*/
export const getMemberOrganisationRole = async ({
organisationId,
reference,
}: GetMemberOrganisationRoleOptions) => {
const organisation = await prisma.organisation.findFirst({
where: {
id: organisationId,
},
include: {
groups: {
where: {
organisationGroupMembers: {
some: {
organisationMember:
reference.type === 'User'
? {
userId: reference.id,
}
: {
id: reference.id,
},
},
},
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Roles not found',
});
}
return getHighestOrganisationRoleInGroup(organisation.groups);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -1,71 +1,61 @@
import type { Prisma } from '@prisma/client';
import type { z } from 'zod';
import { TeamMemberRole } from '@prisma/client';
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TeamEmailSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamEmailSchema';
import { TeamGlobalSettingsSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamGlobalSettingsSchema';
import { TeamMemberSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamMemberSchema';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery, getHighestTeamRoleInGroup } from '../../utils/teams';
export type GetTeamByIdOptions = {
userId?: number;
userId: number;
teamId: number;
};
export const ZGetTeamByIdResponseSchema = TeamSchema.extend({
teamEmail: TeamEmailSchema.nullable(),
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),
currentTeamMember: TeamMemberSchema.pick({
role: true,
}).nullable(),
currentTeamRole: z.nativeEnum(TeamMemberRole),
});
export type TGetTeamByIdResponse = z.infer<typeof ZGetTeamByIdResponseSchema>;
/**
* Get a team given a teamId.
*
* Provide an optional userId to check that the user is a member of the team.
*/
export const getTeamById = async ({
userId,
teamId,
}: GetTeamByIdOptions): Promise<TGetTeamByIdResponse> => {
const whereFilter: Prisma.TeamWhereUniqueInput = {
id: teamId,
};
if (userId !== undefined) {
whereFilter['members'] = {
some: {
userId,
},
};
}
const result = await prisma.team.findUniqueOrThrow({
where: whereFilter,
// Todo: orgs test
const result = await prisma.team.findFirst({
where: buildTeamWhereQuery(teamId, userId),
include: {
teamEmail: true,
teamGlobalSettings: true,
members: {
teamGroups: {
where: {
userId,
},
select: {
role: true,
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
});
const { members, ...team } = result;
if (!result) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
const { teamGroups, ...team } = result;
return {
...team,
currentTeamMember: userId !== undefined ? members[0] : null,
currentTeamRole: getHighestTeamRoleInGroup(teamGroups),
};
};
@ -80,20 +70,23 @@ export type TGetTeamByUrlResponse = Awaited<ReturnType<typeof getTeamByUrl>>;
* Get a team given a team URL.
*/
export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) => {
const whereFilter: Prisma.TeamWhereUniqueInput = {
url: teamUrl,
};
if (userId !== undefined) {
whereFilter['members'] = {
some: {
userId,
},
};
}
const result = await prisma.team.findFirst({
where: whereFilter,
where: {
url: teamUrl,
teamGroups: {
some: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
include: {
teamEmail: true,
emailVerification: {
@ -103,21 +96,17 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
email: true,
},
},
transferVerification: {
select: {
expiresAt: true,
name: true,
email: true,
},
},
subscription: true,
teamGlobalSettings: true,
members: {
teamGroups: {
where: {
userId,
},
select: {
role: true,
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
@ -127,10 +116,10 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { members, ...team } = result;
const { teamGroups, ...team } = result;
return {
...team,
currentTeamMember: members[0],
currentTeamRole: getHighestTeamRoleInGroup(teamGroups),
};
};

View File

@ -1,53 +1,84 @@
import type { z } from 'zod';
import type { Prisma } from '@prisma/client';
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { SubscriptionSchema } from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import { TeamMemberSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamMemberSchema';
import { TeamMemberRole } from '@documenso/prisma/generated/types';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { getHighestTeamRoleInGroup } from '../../utils/teams';
export type GetTeamsOptions = {
userId: number;
teamId?: number;
};
export const ZGetTeamsResponseSchema = TeamSchema.extend({
currentTeamMember: TeamMemberSchema.pick({
role: true,
}),
subscription: SubscriptionSchema.pick({
status: true,
}).nullable(),
teamRole: z.nativeEnum(TeamMemberRole),
}).array();
export type TGetTeamsResponse = z.infer<typeof ZGetTeamsResponseSchema>;
export const getTeams = async ({ userId }: GetTeamsOptions): Promise<TGetTeamsResponse> => {
const teams = await prisma.team.findMany({
where: {
members: {
some: {
userId,
export const getTeams = async ({ userId, teamId }: GetTeamsOptions) => {
let whereQuery: Prisma.TeamWhereInput = {
teamGroups: {
some: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
include: {
subscription: {
select: {
status: true,
};
if (teamId) {
whereQuery = {
teamGroups: {
some: {
teamId,
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
members: {
};
}
const teams = await prisma.team.findMany({
where: whereQuery,
include: {
teamGroups: {
where: {
userId,
},
select: {
role: true,
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
});
return teams.map(({ members, ...team }) => ({
...team,
currentTeamMember: members[0],
}));
return teams.map((team) => {
const teamRole = getHighestTeamRoleInGroup(team.teamGroups);
return {
...team,
teamRole,
};
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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