Merge branch 'main' into feat/automated-fields-signature

This commit is contained in:
Mythie
2024-11-18 10:56:16 +11:00
488 changed files with 43004 additions and 5754 deletions

View File

@ -1,21 +1,25 @@
import { base32 } from '@scure/base';
import { TOTPController } from 'oslo/otp';
import { generateHOTP } from 'oslo/otp';
import type { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricDecrypt } from '../../universal/crypto';
const totp = new TOTPController();
type VerifyTwoFactorAuthenticationTokenOptions = {
user: User;
totpCode: string;
// The number of windows to look back
window?: number;
// The duration that the token is valid for in seconds
period?: number;
};
export const verifyTwoFactorAuthenticationToken = async ({
user,
totpCode,
window = 1,
period = 30_000,
}: VerifyTwoFactorAuthenticationTokenOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
@ -27,7 +31,21 @@ export const verifyTwoFactorAuthenticationToken = async ({
'utf-8',
);
const isValidToken = await totp.verify(totpCode, base32.decode(secret));
const decodedSecret = base32.decode(secret);
return isValidToken;
let now = Date.now();
for (let i = 0; i < window; i++) {
const counter = Math.floor(now / period);
const hotp = await generateHOTP(decodedSecret, counter);
if (totpCode === hotp) {
return true;
}
now -= period;
}
return false;
};

View File

@ -13,6 +13,7 @@ export const getRecipientsStats = async () => {
[ReadStatus.NOT_OPENED]: 0,
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SigningStatus.REJECTED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};

View File

@ -1,11 +1,14 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendConfirmationEmailProps {
userId: number;
@ -45,6 +48,13 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
confirmationLink,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(confirmationTemplate),
renderEmailWithI18N(confirmationTemplate, { plainText: true }),
]);
const i18n = await getI18nInstance();
return mailer.sendMail({
to: {
address: user.email,
@ -54,8 +64,8 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
name: senderName,
address: senderAddress,
},
subject: 'Please confirm your email',
html: render(confirmationTemplate),
text: render(confirmationTemplate, { plainText: true }),
subject: i18n._(msg`Please confirm your email`),
html,
text,
});
};

View File

@ -1,11 +1,14 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ForgotPasswordTemplate } from '@documenso/email/templates/forgot-password';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendForgotPasswordOptions {
userId: number;
@ -39,6 +42,13 @@ export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions)
resetPasswordLink,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
const i18n = await getI18nInstance();
return await mailer.sendMail({
to: {
address: user.email,
@ -48,8 +58,8 @@ export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions)
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Forgot Password?',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Forgot Password?`),
html,
text,
});
};

View File

@ -1,11 +1,11 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendResetPasswordOptions {
userId: number;
@ -26,6 +26,11 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
userName: user.name || '',
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
return await mailer.sendMail({
to: {
address: user.email,
@ -36,7 +41,7 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Password Reset Success!',
html: render(template),
text: render(template, { plainText: true }),
html,
text,
});
};

View File

@ -7,6 +7,10 @@ import {
diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import type { TDocumentEmailSettings } from '../../types/document-email';
export type CreateDocumentMetaOptions = {
documentId: number;
@ -16,6 +20,11 @@ export type CreateDocumentMetaOptions = {
password?: string;
dateFormat?: string;
redirectUrl?: string;
emailSettings?: TDocumentEmailSettings;
signingOrder?: DocumentSigningOrder;
distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean;
language?: SupportedLanguageCodes;
userId: number;
requestMetadata: RequestMetadata;
};
@ -29,6 +38,11 @@ export const upsertDocumentMeta = async ({
password,
userId,
redirectUrl,
signingOrder,
emailSettings,
distributionMethod,
typedSignatureEnabled,
language,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const user = await prisma.user.findFirstOrThrow({
@ -78,6 +92,11 @@ export const upsertDocumentMeta = async ({
timezone,
documentId,
redirectUrl,
signingOrder,
emailSettings,
distributionMethod,
typedSignatureEnabled,
language,
},
update: {
subject,
@ -86,6 +105,11 @@ export const upsertDocumentMeta = async ({
dateFormat,
timezone,
redirectUrl,
signingOrder,
emailSettings,
distributionMethod,
typedSignatureEnabled,
language,
},
});

View File

@ -2,11 +2,18 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import {
DocumentSigningOrder,
DocumentStatus,
RecipientRole,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendPendingEmail } from './send-pending-email';
@ -29,6 +36,7 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
},
},
include: {
documentMeta: true,
Recipient: {
where: {
token,
@ -59,6 +67,16 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has already signed`);
}
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
if (!isRecipientsTurn) {
throw new Error(
`Recipient ${recipient.id} attempted to complete the document before it was their turn`,
);
}
}
const fields = await prisma.field.findMany({
where: {
documentId: document.id,
@ -120,17 +138,48 @@ export const completeDocumentWithToken = async ({
});
});
const pendingRecipients = await prisma.recipient.count({
const pendingRecipients = await prisma.recipient.findMany({
select: {
id: true,
signingOrder: true,
},
where: {
documentId: document.id,
signingStatus: {
not: SigningStatus.SIGNED,
},
role: {
not: RecipientRole.CC,
},
},
// Composite sort so our next recipient is always the one with the lowest signing order or id
// if there is a tie.
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
});
if (pendingRecipients > 0) {
if (pendingRecipients.length > 0) {
await sendPendingEmail({ documentId, recipientId: recipient.id });
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const [nextRecipient] = pendingRecipients;
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: { id: nextRecipient.id },
data: { sendStatus: SendStatus.SENT },
});
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: document.userId,
documentId: document.id,
recipientId: nextRecipient.id,
requestMetadata,
},
});
});
}
}
const haveAllRecipientsSigned = await prisma.document.findFirst({
@ -138,7 +187,7 @@ export const completeDocumentWithToken = async ({
id: document.id,
Recipient: {
every: {
signingStatus: SigningStatus.SIGNED,
OR: [{ signingStatus: SigningStatus.SIGNED }, { role: RecipientRole.CC }],
},
},
},

View File

@ -5,7 +5,9 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentSource, WebhookTriggerEvents } from '@documenso/prisma/client';
import { DocumentSource, DocumentVisibility, WebhookTriggerEvents } from '@documenso/prisma/client';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -48,6 +50,51 @@ export const createDocument = async ({
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
}
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;
let userTeamRole: TeamMemberRole | undefined;
if (teamId) {
const teamWithUserRole = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
},
include: {
teamGlobalSettings: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
});
team = teamWithUserRole;
userTeamRole = teamWithUserRole.members[0]?.role;
}
const determineVisibility = (
globalVisibility: DocumentVisibility | null | undefined,
userRole: TeamMemberRole,
): DocumentVisibility => {
const defaultVisibility = globalVisibility ?? DocumentVisibility.EVERYONE;
if (userRole === TeamMemberRole.ADMIN) {
return defaultVisibility;
}
if (userRole === TeamMemberRole.MANAGER) {
if (defaultVisibility === DocumentVisibility.ADMIN) {
return DocumentVisibility.MANAGER_AND_ABOVE;
}
return defaultVisibility;
}
return DocumentVisibility.EVERYONE;
};
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
@ -56,8 +103,17 @@ export const createDocument = async ({
documentDataId,
userId,
teamId,
visibility: determineVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: {
language: team?.teamGlobalSettings?.documentLanguage,
},
},
},
});

View File

@ -2,18 +2,30 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client';
import type {
Document,
DocumentMeta,
Recipient,
Team,
TeamGlobalSettings,
User,
} from '@documenso/prisma/client';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
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 { 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';
export type DeleteDocumentOptions = {
id: number;
@ -46,8 +58,9 @@ export const deleteDocument = async ({
Recipient: true,
documentMeta: true,
team: {
select: {
include: {
members: true,
teamGlobalSettings: true,
},
},
},
@ -70,6 +83,7 @@ export const deleteDocument = async ({
await handleDocumentOwnerDelete({
document,
user,
team: document.team,
requestMetadata,
});
}
@ -110,6 +124,11 @@ type HandleDocumentOwnerDeleteOptions = {
Recipient: Recipient[];
documentMeta: DocumentMeta | null;
};
team?:
| (Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
})
| null;
user: User;
requestMetadata?: RequestMetadata;
};
@ -117,6 +136,7 @@ type HandleDocumentOwnerDeleteOptions = {
const handleDocumentOwnerDelete = async ({
document,
user,
team,
requestMetadata,
}: HandleDocumentOwnerDeleteOptions) => {
if (document.deletedAt) {
@ -175,6 +195,14 @@ const handleDocumentOwnerDelete = async ({
});
});
const isDocumentDeleteEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentDeleted;
if (!isDocumentDeleteEmailEnabled) {
return deletedDocument;
}
// Send cancellation emails to recipients.
await Promise.all(
document.Recipient.map(async (recipient) => {
@ -191,6 +219,21 @@ 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: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
await mailer.sendMail({
to: {
address: recipient.email,
@ -200,9 +243,9 @@ const handleDocumentOwnerDelete = async ({
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
}),
);

View File

@ -66,6 +66,7 @@ export const findDocumentAuditLogs = async ({
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM,
],

View File

@ -2,10 +2,18 @@ import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Document, Prisma, Team, TeamEmail, User } from '@documenso/prisma/client';
import { RecipientRole, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import type {
Document,
DocumentSource,
Prisma,
Team,
TeamEmail,
User,
} from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DocumentVisibility } from '../../types/document-visibility';
import type { FindResultSet } from '../../types/find-result-set';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
@ -15,6 +23,8 @@ export type FindDocumentsOptions = {
userId: number;
teamId?: number;
term?: string;
templateId?: number;
source?: DocumentSource;
status?: ExtendedDocumentStatus;
page?: number;
perPage?: number;
@ -24,52 +34,58 @@ export type FindDocumentsOptions = {
};
period?: PeriodSelectorValue;
senderIds?: number[];
search?: string;
};
export const findDocuments = async ({
userId,
teamId,
term,
templateId,
source,
status = ExtendedDocumentStatus.ALL,
page = 1,
perPage = 10,
orderBy,
period,
senderIds,
search,
}: FindDocumentsOptions) => {
const { user, team } = await prisma.$transaction(async (tx) => {
const user = await tx.user.findFirstOrThrow({
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
let team = null;
if (teamId !== undefined) {
team = await prisma.team.findFirstOrThrow({
where: {
id: userId,
},
});
let team = null;
if (teamId !== undefined) {
team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
id: teamId,
members: {
some: {
userId,
},
},
include: {
teamEmail: true,
},
include: {
teamEmail: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
});
}
return {
user,
team,
};
});
},
});
}
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const teamMemberRole = team?.members[0].role ?? null;
const termFilters = match(term)
.with(P.string.minLength(1), () => {
@ -82,7 +98,52 @@ export const findDocuments = async ({
})
.otherwise(() => undefined);
const filters = team ? findTeamDocumentsFilter(status, team) : findDocumentsFilter(status, user);
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
{ Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
],
};
const visibilityFilters = [
match(teamMemberRole)
.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 })),
{
OR: [
{
Recipient: {
some: {
email: user.email,
},
},
},
{
userId: user.id,
},
],
},
];
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user);
if (team) {
filters = findTeamDocumentsFilter(status, team, visibilityFilters);
}
if (filters === null) {
return {
@ -147,10 +208,27 @@ export const findDocuments = async ({
};
}
const whereAndClause: Prisma.DocumentWhereInput['AND'] = [
{ ...termFilters },
{ ...filters },
{ ...deletedFilter },
{ ...searchFilter },
];
if (templateId) {
whereAndClause.push({
templateId,
});
}
if (source) {
whereAndClause.push({
source,
});
}
const whereClause: Prisma.DocumentWhereInput = {
...termFilters,
...filters,
...deletedFilter,
AND: whereAndClause,
};
if (period) {
@ -333,6 +411,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
const findTeamDocumentsFilter = (
status: ExtendedDocumentStatus,
team: Team & { teamEmail: TeamEmail | null },
visibilityFilters: Prisma.DocumentWhereInput[],
) => {
const teamEmail = team.teamEmail?.email ?? null;
@ -343,6 +422,7 @@ const findTeamDocumentsFilter = (
OR: [
{
teamId: team.id,
OR: visibilityFilters,
},
],
};
@ -358,6 +438,7 @@ const findTeamDocumentsFilter = (
email: teamEmail,
},
},
OR: visibilityFilters,
});
// Filter to display all documents that have been sent by the team email.
@ -365,6 +446,7 @@ const findTeamDocumentsFilter = (
User: {
email: teamEmail,
},
OR: visibilityFilters,
});
}
@ -389,6 +471,7 @@ const findTeamDocumentsFilter = (
},
},
},
OR: visibilityFilters,
};
})
.with(ExtendedDocumentStatus.DRAFT, () => {
@ -397,6 +480,7 @@ const findTeamDocumentsFilter = (
{
teamId: team.id,
status: ExtendedDocumentStatus.DRAFT,
OR: visibilityFilters,
},
],
};
@ -407,6 +491,7 @@ const findTeamDocumentsFilter = (
User: {
email: teamEmail,
},
OR: visibilityFilters,
});
}
@ -418,6 +503,7 @@ const findTeamDocumentsFilter = (
{
teamId: team.id,
status: ExtendedDocumentStatus.PENDING,
OR: visibilityFilters,
},
],
};
@ -436,11 +522,13 @@ const findTeamDocumentsFilter = (
},
},
},
OR: visibilityFilters,
},
{
User: {
email: teamEmail,
},
OR: visibilityFilters,
},
],
});
@ -454,6 +542,7 @@ const findTeamDocumentsFilter = (
OR: [
{
teamId: team.id,
OR: visibilityFilters,
},
],
};
@ -466,11 +555,13 @@ const findTeamDocumentsFilter = (
email: teamEmail,
},
},
OR: visibilityFilters,
},
{
User: {
email: teamEmail,
},
OR: visibilityFilters,
},
);
}

View File

@ -1,6 +1,10 @@
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import { DocumentVisibility } from '../../types/document-visibility';
import { getTeamById } from '../team/get-team';
export type GetDocumentByIdOptions = {
@ -28,6 +32,11 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
email: true,
},
},
Recipient: {
select: {
email: true,
},
},
team: {
select: {
id: true,
@ -115,5 +124,42 @@ export const getDocumentWhereInput = async ({
);
}
return documentWhereInput;
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: [
{
Recipient: {
some: {
email: user.email,
},
},
},
{
userId: user.id,
},
],
},
];
return {
...documentWhereInput,
OR: [...visibilityFilters],
};
};

View File

@ -1,9 +1,12 @@
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import type { Prisma, User } from '@documenso/prisma/client';
import { SigningStatus } from '@documenso/prisma/client';
import { DocumentVisibility } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@ -11,9 +14,10 @@ export type GetStatsInput = {
user: User;
team?: Omit<GetTeamCountsOption, 'createdAt'>;
period?: PeriodSelectorValue;
search?: string;
};
export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
export const getStats = async ({ user, period, search, ...options }: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) {
@ -27,8 +31,14 @@ export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
}
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
? getTeamCounts({ ...options.team, createdAt })
: getCounts({ user, createdAt }));
? getTeamCounts({
...options.team,
createdAt,
currentUserEmail: user.email,
userId: user.id,
search,
})
: getCounts({ user, createdAt, search }));
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
@ -68,9 +78,18 @@ export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
type GetCountsOption = {
user: User;
createdAt: Prisma.DocumentWhereInput['createdAt'];
search?: string;
};
const getCounts = async ({ user, createdAt }: GetCountsOption) => {
const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
{ Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
],
};
return Promise.all([
// Owner counts.
prisma.document.groupBy({
@ -83,6 +102,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
createdAt,
teamId: null,
deletedAt: null,
AND: [searchFilter],
},
}),
// Not signed counts.
@ -101,6 +121,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
},
},
createdAt,
AND: [searchFilter],
},
}),
// Has signed counts.
@ -138,6 +159,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
},
},
],
AND: [searchFilter],
},
}),
]);
@ -147,7 +169,11 @@ type GetTeamCountsOption = {
teamId: number;
teamEmail?: string;
senderIds?: number[];
currentUserEmail: string;
userId: number;
createdAt: Prisma.DocumentWhereInput['createdAt'];
currentTeamMemberRole?: TeamMemberRole;
search?: string;
};
const getTeamCounts = async (options: GetTeamCountsOption) => {
@ -162,6 +188,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
}
: undefined;
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: options.search, mode: 'insensitive' } },
{ Recipient: { some: { name: { contains: options.search, mode: 'insensitive' } } } },
{ Recipient: { some: { email: { contains: options.search, mode: 'insensitive' } } } },
],
};
let ownerCountsWhereInput: Prisma.DocumentWhereInput = {
userId: userIdWhereClause,
createdAt,
@ -172,6 +206,48 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
let notSignedCountsGroupByArgs = null;
let hasSignedCountsGroupByArgs = null;
const visibilityFiltersWhereInput: Prisma.DocumentWhereInput = {
AND: [
{ deletedAt: null },
{
OR: [
match(options.currentTeamMemberRole)
.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: {
equals: DocumentVisibility.EVERYONE,
},
})),
{
OR: [
{ userId: options.userId },
{ Recipient: { some: { email: options.currentUserEmail } } },
],
},
],
},
],
};
ownerCountsWhereInput = {
...ownerCountsWhereInput,
...visibilityFiltersWhereInput,
...searchFilter,
};
if (teamEmail) {
ownerCountsWhereInput = {
userId: userIdWhereClause,

View File

@ -112,6 +112,7 @@ export const isRecipientAuthorized = async ({
return await verifyTwoFactorAuthenticationToken({
user,
totpCode: token,
window: 10, // 5 minutes worth of tokens
});
})
.exhaustive();

View File

@ -0,0 +1,92 @@
import { SigningStatus } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { jobs } from '@documenso/lib/jobs/client';
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 } from '../../utils/document-audit-logs';
export type RejectDocumentWithTokenOptions = {
token: string;
documentId: number;
reason: string;
requestMetadata?: RequestMetadata;
};
export async function rejectDocumentWithToken({
token,
documentId,
reason,
requestMetadata,
}: RejectDocumentWithTokenOptions) {
// Find the recipient and document in a single query
const recipient = await prisma.recipient.findFirst({
where: {
token,
documentId,
},
include: {
Document: {
include: {
User: true,
},
},
},
});
const document = recipient?.Document;
if (!recipient || !document) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Document or recipient not found',
});
}
// Add the audit log entry before updating the recipient
// Update the recipient status to rejected
const [updatedRecipient] = await prisma.$transaction([
prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signedAt: new Date(),
signingStatus: SigningStatus.REJECTED,
rejectionReason: reason,
},
}),
prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
user: {
name: recipient.name,
email: recipient.email,
},
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
reason,
},
requestMetadata,
}),
}),
]);
// Send email notifications
await jobs.triggerJob({
name: 'send.signing.rejected.emails',
payload: {
recipientId: recipient.id,
documentId,
},
});
return updatedRecipient;
}

View File

@ -1,11 +1,12 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION_ENG,
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '@documenso/lib/constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -16,7 +17,11 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getDocumentWhereInput } from './get-document-by-id';
export type ResendDocumentOptions = {
@ -62,6 +67,7 @@ export const resendDocument = async ({
select: {
teamEmail: true,
name: true,
teamGlobalSettings: true,
},
},
},
@ -86,31 +92,50 @@ export const resendDocument = async ({
throw new Error('Can not send completed document');
}
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
if (!isRecipientSigningRequestEmailEnabled) {
return;
}
await Promise.all(
document.Recipient.map(async (recipient) => {
if (recipient.role === RecipientRole.CC) {
return;
}
const i18n = await getI18nInstance(document.documentMeta?.language);
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const recipientActionVerb =
RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].actionVerb.toLowerCase();
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailMessage = customEmail?.message || '';
let emailSubject = `Reminder: Please ${recipientActionVerb} this document`;
let emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} this document`);
if (selfSigner) {
emailMessage = `You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`;
emailSubject = `Reminder: Please ${recipientActionVerb} your document`;
emailMessage = i18n._(
msg`You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} your document`);
}
if (isTeamDocument && document.team) {
emailSubject = `Reminder: ${document.team.name} invited you to ${recipientActionVerb} a document`;
emailMessage = `${user.name} on behalf of ${document.team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`;
emailSubject = i18n._(
msg`Reminder: ${document.team.name} invited you to ${recipientActionVerb} a document`,
);
emailMessage =
customEmail?.message ||
i18n._(
msg`${user.name} on behalf of ${document.team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
);
}
const customEmailTemplate = {
@ -135,8 +160,24 @@ export const resendDocument = async ({
teamName: document.team?.name,
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
await prisma.$transaction(
async (tx) => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
}),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
address: email,
@ -147,10 +188,13 @@ export const resendDocument = async ({
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(`Reminder: ${customEmail.subject}`, customEmailTemplate)
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${customEmail.subject}`),
customEmailTemplate,
)
: emailSubject,
html: render(template),
text: render(template, { plainText: true }),
html,
text,
});
await tx.documentAuditLog.create({

View File

@ -10,6 +10,7 @@ import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file';
@ -45,6 +46,7 @@ export const sealDocument = async ({
},
include: {
documentData: true,
documentMeta: true,
Recipient: true,
},
});
@ -90,7 +92,9 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData);
const certificate = await getCertificatePdf({ documentId })
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
const certificate = await getCertificatePdf({ documentId, language: documentLanguage })
.then(async (doc) => PDFDocument.load(doc))
.catch(() => null);

View File

@ -1,6 +1,10 @@
import { match } from 'ts-pattern';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
export type SearchDocumentsWithKeywordOptions = {
query: string;
@ -67,10 +71,40 @@ export const searchDocumentsWithKeyword = async ({
},
deletedAt: null,
},
{
title: {
contains: query,
mode: 'insensitive',
},
teamId: {
not: null,
},
team: {
members: {
some: {
userId: userId,
},
},
},
deletedAt: null,
},
],
},
include: {
Recipient: true,
team: {
select: {
url: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
},
},
orderBy: {
createdAt: 'desc',
@ -82,15 +116,48 @@ export const searchDocumentsWithKeyword = async ({
const getSigningLink = (recipients: Recipient[], user: User) =>
`/sign/${recipients.find((r) => r.email === user.email)?.token}`;
const maskedDocuments = documents.map((document) => {
const { Recipient, ...documentWithoutRecipient } = document;
const maskedDocuments = documents
.filter((document) => {
if (!document.teamId || isOwner(document, user)) {
return true;
}
return {
...documentWithoutRecipient,
path: isOwner(document, user) ? `/documents/${document.id}` : getSigningLink(Recipient, user),
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
};
});
const teamMemberRole = document.team?.members[0]?.role;
if (!teamMemberRole) {
return false;
}
const canAccessDocument = match([document.visibility, teamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
return canAccessDocument;
})
.map((document) => {
const { Recipient, ...documentWithoutRecipient } = document;
let documentPath;
if (isOwner(document, user)) {
documentPath = `${formatDocumentsPath(document.team?.url)}/${document.id}`;
} else if (document.teamId && document.team) {
documentPath = `${formatDocumentsPath(document.team.url)}/${document.id}`;
} else {
documentPath = getSigningLink(Recipient, user);
}
return {
...documentWithoutRecipient,
path: documentPath,
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
};
});
return maskedDocuments;
};

View File

@ -1,17 +1,23 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
import { prisma } from '@documenso/prisma';
import { DocumentSource } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
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 { getFile } from '../../universal/upload/get-file';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
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';
export interface SendDocumentOptions {
documentId: number;
@ -32,6 +38,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
select: {
id: true,
url: true,
teamGlobalSettings: true,
},
},
},
@ -53,7 +60,9 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
let documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/documents/${document.id}`;
let documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(
document.team?.url,
)}/${document.id}`;
if (document.team?.url) {
documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/t/${document.team.url}/documents/${
@ -61,14 +70,36 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
}`;
}
// If the document owner is not a recipient then send the email to them separately
if (!document.Recipient.find((recipient) => recipient.email === owner.email)) {
const i18n = await getI18nInstance(document.documentMeta?.language);
const isDocumentCompletedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentCompleted;
// If the document owner is not a recipient, OR recipient emails are disabled, then send the email to them separately.
if (
!document.Recipient.find((recipient) => recipient.email === owner.email) ||
!isDocumentCompletedEmailEnabled
) {
const template = createElement(DocumentCompletedEmailTemplate, {
documentName: document.title,
assetBaseUrl,
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: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: [
{
@ -80,9 +111,9 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Signing Complete!`),
html,
text,
attachments: [
{
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
@ -109,6 +140,10 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
});
}
if (!isDocumentCompletedEmailEnabled) {
return;
}
await Promise.all(
document.Recipient.map(async (recipient) => {
const customEmailTemplate = {
@ -129,6 +164,19 @@ 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: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: [
{
@ -143,9 +191,9 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
subject:
isDirectTemplate && document.documentMeta?.subject
? renderCustomEmailTemplate(document.documentMeta.subject, customEmailTemplate)
: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
: i18n._(msg`Signing Complete!`),
html,
text,
attachments: [
{
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',

View File

@ -1,11 +1,16 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete';
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 { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export interface SendDeleteEmailOptions {
documentId: number;
@ -19,6 +24,12 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
},
include: {
User: true,
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -26,6 +37,14 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
throw new Error('Document not found');
}
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentDeleted;
if (!isDocumentDeletedEmailEnabled) {
return;
}
const { email, name } = document.User;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
@ -36,6 +55,21 @@ 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: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance();
await mailer.sendMail({
to: {
address: email,
@ -45,8 +79,8 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Document Deleted!',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Document Deleted!`),
html,
text,
});
};

View File

@ -3,10 +3,17 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
import {
DocumentSigningOrder,
DocumentStatus,
RecipientRole,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { getFile } from '../../universal/upload/get-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -23,7 +30,7 @@ export const sendDocument = async ({
documentId,
userId,
teamId,
sendEmail = true,
sendEmail,
requestMetadata,
}: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
@ -57,7 +64,9 @@ export const sendDocument = async ({
}),
},
include: {
Recipient: true,
Recipient: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
},
documentMeta: true,
documentData: true,
},
@ -75,6 +84,21 @@ export const sendDocument = async ({
throw new Error('Can not send completed document');
}
const signingOrder = document.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
let recipientsToNotify = document.Recipient;
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
// Get the currently active recipient.
recipientsToNotify = document.Recipient.filter(
(r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC,
).slice(0, 1);
// Secondary filter so we aren't resending if the current active recipient has already
// received the document.
recipientsToNotify.filter((r) => r.sendStatus !== SendStatus.SENT);
}
const { documentData } = document;
if (!documentData.data) {
@ -133,9 +157,16 @@ export const sendDocument = async ({
// throw new Error('Some signers have not been assigned a signature field.');
// }
if (sendEmail) {
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
// Only send email if one of the following is true:
// - It is explicitly set
// - The email is enabled for signing requests AND sendEmail is undefined
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
await Promise.all(
document.Recipient.map(async (recipient) => {
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}

View File

@ -1,11 +1,16 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
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 { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export interface SendPendingEmailOptions {
documentId: number;
@ -28,6 +33,12 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
id: recipientId,
},
},
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -39,6 +50,14 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
throw new Error('Document has no recipients');
}
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentPending;
if (!isDocumentPendingEmailEnabled) {
return;
}
const [recipient] = document.Recipient;
const { email, name } = recipient;
@ -50,6 +69,21 @@ 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: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
await mailer.sendMail({
to: {
address: email,
@ -59,8 +93,8 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Waiting for others to complete signing.',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Waiting for others to complete signing.`),
html,
text,
});
};

View File

@ -2,17 +2,22 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
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 { 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';
export type SuperDeleteDocumentOptions = {
id: number;
@ -28,6 +33,11 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
Recipient: true,
documentMeta: true,
User: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -37,8 +47,16 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
const { status, User: user } = document;
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).documentDeleted;
// if the document is pending, send cancellation emails to all recipients
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
if (
status === DocumentStatus.PENDING &&
document.Recipient.length > 0 &&
isDocumentDeletedEmailEnabled
) {
await Promise.all(
document.Recipient.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
@ -53,6 +71,21 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
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: document.documentMeta?.language,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
await mailer.sendMail({
to: {
address: recipient.email,
@ -62,9 +95,9 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
}),
);

View File

@ -1,12 +1,15 @@
'use server';
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 { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { DocumentVisibility } from '@documenso/prisma/client';
import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
@ -19,6 +22,7 @@ export type UpdateDocumentSettingsOptions = {
data: {
title?: string;
externalId?: string | null;
visibility?: DocumentVisibility | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
};
@ -61,8 +65,62 @@ export const updateDocumentSettings = async ({
teamId: null,
}),
},
include: {
team: {
select: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
},
},
});
if (teamId) {
const currentUserRole = document.team?.members[0]?.role;
match(currentUserRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => {
const allowedVisibilities: DocumentVisibility[] = [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
];
if (
!allowedVisibilities.includes(document.visibility) ||
(data.visibility && !allowedVisibilities.includes(data.visibility))
) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to update the document visibility',
);
}
})
.with(TeamMemberRole.MEMBER, () => {
if (
document.visibility !== DocumentVisibility.EVERYONE ||
(data.visibility && data.visibility !== DocumentVisibility.EVERYONE)
) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to update the document visibility',
);
}
})
.otherwise(() => {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to update the document',
);
});
}
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
@ -91,10 +149,14 @@ export const updateDocumentSettings = async ({
}
}
const isTitleSame = data.title === document.title;
const isExternalIdSame = data.externalId === document.externalId;
const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth;
const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth;
const isTitleSame = data.title === undefined || data.title === document.title;
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
const isGlobalAccessSame =
documentGlobalAccessAuth === undefined || documentGlobalAccessAuth === newGlobalAccessAuth;
const isGlobalActionSame =
documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth;
const isDocumentVisibilitySame =
data.visibility === undefined || data.visibility === document.visibility;
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
@ -165,6 +227,21 @@ export const updateDocumentSettings = async ({
);
}
if (!isDocumentVisibilitySame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.visibility,
to: data.visibility || '',
},
}),
);
}
// Early return if nothing is required.
if (auditLogs.length === 0) {
return document;
@ -182,7 +259,8 @@ export const updateDocumentSettings = async ({
},
data: {
title: data.title,
externalId: data.externalId || null,
externalId: data.externalId,
visibility: data.visibility as DocumentVisibility,
authOptions,
},
});

View File

@ -5,7 +5,11 @@ import { getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
NEXT_PUBLIC_MARKETING_URL,
NEXT_PUBLIC_WEBAPP_URL,
} from '../../constants/app';
import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
/**
@ -46,6 +50,10 @@ export default async function handlerFeatureFlagAll(req: Request) {
if (origin.startsWith(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
if (origin.startsWith(NEXT_PRIVATE_INTERNAL_WEBAPP_URL ?? 'http://localhost:3000')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
}
return res;

View File

@ -7,7 +7,11 @@ import { getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
NEXT_PUBLIC_MARKETING_URL,
NEXT_PUBLIC_WEBAPP_URL,
} from '../../constants/app';
/**
* Evaluate a single feature flag based on the current user if possible.
@ -67,6 +71,10 @@ export default async function handleFeatureFlagGet(req: Request) {
if (origin.startsWith(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
if (origin.startsWith(NEXT_PRIVATE_INTERNAL_WEBAPP_URL ?? 'http://localhost:3000')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
}
return res;

View File

@ -1,3 +1,5 @@
import { isDeepEqual } from 'remeda';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
@ -20,22 +22,15 @@ import {
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { Field } from '@documenso/prisma/client';
import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
export interface SetFieldsForDocumentOptions {
userId: number;
documentId: number;
fields: {
id?: number | null;
type: FieldType;
signerEmail: string;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
fieldMeta?: FieldMeta;
}[];
fields: FieldData[];
requestMetadata?: RequestMetadata;
}
@ -63,6 +58,9 @@ export const setFieldsForDocument = async ({
},
],
},
include: {
Recipient: true,
},
});
const user = await prisma.user.findFirstOrThrow({
@ -97,21 +95,36 @@ export const setFieldsForDocument = async ({
(existingField) => !fields.find((field) => field.id === existingField.id),
);
const linkedFields = fields
.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
const linkedFields = fields.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
return {
...field,
_persisted: existing,
};
})
.filter((field) => {
return (
field._persisted?.Recipient?.sendStatus !== SendStatus.SENT &&
field._persisted?.Recipient?.signingStatus !== SigningStatus.SIGNED
const recipient = document.Recipient.find(
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
);
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, `Recipient not found for field ${field.id}`);
}
// Check whether the existing field can be modified.
if (
existing &&
hasFieldBeenChanged(existing, field) &&
!canRecipientFieldsBeModified(recipient, existingFields)
) {
throw new AppError(
AppErrorCode.INVALID_REQUEST,
'Cannot modify a field where the recipient has already interacted with the document',
);
});
}
return {
...field,
_persisted: existing,
_recipient: recipient,
};
});
const persistedFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
@ -322,3 +335,33 @@ export const setFieldsForDocument = async ({
return [...filteredFields, ...persistedFields];
};
/**
* If you change this you MUST update the `hasFieldBeenChanged` function.
*/
type FieldData = {
id?: number | null;
type: FieldType;
signerEmail: string;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
fieldMeta?: FieldMeta;
};
const hasFieldBeenChanged = (field: Field, newFieldData: FieldData) => {
const currentFieldMeta = field.fieldMeta || null;
const newFieldMeta = newFieldData.fieldMeta || null;
return (
field.type !== newFieldData.type ||
field.page !== newFieldData.pageNumber ||
field.positionX.toNumber() !== newFieldData.pageX ||
field.positionY.toNumber() !== newFieldData.pageY ||
field.width.toNumber() !== newFieldData.pageWidth ||
field.height.toNumber() !== newFieldData.pageHeight ||
!isDeepEqual(currentFieldMeta, newFieldMeta)
);
};

View File

@ -107,7 +107,10 @@ export const setFieldsForTemplate = async ({
}
}
if (field.type === FieldType.CHECKBOX && field.fieldMeta) {
if (field.type === FieldType.CHECKBOX) {
if (!field.fieldMeta) {
throw new Error('Checkbox field is missing required metadata');
}
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const errors = validateCheckboxField(
checkboxFieldParsedMeta?.values?.map((item) => item.value) ?? [],
@ -118,7 +121,10 @@ export const setFieldsForTemplate = async ({
}
}
if (field.type === FieldType.RADIO && field.fieldMeta) {
if (field.type === FieldType.RADIO) {
if (!field.fieldMeta) {
throw new Error('Radio field is missing required metadata');
}
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedRadioFieldValue = radioFieldParsedMeta.values?.find(
(option) => option.checked,
@ -129,7 +135,10 @@ export const setFieldsForTemplate = async ({
}
}
if (field.type === FieldType.DROPDOWN && field.fieldMeta) {
if (field.type === FieldType.DROPDOWN) {
if (!field.fieldMeta) {
throw new Error('Dropdown field is missing required metadata');
}
const dropdownFieldParsedMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(undefined, dropdownFieldParsedMeta);
if (errors.length > 0) {

View File

@ -1,11 +0,0 @@
import { headers } from 'next/headers';
export const getLocale = () => {
const headerItems = headers();
const locales = headerItems.get('accept-language') ?? 'en-US';
const [locale] = locales.split(',');
return locale;
};

View File

@ -2,13 +2,15 @@ import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { encryptSecondaryData } from '../crypto/encrypt';
export type GetCertificatePdfOptions = {
documentId: number;
language?: SupportedLanguageCodes;
};
export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions) => {
export const getCertificatePdf = async ({ documentId, language }: GetCertificatePdfOptions) => {
const { chromium } = await import('playwright');
const encryptedId = encryptSecondaryData({
@ -32,7 +34,19 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
);
}
const page = await browser.newPage();
const browserContext = await browser.newContext();
const page = await browserContext.newPage();
if (language) {
await page.context().addCookies([
{
name: 'language',
value: language,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
]);
}
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
waitUntil: 'networkidle',
@ -43,6 +57,8 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
format: 'A4',
});
await browserContext.close();
void browser.close();
return result;

View File

@ -4,5 +4,8 @@ import { cookies } from 'next/headers';
// eslint-disable-next-line @typescript-eslint/require-await
export const switchI18NLanguage = async (lang: string) => {
cookies().set('i18n', lang);
// Two year expiry.
const maxAge = 60 * 60 * 24 * 365 * 2;
cookies().set('language', lang, { maxAge });
};

View File

@ -1,6 +1,7 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { PDFDocument, RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
import type { PDFDocument } from 'pdf-lib';
import { RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
import { P, match } from 'ts-pattern';
import {
@ -13,7 +14,16 @@ import { FieldType } from '@documenso/prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
import {
ZCheckboxFieldMeta,
ZDateFieldMeta,
ZEmailFieldMeta,
ZInitialsFieldMeta,
ZNameFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const fontCaveat = await fetch(process.env.FONT_CAVEAT_URI).then(async (res) =>
@ -32,7 +42,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
let fontSize = maxFontSize;
const page = pages.at(field.page - 1);
@ -207,16 +216,33 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
}
})
.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 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);
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
if (!customFontSize) {
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
}
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
let textX = fieldX + (fieldWidth - textWidth) / 2;
@ -250,17 +276,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
return pdf;
};
export const insertFieldInPDFBytes = async (
pdf: ArrayBuffer | Uint8Array | string,
field: FieldWithSignature,
) => {
const pdfDoc = await PDFDocument.load(pdf);
await insertFieldInPDF(pdfDoc, field);
return await pdfDoc.save();
};
const adjustPositionForRotation = (
pageWidth: number,
pageHeight: number,

View File

@ -10,6 +10,7 @@ export async function insertTextInPDF(
positionY: number,
page = 0,
useHandwritingFont = true,
customFontSize?: number,
): Promise<string> {
// Fetch the font file from the public URL.
const fontResponse = await fetch(CAVEAT_FONT_PATH());
@ -24,7 +25,7 @@ export async function insertTextInPDF(
const pages = pdfDoc.getPages();
const pdfPage = pages[page];
const textSize = useHandwritingFont ? 50 : 15;
const textSize = customFontSize || (useHandwritingFont ? 50 : 15);
const textWidth = font.widthOfTextAtSize(text, textSize);
const textHeight = font.heightAtSize(textSize);
const fieldSize = { width: 250, height: 64 };

View File

@ -51,7 +51,7 @@ export const createApiToken = async ({
name: tokenName,
token: hashedToken,
expires: expiresIn ? DateTime.now().plus(timeConstantsRecords[expiresIn]).toJSDate() : null,
userId: teamId ? null : userId,
userId,
teamId,
},
});

View File

@ -25,8 +25,7 @@ export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOpt
return await prisma.apiToken.delete({
where: {
id,
userId: teamId ? null : userId,
teamId,
teamId: teamId ?? null,
},
});
};

View File

@ -8,6 +8,7 @@ export const getUserTokens = async ({ userId }: GetUserTokensOptions) => {
return await prisma.apiToken.findMany({
where: {
userId,
teamId: null,
},
select: {
id: true,

View File

@ -23,7 +23,8 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
throw new Error('Expired token');
}
if (apiToken.team) {
// Handle a silly choice from many moons ago
if (apiToken.team && !apiToken.user) {
apiToken.user = await prisma.user.findFirst({
where: {
id: apiToken.team.ownerUserId,
@ -33,9 +34,13 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
const { user } = apiToken;
// This will never happen but we need to narrow types
if (!user) {
throw new Error('Invalid token');
}
return { ...apiToken, user };
return {
...apiToken,
user,
};
};

View File

@ -0,0 +1,46 @@
import { prisma } from '@documenso/prisma';
import { DocumentSigningOrder, SigningStatus } from '@documenso/prisma/client';
export type GetIsRecipientTurnOptions = {
token: string;
};
export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOptions) {
const document = await prisma.document.findFirstOrThrow({
where: {
Recipient: {
some: {
token,
},
},
},
include: {
documentMeta: true,
Recipient: {
orderBy: {
signingOrder: 'asc',
},
},
},
});
if (document.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
return true;
}
const recipients = document.Recipient;
const currentRecipientIndex = recipients.findIndex((r) => r.token === token);
if (currentRecipientIndex === -1) {
return false;
}
for (let i = 0; i < currentRecipientIndex; i++) {
if (recipients[i].signingStatus !== SigningStatus.SIGNED) {
return false;
}
}
return true;
}

View File

@ -1,4 +1,10 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
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';
import {
type TRecipientActionAuthTypes,
@ -16,19 +22,20 @@ import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
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';
export interface SetRecipientsForDocumentOptions {
userId: number;
teamId?: number;
documentId: number;
recipients: {
id?: number | null;
email: string;
name: string;
role: RecipientRole;
actionAuth?: TRecipientActionAuthTypes | null;
}[];
recipients: RecipientData[];
requestMetadata?: RequestMetadata;
}
@ -58,6 +65,15 @@ export const setRecipientsForDocument = async ({
teamId: null,
}),
},
include: {
Field: true,
documentMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
const user = await prisma.user.findFirstOrThrow({
@ -115,25 +131,28 @@ export const setRecipientsForDocument = async ({
),
);
const linkedRecipients = normalizedRecipients
.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
return {
...recipient,
_persisted: existing,
};
})
.filter((recipient) => {
return (
recipient._persisted?.role === RecipientRole.CC ||
(recipient._persisted?.sendStatus !== SendStatus.SENT &&
recipient._persisted?.signingStatus !== SigningStatus.SIGNED)
if (
existing &&
hasRecipientBeenChanged(existing, recipient) &&
!canRecipientBeModified(existing, document.Field)
) {
throw new AppError(
AppErrorCode.INVALID_REQUEST,
'Cannot modify a recipient who has already interacted with the document',
);
});
}
return {
...recipient,
_persisted: existing,
};
});
const persistedRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
@ -156,6 +175,7 @@ export const setRecipientsForDocument = async ({
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
documentId,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
@ -166,6 +186,7 @@ export const setRecipientsForDocument = async ({
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
documentId,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
@ -265,6 +286,52 @@ export const setRecipientsForDocument = async ({
),
});
});
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientRemoved;
// Send emails to deleted recipients.
await Promise.all(
removedRecipients.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientRemovedEmailEnabled) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
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 }),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
}),
);
}
// Filter out recipients that have been removed or have been updated.
@ -281,3 +348,27 @@ export const setRecipientsForDocument = async ({
return [...filteredRecipients, ...persistedRecipients];
};
/**
* If you change this you MUST update the `hasRecipientBeenChanged` function.
*/
type RecipientData = {
id?: number | null;
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes | null;
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return (
recipient.email !== newRecipientData.email ||
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
authOptions.actionAuth !== newRecipientData.actionAuth
);
};

View File

@ -24,6 +24,7 @@ export type SetRecipientsForTemplateOptions = {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes | null;
}[];
};
@ -162,6 +163,7 @@ export const setRecipientsForTemplate = async ({
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
templateId,
authOptions,
},
@ -169,6 +171,7 @@ export const setRecipientsForTemplate = async ({
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
templateId,
authOptions,

View File

@ -1,9 +1,16 @@
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { prisma } from '@documenso/prisma';
import type { RecipientRole, Team } from '@documenso/prisma/client';
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;
@ -11,6 +18,8 @@ export type UpdateRecipientOptions = {
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes | null;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
@ -22,6 +31,8 @@ export const updateRecipient = async ({
email,
name,
role,
signingOrder,
actionAuth,
userId,
teamId,
requestMetadata,
@ -48,6 +59,9 @@ export const updateRecipient = async ({
}),
},
},
include: {
Document: true,
},
});
let team: Team | null = null;
@ -75,6 +89,22 @@ export const updateRecipient = async ({
throw new Error('Recipient not found');
}
if (actionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'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: {
@ -84,6 +114,11 @@ export const updateRecipient = async ({
email: email?.toLowerCase() ?? recipient.email,
name: name ?? recipient.name,
role: role ?? recipient.role,
signingOrder,
authOptions: createRecipientAuthOptions({
accessAuth: recipientAuthOptions.accessAuth,
actionAuth: actionAuth ?? null,
}),
},
});

View File

@ -42,7 +42,16 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
});
if (teamMemberInvite.status === TeamMemberInviteStatus.ACCEPTED) {
return;
const memberExists = await tx.teamMember.findFirst({
where: {
teamId: teamMemberInvite.teamId,
userId: user.id,
},
});
if (memberExists) {
return;
}
}
const { team } = teamMemberInvite;
@ -81,7 +90,7 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
await jobs.triggerJob({
name: 'send.team-member-joined.email',
payload: {
teamId: team.id,
teamId: teamMember.teamId,
memberId: teamMember.id,
},
});

View File

@ -1,9 +1,9 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
@ -11,8 +11,13 @@ 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';
import { prisma } from '@documenso/prisma';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
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 CreateTeamEmailVerificationOptions = {
userId: number;
teamId: number;
@ -45,6 +50,7 @@ export const createTeamEmailVerification = async ({
include: {
teamEmail: true,
emailVerification: true,
teamGlobalSettings: true,
},
});
@ -77,7 +83,7 @@ export const createTeamEmailVerification = async ({
},
});
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
await sendTeamEmailVerificationEmail(data.email, token, team);
},
{ timeout: 30_000 },
);
@ -109,27 +115,47 @@ export const createTeamEmailVerification = async ({
export const sendTeamEmailVerificationEmail = async (
email: string,
token: string,
teamName: string,
teamUrl: string,
team: Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
},
) => {
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const template = createElement(ConfirmTeamEmailTemplate, {
assetBaseUrl,
baseUrl: WEBAPP_BASE_URL,
teamName,
teamUrl,
teamName: team.name,
teamUrl: team.url,
token,
});
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,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `A request to use your email has been initiated by ${teamName} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(
msg`A request to use your email has been initiated by ${team.name} on Documenso`,
),
html,
text,
});
};

View File

@ -1,10 +1,9 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { nanoid } from 'nanoid';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import type { TeamInviteEmailProps } from '@documenso/email/templates/team-invite';
import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
@ -12,9 +11,14 @@ 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 { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
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;
@ -56,6 +60,7 @@ export const createTeamMemberInvites = async ({
},
},
invites: true,
teamGlobalSettings: true,
},
});
@ -109,8 +114,7 @@ export const createTeamMemberInvites = async ({
sendTeamMemberInviteEmail({
email,
token,
teamName: team.name,
teamUrl: team.url,
team,
senderName: userName,
}),
),
@ -131,8 +135,13 @@ export const createTeamMemberInvites = async ({
}
};
type SendTeamMemberInviteEmailOptions = Omit<TeamInviteEmailProps, 'baseUrl' | 'assetBaseUrl'> & {
type SendTeamMemberInviteEmailOptions = {
email: string;
senderName: string;
token: string;
team: Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
};
};
/**
@ -140,22 +149,42 @@ type SendTeamMemberInviteEmailOptions = Omit<TeamInviteEmailProps, 'baseUrl' | '
*/
export const sendTeamMemberInviteEmail = async ({
email,
...emailTemplateOptions
senderName,
token,
team,
}: SendTeamMemberInviteEmailOptions) => {
const template = createElement(TeamInviteEmailTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
...emailTemplateOptions,
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: `You have been invited to join ${emailTemplateOptions.teamName} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`You have been invited to join ${team.name} on Documenso`),
html,
text,
});
};

View File

@ -1,13 +1,18 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
import { WEBAPP_BASE_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 { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export type DeleteTeamEmailOptions = {
userId: number;
userEmail: string;
@ -50,6 +55,7 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
email: true,
},
},
teamGlobalSettings: true,
},
});
@ -73,6 +79,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 }),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: {
address: team.owner.email,
@ -82,9 +101,9 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `Team email has been revoked for ${team.name}`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Team email has been revoked for ${team.name}`),
html,
text,
});
} catch (e) {
// Todo: Teams - Alert us.

View File

@ -1,16 +1,20 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import type { TeamDeleteEmailProps } from '@documenso/email/templates/team-delete';
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { jobs } from '../../jobs/client';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export type DeleteTeamOptions = {
userId: number;
@ -38,6 +42,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
},
},
},
teamGlobalSettings: true,
},
});
@ -60,6 +65,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
name: team.name,
url: team.url,
ownerUserId: team.ownerUserId,
teamGlobalSettings: team.teamGlobalSettings,
},
members: team.members.map((member) => ({
id: member.user.id,
@ -80,29 +86,43 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
);
};
type SendTeamDeleteEmailOptions = Omit<TeamDeleteEmailProps, 'baseUrl' | 'assetBaseUrl'> & {
type SendTeamDeleteEmailOptions = {
email: string;
teamName: string;
team: Pick<Team, 'url' | 'name'> & {
teamGlobalSettings?: TeamGlobalSettings | null;
};
isOwner: boolean;
};
export const sendTeamDeleteEmail = async ({
email,
...emailTemplateOptions
}: SendTeamDeleteEmailOptions) => {
export const sendTeamDeleteEmail = async ({ email, isOwner, team }: SendTeamDeleteEmailOptions) => {
const template = createElement(TeamDeleteEmailTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
...emailTemplateOptions,
teamUrl: team.url,
isOwner,
});
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 }),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `Team "${emailTemplateOptions.teamName}" has been deleted on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Team "${team.name}" has been deleted on Documenso`),
html,
text,
});
};

View File

@ -6,6 +6,8 @@ export type GetTeamByIdOptions = {
teamId: number;
};
export type GetTeamResponse = Awaited<ReturnType<typeof getTeamById>>;
/**
* Get a team given a teamId.
*
@ -28,6 +30,7 @@ export const getTeamById = async ({ userId, teamId }: GetTeamByIdOptions) => {
where: whereFilter,
include: {
teamEmail: true,
teamGlobalSettings: true,
members: {
where: {
userId,
@ -87,6 +90,7 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
},
},
subscription: true,
teamGlobalSettings: true,
members: {
where: {
userId,

View File

@ -1,13 +1,17 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { TeamTransferRequestTemplate } from '@documenso/email/templates/team-transfer-request';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export type RequestTeamOwnershipTransferOptions = {
/**
* The ID of the user initiating the transfer.
@ -93,15 +97,24 @@ export const requestTeamOwnershipTransfer = async ({
token,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
const i18n = await getI18nInstance();
await mailer.sendMail({
to: newOwnerUser.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(
msg`You have been requested to take ownership of team ${team.name} on Documenso`,
),
html,
text,
});
},
{ timeout: 30_000 },

View File

@ -33,6 +33,7 @@ export const resendTeamEmailVerification = async ({
},
include: {
emailVerification: true,
teamGlobalSettings: true,
},
});
@ -61,7 +62,7 @@ export const resendTeamEmailVerification = async ({
},
});
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
await sendTeamEmailVerificationEmail(emailVerification.email, token, team);
},
{ timeout: 30_000 },
);

View File

@ -49,6 +49,9 @@ export const resendTeamMemberInvitation = async ({
},
},
},
include: {
teamGlobalSettings: true,
},
});
if (!team) {
@ -69,9 +72,8 @@ export const resendTeamMemberInvitation = async ({
await sendTeamMemberInviteEmail({
email: teamMemberInvite.email,
token: teamMemberInvite.token,
teamName: team.name,
teamUrl: team.url,
senderName: userName,
team,
});
},
{ timeout: 30_000 },

View File

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

View File

@ -0,0 +1,52 @@
import { prisma } from '@documenso/prisma';
import type { DocumentVisibility } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import type { SupportedLanguageCodes } from '../../constants/i18n';
export type UpdateTeamDocumentSettingsOptions = {
userId: number;
teamId: number;
settings: {
documentVisibility: DocumentVisibility;
documentLanguage: SupportedLanguageCodes;
includeSenderDetails: boolean;
};
};
export const updateTeamDocumentSettings = async ({
userId,
teamId,
settings,
}: UpdateTeamDocumentSettingsOptions) => {
const { documentVisibility, documentLanguage, includeSenderDetails } = settings;
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
},
});
if (!member || member.role !== TeamMemberRole.ADMIN) {
throw new Error('You do not have permission to update this team.');
}
return await prisma.teamGlobalSettings.upsert({
where: {
teamId,
},
create: {
teamId,
documentVisibility,
documentLanguage,
includeSenderDetails,
},
update: {
documentVisibility,
documentLanguage,
includeSenderDetails,
},
});
};

View File

@ -4,6 +4,7 @@ import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
import type { DocumentVisibility } from '@documenso/prisma/client';
export type UpdateTeamOptions = {
userId: number;
@ -11,6 +12,8 @@ export type UpdateTeamOptions = {
data: {
name?: string;
url?: string;
documentVisibility?: DocumentVisibility;
includeSenderDetails?: boolean;
};
};
@ -42,6 +45,18 @@ export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions) =>
data: {
url: data.url,
name: data.name,
teamGlobalSettings: {
upsert: {
create: {
documentVisibility: data.documentVisibility,
includeSenderDetails: data.includeSenderDetails,
},
update: {
documentVisibility: data.documentVisibility,
includeSenderDetails: data.includeSenderDetails,
},
},
},
},
});

View File

@ -1,15 +1,16 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Field, Signature } from '@documenso/prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
DocumentStatus,
FieldType,
@ -17,9 +18,11 @@ import {
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
@ -36,9 +39,12 @@ import {
createRecipientAuthOptions,
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { formatDocumentsPath } from '../../utils/teams';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentFromDirectTemplateOptions = {
directRecipientName?: string;
@ -86,6 +92,11 @@ export const createDocumentFromDirectTemplate = async ({
templateDocumentData: true,
templateMeta: true,
User: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -140,6 +151,9 @@ export const createDocumentFromDirectTemplate = async ({
const metaDateFormat = template.templateMeta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT;
const metaEmailMessage = template.templateMeta?.message || '';
const metaEmailSubject = template.templateMeta?.subject || '';
const metaLanguage =
template.templateMeta?.language ?? template.team?.teamGlobalSettings?.documentLanguage;
const metaSigningOrder = template.templateMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
// Associate, validate and map to a query every direct template recipient field with the provided fields.
const createDirectRecipientFieldArgs = await Promise.all(
@ -230,6 +244,7 @@ export const createDocumentFromDirectTemplate = async ({
createdAt: initialRequestTime,
status: DocumentStatus.PENDING,
externalId: directTemplateExternalId,
visibility: template.team?.teamGlobalSettings?.documentVisibility,
documentDataId: documentData.id,
authOptions: createDocumentAuthOptions({
globalAccessAuth: templateAuthOptions.globalAccessAuth,
@ -254,6 +269,7 @@ export const createDocumentFromDirectTemplate = async ({
recipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
token: nanoid(),
};
}),
@ -265,6 +281,9 @@ export const createDocumentFromDirectTemplate = async ({
dateFormat: metaDateFormat,
message: metaEmailMessage,
subject: metaEmailSubject,
language: metaLanguage,
signingOrder: metaSigningOrder,
distributionMethod: template.templateMeta?.distributionMethod,
},
},
},
@ -328,6 +347,7 @@ export const createDocumentFromDirectTemplate = async ({
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
signedAt: initialRequestTime,
signingOrder: directTemplateRecipient.signingOrder,
Field: {
createMany: {
data: directTemplateNonSignatureFields.map(({ templateField, customText }) => ({
@ -522,6 +542,17 @@ export const createDocumentFromDirectTemplate = async ({
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});
const branding = template.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(template.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(metaLanguage);
await mailer.sendMail({
to: [
{
@ -533,9 +564,9 @@ export const createDocumentFromDirectTemplate = async ({
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Document created from direct template',
html: render(emailTemplate),
text: render(emailTemplate, { plainText: true }),
subject: i18n._(msg`Document created from direct template`),
html,
text,
});
return {
@ -553,6 +584,23 @@ export const createDocumentFromDirectTemplate = async ({
teamId: template.teamId || undefined,
requestMetadata,
});
const updatedDocument = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentData: true,
Recipient: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
data: updatedDocument,
userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined,
});
} catch (err) {
console.error('[CREATE_DOCUMENT_FROM_DIRECT_TEMPLATE]:', err);

View File

@ -10,6 +10,7 @@ export type CreateDocumentFromTemplateLegacyOptions = {
name?: string;
email: string;
role?: RecipientRole;
signingOrder?: number | null;
}[];
};
@ -45,6 +46,12 @@ export const createDocumentFromTemplateLegacy = async ({
Recipient: true,
Field: true,
templateDocumentData: true,
templateMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -67,15 +74,29 @@ export const createDocumentFromTemplateLegacy = async ({
userId,
teamId: template.teamId,
title: template.title,
visibility: template.team?.teamGlobalSettings?.documentVisibility,
documentDataId: documentData.id,
Recipient: {
create: template.Recipient.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
})),
},
documentMeta: {
create: {
subject: template.templateMeta?.subject,
message: template.templateMeta?.message,
timezone: template.templateMeta?.timezone,
dateFormat: template.templateMeta?.dateFormat,
redirectUrl: template.templateMeta?.redirectUrl,
signingOrder: template.templateMeta?.signingOrder ?? undefined,
language:
template.templateMeta?.language || template.team?.teamGlobalSettings?.documentLanguage,
},
},
},
include: {
@ -129,12 +150,14 @@ export const createDocumentFromTemplateLegacy = async ({
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
},
});

View File

@ -1,8 +1,10 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Field } from '@documenso/prisma/client';
import type { DocumentDistributionMethod } from '@documenso/prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
type Field,
type Recipient,
RecipientRole,
SendStatus,
@ -10,6 +12,7 @@ import {
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
@ -23,7 +26,10 @@ import {
} from '../../utils/document-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
type FinalRecipient = Pick<
Recipient,
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder'
> & {
templateRecipientId: number;
fields: Field[];
};
@ -41,6 +47,7 @@ export type CreateDocumentFromTemplateOptions = {
id: number;
name?: string;
email: string;
signingOrder?: number | null;
}[];
/**
@ -54,6 +61,9 @@ export type CreateDocumentFromTemplateOptions = {
password?: string;
dateFormat?: string;
redirectUrl?: string;
signingOrder?: DocumentSigningOrder;
language?: SupportedLanguageCodes;
distributionMethod?: DocumentDistributionMethod;
};
requestMetadata?: RequestMetadata;
};
@ -100,6 +110,11 @@ export const createDocumentFromTemplate = async ({
},
templateDocumentData: true,
templateMeta: true,
team: {
include: {
teamGlobalSettings: true,
},
},
},
});
@ -134,6 +149,7 @@ export const createDocumentFromTemplate = async ({
name: foundRecipient ? foundRecipient.name ?? '' : templateRecipient.name,
email: foundRecipient ? foundRecipient.email : templateRecipient.email,
role: templateRecipient.role,
signingOrder: foundRecipient?.signingOrder ?? templateRecipient.signingOrder,
authOptions: templateRecipient.authOptions,
};
});
@ -150,7 +166,7 @@ export const createDocumentFromTemplate = async ({
const document = await tx.document.create({
data: {
source: DocumentSource.TEMPLATE,
externalId,
externalId: externalId || template.externalId,
templateId: template.id,
userId,
teamId: template.teamId,
@ -160,6 +176,7 @@ export const createDocumentFromTemplate = async ({
globalAccessAuth: templateAuthOptions.globalAccessAuth,
globalActionAuth: templateAuthOptions.globalActionAuth,
}),
visibility: template.team?.teamGlobalSettings?.documentVisibility,
documentMeta: {
create: {
subject: override?.subject || template.templateMeta?.subject,
@ -168,6 +185,17 @@ export const createDocumentFromTemplate = async ({
password: override?.password || template.templateMeta?.password,
dateFormat: override?.dateFormat || template.templateMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
distributionMethod:
override?.distributionMethod || template.templateMeta?.distributionMethod,
emailSettings: template.templateMeta?.emailSettings || undefined,
signingOrder:
override?.signingOrder ||
template.templateMeta?.signingOrder ||
DocumentSigningOrder.PARALLEL,
language:
override?.language ||
template.templateMeta?.language ||
template.team?.teamGlobalSettings?.documentLanguage,
},
},
Recipient: {
@ -189,6 +217,7 @@ export const createDocumentFromTemplate = async ({
recipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
token: nanoid(),
};
}),

View File

@ -1,3 +1,5 @@
import { omit } from 'remeda';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
@ -38,6 +40,7 @@ export const duplicateTemplate = async ({
Recipient: true,
Field: true,
templateDocumentData: true,
templateMeta: true,
},
});
@ -53,6 +56,17 @@ export const duplicateTemplate = async ({
},
});
let templateMeta: Prisma.TemplateCreateArgs['data']['templateMeta'] | undefined = undefined;
if (template.templateMeta) {
templateMeta = {
create: {
...omit(template.templateMeta, ['id', 'templateId']),
emailSettings: template.templateMeta.emailSettings || undefined,
},
};
}
const duplicatedTemplate = await prisma.template.create({
data: {
userId,
@ -66,8 +80,8 @@ export const duplicateTemplate = async ({
token: nanoid(),
})),
},
templateMeta,
},
include: {
Recipient: true,
},

View File

@ -51,6 +51,12 @@ export const findTemplates = async ({
},
Field: true,
Recipient: true,
templateMeta: {
select: {
signingOrder: true,
distributionMethod: true,
},
},
directLink: {
select: {
token: true,

View File

@ -42,6 +42,13 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
templateMeta: true,
Recipient: true,
Field: true,
User: {
select: {
id: true,
name: true,
email: true,
},
},
},
});

View File

@ -33,7 +33,7 @@ export const updateTemplateSettings = async ({
meta,
data,
}: UpdateTemplateSettingsOptions) => {
if (Object.values(data).length === 0) {
if (Object.values(data).length === 0 && Object.keys(meta ?? {}).length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
}
@ -100,7 +100,7 @@ export const updateTemplateSettings = async ({
},
data: {
title: data.title,
externalId: data.externalId || null,
externalId: data.externalId,
type: data.type,
publicDescription: data.publicDescription,
publicTitle: data.publicTitle,
@ -112,9 +112,11 @@ export const updateTemplateSettings = async ({
},
create: {
...meta,
emailSettings: meta?.emailSettings || undefined,
},
update: {
...meta,
emailSettings: meta?.emailSettings || undefined,
},
},
},

View File

@ -3,7 +3,7 @@ import { hash } from '@node-rs/bcrypt';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { prisma } from '@documenso/prisma';
import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/prisma/client';
import { IdentityProvider, TeamMemberInviteStatus } from '@documenso/prisma/client';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { SALT_ROUNDS } from '../../constants/auth';
@ -59,11 +59,11 @@ export const createUser = async ({ name, email, password, signature, url }: Crea
const acceptedTeamInvites = await prisma.teamMemberInvite.findMany({
where: {
status: TeamMemberInviteStatus.ACCEPTED,
email: {
equals: email,
mode: Prisma.QueryMode.insensitive,
mode: 'insensitive',
},
status: TeamMemberInviteStatus.ACCEPTED,
},
});

View File

@ -39,7 +39,7 @@ export const sendConfirmationToken = async ({
mostRecentToken?.createdAt &&
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
return;
// return;
}
const createdToken = await prisma.verificationToken.create({
@ -64,6 +64,7 @@ export const sendConfirmationToken = async ({
return { success: true };
} catch (err) {
console.log(err);
throw new Error(`Failed to send the confirmation email`);
}
};

View File

@ -1,6 +1,6 @@
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../../constants/app';
import { sign } from '../../crypto/sign';
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
@ -29,7 +29,7 @@ export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWeb
const signature = sign(body);
await Promise.race([
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/webhook/trigger`, {
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL}/api/webhook/trigger`, {
method: 'POST',
headers: {
'content-type': 'application/json',