Merge branch 'main' into feat/signing-reminders

This commit is contained in:
Ephraim Atta-Duncan
2025-08-22 05:05:00 +00:00
977 changed files with 92471 additions and 41466 deletions

View File

@ -0,0 +1,20 @@
import { compare } from '@node-rs/bcrypt';
import { prisma } from '@documenso/prisma';
type VerifyPasswordOptions = {
userId: number;
password: string;
};
export const verifyPassword = async ({ userId, password }: VerifyPasswordOptions) => {
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user || !user.password) {
return false;
}
return await compare(password, user.password);
};

View File

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

View File

@ -29,37 +29,26 @@ export async function getSigningVolume({
let findQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
.leftJoin('Document as ud', (join) =>
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.leftJoin('Document as d', (join) =>
join
.onRef('u.id', '=', 'ud.userId')
.on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('ud.deletedAt', 'is', null)
.on('ud.teamId', 'is', null),
)
.leftJoin('Document as td', (join) =>
join
.onRef('t.id', '=', 'td.teamId')
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('td.deletedAt', 'is', null),
.onRef('t.id', '=', 'd.teamId')
.on('d.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('d.deletedAt', 'is', null),
)
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([
eb('u.name', 'ilike', `%${search}%`),
eb('u.email', 'ilike', `%${search}%`),
eb('t.name', 'ilike', `%${search}%`),
]),
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select([
's.id as id',
's.createdAt as createdAt',
's.planId as planId',
sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT d.id)`.as('signingVolume'),
])
.groupBy(['s.id', 'u.name', 't.name', 'u.email']);
.groupBy(['s.id', 'o.name']);
switch (sortBy) {
case 'name':
@ -79,15 +68,11 @@ export async function getSigningVolume({
const countQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([
eb('u.name', 'ilike', `%${search}%`),
eb('u.email', 'ilike', `%${search}%`),
eb('t.name', 'ilike', `%${search}%`),
]),
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select(({ fn }) => [fn.countAll().as('count')]);

View File

@ -1,50 +1,17 @@
import { DocumentStatus, SubscriptionStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
import { SubscriptionStatus, UserSecurityAuditLogType } from '@documenso/prisma/client';
export const getUsersCount = async () => {
return await prisma.user.count();
};
export const getUsersWithSubscriptionsCount = async () => {
return await prisma.user.count({
export const getOrganisationsWithSubscriptionsCount = async () => {
return await prisma.organisation.count({
where: {
subscriptions: {
some: {
status: SubscriptionStatus.ACTIVE,
},
},
},
});
};
export const getUserWithAtLeastOneDocumentPerMonth = async () => {
return await prisma.user.count({
where: {
documents: {
some: {
createdAt: {
gte: DateTime.now().minus({ months: 1 }).toJSDate(),
},
},
},
},
});
};
export const getUserWithAtLeastOneDocumentSignedPerMonth = async () => {
return await prisma.user.count({
where: {
documents: {
some: {
status: {
equals: DocumentStatus.COMPLETED,
},
completedAt: {
gte: DateTime.now().minus({ months: 1 }).toJSDate(),
},
},
subscription: {
status: SubscriptionStatus.ACTIVE,
},
},
});
@ -69,6 +36,8 @@ export const getUserWithSignedDocumentMonthlyGrowth = async () => {
COUNT(DISTINCT "Document"."userId") as "count",
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."userId" END) as "signed_count"
FROM "Document"
INNER JOIN "Team" ON "Document"."teamId" = "Team"."id"
INNER JOIN "Organisation" ON "Team"."organisationId" = "Organisation"."id"
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
@ -80,3 +49,37 @@ export const getUserWithSignedDocumentMonthlyGrowth = async () => {
signed_count: Number(row.signed_count),
}));
};
export type GetMonthlyActiveUsersResult = Array<{
month: string;
count: number;
cume_count: number;
}>;
export const getMonthlyActiveUsers = async () => {
const qb = kyselyPrisma.$kysely
.selectFrom('UserSecurityAuditLog')
.select(({ fn }) => [
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']).as('month'),
fn.count('userId').distinct().as('count'),
fn
.sum(fn.count('userId').distinct())
.over((ob) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']) as any),
)
.as('cume_count'),
])
.where(sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`)
.groupBy(({ fn }) => fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']))
.orderBy('month', 'desc')
.limit(12);
const result = await qb.execute();
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
cume_count: Number(row.cume_count),
}));
};

View File

@ -15,10 +15,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;
@ -26,6 +27,8 @@ export type CreateDocumentMetaOptions = {
password?: string;
dateFormat?: string;
redirectUrl?: string;
emailId?: string | null;
emailReplyTo?: string | null;
emailSettings?: TDocumentEmailSettings;
signingOrder?: DocumentSigningOrder;
allowDictateNextSigner?: boolean;
@ -50,6 +53,8 @@ export const upsertDocumentMeta = async ({
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailId,
emailReplyTo,
emailSettings,
distributionMethod,
typedSignatureEnabled,
@ -59,25 +64,14 @@ export const upsertDocumentMeta = async ({
reminderInterval,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const { documentWhereInput, team } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
include: {
documentMeta: true,
},
@ -91,6 +85,22 @@ export const upsertDocumentMeta = async ({
const { documentMeta: originalDocumentMeta } = document;
// Validate the emailId belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.$transaction(async (tx) => {
const upsertedDocumentMeta = await tx.documentMeta.upsert({
where: {
@ -106,6 +116,8 @@ export const upsertDocumentMeta = async ({
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailId,
emailReplyTo,
emailSettings,
distributionMethod,
typedSignatureEnabled,
@ -123,6 +135,8 @@ export const upsertDocumentMeta = async ({
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailId,
emailReplyTo,
emailSettings,
distributionMethod,
typedSignatureEnabled,

View File

@ -23,6 +23,7 @@ import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendPendingEmail } from './send-pending-email';
@ -140,6 +141,11 @@ export const completeDocumentWithToken = async ({
},
});
const authOptions = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
@ -154,6 +160,7 @@ export const completeDocumentWithToken = async ({
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
actionAuth: authOptions.derivedRecipientActionAuth,
},
}),
});

View File

@ -6,14 +6,12 @@ import {
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid } from '@documenso/lib/universal/id';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { TCreateDocumentV2Request } from '@documenso/trpc/server/document-router/schema';
@ -26,21 +24,25 @@ import {
} from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
userId: number;
teamId?: number;
teamId: number;
documentDataId: string;
normalizePdf?: boolean;
data: {
title: string;
externalId?: string;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes;
globalActionAuth?: TDocumentActionAuthTypes;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
};
@ -59,36 +61,28 @@ export const createDocumentV2 = async ({
}: CreateDocumentOptions) => {
const { title, formValues } = data;
const team = teamId
? await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
include: {
organisation: {
select: {
organisationClaim: true,
},
include: {
teamGlobalSettings: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
})
: null;
},
},
});
if (teamId !== undefined && !team) {
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
const settings = await getTeamSettings({
userId,
teamId,
});
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
@ -113,35 +107,57 @@ export const createDocumentV2 = async ({
}
const authOptions = createDocumentAuthOptions({
globalAccessAuth: data?.globalAccessAuth || null,
globalActionAuth: data?.globalActionAuth || null,
globalAccessAuth: data?.globalAccessAuth || [],
globalActionAuth: data?.globalActionAuth || [],
});
const recipientsHaveActionAuth = data.recipients?.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = data.recipients?.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (authOptions.globalActionAuth || recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
if (
(authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) &&
!team.organisation.organisationClaim.flags.cfr21
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const { teamRole } = await getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
});
const visibility = determineDocumentVisibility(settings.documentVisibility, teamRole);
const emailId = meta?.emailId;
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
const visibility = determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
team?.members[0].role ?? TeamMemberRole.MEMBER,
);
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
title,
qrToken: prefixedId('qr'),
externalId: data.externalId,
documentDataId,
userId,
@ -151,18 +167,7 @@ export const createDocumentV2 = async ({
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: {
...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,
},
create: extractDerivedDocumentMeta(settings, meta),
},
},
});
@ -170,8 +175,8 @@ export const createDocumentV2 = async ({
await Promise.all(
(data.recipients || []).map(async (recipient) => {
const recipientAuthOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null,
actionAuth: recipient.actionAuth || null,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
await tx.recipient.create({
@ -207,7 +212,7 @@ export const createDocumentV2 = async ({
}),
);
// Todo: Is it necessary to create a full audit log with all fields and recipients audit logs?
// Todo: Is it necessary to create a full audit logs with all fields and recipients audit logs?
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
@ -232,6 +237,7 @@ export const createDocumentV2 = async ({
documentMeta: true,
recipients: true,
fields: true,
folder: true,
},
});

View File

@ -1,33 +1,39 @@
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import type { DocumentVisibility } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { prefixedId } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { buildTeamWhereQuery } from '../../utils/teams';
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;
timezone?: string;
userTimezone?: string;
requestMetadata: ApiRequestMetadata;
folderId?: string;
};
export const createDocument = async ({
@ -40,52 +46,39 @@ export const createDocument = async ({
formValues,
requestMetadata,
timezone,
userTimezone,
folderId,
}: CreateDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
teamMembers: {
select: {
teamId: true,
},
},
},
const team = await getTeamById({ userId, teamId });
const settings = await getTeamSettings({
userId,
teamId,
});
if (
teamId !== undefined &&
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
let folderVisibility: DocumentVisibility | undefined;
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;
let userTeamRole: TeamMemberRole | undefined;
if (teamId) {
const teamWithUserRole = await prisma.team.findFirstOrThrow({
if (folderId) {
const folder = await prisma.folder.findFirst({
where: {
id: teamId,
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
include: {
teamGlobalSettings: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
select: {
visibility: true,
},
});
team = teamWithUserRole;
userTeamRole = teamWithUserRole.members[0]?.role;
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
folderVisibility = folder.visibility;
}
if (normalizePdf) {
@ -111,28 +104,29 @@ export const createDocument = async ({
}
}
// userTimezone is last because it's always passed in regardless of the organisation/team settings
// for uploads from the frontend
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
title,
qrToken: prefixedId('qr'),
externalId,
documentDataId,
userId,
teamId,
visibility: determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
folderId,
visibility:
folderVisibility ??
determineDocumentVisibility(settings.documentVisibility, team.currentTeamRole),
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: {
language: team?.teamGlobalSettings?.documentLanguage,
timezone: timezone,
typedSignatureEnabled: team?.teamGlobalSettings?.typedSignatureEnabled ?? true,
uploadSignatureEnabled: team?.teamGlobalSettings?.uploadSignatureEnabled ?? true,
drawSignatureEnabled: team?.teamGlobalSettings?.drawSignatureEnabled ?? true,
},
create: extractDerivedDocumentMeta(settings, {
timezone: timezoneToUse,
}),
},
},
});

View File

@ -1,14 +1,7 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type {
Document,
DocumentMeta,
Recipient,
Team,
TeamGlobalSettings,
User,
} from '@prisma/client';
import type { Document, DocumentMeta, Recipient, User } from '@prisma/client';
import { DocumentStatus, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
@ -17,7 +10,6 @@ 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 { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
@ -29,13 +21,14 @@ import type { ApiRequestMetadata } from '../../universal/extract-request-metadat
import { isDocumentCompleted } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getEmailContext } from '../email/get-email-context';
import { getMemberRoles } from '../team/get-member-roles';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteDocumentOptions = {
id: number;
userId: number;
teamId?: number;
teamId: number;
requestMetadata: ApiRequestMetadata;
};
@ -64,23 +57,26 @@ export const deleteDocument = async ({
include: {
recipients: true,
documentMeta: true,
team: {
include: {
members: true,
teamGlobalSettings: true,
},
},
},
});
if (!document || (teamId !== undefined && teamId !== document.teamId)) {
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isUserTeamMember = await getMemberRoles({
teamId: document.teamId,
reference: {
type: 'User',
id: userId,
},
})
.then(() => true)
.catch(() => false);
const isUserOwner = document.userId === userId;
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
const userRecipient = document.recipients.find((recipient) => recipient.email === user.email);
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
@ -94,7 +90,6 @@ export const deleteDocument = async ({
await handleDocumentOwnerDelete({
document,
user,
team: document.team,
requestMetadata,
});
}
@ -142,11 +137,6 @@ type HandleDocumentOwnerDeleteOptions = {
recipients: Recipient[];
documentMeta: DocumentMeta | null;
};
team?:
| (Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
})
| null;
user: User;
requestMetadata: ApiRequestMetadata;
};
@ -154,13 +144,21 @@ type HandleDocumentOwnerDeleteOptions = {
const handleDocumentOwnerDelete = async ({
document,
user,
team,
requestMetadata,
}: HandleDocumentOwnerDeleteOptions) => {
if (document.deletedAt) {
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
// Soft delete completed documents.
if (isDocumentCompleted(document.status)) {
return await prisma.$transaction(async (tx) => {
@ -235,30 +233,24 @@ const handleDocumentOwnerDelete = async ({
assetBaseUrl,
});
const branding = team?.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,

View File

@ -1,14 +1,22 @@
import { DocumentSource, type Prisma } from '@prisma/client';
import type { Prisma, Recipient } from '@prisma/client';
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
import { omit } from 'remeda';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { nanoid, prefixedId } from '../../universal/id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getDocumentWhereInput } from './get-document-by-id';
export interface DuplicateDocumentOptions {
documentId: number;
userId: number;
teamId?: number;
teamId: number;
}
export const duplicateDocument = async ({
@ -16,7 +24,7 @@ export const duplicateDocument = async ({
userId,
teamId,
}: DuplicateDocumentOptions) => {
const documentWhereInput = await getDocumentWhereInput({
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
@ -34,14 +42,16 @@ export const duplicateDocument = async ({
type: true,
},
},
documentMeta: {
authOptions: true,
visibility: true,
documentMeta: true,
recipients: {
select: {
message: true,
subject: true,
dateFormat: true,
password: true,
timezone: true,
redirectUrl: true,
email: true,
name: true,
role: true,
signingOrder: true,
fields: true,
},
},
},
@ -53,38 +63,88 @@ export const duplicateDocument = async ({
});
}
const createDocumentArguments: Prisma.DocumentCreateArgs = {
const documentData = await prisma.documentData.create({
data: {
title: document.title,
user: {
connect: {
id: document.userId,
},
},
documentData: {
create: {
...document.documentData,
data: document.documentData.initialData,
},
},
documentMeta: {
create: {
...document.documentMeta,
},
},
source: DocumentSource.DOCUMENT,
type: document.documentData.type,
data: document.documentData.initialData,
initialData: document.documentData.initialData,
},
};
});
if (teamId !== undefined) {
createDocumentArguments.data.team = {
connect: {
id: teamId,
let documentMeta: Prisma.DocumentCreateArgs['data']['documentMeta'] | undefined = undefined;
if (document.documentMeta) {
documentMeta = {
create: {
...omit(document.documentMeta, ['id', 'documentId']),
emailSettings: document.documentMeta.emailSettings || undefined,
},
};
}
const createdDocument = await prisma.document.create(createDocumentArguments);
const createdDocument = await prisma.document.create({
data: {
userId: document.userId,
teamId: teamId,
title: document.title,
documentDataId: documentData.id,
authOptions: document.authOptions || undefined,
visibility: document.visibility,
qrToken: prefixedId('qr'),
documentMeta,
source: DocumentSource.DOCUMENT,
},
include: {
recipients: true,
documentMeta: true,
},
});
const recipientsToCreate = document.recipients.map((recipient) => ({
documentId: createdDocument.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
fields: {
createMany: {
data: recipient.fields.map((field) => ({
documentId: createdDocument.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
})),
},
},
}));
const recipients: Recipient[] = [];
for (const recipientData of recipientsToCreate) {
const newRecipient = await prisma.recipient.create({
data: recipientData,
});
recipients.push(newRecipient);
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse({
...mapDocumentToWebhookDocumentPayload(createdDocument),
recipients,
documentMeta: createdDocument.documentMeta,
}),
userId: userId,
teamId: teamId,
});
return {
documentId: createdDocument.id,

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';
@ -27,6 +28,7 @@ export type FindDocumentsOptions = {
period?: PeriodSelectorValue;
senderIds?: number[];
query?: string;
folderId?: string;
};
export const findDocuments = async ({
@ -41,6 +43,7 @@ export const findDocuments = async ({
period,
senderIds,
query = '',
folderId,
}: FindDocumentsOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@ -51,32 +54,15 @@ export const findDocuments = async ({
let team = null;
if (teamId !== undefined) {
team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
teamEmail: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
team = await getTeamById({
userId,
teamId,
});
}
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const teamMemberRole = team?.members[0].role ?? null;
const teamMemberRole = team?.currentTeamRole ?? null;
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
@ -120,10 +106,10 @@ export const findDocuments = async ({
},
];
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user);
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user, folderId);
if (team) {
filters = findTeamDocumentsFilter(status, team, visibilityFilters);
filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId);
}
if (filters === null) {
@ -227,6 +213,12 @@ export const findDocuments = async ({
};
}
if (folderId !== undefined) {
whereClause.folderId = folderId;
} else {
whereClause.folderId = null;
}
const [data, count] = await Promise.all([
prisma.document.findMany({
where: whereClause,
@ -273,13 +265,17 @@ export const findDocuments = async ({
} satisfies FindResultResponse<typeof data>;
};
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
const findDocumentsFilter = (
status: ExtendedDocumentStatus,
user: User,
folderId?: string | null,
) => {
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
.with(ExtendedDocumentStatus.ALL, () => ({
OR: [
{
userId: user.id,
teamId: null,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.COMPLETED,
@ -288,6 +284,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
email: user.email,
},
},
folderId: folderId,
},
{
status: ExtendedDocumentStatus.PENDING,
@ -296,6 +293,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
email: user.email,
},
},
folderId: folderId,
},
],
}))
@ -315,15 +313,14 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
}))
.with(ExtendedDocumentStatus.DRAFT, () => ({
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.DRAFT,
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
OR: [
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.PENDING,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.PENDING,
@ -336,6 +333,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
},
},
},
folderId: folderId,
},
],
}))
@ -343,8 +341,8 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
OR: [
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.COMPLETED,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.COMPLETED,
@ -353,6 +351,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
email: user.email,
},
},
folderId: folderId,
},
],
}))
@ -360,8 +359,8 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
OR: [
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.REJECTED,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.REJECTED,
@ -371,6 +370,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
signingStatus: SigningStatus.REJECTED,
},
},
folderId: folderId,
},
],
}))
@ -410,6 +410,7 @@ const findTeamDocumentsFilter = (
status: ExtendedDocumentStatus,
team: Team & { teamEmail: TeamEmail | null },
visibilityFilters: Prisma.DocumentWhereInput[],
folderId?: string,
) => {
const teamEmail = team.teamEmail?.email ?? null;
@ -420,6 +421,7 @@ const findTeamDocumentsFilter = (
OR: [
{
teamId: team.id,
folderId: folderId,
OR: visibilityFilters,
},
],
@ -437,6 +439,7 @@ const findTeamDocumentsFilter = (
},
},
OR: visibilityFilters,
folderId: folderId,
});
// Filter to display all documents that have been sent by the team email.
@ -445,6 +448,7 @@ const findTeamDocumentsFilter = (
email: teamEmail,
},
OR: visibilityFilters,
folderId: folderId,
});
}
@ -470,6 +474,7 @@ const findTeamDocumentsFilter = (
},
},
OR: visibilityFilters,
folderId: folderId,
};
})
.with(ExtendedDocumentStatus.DRAFT, () => {
@ -479,6 +484,7 @@ const findTeamDocumentsFilter = (
teamId: team.id,
status: ExtendedDocumentStatus.DRAFT,
OR: visibilityFilters,
folderId: folderId,
},
],
};
@ -490,6 +496,7 @@ const findTeamDocumentsFilter = (
email: teamEmail,
},
OR: visibilityFilters,
folderId: folderId,
});
}
@ -502,6 +509,7 @@ const findTeamDocumentsFilter = (
teamId: team.id,
status: ExtendedDocumentStatus.PENDING,
OR: visibilityFilters,
folderId: folderId,
},
],
};
@ -521,12 +529,14 @@ const findTeamDocumentsFilter = (
},
},
OR: visibilityFilters,
folderId: folderId,
},
{
user: {
email: teamEmail,
},
OR: visibilityFilters,
folderId: folderId,
},
],
});

View File

@ -0,0 +1,38 @@
import { prisma } from '@documenso/prisma';
export type GetDocumentByAccessTokenOptions = {
token: string;
};
export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTokenOptions) => {
if (!token) {
throw new Error('Missing token');
}
const result = await prisma.document.findFirstOrThrow({
where: {
qrToken: token,
},
select: {
id: true,
title: true,
completedAt: true,
documentData: {
select: {
id: true,
type: true,
data: true,
initialData: true,
},
},
documentMeta: {
select: {
password: true,
},
},
recipients: true,
},
});
return result;
};

View File

@ -1,5 +1,5 @@
import type { Prisma } from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
@ -11,18 +11,27 @@ import { getTeamById } from '../team/get-team';
export type GetDocumentByIdOptions = {
documentId: number;
userId: number;
teamId?: number;
teamId: number;
folderId?: string;
};
export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumentByIdOptions) => {
const documentWhereInput = await getDocumentWhereInput({
export const getDocumentById = async ({
documentId,
userId,
teamId,
folderId,
}: GetDocumentByIdOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: documentWhereInput,
where: {
...documentWhereInput,
folderId,
},
include: {
documentData: true,
documentMeta: true,
@ -59,18 +68,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 +80,55 @@ 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({
teamId: team.id,
});
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
// Allow access to only team documents.
if (!overlapUserTeamScope) {
documentWhereInput.OR = [
{
teamId: team.id,
const teamVisibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
])
.with(TeamMemberRole.MANAGER, () => [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
])
.otherwise(() => [DocumentVisibility.EVERYONE]);
const documentOrInput: Prisma.DocumentWhereInput[] = [
// Allow access if they own the document.
{
userId,
},
// Or, if they belong to the team that the document is associated with.
{
visibility: {
in: teamVisibilityFilters,
},
];
}
teamId: team.id,
},
// Or, if they are a recipient of the document.
{
status: {
not: DocumentStatus.DRAFT,
},
recipients: {
some: {
email: user.email,
},
},
},
];
// Allow access to documents sent to or from the team email.
if (team.teamEmail) {
documentWhereInput.OR.push(
documentOrInput.push(
{
recipients: {
some: {
@ -133,42 +144,13 @@ export const getDocumentWhereInput = async ({
);
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const visibilityFilters = [
...match(team.currentTeamMember?.role)
.with(TeamMemberRole.ADMIN, () => [
{ visibility: DocumentVisibility.EVERYONE },
{ visibility: DocumentVisibility.MANAGER_AND_ABOVE },
{ visibility: DocumentVisibility.ADMIN },
])
.with(TeamMemberRole.MANAGER, () => [
{ visibility: DocumentVisibility.EVERYONE },
{ visibility: DocumentVisibility.MANAGER_AND_ABOVE },
])
.otherwise(() => [{ visibility: DocumentVisibility.EVERYONE }]),
{
OR: [
{
recipients: {
some: {
email: user.email,
},
},
},
{
userId: user.id,
},
],
},
];
const documentWhereInput: Prisma.DocumentWhereUniqueInput = {
id: documentId,
OR: documentOrInput,
};
return {
...documentWhereInput,
OR: [...visibilityFilters],
documentWhereInput,
team,
};
};

View File

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

View File

@ -17,6 +17,7 @@ export const getDocumentCertificateAuditLogs = async ({
in: [
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
],
@ -36,6 +37,9 @@ export const getDocumentCertificateAuditLogs = async ({
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
),
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT &&

View File

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

View File

@ -11,13 +11,20 @@ import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-d
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
export type GetStatsInput = {
user: User;
user: Pick<User, 'id' | 'email'>;
team?: Omit<GetTeamCountsOption, 'createdAt'>;
period?: PeriodSelectorValue;
search?: string;
folderId?: string;
};
export const getStats = async ({ user, period, search = '', ...options }: GetStatsInput) => {
export const getStats = async ({
user,
period,
search = '',
folderId,
...options
}: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) {
@ -37,8 +44,9 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
currentUserEmail: user.email,
userId: user.id,
search,
folderId,
})
: getCounts({ user, createdAt, search }));
: getCounts({ user, createdAt, search, folderId }));
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
@ -81,12 +89,13 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
};
type GetCountsOption = {
user: User;
user: Pick<User, 'id' | 'email'>;
createdAt: Prisma.DocumentWhereInput['createdAt'];
search?: string;
folderId?: string | null;
};
const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption) => {
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
@ -95,6 +104,8 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
],
};
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
return Promise.all([
// Owner counts.
prisma.document.groupBy({
@ -105,9 +116,8 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
where: {
userId: user.id,
createdAt,
teamId: null,
deletedAt: null,
AND: [searchFilter],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
// Not signed counts.
@ -126,7 +136,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
},
},
createdAt,
AND: [searchFilter],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
// Has signed counts.
@ -164,7 +174,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
},
},
],
AND: [searchFilter],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
]);
@ -179,10 +189,11 @@ type GetTeamCountsOption = {
createdAt: Prisma.DocumentWhereInput['createdAt'];
currentTeamMemberRole?: TeamMemberRole;
search?: string;
folderId?: string | null;
};
const getTeamCounts = async (options: GetTeamCountsOption) => {
const { createdAt, teamId, teamEmail } = options;
const { createdAt, teamId, teamEmail, folderId } = options;
const senderIds = options.senderIds ?? [];
@ -206,6 +217,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
createdAt,
teamId,
deletedAt: null,
folderId,
};
let notSignedCountsGroupByArgs = null;
@ -278,6 +290,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
where: {
userId: userIdWhereClause,
createdAt,
folderId,
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
@ -298,6 +311,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
where: {
userId: userIdWhereClause,
createdAt,
folderId,
OR: [
{
status: ExtendedDocumentStatus.PENDING,
@ -319,7 +333,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
documentDeletedAt: null,
},
},
deletedAt: null,
},
],
},

View File

@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
import { verifyPassword } from '../2fa/verify-password';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
@ -60,23 +61,26 @@ export const isRecipientAuthorized = async ({
recipientAuth: recipient.authOptions,
});
const authMethod: TDocumentAuth | null =
const authMethods: TDocumentAuth[] =
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
// Early true return when auth is not required.
if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) {
if (
authMethods.length === 0 ||
authMethods.some((method) => method === DocumentAuth.EXPLICIT_NONE)
) {
return true;
}
// Create auth options when none are passed for account.
if (!authOptions && authMethod === DocumentAuth.ACCOUNT) {
if (!authOptions && authMethods.some((method) => method === DocumentAuth.ACCOUNT)) {
authOptions = {
type: DocumentAuth.ACCOUNT,
};
}
// Authentication required does not match provided method.
if (!authOptions || authOptions.type !== authMethod || !userId) {
if (!authOptions || !authMethods.includes(authOptions.type) || !userId) {
return false;
}
@ -117,6 +121,15 @@ export const isRecipientAuthorized = async ({
window: 10, // 5 minutes worth of tokens
});
})
.with({ type: DocumentAuth.PASSWORD }, async ({ password }) => {
return await verifyPassword({
userId,
password,
});
})
.with({ type: DocumentAuth.EXPLICIT_NONE }, () => {
return true;
})
.exhaustive();
};
@ -160,7 +173,7 @@ const verifyPasskey = async ({
}: VerifyPasskeyOptions): Promise<void> => {
const passkey = await prisma.passkey.findFirst({
where: {
credentialId: Buffer.from(authenticationResponse.id, 'base64'),
credentialId: new Uint8Array(Buffer.from(authenticationResponse.id, 'base64')),
userId,
},
});

View File

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

View File

@ -1,12 +1,10 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import type { Prisma } from '@prisma/client';
import { DocumentStatus, OrganisationType, RecipientRole, SigningStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
@ -22,14 +20,14 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { isDocumentCompleted } from '../../utils/document';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getEmailContext } from '../email/get-email-context';
import { getDocumentWhereInput } from './get-document-by-id';
export type ResendDocumentOptions = {
documentId: number;
userId: number;
recipients: number[];
teamId?: number;
teamId: number;
requestMetadata: ApiRequestMetadata;
};
@ -46,7 +44,7 @@ export const resendDocument = async ({
},
});
const documentWhereInput: Prisma.DocumentWhereUniqueInput = await getDocumentWhereInput({
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
@ -55,27 +53,18 @@ export const resendDocument = async ({
const document = await prisma.document.findUnique({
where: documentWhereInput,
include: {
recipients: {
where: {
id: {
in: recipients,
},
signingStatus: SigningStatus.NOT_SIGNED,
},
},
recipients: true,
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
teamGlobalSettings: true,
},
},
},
});
const customEmail = document?.documentMeta;
const isTeamDocument = document?.team !== null;
if (!document) {
throw new Error('Document not found');
@ -93,6 +82,11 @@ export const resendDocument = async ({
throw new Error('Can not send completed document');
}
const recipientsToRemind = document.recipients.filter(
(recipient) =>
recipients.includes(recipient.id) && recipient.signingStatus === SigningStatus.NOT_SIGNED,
);
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
@ -101,13 +95,23 @@ export const resendDocument = async ({
return;
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
await Promise.all(
document.recipients.map(async (recipient) => {
recipientsToRemind.map(async (recipient) => {
if (recipient.role === RecipientRole.CC) {
return;
}
const i18n = await getI18nInstance(document.documentMeta?.language);
const i18n = await getI18nInstance(emailLanguage);
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
@ -128,7 +132,7 @@ export const resendDocument = async ({
emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} your document`);
}
if (isTeamDocument && document.team) {
if (organisationType === OrganisationType.ORGANISATION) {
emailSubject = i18n._(
msg`Reminder: ${document.team.name} invited you to ${recipientActionVerb} a document`,
);
@ -151,27 +155,26 @@ export const resendDocument = async ({
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: isTeamDocument ? document.team?.teamEmail?.email || user.email : user.email,
inviterEmail:
organisationType === OrganisationType.ORGANISATION
? document.team?.teamEmail?.email || user.email
: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
role: recipient.role,
selfSigner,
isTeamInvite: isTeamDocument,
organisationType,
teamName: document.team?.name,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -184,10 +187,8 @@ export const resendDocument = async ({
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: customEmail?.subject
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${customEmail.subject}`),

View File

@ -17,12 +17,15 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getAuditLogsPdf } from '../htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
import { flattenForm } from '../pdf/flatten-form';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { legacy_insertFieldInPDF } from '../pdf/legacy-insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendCompletedEmail } from './send-completed-email';
@ -47,15 +50,6 @@ export const sealDocument = async ({
documentData: true,
documentMeta: true,
recipients: true,
team: {
select: {
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
},
},
},
},
},
});
@ -65,6 +59,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,19 +114,35 @@ 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((e) => {
console.log('Failed to get certificate PDF');
console.error(e);
return null;
})
: null;
const auditLogData = settings.includeAuditLog
? await getAuditLogsPdf({
documentId,
language: document.documentMeta?.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null;
const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(doc);
flattenForm(doc);
await flattenForm(doc);
flattenAnnotations(doc);
// Add rejection stamp if the document is rejected
@ -145,12 +160,24 @@ export const sealDocument = async ({
});
}
if (auditLogData) {
const auditLog = await PDFDocument.load(auditLogData);
const auditLogPages = await doc.copyPages(auditLog, auditLog.getPageIndices());
auditLogPages.forEach((page) => {
doc.addPage(page);
});
}
for (const field of fields) {
await insertFieldInPDF(doc, field);
document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(doc, field)
: await insertFieldInPDF(doc, field);
}
// Re-flatten post-insertion to handle fields that create arcoFields
flattenForm(doc);
await flattenForm(doc);
const pdfBytes = await doc.save();

View File

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

View File

@ -14,11 +14,10 @@ import { extractDerivedDocumentEmailSettings } from '../../types/document-email'
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { env } from '../../utils/env';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { formatDocumentsPath } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
export interface SendDocumentOptions {
documentId: number;
@ -39,7 +38,6 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
select: {
id: true,
url: true,
teamGlobalSettings: true,
},
},
},
@ -55,6 +53,15 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
throw new Error('Document has no recipients');
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const { user: owner } = document;
const completedDocument = await getFileServerSide(document.documentData);
@ -71,8 +78,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 +98,17 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
downloadLink: documentOwnerDownloadLink,
});
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: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
{
@ -113,10 +116,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
address: owner.email,
},
],
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Signing Complete!`),
html,
text,
@ -170,19 +171,17 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
: undefined,
});
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: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
{
@ -190,10 +189,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
address: recipient.email,
},
],
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
replyTo: replyToEmail,
subject:
isDirectTemplate && document.documentMeta?.subject
? renderCustomEmailTemplate(document.documentMeta.subject, customEmailTemplate)

View File

@ -10,9 +10,8 @@ import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getEmailContext } from '../email/get-email-context';
export interface SendDeleteEmailOptions {
documentId: number;
@ -27,11 +26,6 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
include: {
user: true,
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -49,6 +43,15 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
return;
}
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const { email, name } = document.user;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
@ -59,30 +62,23 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
assetBaseUrl,
});
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: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance();
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: email,
name: name || '',
},
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
subject: i18n._(msg`Document Deleted!`),
html,
text,

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

@ -9,9 +9,8 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getEmailContext } from '../email/get-email-context';
export interface SendPendingEmailOptions {
documentId: number;
@ -35,11 +34,6 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
},
},
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -51,6 +45,15 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
throw new Error('Document has no recipients');
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentPending;
@ -70,30 +73,24 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
assetBaseUrl,
});
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: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Waiting for others to complete signing.`),
html,
text,

View File

@ -9,14 +9,13 @@ 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 { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getEmailContext } from '../email/get-email-context';
export type SuperDeleteDocumentOptions = {
id: number;
@ -32,11 +31,6 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
recipients: true,
documentMeta: true,
user: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -46,6 +40,15 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
});
}
const { branding, settings, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const { status, user } = document;
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
@ -72,30 +75,26 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
assetBaseUrl,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,

View File

@ -1,8 +1,8 @@
import { DocumentVisibility } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
@ -12,17 +12,19 @@ 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;
externalId?: string | null;
visibility?: DocumentVisibility | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
useLegacyFieldInsertion?: boolean;
};
requestMetadata: ApiRequestMetadata;
};
@ -34,34 +36,20 @@ export const updateDocument = async ({
data,
requestMetadata,
}: UpdateDocumentOptions) => {
const { documentWhereInput, team } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
include: {
team: {
select: {
members: {
where: {
userId,
},
organisation: {
select: {
role: true,
organisationClaim: true,
},
},
},
@ -75,45 +63,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.
@ -135,25 +120,20 @@ export const updateDocument = async ({
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
if (newGlobalActionAuth.length > 0 && !document.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const isTitleSame = data.title === undefined || data.title === document.title;
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
const isGlobalAccessSame =
documentGlobalAccessAuth === undefined || documentGlobalAccessAuth === newGlobalAccessAuth;
documentGlobalAccessAuth === undefined ||
isDeepEqual(documentGlobalAccessAuth, newGlobalAccessAuth);
const isGlobalActionSame =
documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth;
documentGlobalActionAuth === undefined ||
isDeepEqual(documentGlobalActionAuth, newGlobalActionAuth);
const isDocumentVisibilitySame =
data.visibility === undefined || data.visibility === document.visibility;
@ -236,7 +216,7 @@ export const updateDocument = async ({
}
// Early return if nothing is required.
if (auditLogs.length === 0) {
if (auditLogs.length === 0 && data.useLegacyFieldInsertion === undefined) {
return document;
}
@ -254,6 +234,7 @@ export const updateDocument = async ({
title: data.title,
externalId: data.externalId,
visibility: data.visibility as DocumentVisibility,
useLegacyFieldInsertion: data.useLegacyFieldInsertion,
authOptions,
},
});

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

@ -3,7 +3,6 @@ import { FieldType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TRecipientActionAuth } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { isRecipientAuthorized } from './is-recipient-authorized';
export type ValidateFieldAuthOptions = {
@ -26,14 +25,9 @@ export const validateFieldAuth = async ({
userId,
authOptions,
}: ValidateFieldAuthOptions) => {
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: documentAuthOptions,
recipientAuth: recipient.authOptions,
});
// Override all non-signature fields to not require any auth.
if (field.type !== FieldType.SIGNATURE) {
return null;
return undefined;
}
const isValid = await isRecipientAuthorized({
@ -50,5 +44,5 @@ export const validateFieldAuth = async ({
});
}
return derivedRecipientActionAuth;
return authOptions?.type;
};

View File

@ -15,7 +15,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type ViewedDocumentOptions = {
token: string;
recipientAccessAuth?: TDocumentAccessAuthTypes | null;
recipientAccessAuth?: TDocumentAccessAuthTypes[];
requestMetadata?: RequestMetadata;
};
@ -27,7 +27,6 @@ export const viewedDocument = async ({
const recipient = await prisma.recipient.findFirst({
where: {
token,
readStatus: ReadStatus.NOT_OPENED,
},
});
@ -37,6 +36,30 @@ export const viewedDocument = async ({
const { documentId } = recipient;
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED,
documentId,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
accessAuth: recipientAccessAuth ?? [],
},
}),
});
// Early return if already opened.
if (recipient.readStatus === ReadStatus.OPENED) {
return;
}
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
@ -63,7 +86,7 @@ export const viewedDocument = async ({
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
accessAuth: recipientAccessAuth || undefined,
accessAuth: recipientAccessAuth ?? [],
},
}),
});

View File

@ -0,0 +1,235 @@
import type { BrandingSettings } from '@documenso/email/providers/branding';
import { prisma } from '@documenso/prisma';
import type {
DocumentMeta,
EmailDomain,
Organisation,
OrganisationEmail,
OrganisationType,
} from '@documenso/prisma/client';
import {
EmailDomainStatus,
type OrganisationClaim,
type OrganisationGlobalSettings,
} from '@documenso/prisma/client';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
organisationGlobalSettingsToBranding,
teamGlobalSettingsToBranding,
} from '../../utils/team-global-settings-to-branding';
import { extractDerivedTeamSettings } from '../../utils/teams';
type EmailMetaOption = Partial<Pick<DocumentMeta, 'emailId' | 'emailReplyTo' | 'language'>>;
type BaseGetEmailContextOptions = {
/**
* The source to extract the email context from.
* - "Team" will use the team settings followed by the inherited organisation settings
* - "Organisation" will use the organisation settings
*/
source:
| {
type: 'team';
teamId: number;
}
| {
type: 'organisation';
organisationId: string;
};
/**
* The email type being sent, used to determine what email sender and language to use.
* - INTERNAL: Emails to users, such as team invites, etc.
* - RECIPIENT: Emails to recipients, such as document sent, document signed, etc.
*/
emailType: 'INTERNAL' | 'RECIPIENT';
};
type InternalGetEmailContextOptions = BaseGetEmailContextOptions & {
emailType: 'INTERNAL';
meta?: EmailMetaOption | null;
};
type RecipientGetEmailContextOptions = BaseGetEmailContextOptions & {
emailType: 'RECIPIENT';
/**
* Force meta options as a typesafe way to ensure developers don't forget to
* pass it in if it is available.
*/
meta: EmailMetaOption | null | undefined;
};
type GetEmailContextOptions = InternalGetEmailContextOptions | RecipientGetEmailContextOptions;
type EmailContextResponse = {
allowedEmails: OrganisationEmail[];
branding: BrandingSettings;
settings: Omit<OrganisationGlobalSettings, 'id'>;
claims: OrganisationClaim;
organisationType: OrganisationType;
senderEmail: {
name: string;
address: string;
};
replyToEmail: string | undefined;
emailLanguage: string;
};
export const getEmailContext = async (
options: GetEmailContextOptions,
): Promise<EmailContextResponse> => {
const { source, meta } = options;
let emailContext: Omit<EmailContextResponse, 'senderEmail' | 'replyToEmail' | 'emailLanguage'>;
if (source.type === 'organisation') {
emailContext = await handleOrganisationEmailContext(source.organisationId);
} else {
emailContext = await handleTeamEmailContext(source.teamId);
}
const emailLanguage = meta?.language || emailContext.settings.documentLanguage;
// Immediate return for internal emails.
if (options.emailType === 'INTERNAL') {
return {
...emailContext,
senderEmail: DOCUMENSO_INTERNAL_EMAIL,
replyToEmail: undefined,
emailLanguage, // Not sure if we want to use this for internal emails.
};
}
const replyToEmail = meta?.emailReplyTo || emailContext.settings.emailReplyTo || undefined;
const senderEmailId = meta?.emailId === null ? null : emailContext.settings.emailId;
const foundSenderEmail = emailContext.allowedEmails.find((email) => email.id === senderEmailId);
// Reset the emailId to null if not found.
if (!foundSenderEmail) {
emailContext.settings.emailId = null;
}
const senderEmail = foundSenderEmail
? {
name: foundSenderEmail.emailName,
address: foundSenderEmail.email,
}
: DOCUMENSO_INTERNAL_EMAIL;
return {
...emailContext,
senderEmail,
replyToEmail,
emailLanguage,
};
};
const handleOrganisationEmailContext = async (organisationId: string) => {
const organisation = await prisma.organisation.findFirst({
where: {
id: organisationId,
},
include: {
organisationClaim: true,
organisationGlobalSettings: true,
emailDomains: {
omit: {
privateKey: true,
},
include: {
emails: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const claims = organisation.organisationClaim;
const allowedEmails = getAllowedEmails(organisation);
return {
allowedEmails,
branding: organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
claims.flags.hidePoweredBy ?? false,
),
settings: organisation.organisationGlobalSettings,
claims,
organisationType: organisation.type,
};
};
const handleTeamEmailContext = async (teamId: number) => {
const team = await prisma.team.findFirst({
where: {
id: teamId,
},
include: {
teamGlobalSettings: true,
organisation: {
include: {
organisationClaim: true,
organisationGlobalSettings: true,
emailDomains: {
omit: {
privateKey: true,
},
include: {
emails: true,
},
},
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const organisation = team.organisation;
const claims = organisation.organisationClaim;
const allowedEmails = getAllowedEmails(organisation);
const teamSettings = extractDerivedTeamSettings(
organisation.organisationGlobalSettings,
team.teamGlobalSettings,
);
return {
allowedEmails,
branding: teamGlobalSettingsToBranding(
teamSettings,
teamId,
claims.flags.hidePoweredBy ?? false,
),
settings: teamSettings,
claims,
organisationType: organisation.type,
};
};
const getAllowedEmails = (
organisation: Organisation & {
emailDomains: (Pick<EmailDomain, 'status'> & { emails: OrganisationEmail[] })[];
organisationClaim: OrganisationClaim;
},
) => {
if (!organisation.organisationClaim.flags.emailDomains) {
return [];
}
return organisation.emailDomains
.filter((emailDomain) => emailDomain.status === EmailDomainStatus.ACTIVE)
.flatMap((emailDomain) => emailDomain.emails);
};

View File

@ -27,7 +27,6 @@ export const createEmbeddingPresignToken = async ({
// In development mode, allow setting expiresIn to 0 for testing
// In production, enforce a minimum expiration time
const isDevelopment = env('NODE_ENV') !== 'production';
console.log('isDevelopment', isDevelopment);
const minExpirationMinutes = isDevelopment ? 0 : 5;
// Ensure expiresIn is at least the minimum allowed value

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
@ -10,6 +11,7 @@ import { validateTextField } from '@documenso/lib/advanced-fields-validation/val
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import { prisma } from '@documenso/prisma';
import { AUTO_SIGNABLE_FIELD_TYPES } from '../../constants/autosign';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
@ -205,6 +207,29 @@ export const signFieldWithToken = async ({
throw new Error('Typed signatures are not allowed. Please draw your signature');
}
if (field.fieldMeta?.readOnly && !AUTO_SIGNABLE_FIELD_TYPES.includes(field.type)) {
// !: This is a bit of a hack at the moment, readonly fields with default values
// !: should be inserted with their default value on document creation instead of
// !: this weird programattic approach. Until that's fixed though this will verify
// !: that the programmatic signed value is only that of its default.
const isAutomaticSigningValueValid = match(field.fieldMeta)
.with({ type: 'text' }, (meta) => customText === meta.text)
.with({ type: 'number' }, (meta) => customText === meta.value)
.with({ type: 'checkbox' }, (meta) =>
isDeepEqual(
fromCheckboxValue(customText ?? ''),
meta.values?.filter((v) => v.checked).map((v) => v.value) ?? [],
),
)
.with({ type: 'radio' }, (meta) => customText === meta.values?.find((v) => v.checked)?.value)
.with({ type: 'dropdown' }, (meta) => customText === meta.defaultValue)
.otherwise(() => false);
if (!isAutomaticSigningValueValid) {
throw new Error('Field is read only and only accepts its default value for signing.');
}
}
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
return await prisma.$transaction(async (tx) => {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,38 @@
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { getTeamById } from '../team/get-team';
import { getTeamSettings } from '../team/get-team-settings';
export interface CreateFolderOptions {
userId: number;
teamId: number;
name: string;
parentId?: string | null;
type?: TFolderType;
}
export const createFolder = async ({
userId,
teamId,
name,
parentId,
type = FolderType.DOCUMENT,
}: CreateFolderOptions) => {
const team = await getTeamById({ userId, teamId });
const settings = await getTeamSettings({ userId, teamId });
return await prisma.folder.create({
data: {
name,
userId,
teamId,
parentId,
type,
visibility: determineDocumentVisibility(settings.documentVisibility, team.currentTeamRole),
},
});
};

View File

@ -0,0 +1,57 @@
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export interface DeleteFolderOptions {
userId: number;
teamId: number;
folderId: string;
}
export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOptions) => {
const team = await getTeamById({ userId, teamId });
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
include: {
documents: true,
subfolders: true,
templates: true,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
const hasPermission = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => folder.visibility !== DocumentVisibility.ADMIN)
.with(TeamMemberRole.MEMBER, () => folder.visibility === DocumentVisibility.EVERYONE)
.otherwise(() => false);
if (!hasPermission) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to delete this folder',
});
}
return await prisma.folder.delete({
where: {
id: folderId,
},
});
};

View File

@ -0,0 +1,122 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
import { getTeamById } from '../team/get-team';
export interface FindFoldersOptions {
userId: number;
teamId: number;
parentId?: string | null;
type?: TFolderType;
}
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => {
const team = await getTeamById({ userId, teamId });
const visibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const whereClause = {
AND: [
{ parentId },
{
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
},
],
};
try {
const folders = await prisma.folder.findMany({
where: {
...whereClause,
...(type ? { type } : {}),
},
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
});
const foldersWithDetails = await Promise.all(
folders.map(async (folder) => {
try {
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
prisma.folder.findMany({
where: {
parentId: folder.id,
teamId,
...visibilityFilters,
},
orderBy: {
createdAt: 'desc',
},
}),
prisma.document.count({
where: {
folderId: folder.id,
},
}),
prisma.template.count({
where: {
folderId: folder.id,
},
}),
prisma.folder.count({
where: {
parentId: folder.id,
teamId,
...visibilityFilters,
},
}),
]);
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
...subfolder,
subfolders: [],
_count: {
documents: 0,
templates: 0,
subfolders: 0,
},
}));
return {
...folder,
subfolders: subfoldersWithEmptySubfolders,
_count: {
documents: documentCount,
templates: templateCount,
subfolders: subfolderCount,
},
};
} catch (error) {
console.error('Error processing folder:', folder.id, error);
throw error;
}
}),
);
return foldersWithDetails;
} catch (error) {
console.error('Error in findFolders:', error);
throw error;
}
};

View File

@ -0,0 +1,79 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
import { getTeamById } from '../team/get-team';
export interface GetFolderBreadcrumbsOptions {
userId: number;
teamId: number;
folderId: string;
type?: TFolderType;
}
export const getFolderBreadcrumbs = async ({
userId,
teamId,
folderId,
type,
}: GetFolderBreadcrumbsOptions) => {
const team = await getTeamById({ userId, teamId });
const visibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const whereClause = (folderId: string) => ({
id: folderId,
...(type ? { type } : {}),
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
});
const breadcrumbs = [];
let currentFolderId = folderId;
const currentFolder = await prisma.folder.findFirst({
where: whereClause(currentFolderId),
});
if (!currentFolder) {
return [];
}
breadcrumbs.push(currentFolder);
while (currentFolder?.parentId) {
const parentFolder = await prisma.folder.findFirst({
where: whereClause(currentFolder.parentId),
});
if (!parentFolder) {
break;
}
breadcrumbs.unshift(parentFolder);
currentFolderId = parentFolder.id;
currentFolder.parentId = parentFolder.parentId;
}
return breadcrumbs;
};

View File

@ -0,0 +1,58 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
import { getTeamById } from '../team/get-team';
export interface GetFolderByIdOptions {
userId: number;
teamId: number;
folderId?: string;
type?: TFolderType;
}
export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => {
const team = await getTeamById({ userId, teamId });
const visibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const whereClause = {
id: folderId,
...(type ? { type } : {}),
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
};
const folder = await prisma.folder.findFirst({
where: whereClause,
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return folder;
};

View File

@ -0,0 +1,92 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { FolderType } from '@documenso/lib/types/folder-type';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { getTeamById } from '../team/get-team';
export interface MoveDocumentToFolderOptions {
userId: number;
teamId: number;
documentId: number;
folderId?: string | null;
requestMetadata?: ApiRequestMetadata;
}
export const moveDocumentToFolder = async ({
userId,
teamId,
documentId,
folderId,
}: MoveDocumentToFolderOptions) => {
const team = await getTeamById({ userId, teamId });
const visibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const documentWhereClause = {
id: documentId,
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
};
const document = await prisma.document.findFirst({
where: documentWhereClause,
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (folderId) {
const folderWhereClause = {
id: folderId,
type: FolderType.DOCUMENT,
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
};
const folder = await prisma.folder.findFirst({
where: folderWhereClause,
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
return await prisma.document.update({
where: {
id: documentId,
},
data: {
folderId,
},
});
};

View File

@ -0,0 +1,89 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface MoveFolderOptions {
userId: number;
teamId?: number;
folderId?: string;
parentId?: string | null;
requestMetadata?: ApiRequestMetadata;
}
export const moveFolder = async ({ userId, teamId, folderId, parentId }: MoveFolderOptions) => {
return await prisma.$transaction(async (tx) => {
const folder = await tx.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
if (parentId) {
const parentFolder = await tx.folder.findFirst({
where: {
id: parentId,
userId,
teamId,
type: folder.type,
},
});
if (!parentFolder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
if (parentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into itself',
});
}
let currentParentId = parentFolder.parentId;
while (currentParentId) {
if (currentParentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into its descendant',
});
}
const currentParent = await tx.folder.findUnique({
where: {
id: currentParentId,
},
select: {
parentId: true,
},
});
if (!currentParent) {
break;
}
currentParentId = currentParent.parentId;
}
}
return await tx.folder.update({
where: {
id: folderId,
},
data: {
parentId,
},
});
});
};

View File

@ -0,0 +1,63 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface MoveTemplateToFolderOptions {
userId: number;
teamId?: number;
templateId: number;
folderId?: string | null;
}
export const moveTemplateToFolder = async ({
userId,
teamId,
templateId,
folderId,
}: MoveTemplateToFolderOptions) => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
if (folderId !== null) {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
type: FolderType.TEMPLATE,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
return await prisma.template.update({
where: {
id: templateId,
},
data: {
folderId,
},
});
};

View File

@ -0,0 +1,40 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface PinFolderOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const pinFolder = async ({ userId, teamId, folderId, type }: PinFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
pinned: true,
},
});
};

View File

@ -0,0 +1,40 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface UnpinFolderOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const unpinFolder = async ({ userId, teamId, folderId, type }: UnpinFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
pinned: false,
},
});
};

View File

@ -0,0 +1,56 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '@documenso/prisma/generated/types';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface UpdateFolderOptions {
userId: number;
teamId?: number;
folderId: string;
name: string;
visibility: DocumentVisibility;
type?: TFolderType;
}
export const updateFolder = async ({
userId,
teamId,
folderId,
name,
visibility,
type,
}: UpdateFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
const isTemplateFolder = folder.type === FolderType.TEMPLATE;
const effectiveVisibility =
isTemplateFolder && teamId !== null ? DocumentVisibility.EVERYONE : visibility;
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
name,
visibility: effectiveVisibility,
},
});
};

View File

@ -0,0 +1,83 @@
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
import { env } from '../../utils/env';
import { encryptSecondaryData } from '../crypto/encrypt';
export type GetAuditLogsPdfOptions = {
documentId: number;
// eslint-disable-next-line @typescript-eslint/ban-types
language?: SupportedLanguageCodes | (string & {});
};
export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfOptions) => {
const { chromium } = await import('playwright');
const encryptedId = encryptSecondaryData({
data: documentId.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
});
let browser: Browser;
const browserlessUrl = env('NEXT_PRIVATE_BROWSERLESS_URL');
if (browserlessUrl) {
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
browser = await chromium.connectOverCDP(browserlessUrl);
} else {
browser = await chromium.launch();
}
if (!browser) {
throw new Error(
'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed',
);
}
const browserContext = await browser.newContext();
const page = await browserContext.newPage();
const lang = isValidLanguageCode(language) ? language : 'en';
await page.context().addCookies([
{
name: 'language',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
]);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
// !: This is a workaround to ensure the page is loaded correctly.
// !: It's not clear why but suddenly browserless cdp connections would
// !: cause the page to render blank until a reload is performed.
await page.reload({
waitUntil: 'networkidle',
timeout: 10_000,
});
await page.waitForSelector('h1', {
state: 'visible',
timeout: 10_000,
});
const result = await page.pdf({
format: 'A4',
printBackground: true,
});
await browserContext.close();
void browser.close();
return result;
};

View File

@ -46,7 +46,7 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
await page.context().addCookies([
{
name: 'language',
name: 'lang',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
@ -57,8 +57,22 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
timeout: 10_000,
});
// !: This is a workaround to ensure the page is loaded correctly.
// !: It's not clear why but suddenly browserless cdp connections would
// !: cause the page to render blank until a reload is performed.
await page.reload({
waitUntil: 'networkidle',
timeout: 10_000,
});
await page.waitForSelector('h1', {
state: 'visible',
timeout: 10_000,
});
const result = await page.pdf({
format: 'A4',
printBackground: true,
});
await browserContext.close();

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import fontkit from '@pdf-lib/fontkit';
import type { PDFField, PDFWidgetAnnotation } from 'pdf-lib';
import {
PDFCheckBox,
@ -13,6 +14,8 @@ import {
translate,
} from 'pdf-lib';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export const removeOptionalContentGroups = (document: PDFDocument) => {
const context = document.context;
const catalog = context.lookup(context.trailerInfo.Root);
@ -21,12 +24,20 @@ export const removeOptionalContentGroups = (document: PDFDocument) => {
}
};
export const flattenForm = (document: PDFDocument) => {
export const flattenForm = async (document: PDFDocument) => {
removeOptionalContentGroups(document);
const form = document.getForm();
form.updateFieldAppearances();
const fontNoto = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
async (res) => res.arrayBuffer(),
);
document.registerFontkit(fontkit);
const font = await document.embedFont(fontNoto);
form.updateFieldAppearances(font);
for (const field of form.getFields()) {
for (const widget of field.acroField.getWidgets()) {

View File

@ -1,8 +1,15 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { FieldType } from '@prisma/client';
import type { PDFDocument } from 'pdf-lib';
import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib';
import type { PDFDocument, PDFFont, PDFTextField } from 'pdf-lib';
import {
RotationTypes,
TextAlignment,
degrees,
radiansToDegrees,
rgb,
setFontAndSize,
} from 'pdf-lib';
import { P, match } from 'ts-pattern';
import {
@ -34,6 +41,13 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
]);
const isSignatureField = isSignatureFieldType(field.type);
/**
* Red box is the original field width, height and position.
*
* Blue box is the adjusted field width, height and position. It will represent
* where the text will overflow into.
*/
const isDebugMode =
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
@ -226,30 +240,79 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
}));
const selected: string[] = fromCheckboxValue(field.customText);
const direction = meta.data.direction ?? 'vertical';
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const topPadding = 12;
const leftCheckboxPadding = 8;
const leftCheckboxLabelPadding = 12;
const checkboxSpaceY = 13;
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
if (direction === 'horizontal') {
// Horizontal layout: arrange checkboxes side by side with wrapping
let currentX = leftCheckboxPadding;
let currentY = topPadding;
const maxWidth = pageWidth - fieldX - leftCheckboxPadding * 2;
if (selected.includes(item.value)) {
checkbox.check();
for (const [index, item] of (values ?? []).entries()) {
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
if (selected.includes(item.value)) {
checkbox.check();
}
const labelText = item.value.includes('empty-value-') ? '' : item.value;
const labelWidth = font.widthOfTextAtSize(labelText, 12);
const itemWidth = leftCheckboxLabelPadding + labelWidth + 16; // checkbox + padding + label + margin
// Check if item fits on current line, if not wrap to next line
if (currentX + itemWidth > maxWidth && index > 0) {
currentX = leftCheckboxPadding;
currentY += checkboxSpaceY;
}
page.drawText(labelText, {
x: fieldX + currentX + leftCheckboxLabelPadding,
y: pageHeight - (fieldY + currentY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
checkbox.addToPage(page, {
x: fieldX + currentX,
y: pageHeight - (fieldY + currentY),
height: 8,
width: 8,
});
currentX += itemWidth;
}
} else {
// Vertical layout: original behavior
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * checkboxSpaceY + topPadding;
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
checkbox.addToPage(page, {
x: fieldX,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
if (selected.includes(item.value)) {
checkbox.check();
}
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + leftCheckboxPadding + leftCheckboxLabelPadding,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
checkbox.addToPage(page, {
x: fieldX + leftCheckboxPadding,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
}
}
})
.with({ type: FieldType.RADIO }, (field) => {
@ -268,21 +331,28 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const selected = field.customText.split(',');
const topPadding = 12;
const leftRadioPadding = 8;
const leftRadioLabelPadding = 12;
const radioSpaceY = 13;
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const offsetY = index * radioSpaceY + topPadding;
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
// Draw label.
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
x: fieldX + leftRadioPadding + leftRadioLabelPadding,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
// Draw radio button.
radio.addOptionToPage(item.value, page, {
x: fieldX,
x: fieldX + leftRadioPadding,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
@ -304,61 +374,145 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
} as const;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const Parser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
const fieldMetaParser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
const meta = fieldMetaParser ? fieldMetaParser.safeParse(field.fieldMeta) : null;
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'left';
let fontSize = customFontSize || maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const textHeight = font.heightAtSize(fontSize);
// Scale font only if no custom font and height exceeds field height.
if (!customFontSize) {
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
const scalingFactor = Math.min(fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
}
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
/**
* Calculate whether the field should be multiline.
*
* - True = text will overflow downwards.
* - False = text will overflow sideways.
*/
const isMultiline =
field.type === FieldType.TEXT &&
(textWidth > fieldWidth || field.customText.includes('\n'));
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
const padding = 8; // PDF points, roughly equivalent to 0.5rem
const padding = 8;
// Calculate X position based on text alignment with padding
let textX = fieldX + padding; // Left alignment starts after padding
if (textAlign === 'center') {
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
} else if (textAlign === 'right') {
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
}
let textY = fieldY + (fieldHeight - textHeight) / 2;
const textAlignmentOptions = getTextAlignmentOptions(textAlign, fieldX, isMultiline, padding);
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
let textFieldBoxY = pageHeight - fieldY - fieldHeight;
const textFieldBoxX = textAlignmentOptions.xPos;
const textField = pdf.getForm().createTextField(`text.${field.secondaryId}`);
textField.setAlignment(textAlignmentOptions.textAlignment);
/**
* From now on we will adjust the field size and position so the text
* overflows correctly in the X or Y axis depending on the field type.
*/
let adjustedFieldWidth = fieldWidth - padding * 2; //
let adjustedFieldHeight = fieldHeight;
let adjustedFieldX = textFieldBoxX;
let adjustedFieldY = textFieldBoxY;
let textToInsert = field.customText;
// The padding to use when fields go off the page.
const pagePadding = 4;
// Handle multiline text, which will overflow on the Y axis.
if (isMultiline) {
textToInsert = breakLongString(textToInsert, adjustedFieldWidth, font, fontSize);
textField.enableMultiline();
textField.disableCombing();
textField.disableScrolling();
// Adjust the textFieldBox so it extends to the bottom of the page so text can wrap.
textFieldBoxY = pageHeight - fieldY - fieldHeight;
// Calculate how much PX from the current field to bottom of the page.
const fieldYOffset = pageHeight - (fieldY + fieldHeight) - pagePadding;
// Field height will be from current to bottom of page.
adjustedFieldHeight = fieldHeight + fieldYOffset;
// Need to move the field Y so it offsets the new field height.
adjustedFieldY = adjustedFieldY - fieldYOffset;
}
// Handle non-multiline text, which will overflow on the X axis.
if (!isMultiline) {
// Left align will extend all the way to the right of the page
if (textAlignmentOptions.textAlignment === TextAlignment.Left) {
adjustedFieldWidth = pageWidth - textFieldBoxX - pagePadding;
}
// Right align will extend all the way to the left of the page.
if (textAlignmentOptions.textAlignment === TextAlignment.Right) {
adjustedFieldWidth = textFieldBoxX + fieldWidth - pagePadding;
adjustedFieldX = adjustedFieldX - adjustedFieldWidth + fieldWidth;
}
// Center align will extend to the closest page edge, then use that * 2 as the width.
if (textAlignmentOptions.textAlignment === TextAlignment.Center) {
const fieldMidpoint = textFieldBoxX + fieldWidth / 2;
const isCloserToLeftEdge = fieldMidpoint < pageWidth / 2;
// If field is closer to left edge, the width must be based of the left.
if (isCloserToLeftEdge) {
adjustedFieldWidth = (textFieldBoxX - pagePadding) * 2 + fieldWidth;
adjustedFieldX = pagePadding;
}
// If field is closer to right edge, the width must be based of the right
if (!isCloserToLeftEdge) {
adjustedFieldWidth = (pageWidth - textFieldBoxX - pagePadding - fieldWidth / 2) * 2;
adjustedFieldX = pageWidth - adjustedFieldWidth - pagePadding;
}
}
}
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
adjustedFieldX,
adjustedFieldY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
adjustedFieldX = adjustedPosition.xPos;
adjustedFieldY = adjustedPosition.yPos;
}
page.drawText(field.customText, {
x: textX,
y: textY,
size: fontSize,
font,
// Set properties for the text field
setTextFieldFontSize(textField, font, fontSize);
textField.setText(textToInsert);
// Set the position and size of the text field
textField.addToPage(page, {
x: adjustedFieldX,
y: adjustedFieldY,
width: adjustedFieldWidth,
height: adjustedFieldHeight,
rotate: degrees(pageRotationInDegrees),
font,
// Hide borders.
borderWidth: 0,
borderColor: undefined,
backgroundColor: undefined,
...(isDebugMode ? { borderWidth: 1, borderColor: rgb(0, 0, 1) } : {}),
});
});
@ -393,3 +547,156 @@ const adjustPositionForRotation = (
yPos,
};
};
const textAlignmentMap = {
left: TextAlignment.Left,
center: TextAlignment.Center,
right: TextAlignment.Right,
} as const;
/**
* Get the PDF-lib alignment position, and the X position of the field with padding included.
*
* @param textAlign - The text alignment of the field.
* @param fieldX - The X position of the field.
* @param isMultiline - Whether the field is multiline.
* @param padding - The padding of the field. Defaults to 8.
*
* @returns The X position and text alignment for the field.
*/
const getTextAlignmentOptions = (
textAlign: 'left' | 'center' | 'right',
fieldX: number,
isMultiline: boolean,
padding: number = 8,
) => {
const textAlignment = textAlignmentMap[textAlign];
// For multiline, it needs to be centered so we just basic left padding.
if (isMultiline) {
return {
xPos: fieldX + padding,
textAlignment,
};
}
return match(textAlign)
.with('left', () => ({
xPos: fieldX + padding,
textAlignment,
}))
.with('center', () => ({
xPos: fieldX,
textAlignment,
}))
.with('right', () => ({
xPos: fieldX - padding,
textAlignment,
}))
.exhaustive();
};
/**
* Break a long string into multiple lines so it fits within a given width,
* using natural word breaking similar to word processors.
*
* - Keeps words together when possible
* - Only breaks words when they're too long to fit on a line
* - Handles whitespace intelligently
*
* @param text - The text to break into lines
* @param maxWidth - The maximum width of each line in PX
* @param font - The PDF font object
* @param fontSize - The font size in points
* @returns Object containing the result string and line count
*/
function breakLongString(text: string, maxWidth: number, font: PDFFont, fontSize: number): string {
// Handle empty text
if (!text) {
return '';
}
const lines: string[] = [];
// Process each original line separately to preserve newlines
for (const paragraph of text.split('\n')) {
// If paragraph fits on one line or is empty, add it as-is
if (paragraph === '' || font.widthOfTextAtSize(paragraph, fontSize) <= maxWidth) {
lines.push(paragraph);
continue;
}
// Split paragraph into words
const words = paragraph.split(' ');
let currentLine = '';
for (const word of words) {
// Check if adding word to current line would exceed max width
const lineWithWord = currentLine.length === 0 ? word : `${currentLine} ${word}`;
if (font.widthOfTextAtSize(lineWithWord, fontSize) <= maxWidth) {
// Word fits, add it to current line
currentLine = lineWithWord;
} else {
// Word doesn't fit on current line
// First, save current line if it's not empty
if (currentLine.length > 0) {
lines.push(currentLine);
currentLine = '';
}
// Check if word fits on a line by itself
if (font.widthOfTextAtSize(word, fontSize) <= maxWidth) {
// Word fits on its own line
currentLine = word;
} else {
// Word is too long, need to break it character by character
let charLine = '';
// Process each character in the word
for (const char of word) {
const nextCharLine = charLine + char;
if (font.widthOfTextAtSize(nextCharLine, fontSize) <= maxWidth) {
// Character fits, add it
charLine = nextCharLine;
} else {
// Character doesn't fit, push current charLine and start a new one
lines.push(charLine);
charLine = char;
}
}
// Add any remaining characters as the current line
currentLine = charLine;
}
}
}
// Add the last line if not empty
if (currentLine.length > 0) {
lines.push(currentLine);
}
}
return lines.join('\n');
}
const setTextFieldFontSize = (textField: PDFTextField, font: PDFFont, fontSize: number) => {
textField.defaultUpdateAppearances(font);
textField.updateAppearances(font);
try {
textField.setFontSize(fontSize);
} catch (err) {
let da = textField.acroField.getDefaultAppearance() ?? '';
da += `\n ${setFontAndSize(font.name, fontSize)}`;
textField.acroField.setDefaultAppearance(da);
}
textField.defaultUpdateAppearances(font);
textField.updateAppearances(font);
};

View File

@ -0,0 +1,395 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { FieldType } from '@prisma/client';
import type { PDFDocument } from 'pdf-lib';
import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib';
import { P, match } from 'ts-pattern';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
DEFAULT_STANDARD_FONT_SIZE,
MIN_HANDWRITING_FONT_SIZE,
MIN_STANDARD_FONT_SIZE,
} from '@documenso/lib/constants/pdf';
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
ZCheckboxFieldMeta,
ZDateFieldMeta,
ZEmailFieldMeta,
ZInitialsFieldMeta,
ZNameFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
]);
const isSignatureField = isSignatureFieldType(field.type);
const isDebugMode =
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
pdf.registerFontkit(fontkit);
const pages = pdf.getPages();
const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
const page = pages.at(field.page - 1);
if (!page) {
throw new Error(`Page ${field.page} does not exist`);
}
const pageRotation = page.getRotation();
let pageRotationInDegrees = match(pageRotation.type)
.with(RotationTypes.Degrees, () => pageRotation.angle)
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
.exhaustive();
// Round to the closest multiple of 90 degrees.
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
//
// To account for this, we swap the width and height for pages that are rotated by 90/270
// degrees. This is so we can calculate the virtual position the field was placed if it
// was correctly oriented in the frontend.
//
// Then when we insert the fields, we apply a transformation to the position of the field
// so it is rotated correctly.
if (isPageRotatedToLandscape) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
}
const fieldWidth = pageWidth * (Number(field.width) / 100);
const fieldHeight = pageHeight * (Number(field.height) / 100);
const fieldX = pageWidth * (Number(field.positionX) / 100);
const fieldY = pageHeight * (Number(field.positionY) / 100);
// Draw debug box if debug mode is enabled
if (isDebugMode) {
let debugX = fieldX;
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
debugX,
debugY,
pageRotationInDegrees,
);
debugX = adjustedPosition.xPos;
debugY = adjustedPosition.yPos;
}
page.drawRectangle({
x: debugX,
y: debugY,
width: fieldWidth,
height: fieldHeight,
borderColor: rgb(1, 0, 0), // Red
borderWidth: 1,
rotate: degrees(pageRotationInDegrees),
});
}
const font = await pdf.embedFont(
isSignatureField ? fontCaveat : fontNoto,
isSignatureField ? { features: { calt: false } } : undefined,
);
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
await pdf.embedFont(fontCaveat);
}
await match(field)
.with(
{
type: P.union(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE),
},
async (field) => {
if (field.signature?.signatureImageAsBase64) {
const image = await pdf.embedPng(field.signature?.signatureImageAsBase64 ?? '');
let imageWidth = image.width;
let imageHeight = image.height;
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
imageY = pageHeight - imageY - imageHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
imageX,
imageY,
pageRotationInDegrees,
);
imageX = adjustedPosition.xPos;
imageY = adjustedPosition.yPos;
}
page.drawImage(image, {
x: imageX,
y: imageY,
width: imageWidth,
height: imageHeight,
rotate: degrees(pageRotationInDegrees),
});
} else {
const signatureText = field.signature?.typedSignature ?? '';
const longestLineInTextForWidth = signatureText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let fontSize = maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
let textHeight = font.heightAtSize(fontSize);
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
textHeight = font.heightAtSize(fontSize);
let textX = fieldX + (fieldWidth - textWidth) / 2;
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
}
page.drawText(signatureText, {
x: textX,
y: textY,
size: fontSize,
font,
rotate: degrees(pageRotationInDegrees),
});
}
},
)
.with({ type: FieldType.CHECKBOX }, (field) => {
const meta = ZCheckboxFieldMeta.safeParse(field.fieldMeta);
if (!meta.success) {
console.error(meta.error);
throw new Error('Invalid checkbox field meta');
}
const values = meta.data.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const selected: string[] = fromCheckboxValue(field.customText);
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
if (selected.includes(item.value)) {
checkbox.check();
}
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
checkbox.addToPage(page, {
x: fieldX,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
}
})
.with({ type: FieldType.RADIO }, (field) => {
const meta = ZRadioFieldMeta.safeParse(field.fieldMeta);
if (!meta.success) {
console.error(meta.error);
throw new Error('Invalid radio field meta');
}
const values = meta?.data.values?.map((item) => ({
...item,
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
}));
const selected = field.customText.split(',');
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * 16;
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + 16,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
radio.addOptionToPage(item.value, page, {
x: fieldX,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
if (selected.includes(item.value)) {
radio.select(item.value);
}
}
})
.otherwise((field) => {
const fieldMetaParsers = {
[FieldType.TEXT]: ZTextFieldMeta,
[FieldType.NUMBER]: ZNumberFieldMeta,
[FieldType.DATE]: ZDateFieldMeta,
[FieldType.EMAIL]: ZEmailFieldMeta,
[FieldType.NAME]: ZNameFieldMeta,
[FieldType.INITIALS]: ZInitialsFieldMeta,
} as const;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const Parser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
const longestLineInTextForWidth = field.customText
.split('\n')
.sort((a, b) => b.length - a.length)[0];
let fontSize = customFontSize || maxFontSize;
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
const textHeight = font.heightAtSize(fontSize);
if (!customFontSize) {
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
}
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
const padding = 8; // PDF points, roughly equivalent to 0.5rem
// Calculate X position based on text alignment with padding
let textX = fieldX + padding; // Left alignment starts after padding
if (textAlign === 'center') {
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
} else if (textAlign === 'right') {
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
}
let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
textX,
textY,
pageRotationInDegrees,
);
textX = adjustedPosition.xPos;
textY = adjustedPosition.yPos;
}
page.drawText(field.customText, {
x: textX,
y: textY,
size: fontSize,
font,
rotate: degrees(pageRotationInDegrees),
});
});
return pdf;
};
const adjustPositionForRotation = (
pageWidth: number,
pageHeight: number,
xPos: number,
yPos: number,
pageRotationInDegrees: number,
) => {
if (pageRotationInDegrees === 270) {
xPos = pageWidth - xPos;
[xPos, yPos] = [yPos, xPos];
}
if (pageRotationInDegrees === 90) {
yPos = pageHeight - yPos;
[xPos, yPos] = [yPos, xPos];
}
// Invert all the positions since it's rotated by 180 degrees.
if (pageRotationInDegrees === 180) {
xPos = pageWidth - xPos;
yPos = pageHeight - yPos;
}
return {
xPos,
yPos,
};
};

View File

@ -11,7 +11,7 @@ export const normalizePdf = async (pdf: Buffer) => {
}
removeOptionalContentGroups(pdfDoc);
flattenForm(pdfDoc);
await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc);
return Buffer.from(await pdfDoc.save());

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +0,0 @@
import { prisma } from '@documenso/prisma';
export type GetApiTokenByIdOptions = {
id: number;
userId: number;
};
export const getApiTokenById = async ({ id, userId }: GetApiTokenByIdOptions) => {
return await prisma.apiToken.findFirstOrThrow({
where: {
id,
userId,
},
});
};

View File

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

View File

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

View File

@ -1,7 +1,6 @@
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
@ -12,18 +11,19 @@ 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;
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
requestMetadata: ApiRequestMetadata;
}
@ -35,27 +35,25 @@ export const createDocumentRecipients = async ({
recipients: recipientsToCreate,
requestMetadata,
}: CreateDocumentRecipientsOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -71,20 +69,15 @@ export const createDocumentRecipients = async ({
});
}
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipientsToCreate.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
@ -110,8 +103,8 @@ export const createDocumentRecipients = async ({
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null,
actionAuth: recipient.actionAuth || null,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const createdRecipient = await tx.recipient.create({
@ -140,8 +133,8 @@ export const createDocumentRecipients = async ({
recipientName: createdRecipient.name,
recipientId: createdRecipient.id,
recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth || undefined,
actionAuth: recipient.actionAuth || undefined,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
},
}),
});

View File

@ -1,7 +1,6 @@
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
@ -9,18 +8,19 @@ 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;
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
@ -33,24 +33,19 @@ export const createTemplateRecipients = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
team: buildTeamWhereQuery({ teamId, userId }),
},
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -60,20 +55,15 @@ export const createTemplateRecipients = async ({
});
}
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipientsToCreate.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
@ -99,8 +89,8 @@ export const createTemplateRecipients = async ({
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null,
actionAuth: recipient.actionAuth || null,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const createdRecipient = await tx.recipient.create({

View File

@ -11,15 +11,16 @@ 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 { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
export interface DeleteDocumentRecipientOptions {
userId: number;
teamId?: number;
teamId: number;
recipientId: number;
requestMetadata: ApiRequestMetadata;
}
@ -37,21 +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,
@ -137,22 +124,29 @@ export const deleteDocumentRecipient = async ({
assetBaseUrl,
});
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipientToDelete.email,
name: recipientToDelete.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,8 +4,8 @@ import { msg } from '@lingui/core/macro';
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { mailer } from '@documenso/email/mailer';
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -25,16 +25,16 @@ 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 { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { canRecipientBeModified } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getDocumentWhereInput } from '../document/get-document-by-id';
import { getEmailContext } from '../email/get-email-context';
export interface SetDocumentRecipientsOptions {
userId: number;
teamId?: number;
teamId: number;
documentId: number;
recipients: RecipientData[];
requestMetadata: ApiRequestMetadata;
@ -47,31 +47,24 @@ export const setDocumentRecipients = async ({
recipients,
requestMetadata,
}: SetDocumentRecipientsOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
include: {
fields: true,
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
@ -96,20 +89,24 @@ export const setDocumentRecipients = async ({
throw new Error('Document already complete');
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta: document.documentMeta,
});
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const normalizedRecipients = recipients.map((recipient) => ({
@ -137,6 +134,9 @@ export const setDocumentRecipients = async ({
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
const canPersistedRecipientBeModified =
existing && canRecipientBeModified(existing, document.fields);
if (
existing &&
hasRecipientBeenChanged(existing, recipient) &&
@ -150,6 +150,7 @@ export const setDocumentRecipients = async ({
return {
...recipient,
_persisted: existing,
canPersistedRecipientBeModified,
};
});
@ -165,6 +166,13 @@ export const setDocumentRecipients = async ({
});
}
if (recipient._persisted && !recipient.canPersistedRecipientBeModified) {
return {
...recipient._persisted,
clientId: recipient.clientId,
};
}
const upsertedRecipient = await tx.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
@ -245,14 +253,17 @@ export const setDocumentRecipients = async ({
metadata: requestMetadata,
data: {
...baseAuditLog,
accessAuth: recipient.accessAuth || undefined,
actionAuth: recipient.actionAuth || undefined,
accessAuth: recipient.accessAuth || [],
actionAuth: recipient.actionAuth || [],
},
}),
});
}
return upsertedRecipient;
return {
...upsertedRecipient,
clientId: recipient.clientId,
};
}),
);
});
@ -303,26 +314,20 @@ export const setDocumentRecipients = async ({
assetBaseUrl,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,
@ -332,7 +337,7 @@ export const setDocumentRecipients = async ({
}
// Filter out recipients that have been removed or have been updated.
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
const filteredRecipients: RecipientDataWithClientId[] = existingRecipients.filter((recipient) => {
const isRemoved = removedRecipients.find(
(removedRecipient) => removedRecipient.id === recipient.id,
);
@ -353,26 +358,31 @@ export const setDocumentRecipients = async ({
*/
type RecipientData = {
id?: number | null;
clientId?: string | null;
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};
type RecipientDataWithClientId = Recipient & {
clientId?: string | null;
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
const newRecipientAccessAuth = newRecipientData.accessAuth || null;
const newRecipientActionAuth = newRecipientData.actionAuth || null;
const newRecipientAccessAuth = newRecipientData.accessAuth || [];
const newRecipientActionAuth = newRecipientData.actionAuth || [];
return (
recipient.email !== newRecipientData.email ||
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
authOptions.accessAuth !== newRecipientAccessAuth ||
authOptions.actionAuth !== newRecipientActionAuth
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
);
};

View File

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

View File

@ -1,8 +1,6 @@
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import {
@ -19,10 +17,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,28 +34,26 @@ export const updateDocumentRecipients = async ({
recipients,
requestMetadata,
}: UpdateDocumentRecipientsOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
where: documentWhereInput,
include: {
fields: true,
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -72,20 +69,15 @@ export const updateDocumentRecipients = async ({
});
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isEnterprise = await isUserEnterprise({
userId,
teamId,
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
if (!isEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const recipientsToUpdate = recipients.map((recipient) => {
@ -110,10 +102,7 @@ export const updateDocumentRecipients = async ({
});
}
if (
hasRecipientBeenChanged(originalRecipient, recipient) &&
!canRecipientBeModified(originalRecipient, document.fields)
) {
if (!canRecipientBeModified(originalRecipient, document.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify a recipient who has already interacted with the document',
});
@ -209,31 +198,12 @@ export const updateDocumentRecipients = async ({
};
};
/**
* If you change this you MUST update the `hasRecipientBeenChanged` function.
*/
type RecipientData = {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null;
actionAuth?: TRecipientActionAuthTypes | null;
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
const newRecipientAccessAuth = newRecipientData.accessAuth || null;
const newRecipientActionAuth = newRecipientData.actionAuth || null;
return (
recipient.email !== newRecipientData.email ||
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
authOptions.accessAuth !== newRecipientAccessAuth ||
authOptions.actionAuth !== newRecipientActionAuth
);
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,13 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import type { Team } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
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 { createTokenVerification } from '@documenso/lib/utils/token-verification';
@ -17,7 +16,8 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
export type CreateTeamEmailVerificationOptions = {
userId: number;
@ -34,33 +34,26 @@ export const createTeamEmailVerification = async ({
data,
}: CreateTeamEmailVerificationOptions): Promise<void> => {
try {
const team = await prisma.team.findFirstOrThrow({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
include: {
teamEmail: true,
emailVerification: true,
},
});
if (team.teamEmail || team.emailVerification) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Team already has an email or existing email verification.',
});
}
await prisma.$transaction(
async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
include: {
teamEmail: true,
emailVerification: true,
teamGlobalSettings: true,
},
});
if (team.teamEmail || team.emailVerification) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Team already has an email or existing email verification.',
});
}
const existingTeamEmail = await tx.teamEmail.findFirst({
where: {
email: data.email,
@ -116,13 +109,7 @@ export const createTeamEmailVerification = async ({
* @param teamName The name of the team the user is being invited to.
* @param teamUrl The url of the team the user is being invited to.
*/
export const sendTeamEmailVerificationEmail = async (
email: string,
token: string,
team: Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
},
) => {
export const sendTeamEmailVerificationEmail = async (email: string, token: string, team: Team) => {
const assetBaseUrl = env('NEXT_PUBLIC_WEBAPP_URL') || 'http://localhost:3000';
const template = createElement(ConfirmTeamEmailTemplate, {
@ -133,29 +120,28 @@ export const sendTeamEmailVerificationEmail = async (
token,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = team.teamGlobalSettings?.documentLanguage;
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: team.id,
},
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(
msg`A request to use your email has been initiated by ${team.name} on Documenso`,
),

View File

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

View File

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

View File

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

View File

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

View File

@ -5,14 +5,14 @@ import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
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 { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
export type DeleteTeamEmailOptions = {
userId: number;
@ -26,47 +26,49 @@ export type DeleteTeamEmailOptions = {
* The user must either be part of the team with the required permissions, or the owner of the email.
*/
export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamEmailOptions) => {
const team = await prisma.$transaction(async (tx) => {
const foundTeam = await tx.team.findFirstOrThrow({
where: {
id: teamId,
OR: [
{
teamEmail: {
email: userEmail,
},
},
{
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
],
},
include: {
teamEmail: true,
owner: {
select: {
name: true,
email: true,
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId,
},
});
const team = await prisma.team.findFirstOrThrow({
where: {
OR: [
buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
{
id: teamId,
teamEmail: {
email: userEmail,
},
},
],
},
include: {
teamEmail: true,
organisation: {
select: {
owner: {
select: {
name: true,
email: true,
},
},
},
teamGlobalSettings: true,
},
});
},
});
await tx.teamEmail.delete({
where: {
teamId,
},
});
return foundTeam;
await prisma.teamEmail.delete({
where: {
teamId,
},
});
try {
@ -80,28 +82,19 @@ 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 [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: team.owner.email,
name: team.owner.name ?? '',
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
address: team.organisation.owner.email,
name: team.organisation.owner.name ?? '',
},
from: senderEmail,
subject: i18n._(msg`Team email has been revoked for ${team.name}`),
html,
text,

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,
},
});
});
};

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