Merge branch 'main' into feat/org-insights

This commit is contained in:
Ephraim Duncan
2025-08-13 11:13:55 +00:00
committed by GitHub
260 changed files with 21956 additions and 2726 deletions

View File

@ -1,5 +1,10 @@
import { initContract } from '@ts-rest/core';
import {
ZCreateTemplateV2RequestSchema,
ZCreateTemplateV2ResponseSchema,
} from '@documenso/trpc/server/template-router/schema';
import {
ZAuthorizationHeadersSchema,
ZCreateDocumentFromTemplateMutationResponseSchema,
@ -87,6 +92,18 @@ export const ApiContractV1 = c.router(
summary: 'Upload a new document and get a presigned URL',
},
createTemplate: {
method: 'POST',
path: '/api/v1/templates',
body: ZCreateTemplateV2RequestSchema,
responses: {
200: ZCreateTemplateV2ResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Create a new template and get a presigned URL',
},
deleteTemplate: {
method: 'DELETE',
path: '/api/v1/templates/:id',

View File

@ -30,6 +30,7 @@ import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-
import { updateDocumentRecipients } from '@documenso/lib/server-only/recipient/update-document-recipients';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
@ -400,6 +401,109 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}
}),
createTemplate: authenticatedMiddleware(async (args, user, team) => {
const { body } = args;
const {
title,
folderId,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
publicTitle,
publicDescription,
type,
meta,
} = body;
try {
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
return {
status: 500,
body: {
message: 'Create template is not available without S3 transport.',
},
};
}
const dateFormat = meta?.dateFormat
? DATE_FORMATS.find((format) => format.value === meta?.dateFormat)
: DATE_FORMATS.find((format) => format.value === DEFAULT_DOCUMENT_DATE_FORMAT);
if (meta?.dateFormat && !dateFormat) {
return {
status: 400,
body: {
message: 'Invalid date format. Please provide a valid date format',
},
};
}
const timezone = meta?.timezone
? TIME_ZONES.find((tz) => tz === meta?.timezone)
: DEFAULT_DOCUMENT_TIME_ZONE;
const isTimeZoneValid = meta?.timezone ? TIME_ZONES.includes(String(timezone)) : true;
if (!isTimeZoneValid) {
return {
status: 400,
body: {
message: 'Invalid timezone. Please provide a valid timezone',
},
};
}
const fileName = title?.endsWith('.pdf') ? title : `${title}.pdf`;
const { url, key } = await getPresignPostUrl(fileName, 'application/pdf');
const templateDocumentData = await createDocumentData({
data: key,
type: DocumentDataType.S3_PATH,
});
const createdTemplate = await createTemplate({
userId: user.id,
teamId: team.id,
templateDocumentDataId: templateDocumentData.id,
data: {
title,
folderId,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
publicTitle,
publicDescription,
type,
},
meta,
});
const fullTemplate = await getTemplateById({
id: createdTemplate.id,
userId: user.id,
teamId: team.id,
});
return {
status: 200,
body: {
uploadUrl: url,
template: fullTemplate,
},
};
} catch (err) {
return {
status: 404,
body: {
message: 'An error has occured while creating the template',
},
};
}
}),
deleteTemplate: authenticatedMiddleware(async (args, user, team, { logger }) => {
const { id: templateId } = args.params;
@ -1432,7 +1536,6 @@ const updateDocument = async ({
return await prisma.document.update({
where: {
id: documentId,
userId,
team: buildTeamWhereQuery({ teamId, userId }),
},
data: {

View File

@ -1178,13 +1178,12 @@ test.describe('Unauthorized Access - Document API V2', () => {
const { user: firstRecipientUser } = await seedUser();
const { user: secondRecipientUser } = await seedUser();
await prisma.template.update({
const updatedTemplate = await prisma.template.update({
where: { id: template.id },
data: {
recipients: {
create: [
{
id: firstRecipientUser.id,
name: firstRecipientUser.name || '',
email: firstRecipientUser.email,
token: nanoid(12),
@ -1193,7 +1192,6 @@ test.describe('Unauthorized Access - Document API V2', () => {
signingStatus: SigningStatus.NOT_SIGNED,
},
{
id: secondRecipientUser.id,
name: secondRecipientUser.name || '',
email: secondRecipientUser.email,
token: nanoid(12),
@ -1204,21 +1202,35 @@ test.describe('Unauthorized Access - Document API V2', () => {
],
},
},
include: {
recipients: true,
},
});
const recipientAId = updatedTemplate.recipients.find(
(recipient) => recipient.email === firstRecipientUser.email,
)?.id;
const recipientBId = updatedTemplate.recipients.find(
(recipient) => recipient.email === secondRecipientUser.email,
)?.id;
if (!recipientAId || !recipientBId) {
throw new Error('Recipient IDs not found');
}
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
templateId: template.id,
recipients: [
{
id: firstRecipientUser.id,
id: recipientAId,
name: firstRecipientUser.name,
email: firstRecipientUser.email,
role: RecipientRole.SIGNER,
},
{
id: secondRecipientUser.id,
id: recipientBId,
name: secondRecipientUser.name,
email: secondRecipientUser.email,
role: RecipientRole.SIGNER,

View File

@ -244,7 +244,7 @@ test.describe('Signing Certificate Tests', () => {
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/settings/preferences`,
redirectPath: `/t/${team.url}/settings/document`,
});
await page

View File

@ -168,7 +168,7 @@ test('[TEAMS]: can rename a document folder', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Settings' }).click();
await page.getByLabel('Name').fill('Team Archive');
await page.getByRole('button', { name: 'Save Changes' }).click();
await page.getByRole('button', { name: 'Update' }).click();
await expect(page.getByText('Team Archive')).toBeVisible();
});
@ -379,10 +379,11 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
.filter({ hasText: /^Upload Template DocumentDrag & drop your PDF here\.$/ })
.nth(2)
.click();
await page.locator('input[type="file"]').waitFor({ state: 'attached' });
await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' });
await page
.locator('input[type="file"]')
.nth(0)
.setInputFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
await page.waitForTimeout(3000);
@ -470,7 +471,7 @@ test('[TEAMS]: can rename a template folder', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Settings' }).click();
await page.getByLabel('Name').fill('Updated Team Template Folder');
await page.getByRole('button', { name: 'Save Changes' }).click();
await page.getByRole('button', { name: 'Update' }).click();
await expect(page.getByText('Updated Team Template Folder')).toBeVisible();
});

View File

@ -8,7 +8,7 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
const { user, organisation, team } = await seedUser({
isPersonalOrganisation: false,
});
@ -16,7 +16,7 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/preferences`,
redirectPath: `/o/${organisation.url}/settings/document`,
});
// Update document preferences.
@ -24,26 +24,25 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
await page.getByRole('option', { name: 'Only managers and above can' }).click();
await page.getByRole('combobox').filter({ hasText: 'English' }).click();
await page.getByRole('option', { name: 'German' }).click();
await page.getByTestId('signature-types-combobox').click();
// Set default timezone
await page.getByRole('combobox').filter({ hasText: 'Local timezone' }).click();
await page.getByRole('option', { name: 'Australia/Perth' }).click();
// Set default date
await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm a' }).click();
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
await page.getByTestId('signature-types-trigger').click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Upload' }).click();
await page.getByRole('combobox').nth(3).click();
await page.getByTestId('include-sender-details-trigger').click();
await page.getByRole('option', { name: 'No' }).click();
await page.getByRole('combobox').filter({ hasText: 'Yes' }).click();
await page.getByTestId('include-signing-certificate-trigger').click();
await page.getByRole('option', { name: 'No' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Update branding.
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
await page.getByRole('button', { name: 'Update' }).nth(1).click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
teamId: team.id,
});
@ -51,34 +50,30 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
// Check that the team settings have inherited these values.
expect(teamSettings.documentVisibility).toEqual(DocumentVisibility.MANAGER_AND_ABOVE);
expect(teamSettings.documentLanguage).toEqual('de');
expect(teamSettings.documentTimezone).toEqual('Australia/Perth');
expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(teamSettings.includeSenderDetails).toEqual(false);
expect(teamSettings.includeSigningCertificate).toEqual(false);
expect(teamSettings.typedSignatureEnabled).toEqual(true);
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
expect(teamSettings.drawSignatureEnabled).toEqual(false);
expect(teamSettings.brandingEnabled).toEqual(true);
expect(teamSettings.brandingUrl).toEqual('https://documenso.com');
expect(teamSettings.brandingCompanyDetails).toEqual('BrandDetails');
// Edit the team settings
await page.goto(`/t/${team.url}/settings/preferences`);
await page.goto(`/t/${team.url}/settings/document`);
await page
.getByRole('group')
.locator('div')
.filter({
hasText: 'Default Document Visibility',
})
.getByRole('combobox')
.click();
await page.getByTestId('document-visibility-trigger').click();
await page.getByRole('option', { name: 'Everyone can access and view' }).click();
await page
.getByRole('group')
.locator('div')
.filter({ hasText: 'Default Document Language' })
.getByRole('combobox')
.click();
await page.getByTestId('document-language-trigger').click();
await page.getByRole('option', { name: 'Polish' }).click();
// Override team timezone settings
await page.getByTestId('document-timezone-trigger').click();
await page.getByRole('option', { name: 'Europe/London' }).click();
// Override team date format settings
await page.getByTestId('document-date-format-trigger').click();
await page.getByRole('option', { name: 'MM/DD/YYYY' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
@ -89,6 +84,8 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
// Check that the team settings have inherited/overriden the correct values.
expect(updatedTeamSettings.documentVisibility).toEqual(DocumentVisibility.EVERYONE);
expect(updatedTeamSettings.documentLanguage).toEqual('pl');
expect(updatedTeamSettings.documentTimezone).toEqual('Europe/London');
expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy hh:mm a');
expect(updatedTeamSettings.includeSenderDetails).toEqual(false);
expect(updatedTeamSettings.includeSigningCertificate).toEqual(false);
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
@ -110,4 +107,228 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
expect(documentMeta.uploadSignatureEnabled).toEqual(false);
expect(documentMeta.drawSignatureEnabled).toEqual(false);
expect(documentMeta.language).toEqual('pl');
expect(documentMeta.timezone).toEqual('Europe/London');
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy hh:mm a');
});
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
const { user, organisation, team } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
// Update branding preferences.
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings have inherited these values.
expect(teamSettings.brandingEnabled).toEqual(true);
expect(teamSettings.brandingUrl).toEqual('https://documenso.com');
expect(teamSettings.brandingCompanyDetails).toEqual('BrandDetails');
// Edit the team branding settings
await page.goto(`/t/${team.url}/settings/branding`);
// Override team settings with different values
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails');
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings have overridden the organisation values.
expect(updatedTeamSettings.brandingEnabled).toEqual(true);
expect(updatedTeamSettings.brandingUrl).toEqual('https://example.com');
expect(updatedTeamSettings.brandingCompanyDetails).toEqual('UpdatedBrandDetails');
// Test inheritance by setting team back to inherit from organisation
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
await page.waitForTimeout(2000);
const inheritedTeamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings now inherit from organisation again.
expect(inheritedTeamSettings.brandingEnabled).toEqual(true);
expect(inheritedTeamSettings.brandingUrl).toEqual('https://documenso.com');
expect(inheritedTeamSettings.brandingCompanyDetails).toEqual('BrandDetails');
// Verify that a document can be created successfully with the branding settings
const document = await seedTeamDocumentWithMeta(team);
// Confirm the document was created successfully with the team's branding settings
expect(document).toBeDefined();
expect(document.teamId).toEqual(team.id);
});
test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
const { user, organisation, team } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/email`,
});
// Update email preferences at organisation level.
// Set reply to email
await page.getByRole('textbox', { name: 'Reply to email' }).click();
await page.getByRole('textbox', { name: 'Reply to email' }).fill('organisation@documenso.com');
// Update email document settings by enabling/disabling some checkboxes
await page.getByRole('checkbox', { name: 'Send recipient signed email' }).uncheck();
await page.getByRole('checkbox', { name: 'Send document pending email' }).uncheck();
await page.getByRole('checkbox', { name: 'Send document deleted email' }).uncheck();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings have inherited these values.
expect(teamSettings.emailReplyTo).toEqual('organisation@documenso.com');
expect(teamSettings.emailDocumentSettings).toEqual({
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: false, // unchecked
documentPending: false, // unchecked
documentCompleted: true,
documentDeleted: false, // unchecked
ownerDocumentCompleted: true,
});
// Edit the team email settings
await page.goto(`/t/${team.url}/settings/email`);
// Override team settings with different values
await page.getByRole('textbox', { name: 'Reply to email' }).click();
await page.getByRole('textbox', { name: 'Reply to email' }).fill('team@example.com');
// Change email document settings inheritance to controlled
await page.getByRole('combobox').filter({ hasText: 'Inherit from organisation' }).click();
await page.getByRole('option', { name: 'Override organisation settings' }).click();
// Update some email settings
await page.getByRole('checkbox', { name: 'Send recipient signing request email' }).uncheck();
await page
.getByRole('checkbox', { name: 'Send document completed email', exact: true })
.uncheck();
await page
.getByRole('checkbox', { name: 'Send document completed email to the owner' })
.uncheck();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings have overridden the organisation values.
expect(updatedTeamSettings.emailReplyTo).toEqual('team@example.com');
expect(updatedTeamSettings.emailDocumentSettings).toEqual({
recipientSigned: true,
recipientSigningRequest: false,
recipientRemoved: true,
documentPending: true,
documentCompleted: false,
documentDeleted: true,
ownerDocumentCompleted: false,
});
// Verify that a document can be created successfully with the team email settings
const teamOverrideDocument = await seedTeamDocumentWithMeta(team);
const teamOverrideDocumentMeta = await prisma.documentMeta.findFirstOrThrow({
where: {
documentId: teamOverrideDocument.id,
},
});
expect(teamOverrideDocumentMeta.emailReplyTo).toEqual('team@example.com');
expect(teamOverrideDocumentMeta.emailSettings).toEqual({
recipientSigned: true,
recipientSigningRequest: false,
recipientRemoved: true,
documentPending: true,
documentCompleted: false,
documentDeleted: true,
ownerDocumentCompleted: false,
});
// Test inheritance by setting team back to inherit from organisation
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
await page.waitForTimeout(1000);
const inheritedTeamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings now inherit from organisation again.
expect(inheritedTeamSettings.emailReplyTo).toEqual('organisation@documenso.com');
expect(inheritedTeamSettings.emailDocumentSettings).toEqual({
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: false,
documentPending: false,
documentCompleted: true,
documentDeleted: false,
ownerDocumentCompleted: true,
});
// Verify that a document can be created successfully with the email settings
const document = await seedTeamDocumentWithMeta(team);
const documentMeta = await prisma.documentMeta.findFirstOrThrow({
where: {
documentId: document.id,
},
});
expect(documentMeta.emailReplyTo).toEqual('organisation@documenso.com');
expect(documentMeta.emailSettings).toEqual({
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: false,
documentPending: false,
documentCompleted: true,
documentDeleted: false,
ownerDocumentCompleted: true,
});
});

View File

@ -15,7 +15,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/preferences`,
redirectPath: `/t/${team.url}/settings/document`,
});
const document = await seedTeamDocumentWithMeta(team);
@ -45,17 +45,17 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/preferences`,
redirectPath: `/t/${team.url}/settings/document`,
});
const allTabs = ['Type', 'Upload', 'Draw'];
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
for (const tabs of tabTest) {
await page.goto(`/t/${team.url}/settings/preferences`);
await page.goto(`/t/${team.url}/settings/document`);
// Update combobox to have the correct tabs
await page.getByTestId('signature-types-combobox').click();
await page.getByTestId('signature-types-trigger').click();
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
@ -112,17 +112,17 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/preferences`,
redirectPath: `/t/${team.url}/settings/document`,
});
const allTabs = ['Type', 'Upload', 'Draw'];
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
for (const tabs of tabTest) {
await page.goto(`/t/${team.url}/settings/preferences`);
await page.goto(`/t/${team.url}/settings/document`);
// Update combobox to have the correct tabs
await page.getByTestId('signature-types-combobox').click();
await page.getByTestId('signature-types-trigger').click();
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();

View File

@ -268,7 +268,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
page.getByTestId('template-use-dialog-file-input').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
@ -361,7 +361,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
page.getByTestId('template-use-dialog-file-input').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}

View File

@ -0,0 +1,154 @@
import { CreateEmailIdentityCommand, SESv2Client } from '@aws-sdk/client-sesv2';
import { EmailDomainStatus } from '@prisma/client';
import { generateKeyPair } from 'crypto';
import { promisify } from 'util';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
export const getSesClient = () => {
const accessKeyId = env('NEXT_PRIVATE_SES_ACCESS_KEY_ID');
const secretAccessKey = env('NEXT_PRIVATE_SES_SECRET_ACCESS_KEY');
const region = env('NEXT_PRIVATE_SES_REGION');
if (!accessKeyId || !secretAccessKey || !region) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Missing AWS SES credentials',
});
}
return new SESv2Client({
region,
credentials: {
accessKeyId,
secretAccessKey,
},
});
};
/**
* Removes first and last line, then removes all newlines
*/
const flattenKey = (key: string) => {
return key.trim().split('\n').slice(1, -1).join('');
};
export async function verifyDomainWithDKIM(domain: string, selector: string, privateKey: string) {
const command = new CreateEmailIdentityCommand({
EmailIdentity: domain,
DkimSigningAttributes: {
DomainSigningSelector: selector,
DomainSigningPrivateKey: privateKey,
},
});
return await getSesClient().send(command);
}
type CreateEmailDomainOptions = {
domain: string;
organisationId: string;
};
type DomainRecord = {
name: string;
value: string;
type: string;
};
export const createEmailDomain = async ({ domain, organisationId }: CreateEmailDomainOptions) => {
const encryptionKey = DOCUMENSO_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
const selector = `documenso-${organisationId}`.replace(/[_.]/g, '-');
const recordName = `${selector}._domainkey.${domain}`;
// Check if domain already exists
const existingDomain = await prisma.emailDomain.findUnique({
where: {
domain,
},
});
if (existingDomain) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Domain already exists in database',
});
}
// Generate DKIM key pair
const generateKeyPairAsync = promisify(generateKeyPair);
const { publicKey, privateKey } = await generateKeyPairAsync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
});
// Format public key for DNS record
const publicKeyFlattened = flattenKey(publicKey);
const privateKeyFlattened = flattenKey(privateKey);
// Create DNS records
const records: DomainRecord[] = generateEmailDomainRecords(recordName, publicKeyFlattened);
const encryptedPrivateKey = symmetricEncrypt({
key: encryptionKey,
data: privateKeyFlattened,
});
const emailDomain = await prisma.$transaction(async (tx) => {
await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => {
if (err.name === 'AlreadyExistsException') {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Domain already exists in SES',
});
}
throw err;
});
// Create email domain record.
return await tx.emailDomain.create({
data: {
id: generateDatabaseId('email_domain'),
domain,
status: EmailDomainStatus.PENDING,
organisationId,
selector: recordName,
publicKey: publicKeyFlattened,
privateKey: encryptedPrivateKey,
},
select: {
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
publicKey: true,
createdAt: true,
updatedAt: true,
emails: true,
},
});
});
return {
emailDomain,
records,
};
};

View File

@ -0,0 +1,52 @@
import { DeleteEmailIdentityCommand } from '@aws-sdk/client-sesv2';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { getSesClient } from './create-email-domain';
type DeleteEmailDomainOptions = {
emailDomainId: string;
};
/**
* Delete the email domain and SES email identity.
*
* Permission is assumed to be checked in the caller.
*/
export const deleteEmailDomain = async ({ emailDomainId }: DeleteEmailDomainOptions) => {
const emailDomain = await prisma.emailDomain.findUnique({
where: {
id: emailDomainId,
},
});
if (!emailDomain) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email domain not found',
});
}
const sesClient = getSesClient();
await sesClient
.send(
new DeleteEmailIdentityCommand({
EmailIdentity: emailDomain.domain,
}),
)
.catch((err) => {
console.error(err);
// Do nothing if it no longer exists in SES.
if (err.name === 'NotFoundException') {
return;
}
});
await prisma.emailDomain.delete({
where: {
id: emailDomainId,
},
});
};

View File

@ -0,0 +1,45 @@
import { GetEmailIdentityCommand } from '@aws-sdk/client-sesv2';
import { EmailDomainStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { getSesClient } from './create-email-domain';
export const verifyEmailDomain = async (emailDomainId: string) => {
const emailDomain = await prisma.emailDomain.findUnique({
where: {
id: emailDomainId,
},
});
if (!emailDomain) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email domain not found',
});
}
const sesClient = getSesClient();
const response = await sesClient.send(
new GetEmailIdentityCommand({
EmailIdentity: emailDomain.domain,
}),
);
const isVerified = response.VerificationStatus === 'SUCCESS';
const updatedEmailDomain = await prisma.emailDomain.update({
where: {
id: emailDomainId,
},
data: {
status: isVerified ? EmailDomainStatus.ACTIVE : EmailDomainStatus.PENDING,
},
});
return {
emailDomain: updatedEmailDomain,
isVerified,
};
};

View File

@ -1,13 +0,0 @@
import { z } from 'zod';
export const ZEarlyAdopterCheckoutMetadataSchema = z.object({
name: z.string(),
email: z.string(),
signatureText: z.string(),
signatureDataUrl: z.string().optional(),
source: z.literal('marketing'),
});
export type TEarlyAdopterCheckoutMetadataSchema = z.infer<
typeof ZEarlyAdopterCheckoutMetadataSchema
>;

View File

@ -81,17 +81,34 @@ export const onSubscriptionCreated = async ({ subscription }: OnSubscriptionCrea
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('trialing', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
await prisma.subscription.create({
data: {
const periodEnd =
subscription.status === 'trialing' && subscription.trial_end
? new Date(subscription.trial_end * 1000)
: new Date(subscription.current_period_end * 1000);
await prisma.subscription.upsert({
where: {
organisationId,
},
create: {
organisationId,
status,
customerId,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
periodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
status,
customerId,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
@ -172,14 +189,17 @@ const handleOrganisationUpdate = async ({ customerId, claim }: HandleOrganisatio
}
// Todo: logging
if (organisation.subscription) {
console.error('Organisation already has a subscription');
if (
organisation.subscription &&
organisation.subscription.status !== SubscriptionStatus.INACTIVE
) {
console.error('Organisation already has an active subscription');
// This should never happen
throw Response.json(
{
success: false,
message: `Organisation already has a subscription`,
message: `Organisation already has an active subscription`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);

View File

@ -1,8 +1,9 @@
import { SubscriptionStatus } from '@prisma/client';
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
export type OnSubscriptionUpdatedOptions = {
@ -55,8 +56,12 @@ export const onSubscriptionUpdated = async ({
);
}
if (organisation.subscription?.planId !== subscription.id) {
console.error('[WARNING]: Organisation has two subscriptions');
if (
organisation.subscription &&
organisation.subscription.status !== SubscriptionStatus.INACTIVE &&
organisation.subscription.planId !== subscription.id
) {
console.error('[WARNING]: Organisation might have two subscriptions');
}
const previousItem = previousAttributes?.items?.data[0];
@ -83,20 +88,41 @@ export const onSubscriptionUpdated = async ({
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('trialing', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
const periodEnd =
subscription.status === 'trialing' && subscription.trial_end
? new Date(subscription.trial_end * 1000)
: new Date(subscription.current_period_end * 1000);
// Migrate the organisation type if it is no longer an individual plan.
if (
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.INDIVIDUAL &&
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.FREE &&
organisation.type === OrganisationType.PERSONAL
) {
await prisma.organisation.update({
where: {
id: organisation.id,
},
data: {
type: OrganisationType.ORGANISATION,
},
});
}
await prisma.$transaction(async (tx) => {
await tx.subscription.update({
where: {
planId: subscription.id,
organisationId: organisation.id,
},
data: {
organisationId: organisation.id,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
periodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
@ -10,7 +10,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
width: 0,
});
const calculateBounds = () => {
const calculateBounds = useCallback(() => {
const $el =
typeof elementOrSelector === 'string'
? document.querySelector<HTMLElement>(elementOrSelector)
@ -32,11 +32,11 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
width,
height,
};
};
}, [elementOrSelector, withScroll]);
useEffect(() => {
setBounds(calculateBounds());
}, [calculateBounds]);
}, []);
useEffect(() => {
const onResize = () => {
@ -48,7 +48,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateBounds]);
}, []);
useEffect(() => {
const $el =
@ -69,7 +69,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => {
observer.disconnect();
};
}, [calculateBounds]);
}, []);
return bounds;
};

View File

@ -12,3 +12,5 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL =
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
export const API_V2_BETA_URL = '/api/v2-beta';
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';

View File

@ -9,6 +9,7 @@ export const VALID_DATE_FORMAT_VALUES = [
'yyyy-MM-dd',
'dd/MM/yyyy hh:mm a',
'MM/dd/yyyy hh:mm a',
'dd.MM.yyyy HH:mm',
'yyyy-MM-dd HH:mm',
'yy-MM-dd hh:mm a',
'yyyy-MM-dd HH:mm:ss',
@ -40,6 +41,11 @@ export const DATE_FORMATS = [
label: 'MM/DD/YYYY',
value: 'MM/dd/yyyy hh:mm a',
},
{
key: 'DDMMYYYYHHMM',
label: 'DD.MM.YYYY HH:mm',
value: 'dd.MM.yyyy HH:mm',
},
{
key: 'YYYYMMDDHHmm',
label: 'YYYY-MM-DD HH:mm',

View File

@ -49,15 +49,24 @@ type DocumentSignatureTypeData = {
export const DOCUMENT_SIGNATURE_TYPES = {
[DocumentSignatureType.DRAW]: {
label: msg`Draw`,
label: msg({
message: `Draw`,
context: `Draw signatute type`,
}),
value: DocumentSignatureType.DRAW,
},
[DocumentSignatureType.TYPE]: {
label: msg`Type`,
label: msg({
message: `Type`,
context: `Type signatute type`,
}),
value: DocumentSignatureType.TYPE,
},
[DocumentSignatureType.UPLOAD]: {
label: msg`Upload`,
label: msg({
message: `Upload`,
context: `Upload signatute type`,
}),
value: DocumentSignatureType.UPLOAD,
},
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;

View File

@ -3,6 +3,11 @@ import { env } from '../utils/env';
export const FROM_ADDRESS = env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com';
export const FROM_NAME = env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso';
export const DOCUMENSO_INTERNAL_EMAIL = {
name: FROM_NAME,
address: FROM_ADDRESS,
};
export const SERVICE_USER_EMAIL = 'serviceaccount@documenso.com';
export const EMAIL_VERIFICATION_STATE = {

View File

@ -4,39 +4,114 @@ import { RecipientRole } from '@prisma/client';
export const RECIPIENT_ROLES_DESCRIPTION = {
[RecipientRole.APPROVER]: {
actionVerb: msg`Approve`,
actioned: msg`Approved`,
progressiveVerb: msg`Approving`,
roleName: msg`Approver`,
roleNamePlural: msg`Approvers`,
actionVerb: msg({
message: `Approve`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Approved`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Approving`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Approver`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Approvers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.CC]: {
actionVerb: msg`CC`,
actioned: msg`CC'd`,
progressiveVerb: msg`CC`,
roleName: msg`Cc`,
roleNamePlural: msg`Ccers`,
actionVerb: msg({
message: `CC`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `CC'd`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `CC`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Cc`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Ccers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.SIGNER]: {
actionVerb: msg`Sign`,
actioned: msg`Signed`,
progressiveVerb: msg`Signing`,
roleName: msg`Signer`,
roleNamePlural: msg`Signers`,
actionVerb: msg({
message: `Sign`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Signed`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Signing`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Signer`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Signers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.VIEWER]: {
actionVerb: msg`View`,
actioned: msg`Viewed`,
progressiveVerb: msg`Viewing`,
roleName: msg`Viewer`,
roleNamePlural: msg`Viewers`,
actionVerb: msg({
message: `View`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Viewed`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Viewing`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Viewer`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Viewers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.ASSISTANT]: {
actionVerb: msg`Assist`,
actioned: msg`Assisted`,
progressiveVerb: msg`Assisting`,
roleName: msg`Assistant`,
roleNamePlural: msg`Assistants`,
actionVerb: msg({
message: `Assist`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Assisted`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Assisting`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Assistant`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Assistants`,
context: `Recipient role plural name`,
}),
},
} satisfies Record<keyof typeof RecipientRole, unknown>;

View File

@ -3,6 +3,10 @@ import { msg } from '@lingui/core/macro';
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;
export const isTemplateRecipientEmailPlaceholder = (email: string) => {
return TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX.test(email);
};
export const DIRECT_TEMPLATE_DOCUMENTATION = [
{
title: msg`Enable Direct Link Signing`,

View File

@ -9,7 +9,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -43,11 +42,13 @@ export const run = async ({
},
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const { documentMeta, user: documentOwner } = document;
@ -59,9 +60,7 @@ export const run = async ({
return;
}
const lang = documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
// Send cancellation emails to all recipients who have been sent the document or viewed it
const recipientsToNotify = document.recipients.filter(
@ -82,9 +81,9 @@ export const run = async ({
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -95,10 +94,8 @@ export const run = async ({
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document "${document.title}" Cancelled`),
html,
text,

View File

@ -8,7 +8,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -56,7 +55,8 @@ export const run = async ({
},
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
@ -80,29 +80,24 @@ export const run = async ({
organisationUrl: organisation.url,
});
const lang = settings.documentLanguage;
// !: Replace with the actual language of the recipient later
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`A new member has joined your organisation`),
html,
text,

View File

@ -8,7 +8,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -52,7 +51,8 @@ export const run = async ({
},
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
@ -76,28 +76,23 @@ export const run = async ({
organisationUrl: organisation.url,
});
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`A member has left your organisation`),
html,
text,

View File

@ -8,7 +8,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -71,17 +70,18 @@ export const run = async ({
return;
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
const template = createElement(DocumentRecipientSignedEmailTemplate, {
documentName: document.title,
@ -92,9 +92,9 @@ export const run = async ({
await io.runTask('send-recipient-signed-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -105,10 +105,7 @@ export const run = async ({
name: owner.name ?? '',
address: owner.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`${recipientReference} has signed "${document.title}"`),
html,
text,

View File

@ -10,7 +10,7 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -52,7 +52,7 @@ export const run = async ({
}),
]);
const { documentMeta, user: documentOwner } = document;
const { user: documentOwner } = document;
const isEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
@ -62,16 +62,16 @@ export const run = async ({
return;
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const lang = documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
// Send confirmation email to the recipient who rejected
await io.runTask('send-rejection-confirmation-email', async () => {
@ -84,9 +84,9 @@ export const run = async ({
});
const [html, text] = await Promise.all([
renderEmailWithI18N(recipientTemplate, { lang, branding }),
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(recipientTemplate, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -97,10 +97,8 @@ export const run = async ({
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document "${document.title}" - Rejection Confirmed`),
html,
text,
@ -120,9 +118,9 @@ export const run = async ({
});
const [html, text] = await Promise.all([
renderEmailWithI18N(ownerTemplate, { lang, branding }),
renderEmailWithI18N(ownerTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(ownerTemplate, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -133,10 +131,7 @@ export const run = async ({
name: documentOwner.name || '',
address: documentOwner.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: DOCUMENSO_INTERNAL_EMAIL, // Purposefully using internal email here.
subject: i18n._(msg`Document "${document.title}" - Rejected by ${recipient.name}`),
html,
text,

View File

@ -15,7 +15,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
@ -80,12 +79,15 @@ export const run = async ({
return;
}
const { branding, settings, organisationType } = await getEmailContext({
source: {
type: 'team',
teamId: document.teamId,
},
});
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const customEmail = document?.documentMeta;
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
@ -95,9 +97,7 @@ export const run = async ({
const { email, name } = recipient;
const selfSigner = email === user.email;
const lang = documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
@ -166,9 +166,9 @@ export const run = async ({
await io.runTask('send-signing-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -179,10 +179,8 @@ export const run = async ({
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: renderCustomEmailTemplate(
documentMeta?.subject || emailSubject,
customEmailTemplate,

View File

@ -13,7 +13,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { AppError } from '../../../errors/app-error';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -162,24 +161,23 @@ export const run = async ({
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId,
},
});
const lang = template.templateMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
const [html, text] = await Promise.all([
renderEmailWithI18N(completionTemplate, {
lang,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(completionTemplate, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -190,10 +188,7 @@ export const run = async ({
name: user.name || '',
address: user.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`Bulk Send Complete: ${template.title}`),
html,
text,

View File

@ -9,6 +9,7 @@ import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
@ -145,7 +146,24 @@ export const run = async ({
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
}).catch((e) => {
console.log('Failed to get certificate PDF');
console.error(e);
return null;
})
: null;
const auditLogData = settings.includeAuditLog
? await getAuditLogsPdf({
documentId,
language: document.documentMeta?.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null;
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
@ -174,6 +192,16 @@ export const run = async ({
});
}
if (auditLogData) {
const auditLogDoc = await PDFDocument.load(auditLogData);
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
pdfDoc.addPage(page);
});
}
for (const field of fields) {
if (field.inserted) {
document.useLegacyFieldInsertion

View File

@ -16,6 +16,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/client-sesv2": "^3.410.0",
"@aws-sdk/cloudfront-signer": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
"@aws-sdk/signature-v4-crt": "^3.410.0",

View File

@ -23,6 +23,8 @@ export type CreateDocumentMetaOptions = {
password?: string;
dateFormat?: string;
redirectUrl?: string;
emailId?: string | null;
emailReplyTo?: string | null;
emailSettings?: TDocumentEmailSettings;
signingOrder?: DocumentSigningOrder;
allowDictateNextSigner?: boolean;
@ -46,6 +48,8 @@ export const upsertDocumentMeta = async ({
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailId,
emailReplyTo,
emailSettings,
distributionMethod,
typedSignatureEnabled,
@ -54,7 +58,7 @@ export const upsertDocumentMeta = async ({
language,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
const { documentWhereInput, team } = await getDocumentWhereInput({
documentId,
userId,
teamId,
@ -75,6 +79,22 @@ export const upsertDocumentMeta = async ({
const { documentMeta: originalDocumentMeta } = document;
// Validate the emailId belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.$transaction(async (tx) => {
const upsertedDocumentMeta = await tx.documentMeta.upsert({
where: {
@ -90,6 +110,8 @@ export const upsertDocumentMeta = async ({
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailId,
emailReplyTo,
emailSettings,
distributionMethod,
typedSignatureEnabled,
@ -106,6 +128,8 @@ export const upsertDocumentMeta = async ({
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailId,
emailReplyTo,
emailSettings,
distributionMethod,
typedSignatureEnabled,

View File

@ -24,6 +24,7 @@ import {
} from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { buildTeamWhereQuery } from '../../utils/teams';
@ -134,6 +135,24 @@ export const createDocumentV2 = async ({
const visibility = determineDocumentVisibility(settings.documentVisibility, teamRole);
const emailId = meta?.emailId;
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
@ -148,15 +167,7 @@ export const createDocumentV2 = async ({
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: {
...meta,
signingOrder: meta?.signingOrder || undefined,
emailSettings: meta?.emailSettings || undefined,
language: meta?.language || settings.documentLanguage,
typedSignatureEnabled: meta?.typedSignatureEnabled ?? settings.typedSignatureEnabled,
uploadSignatureEnabled: meta?.uploadSignatureEnabled ?? settings.uploadSignatureEnabled,
drawSignatureEnabled: meta?.drawSignatureEnabled ?? settings.drawSignatureEnabled,
},
create: extractDerivedDocumentMeta(settings, meta),
},
},
});
@ -201,7 +212,7 @@ export const createDocumentV2 = async ({
}),
);
// Todo: Is it necessary to create a full audit log with all fields and recipients audit logs?
// Todo: Is it necessary to create a full audit logs with all fields and recipients audit logs?
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({

View File

@ -15,7 +15,9 @@ import {
import { prefixedId } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -29,6 +31,7 @@ export type CreateDocumentOptions = {
formValues?: Record<string, string | number | boolean>;
normalizePdf?: boolean;
timezone?: string;
userTimezone?: string;
requestMetadata: ApiRequestMetadata;
folderId?: string;
};
@ -43,6 +46,7 @@ export const createDocument = async ({
formValues,
requestMetadata,
timezone,
userTimezone,
folderId,
}: CreateDocumentOptions) => {
const team = await getTeamById({ userId, teamId });
@ -58,8 +62,10 @@ export const createDocument = async ({
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
select: {
visibility: true,
@ -98,6 +104,10 @@ export const createDocument = async ({
}
}
// userTimezone is last because it's always passed in regardless of the organisation/team settings
// for uploads from the frontend
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
@ -114,13 +124,9 @@ export const createDocument = async ({
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: {
language: settings.documentLanguage,
timezone: timezone,
typedSignatureEnabled: settings.typedSignatureEnabled,
uploadSignatureEnabled: settings.uploadSignatureEnabled,
drawSignatureEnabled: settings.drawSignatureEnabled,
},
create: extractDerivedDocumentMeta(settings, {
timezone: timezoneToUse,
}),
},
},
});

View File

@ -10,7 +10,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
@ -151,11 +150,13 @@ const handleDocumentOwnerDelete = async ({
return;
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
// Soft delete completed documents.
@ -232,28 +233,24 @@ const handleDocumentOwnerDelete = async ({
assetBaseUrl,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,

View File

@ -5,7 +5,6 @@ import { DocumentStatus, OrganisationType, RecipientRole, SigningStatus } from '
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
@ -54,14 +53,7 @@ export const resendDocument = async ({
const document = await prisma.document.findUnique({
where: documentWhereInput,
include: {
recipients: {
where: {
id: {
in: recipients,
},
signingStatus: SigningStatus.NOT_SIGNED,
},
},
recipients: true,
documentMeta: true,
team: {
select: {
@ -90,6 +82,11 @@ export const resendDocument = async ({
throw new Error('Can not send completed document');
}
const recipientsToRemind = document.recipients.filter(
(recipient) =>
recipients.includes(recipient.id) && recipient.signingStatus === SigningStatus.NOT_SIGNED,
);
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
@ -98,21 +95,23 @@ export const resendDocument = async ({
return;
}
const { branding, settings, organisationType } = await getEmailContext({
source: {
type: 'team',
teamId: document.teamId,
},
});
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
await Promise.all(
document.recipients.map(async (recipient) => {
recipientsToRemind.map(async (recipient) => {
if (recipient.role === RecipientRole.CC) {
return;
}
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
@ -171,11 +170,11 @@ export const resendDocument = async ({
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -188,10 +187,8 @@ export const resendDocument = async ({
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: customEmail?.subject
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${customEmail.subject}`),

View File

@ -17,6 +17,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getAuditLogsPdf } from '../htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
@ -125,6 +126,18 @@ export const sealDocument = async ({
})
: null;
const auditLogData = settings.includeAuditLog
? await getAuditLogsPdf({
documentId,
language: document.documentMeta?.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null;
const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
@ -147,6 +160,16 @@ export const sealDocument = async ({
});
}
if (auditLogData) {
const auditLog = await PDFDocument.load(auditLogData);
const auditLogPages = await doc.copyPages(auditLog, auditLog.getPageIndices());
auditLogPages.forEach((page) => {
doc.addPage(page);
});
}
for (const field of fields) {
document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(doc, field)

View File

@ -14,7 +14,6 @@ import { extractDerivedDocumentEmailSettings } from '../../types/document-email'
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { env } from '../../utils/env';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
@ -54,11 +53,13 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
throw new Error('Document has no recipients');
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const { user: owner } = document;
@ -97,18 +98,16 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
downloadLink: documentOwnerDownloadLink,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
@ -117,10 +116,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
address: owner.email,
},
],
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Signing Complete!`),
html,
text,
@ -174,18 +171,16 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
: undefined,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
@ -194,10 +189,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
address: recipient.email,
},
],
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
replyTo: replyToEmail,
subject:
isDirectTemplate && document.documentMeta?.subject
? renderCustomEmailTemplate(document.documentMeta.subject, customEmailTemplate)

View File

@ -10,7 +10,6 @@ import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
@ -44,11 +43,13 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
return;
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const { email, name } = document.user;
@ -61,28 +62,23 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
assetBaseUrl,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: email,
name: name || '',
},
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
subject: i18n._(msg`Document Deleted!`),
html,
text,

View File

@ -9,7 +9,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
@ -46,11 +45,13 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
throw new Error('Document has no recipients');
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(
@ -72,28 +73,24 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
assetBaseUrl,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Waiting for others to complete signing.`),
html,
text,

View File

@ -9,7 +9,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
@ -41,11 +40,13 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
});
}
const { branding, settings } = await getEmailContext({
const { branding, settings, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const { status, user } = document;
@ -92,10 +93,8 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,

View File

@ -1,5 +1,6 @@
import { DocumentVisibility } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -128,9 +129,11 @@ export const updateDocument = async ({
const isTitleSame = data.title === undefined || data.title === document.title;
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
const isGlobalAccessSame =
documentGlobalAccessAuth === undefined || documentGlobalAccessAuth === newGlobalAccessAuth;
documentGlobalAccessAuth === undefined ||
isDeepEqual(documentGlobalAccessAuth, newGlobalAccessAuth);
const isGlobalActionSame =
documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth;
documentGlobalActionAuth === undefined ||
isDeepEqual(documentGlobalActionAuth, newGlobalActionAuth);
const isDocumentVisibilitySame =
data.visibility === undefined || data.visibility === document.visibility;

View File

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

View File

@ -26,7 +26,6 @@ export const deleteField = async ({
id: fieldId,
document: {
id: documentId,
userId,
team: buildTeamWhereQuery({ teamId, userId }),
},
},

View File

@ -48,7 +48,6 @@ export const updateField = async ({
id: fieldId,
document: {
id: documentId,
userId,
team: buildTeamWhereQuery({ teamId, userId }),
},
},

View File

@ -4,6 +4,7 @@ import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export interface DeleteFolderOptions {
@ -18,8 +19,10 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
include: {
documents: true,

View File

@ -2,6 +2,8 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface MoveFolderOptions {
userId: number;
teamId?: number;
@ -15,8 +17,10 @@ export const moveFolder = async ({ userId, teamId, folderId, parentId }: MoveFol
const folder = await tx.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
});

View File

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

View File

@ -2,6 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface PinFolderOptions {
userId: number;
@ -14,8 +15,10 @@ export const pinFolder = async ({ userId, teamId, folderId, type }: PinFolderOpt
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
type,
},
});

View File

@ -2,6 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface UnpinFolderOptions {
userId: number;
@ -14,8 +15,10 @@ export const unpinFolder = async ({ userId, teamId, folderId, type }: UnpinFolde
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
type,
},
});

View File

@ -4,6 +4,7 @@ import { DocumentVisibility } from '@documenso/prisma/generated/types';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface UpdateFolderOptions {
userId: number;
@ -25,8 +26,10 @@ export const updateFolder = async ({
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
type,
},
});

View File

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

View File

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

View File

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

View File

@ -9,7 +9,6 @@ import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/str
import { mailer } from '@documenso/email/mailer';
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
@ -190,7 +189,8 @@ export const sendOrganisationMemberInviteEmail = async ({
organisationName: organisation.name,
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
@ -199,24 +199,21 @@ export const sendOrganisationMemberInviteEmail = async ({
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: settings.documentLanguage,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(template, {
lang: settings.documentLanguage,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(settings.documentLanguage);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`You have been invited to join ${organisation.name} on Documenso`),
html,
text,

View File

@ -240,35 +240,79 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
}));
const selected: string[] = fromCheckboxValue(field.customText);
const direction = meta.data.direction ?? 'vertical';
const topPadding = 12;
const leftCheckboxPadding = 8;
const leftCheckboxLabelPadding = 12;
const checkboxSpaceY = 13;
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * checkboxSpaceY + topPadding;
if (direction === 'horizontal') {
// Horizontal layout: arrange checkboxes side by side with wrapping
let currentX = leftCheckboxPadding;
let currentY = topPadding;
const maxWidth = pageWidth - fieldX - leftCheckboxPadding * 2;
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
for (const [index, item] of (values ?? []).entries()) {
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
if (selected.includes(item.value)) {
checkbox.check();
if (selected.includes(item.value)) {
checkbox.check();
}
const labelText = item.value.includes('empty-value-') ? '' : item.value;
const labelWidth = font.widthOfTextAtSize(labelText, 12);
const itemWidth = leftCheckboxLabelPadding + labelWidth + 16; // checkbox + padding + label + margin
// Check if item fits on current line, if not wrap to next line
if (currentX + itemWidth > maxWidth && index > 0) {
currentX = leftCheckboxPadding;
currentY += checkboxSpaceY;
}
page.drawText(labelText, {
x: fieldX + currentX + leftCheckboxLabelPadding,
y: pageHeight - (fieldY + currentY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
checkbox.addToPage(page, {
x: fieldX + currentX,
y: pageHeight - (fieldY + currentY),
height: 8,
width: 8,
});
currentX += itemWidth;
}
} else {
// Vertical layout: original behavior
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * checkboxSpaceY + topPadding;
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + leftCheckboxPadding + leftCheckboxLabelPadding,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
checkbox.addToPage(page, {
x: fieldX + leftCheckboxPadding,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
if (selected.includes(item.value)) {
checkbox.check();
}
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
x: fieldX + leftCheckboxPadding + leftCheckboxLabelPadding,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
checkbox.addToPage(page, {
x: fieldX + leftCheckboxPadding,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
}
}
})
.with({ type: FieldType.RADIO }, (field) => {

View File

@ -11,7 +11,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
@ -125,31 +124,29 @@ export const deleteDocumentRecipient = async ({
assetBaseUrl,
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipientToDelete.email,
name: recipientToDelete.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,

View File

@ -25,7 +25,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { canRecipientBeModified } from '../../utils/recipients';
@ -71,13 +70,6 @@ export const setDocumentRecipients = async ({
},
});
const { branding, settings } = await getEmailContext({
source: {
type: 'team',
teamId,
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@ -97,6 +89,15 @@ export const setDocumentRecipients = async ({
throw new Error('Document already complete');
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta: document.documentMeta,
});
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
@ -302,24 +303,20 @@ export const setDocumentRecipients = async ({
assetBaseUrl,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,

View File

@ -8,14 +8,12 @@ import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
@ -122,33 +120,28 @@ export const sendTeamEmailVerificationEmail = async (email: string, token: strin
token,
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: team.id,
},
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const lang = settings.documentLanguage as SupportedLanguageCodes;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(
msg`A request to use your email has been initiated by ${team.name} on Documenso`,
),

View File

@ -5,7 +5,6 @@ import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { prisma } from '@documenso/prisma';
@ -27,7 +26,8 @@ export type DeleteTeamEmailOptions = {
* The user must either be part of the team with the required permissions, or the owner of the email.
*/
export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamEmailOptions) => {
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId,
@ -82,24 +82,19 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
teamUrl: team.url,
});
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: team.organisation.owner.email,
name: team.organisation.owner.name ?? '',
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`Team email has been revoked for ${team.name}`),
html,
text,

View File

@ -7,7 +7,6 @@ import { uniqueBy } from 'remeda';
import { mailer } from '@documenso/email/mailer';
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
@ -130,28 +129,24 @@ export const sendTeamDeleteEmail = async ({
teamUrl: team.url,
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId,
},
});
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`Team "${team.name}" has been deleted on Documenso`),
html,
text,

View File

@ -33,5 +33,13 @@ export const getTeamSettings = async ({ userId, teamId }: GetTeamSettingsOptions
const organisationSettings = team.organisation.organisationGlobalSettings;
const teamSettings = team.teamGlobalSettings;
// Override branding settings if inherit is enabled.
if (teamSettings.brandingEnabled === null) {
teamSettings.brandingEnabled = organisationSettings.brandingEnabled;
teamSettings.brandingLogo = organisationSettings.brandingLogo;
teamSettings.brandingUrl = organisationSettings.brandingUrl;
teamSettings.brandingCompanyDetails = organisationSettings.brandingCompanyDetails;
}
return extractDerivedTeamSettings(organisationSettings, teamSettings);
};

View File

@ -3,7 +3,6 @@ import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
DocumentStatus,
FieldType,
@ -25,8 +24,6 @@ import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/f
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';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
@ -38,6 +35,7 @@ import {
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { isRequiredField } from '../../utils/advanced-fields-helpers';
import { extractDerivedDocumentMeta } from '../../utils/document';
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
@ -45,7 +43,6 @@ import {
createRecipientAuthOptions,
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
import { sendDocument } from '../document/send-document';
@ -116,7 +113,8 @@ export const createDocumentFromDirectTemplate = async ({
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' });
}
const { branding, settings } = await getEmailContext({
const { branding, settings, senderEmail, emailLanguage } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: template.teamId,
@ -169,13 +167,7 @@ export const createDocumentFromDirectTemplate = async ({
const nonDirectTemplateRecipients = template.recipients.filter(
(recipient) => recipient.id !== directTemplateRecipient.id,
);
const metaTimezone = template.templateMeta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE;
const metaDateFormat = template.templateMeta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT;
const metaEmailMessage = template.templateMeta?.message || '';
const metaEmailSubject = template.templateMeta?.subject || '';
const metaLanguage = template.templateMeta?.language ?? settings.documentLanguage;
const metaSigningOrder = template.templateMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, template.templateMeta);
// Associate, validate and map to a query every direct template recipient field with the provided fields.
// Only process fields that are either required or have been signed by the user
@ -234,7 +226,9 @@ export const createDocumentFromDirectTemplate = async ({
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
if (templateField.type === FieldType.DATE) {
customText = DateTime.now().setZone(metaTimezone).toFormat(metaDateFormat);
customText = DateTime.now()
.setZone(derivedDocumentMeta.timezone)
.toFormat(derivedDocumentMeta.dateFormat);
}
if (isSignatureField && !signatureImageAsBase64 && !typedSignature) {
@ -318,18 +312,7 @@ export const createDocumentFromDirectTemplate = async ({
},
},
documentMeta: {
create: {
timezone: metaTimezone,
dateFormat: metaDateFormat,
message: metaEmailMessage,
subject: metaEmailSubject,
language: metaLanguage,
signingOrder: metaSigningOrder,
distributionMethod: template.templateMeta?.distributionMethod,
typedSignatureEnabled: template.templateMeta?.typedSignatureEnabled,
uploadSignatureEnabled: template.templateMeta?.uploadSignatureEnabled,
drawSignatureEnabled: template.templateMeta?.drawSignatureEnabled,
},
create: derivedDocumentMeta,
},
},
include: {
@ -589,11 +572,11 @@ export const createDocumentFromDirectTemplate = async ({
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding, plainText: true }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(metaLanguage);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
@ -602,10 +585,7 @@ export const createDocumentFromDirectTemplate = async ({
address: templateOwner.email,
},
],
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
subject: i18n._(msg`Document created from direct template`),
html,
text,

View File

@ -3,6 +3,7 @@ import { DocumentSource, type RecipientRole } from '@prisma/client';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from '../team/get-team-settings';
@ -78,18 +79,7 @@ export const createDocumentFromTemplateLegacy = async ({
})),
},
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 || settings.documentLanguage,
typedSignatureEnabled: template.templateMeta?.typedSignatureEnabled,
uploadSignatureEnabled: template.templateMeta?.uploadSignatureEnabled,
drawSignatureEnabled: template.templateMeta?.drawSignatureEnabled,
},
create: extractDerivedDocumentMeta(settings, template.templateMeta),
},
},

View File

@ -1,6 +1,5 @@
import type { DocumentDistributionMethod } from '@prisma/client';
import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
type Field,
type Recipient,
@ -40,6 +39,7 @@ import {
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
createDocumentAuthOptions,
@ -228,6 +228,7 @@ const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillField
type: 'checkbox',
label: field.label,
values: newValues,
direction: checkboxMeta.direction ?? 'vertical',
};
return meta;
@ -377,7 +378,7 @@ export const createDocumentFromTemplate = async ({
visibility: template.visibility || settings.documentVisibility,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
documentMeta: {
create: {
create: extractDerivedDocumentMeta(settings, {
subject: override?.subject || template.templateMeta?.subject,
message: override?.message || template.templateMeta?.message,
timezone: override?.timezone || template.templateMeta?.timezone,
@ -386,13 +387,8 @@ export const createDocumentFromTemplate = async ({
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
distributionMethod:
override?.distributionMethod || template.templateMeta?.distributionMethod,
// last `undefined` is due to JsonValue's
emailSettings:
override?.emailSettings || template.templateMeta?.emailSettings || undefined,
signingOrder:
override?.signingOrder ||
template.templateMeta?.signingOrder ||
DocumentSigningOrder.PARALLEL,
emailSettings: override?.emailSettings || template.templateMeta?.emailSettings,
signingOrder: override?.signingOrder || template.templateMeta?.signingOrder,
language:
override?.language || template.templateMeta?.language || settings.documentLanguage,
typedSignatureEnabled:
@ -402,10 +398,8 @@ export const createDocumentFromTemplate = async ({
drawSignatureEnabled:
override?.drawSignatureEnabled ?? template.templateMeta?.drawSignatureEnabled,
allowDictateNextSigner:
override?.allowDictateNextSigner ??
template.templateMeta?.allowDictateNextSigner ??
false,
},
override?.allowDictateNextSigner ?? template.templateMeta?.allowDictateNextSigner,
}),
},
recipients: {
createMany: {

View File

@ -1,16 +1,32 @@
import type { DocumentVisibility, Template, TemplateMeta } from '@prisma/client';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TemplateSchema } from '@documenso/prisma/generated/zod/modelSchema//TemplateSchema';
import type { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions } from '../../utils/document-auth';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from '../team/get-team-settings';
export type CreateTemplateOptions = TCreateTemplateMutationSchema & {
export type CreateTemplateOptions = {
userId: number;
teamId: number;
templateDocumentDataId: string;
data: {
title: string;
folderId?: string;
externalId?: string | null;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
publicTitle?: string;
publicDescription?: string;
type?: Template['type'];
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
};
export const ZCreateTemplateResponseSchema = TemplateSchema;
@ -18,12 +34,14 @@ export const ZCreateTemplateResponseSchema = TemplateSchema;
export type TCreateTemplateResponse = z.infer<typeof ZCreateTemplateResponseSchema>;
export const createTemplate = async ({
title,
userId,
teamId,
templateDocumentDataId,
folderId,
data,
meta = {},
}: CreateTemplateOptions) => {
const { title, folderId } = data;
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
});
@ -52,20 +70,42 @@ export const createTemplate = async ({
teamId,
});
const emailId = meta.emailId;
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.template.create({
data: {
title,
teamId,
userId,
templateDocumentDataId,
teamId,
folderId: folderId,
folderId,
externalId: data.externalId,
visibility: data.visibility ?? settings.documentVisibility,
authOptions: createDocumentAuthOptions({
globalAccessAuth: data.globalAccessAuth || [],
globalActionAuth: data.globalActionAuth || [],
}),
publicTitle: data.publicTitle,
publicDescription: data.publicDescription,
type: data.type,
templateMeta: {
create: {
language: settings.documentLanguage,
typedSignatureEnabled: settings.typedSignatureEnabled,
uploadSignatureEnabled: settings.uploadSignatureEnabled,
drawSignatureEnabled: settings.drawSignatureEnabled,
},
create: extractDerivedDocumentMeta(settings, meta),
},
},
});

View File

@ -41,6 +41,7 @@ export const updateTemplate = async ({
templateMeta: true,
team: {
select: {
organisationId: true,
organisation: {
select: {
organisationClaim: true,
@ -86,6 +87,24 @@ export const updateTemplate = async ({
globalActionAuth: newGlobalActionAuth,
});
const emailId = meta.emailId;
// Validate the emailId belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: template.team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.template.update({
where: {
id: templateId,

View File

@ -2,6 +2,8 @@ import type { WebhookTriggerEvents } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type GetAllWebhooksByEventTriggerOptions = {
event: WebhookTriggerEvents;
userId: number;
@ -19,22 +21,10 @@ export const getAllWebhooksByEventTrigger = async ({
eventTriggers: {
has: event,
},
team: {
id: teamId,
teamGroups: {
some: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
});
};

View File

@ -1,5 +1,8 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { buildTeamWhereQuery } from '../../utils/teams';
export type GetWebhookByIdOptions = {
id: string;
userId: number;
@ -10,23 +13,11 @@ export const getWebhookById = async ({ id, userId, teamId }: GetWebhookByIdOptio
return await prisma.webhook.findFirstOrThrow({
where: {
id,
userId,
team: {
id: teamId,
teamGroups: {
some: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
team: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_TEAM,
}),
},
});
};

View File

@ -0,0 +1,44 @@
import type { WebhookTriggerEvents } from '@prisma/client';
import { getWebhookById } from './get-webhook-by-id';
import { generateSampleWebhookPayload } from './trigger/generate-sample-data';
import { triggerWebhook } from './trigger/trigger-webhook';
export type TriggerTestWebhookOptions = {
id: string;
event: WebhookTriggerEvents;
userId: number;
teamId: number;
};
export const triggerTestWebhook = async ({
id,
event,
userId,
teamId,
}: TriggerTestWebhookOptions) => {
const webhook = await getWebhookById({ id, userId, teamId });
if (!webhook.enabled) {
throw new Error('Webhook is disabled');
}
if (!webhook.eventTriggers.includes(event)) {
throw new Error(`Webhook does not support event: ${event}`);
}
const samplePayload = generateSampleWebhookPayload(event, webhook.webhookUrl);
try {
await triggerWebhook({
event,
data: samplePayload,
userId,
teamId,
});
return { success: true, message: 'Test webhook triggered successfully' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
};

View File

@ -0,0 +1,485 @@
import {
DocumentDistributionMethod,
DocumentSigningOrder,
DocumentSource,
DocumentStatus,
DocumentVisibility,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import type { WebhookPayload } from '../../../types/webhook-payload';
export const generateSampleWebhookPayload = (
event: WebhookTriggerEvents,
webhookUrl: string,
): WebhookPayload => {
const now = new Date();
const basePayload = {
id: 10,
externalId: null,
userId: 1,
authOptions: null,
formValues: null,
visibility: DocumentVisibility.EVERYONE,
title: 'documenso.pdf',
status: DocumentStatus.DRAFT,
documentDataId: 'hs8qz1ktr9204jn7mg6c5dxy0',
createdAt: now,
updatedAt: now,
completedAt: null,
deletedAt: null,
teamId: null,
templateId: null,
source: DocumentSource.DOCUMENT,
documentMeta: {
id: 'doc_meta_123',
subject: 'Please sign this document',
message: 'Hello, please review and sign this document.',
timezone: 'UTC',
password: null,
dateFormat: 'MM/DD/YYYY',
redirectUrl: null,
signingOrder: DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: false,
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
language: 'en',
distributionMethod: DocumentDistributionMethod.EMAIL,
emailSettings: null,
},
recipients: [
{
id: 52,
documentId: 10,
templateId: null,
email: 'signer@documenso.com',
name: 'John Doe',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
rejectionReason: null,
role: RecipientRole.SIGNER,
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.NOT_SENT,
},
],
Recipient: [
{
id: 52,
documentId: 10,
templateId: null,
email: 'signer@documenso.com',
name: 'John Doe',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
rejectionReason: null,
role: RecipientRole.SIGNER,
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.NOT_SENT,
},
],
};
if (event === WebhookTriggerEvents.DOCUMENT_CREATED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.DRAFT,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_SENT) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
email: 'signer2@documenso.com',
name: 'Signer 2',
role: RecipientRole.VIEWER,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
rejectionReason: null,
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
Recipient: [
{
...basePayload.Recipient[0],
email: 'signer1@documenso.com',
name: 'Signer 1',
token: 'SIGNING_TOKEN',
signingOrder: 2,
role: RecipientRole.SIGNER,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
signedAt: null,
authOptions: null,
rejectionReason: null,
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_OPENED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
email: 'signer2@documenso.com',
name: 'Signer 2',
role: RecipientRole.VIEWER,
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
rejectionReason: null,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
Recipient: [
{
...basePayload.Recipient[0],
email: 'signer2@documenso.com',
name: 'Signer 2',
role: RecipientRole.VIEWER,
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
rejectionReason: null,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_SIGNED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.COMPLETED,
completedAt: now,
recipients: [
{
...basePayload.recipients[0],
id: 51,
email: 'signer1@documenso.com',
name: 'Signer 1',
token: 'SIGNING_TOKEN',
signedAt: now,
authOptions: {
accessAuth: null,
actionAuth: null,
},
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
signingOrder: 1,
rejectionReason: null,
},
],
Recipient: [
{
...basePayload.Recipient[0],
id: 51,
email: 'signer1@documenso.com',
name: 'Signer 1',
token: 'SIGNING_TOKEN',
signedAt: now,
authOptions: {
accessAuth: null,
actionAuth: null,
},
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
signingOrder: 1,
rejectionReason: null,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_COMPLETED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.COMPLETED,
completedAt: now,
recipients: [
{
id: 50,
documentId: 10,
templateId: null,
email: 'signer2@documenso.com',
name: 'Signer 2',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
signedAt: now,
authOptions: {
accessAuth: null,
actionAuth: null,
},
signingOrder: 1,
rejectionReason: null,
role: RecipientRole.VIEWER,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
},
{
id: 51,
documentId: 10,
templateId: null,
email: 'signer1@documenso.com',
name: 'Signer 1',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
signedAt: now,
authOptions: {
accessAuth: null,
actionAuth: null,
},
signingOrder: 2,
rejectionReason: null,
role: RecipientRole.SIGNER,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
},
],
Recipient: [
{
id: 50,
documentId: 10,
templateId: null,
email: 'signer2@documenso.com',
name: 'Signer 2',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
signedAt: now,
authOptions: {
accessAuth: null,
actionAuth: null,
},
signingOrder: 1,
rejectionReason: null,
role: RecipientRole.VIEWER,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
},
{
id: 51,
documentId: 10,
templateId: null,
email: 'signer1@documenso.com',
name: 'Signer 1',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
signedAt: now,
authOptions: {
accessAuth: null,
actionAuth: null,
},
signingOrder: 2,
rejectionReason: null,
role: RecipientRole.SIGNER,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_REJECTED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
signedAt: now,
authOptions: {
accessAuth: null,
actionAuth: null,
},
rejectionReason: 'I do not agree with the terms',
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.REJECTED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
signingOrder: 1,
},
],
Recipient: [
{
...basePayload.Recipient[0],
signedAt: now,
authOptions: {
accessAuth: null,
actionAuth: null,
},
rejectionReason: 'I do not agree with the terms',
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.REJECTED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
signingOrder: 1,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_CANCELLED) {
return {
event,
payload: {
...basePayload,
id: 7,
externalId: null,
userId: 3,
status: DocumentStatus.PENDING,
documentDataId: 'cm6exvn93006hi02ru90a265a',
documentMeta: {
...basePayload.documentMeta,
id: 'cm6exvn96006ji02rqvzjvwoy',
subject: '',
message: '',
timezone: 'Etc/UTC',
dateFormat: 'yyyy-MM-dd hh:mm a',
redirectUrl: '',
emailSettings: {
documentDeleted: true,
documentPending: true,
recipientSigned: true,
recipientRemoved: true,
documentCompleted: true,
ownerDocumentCompleted: true,
recipientSigningRequest: true,
},
},
recipients: [
{
id: 7,
documentId: 7,
templateId: null,
email: 'signer1@documenso.com',
name: 'Signer 1',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
signedAt: null,
authOptions: {
accessAuth: null,
actionAuth: null,
},
signingOrder: 1,
rejectionReason: null,
role: RecipientRole.SIGNER,
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.SENT,
},
],
Recipient: [
{
id: 7,
documentId: 7,
templateId: null,
email: 'signer@documenso.com',
name: 'Signer',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
signedAt: null,
authOptions: {
accessAuth: null,
actionAuth: null,
},
signingOrder: 1,
rejectionReason: null,
role: RecipientRole.SIGNER,
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.SENT,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
throw new Error(`Unsupported event type: ${event}`);
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -58,6 +58,9 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([
'REDIRECT_URL',
'SUBJECT',
'TIMEZONE',
'EMAIL_ID',
'EMAIL_REPLY_TO',
'EMAIL_SETTINGS',
]);
export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']);
@ -109,6 +112,9 @@ export const ZDocumentAuditLogDocumentMetaSchema = z.union([
z.literal(DOCUMENT_META_DIFF_TYPE.REDIRECT_URL),
z.literal(DOCUMENT_META_DIFF_TYPE.SUBJECT),
z.literal(DOCUMENT_META_DIFF_TYPE.TIMEZONE),
z.literal(DOCUMENT_META_DIFF_TYPE.EMAIL_ID),
z.literal(DOCUMENT_META_DIFF_TYPE.EMAIL_REPLY_TO),
z.literal(DOCUMENT_META_DIFF_TYPE.EMAIL_SETTINGS),
]),
from: z.string().nullable(),
to: z.string().nullable(),

View File

@ -54,15 +54,7 @@ export const ZDocumentEmailSettingsSchema = z
.default(true),
})
.strip()
.catch(() => ({
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: true,
documentPending: true,
documentCompleted: true,
documentDeleted: true,
ownerDocumentCompleted: true,
}));
.catch(() => ({ ...DEFAULT_DOCUMENT_EMAIL_SETTINGS }));
export type TDocumentEmailSettings = z.infer<typeof ZDocumentEmailSettingsSchema>;
@ -88,3 +80,13 @@ export const extractDerivedDocumentEmailSettings = (
ownerDocumentCompleted: emailSettings.ownerDocumentCompleted,
};
};
export const DEFAULT_DOCUMENT_EMAIL_SETTINGS: TDocumentEmailSettings = {
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: true,
documentPending: true,
documentCompleted: true,
documentDeleted: true,
ownerDocumentCompleted: true,
};

View File

@ -58,6 +58,8 @@ export const ZDocumentSchema = DocumentSchema.pick({
allowDictateNextSigner: true,
language: true,
emailSettings: true,
emailId: true,
emailReplyTo: true,
}).nullable(),
folder: FolderSchema.pick({
id: true,

View File

@ -0,0 +1,40 @@
import type { z } from 'zod';
import { EmailDomainSchema } from '@documenso/prisma/generated/zod/modelSchema/EmailDomainSchema';
import { ZOrganisationEmailLiteSchema } from './organisation-email';
/**
* The full email domain response schema.
*
* Mainly used for returning a single email domain from the API.
*/
export const ZEmailDomainSchema = EmailDomainSchema.pick({
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
publicKey: true,
createdAt: true,
updatedAt: true,
}).extend({
emails: ZOrganisationEmailLiteSchema.array(),
});
export type TEmailDomain = z.infer<typeof ZEmailDomainSchema>;
/**
* A version of the email domain response schema when returning multiple email domains at once from a single API endpoint.
*/
export const ZEmailDomainManySchema = EmailDomainSchema.pick({
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
createdAt: true,
updatedAt: true,
});
export type TEmailDomainMany = z.infer<typeof ZEmailDomainManySchema>;

View File

@ -96,6 +96,7 @@ export const ZCheckboxFieldMeta = ZBaseFieldMeta.extend({
.optional(),
validationRule: z.string().optional(),
validationLength: z.number().optional(),
direction: z.enum(['vertical', 'horizontal']).optional().default('vertical'),
});
export type TCheckboxFieldMeta = z.infer<typeof ZCheckboxFieldMeta>;

View File

@ -0,0 +1,42 @@
import { EmailDomainStatus } from '@prisma/client';
import { z } from 'zod';
import { OrganisationEmailSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationEmailSchema';
export const ZOrganisationEmailSchema = OrganisationEmailSchema.pick({
id: true,
createdAt: true,
updatedAt: true,
email: true,
emailName: true,
// replyTo: true,
emailDomainId: true,
organisationId: true,
}).extend({
emailDomain: z.object({
id: z.string(),
status: z.nativeEnum(EmailDomainStatus),
}),
});
export type TOrganisationEmail = z.infer<typeof ZOrganisationEmailSchema>;
/**
* A lite version of the organisation email response schema without relations.
*/
export const ZOrganisationEmailLiteSchema = OrganisationEmailSchema.pick({
id: true,
createdAt: true,
updatedAt: true,
email: true,
emailName: true,
// replyTo: true,
emailDomainId: true,
organisationId: true,
});
export const ZOrganisationEmailManySchema = ZOrganisationEmailLiteSchema.extend({
// Put anything extra here.
});
export type TOrganisationEmailMany = z.infer<typeof ZOrganisationEmailManySchema>;

View File

@ -19,6 +19,8 @@ export const ZClaimFlagsSchema = z.object({
unlimitedDocuments: z.boolean().optional(),
emailDomains: z.boolean().optional(),
embedAuthoring: z.boolean().optional(),
embedAuthoringWhiteLabel: z.boolean().optional(),
@ -50,6 +52,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
key: 'hidePoweredBy',
label: 'Hide Documenso branding by',
},
emailDomains: {
key: 'emailDomains',
label: 'Email domains',
},
embedAuthoring: {
key: 'embedAuthoring',
label: 'Embed authoring',
@ -128,6 +134,7 @@ export const internalClaims: InternalClaims = {
unlimitedDocuments: true,
allowCustomBranding: true,
hidePoweredBy: true,
emailDomains: false,
embedAuthoring: false,
embedAuthoringWhiteLabel: true,
embedSigning: false,
@ -144,6 +151,7 @@ export const internalClaims: InternalClaims = {
unlimitedDocuments: true,
allowCustomBranding: true,
hidePoweredBy: true,
emailDomains: true,
embedAuthoring: true,
embedAuthoringWhiteLabel: true,
embedSigning: true,

View File

@ -55,6 +55,8 @@ export const ZTemplateSchema = TemplateSchema.pick({
redirectUrl: true,
language: true,
emailSettings: true,
emailId: true,
emailReplyTo: true,
}).nullable(),
directLink: TemplateDirectLinkSchema.nullable(),
user: UserSchema.pick({

View File

@ -1,4 +1,4 @@
import type { Document, DocumentMeta, Recipient } from '@prisma/client';
import type { Document, DocumentMeta, Recipient, WebhookTriggerEvents } from '@prisma/client';
import {
DocumentDistributionMethod,
DocumentSigningOrder,
@ -87,6 +87,13 @@ export const ZWebhookDocumentSchema = z.object({
export type TWebhookRecipient = z.infer<typeof ZWebhookRecipientSchema>;
export type TWebhookDocument = z.infer<typeof ZWebhookDocumentSchema>;
export type WebhookPayload = {
event: WebhookTriggerEvents;
payload: TWebhookDocument;
createdAt: string;
webhookEndpoint: string;
};
export const mapDocumentToWebhookDocumentPayload = (
document: Document & {
recipients: Recipient[];

View File

@ -11,7 +11,9 @@ export const prefixedId = (prefix: string, length = 16) => {
};
type DatabaseIdPrefix =
| 'email_domain'
| 'org'
| 'org_email'
| 'org_claim'
| 'org_group'
| 'org_setting'

View File

@ -205,12 +205,18 @@ export const diffDocumentMetaChanges = (
const oldTimezone = oldData?.timezone ?? '';
const oldPassword = oldData?.password ?? null;
const oldRedirectUrl = oldData?.redirectUrl ?? '';
const oldEmailId = oldData?.emailId || null;
const oldEmailReplyTo = oldData?.emailReplyTo || null;
const oldEmailSettings = oldData?.emailSettings || null;
const newDateFormat = newData?.dateFormat ?? '';
const newMessage = newData?.message ?? '';
const newSubject = newData?.subject ?? '';
const newTimezone = newData?.timezone ?? '';
const newRedirectUrl = newData?.redirectUrl ?? '';
const newEmailId = newData?.emailId || null;
const newEmailReplyTo = newData?.emailReplyTo || null;
const newEmailSettings = newData?.emailSettings || null;
if (oldDateFormat !== newDateFormat) {
diffs.push({
@ -258,6 +264,30 @@ export const diffDocumentMetaChanges = (
});
}
if (oldEmailId !== newEmailId) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.EMAIL_ID,
from: oldEmailId,
to: newEmailId,
});
}
if (oldEmailReplyTo !== newEmailReplyTo) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.EMAIL_REPLY_TO,
from: oldEmailReplyTo,
to: newEmailReplyTo,
});
}
if (!isDeepEqual(oldEmailSettings, newEmailSettings)) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.EMAIL_SETTINGS,
from: JSON.stringify(oldEmailSettings),
to: JSON.stringify(newEmailSettings),
});
}
return diffs;
};
@ -275,87 +305,150 @@ export const formatDocumentAuditLogAction = (
const description = match(auditLog)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({
anonymous: msg`A field was added`,
anonymous: msg({
message: `A field was added`,
context: `Audit log format`,
}),
identified: msg`${prefix} added a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({
anonymous: msg`A field was removed`,
anonymous: msg({
message: `A field was removed`,
context: `Audit log format`,
}),
identified: msg`${prefix} removed a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({
anonymous: msg`A field was updated`,
anonymous: msg({
message: `A field was updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({
anonymous: msg`A recipient was added`,
anonymous: msg({
message: `A recipient was added`,
context: `Audit log format`,
}),
identified: msg`${prefix} added a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({
anonymous: msg`A recipient was removed`,
anonymous: msg({
message: `A recipient was removed`,
context: `Audit log format`,
}),
identified: msg`${prefix} removed a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({
anonymous: msg`A recipient was updated`,
anonymous: msg({
message: `A recipient was updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({
anonymous: msg`Document created`,
anonymous: msg({
message: `Document created`,
context: `Audit log format`,
}),
identified: msg`${prefix} created the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({
anonymous: msg`Document deleted`,
anonymous: msg({
message: `Document deleted`,
context: `Audit log format`,
}),
identified: msg`${prefix} deleted the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
anonymous: msg`Field signed`,
anonymous: msg({
message: `Field signed`,
context: `Audit log format`,
}),
identified: msg`${prefix} signed a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({
anonymous: msg`Field unsigned`,
anonymous: msg({
message: `Field unsigned`,
context: `Audit log format`,
}),
identified: msg`${prefix} unsigned a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({
anonymous: msg`Field prefilled by assistant`,
anonymous: msg({
message: `Field prefilled by assistant`,
context: `Audit log format`,
}),
identified: msg`${prefix} prefilled a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
anonymous: msg`Document visibility updated`,
anonymous: msg({
message: `Document visibility updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document visibility`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({
anonymous: msg`Document access auth updated`,
anonymous: msg({
message: `Document access auth updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document access auth requirements`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({
anonymous: msg`Document signing auth updated`,
anonymous: msg({
message: `Document signing auth updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document signing auth requirements`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
anonymous: msg`Document updated`,
anonymous: msg({
message: `Document updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({
anonymous: msg`Document opened`,
anonymous: msg({
message: `Document opened`,
context: `Audit log format`,
}),
identified: msg`${prefix} opened the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED }, () => ({
anonymous: msg`Document viewed`,
anonymous: msg({
message: `Document viewed`,
context: `Audit log format`,
}),
identified: msg`${prefix} viewed the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({
anonymous: msg`Document title updated`,
anonymous: msg({
message: `Document title updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document title`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED }, () => ({
anonymous: msg`Document external ID updated`,
anonymous: msg({
message: `Document external ID updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document external ID`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
anonymous: msg`Document sent`,
anonymous: msg({
message: `Document sent`,
context: `Audit log format`,
}),
identified: msg`${prefix} sent the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, () => ({
anonymous: msg`Document moved to team`,
anonymous: msg({
message: `Document moved to team`,
context: `Audit log format`,
}),
identified: msg`${prefix} moved the document to team`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
@ -390,8 +483,14 @@ export const formatDocumentAuditLogAction = (
: msg`${prefix} sent an email to ${data.recipientEmail}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => ({
anonymous: msg`Document completed`,
identified: msg`Document completed`,
anonymous: msg({
message: `Document completed`,
context: `Audit log format`,
}),
identified: msg({
message: `Document completed`,
context: `Audit log format`,
}),
}))
.exhaustive();

View File

@ -1,8 +1,62 @@
import type { Document } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import type {
Document,
DocumentMeta,
OrganisationGlobalSettings,
TemplateMeta,
} from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder, DocumentStatus } from '@prisma/client';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones';
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
export const isDocumentCompleted = (document: Pick<Document, 'status'> | DocumentStatus) => {
const status = typeof document === 'string' ? document : document.status;
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
};
/**
* Extracts the derived document meta which should be used when creating a document
* from scratch, or from a template.
*
* Uses the following, the lower number overrides the higher number:
* 1. Merged organisation/team settings
* 2. Meta overrides
*
* @param settings - The merged organisation/team settings.
* @param overrideMeta - The meta to override the settings with.
* @returns The derived document meta.
*/
export const extractDerivedDocumentMeta = (
settings: Omit<OrganisationGlobalSettings, 'id'>,
overrideMeta: Partial<DocumentMeta | TemplateMeta> | undefined | null,
) => {
const meta = overrideMeta ?? {};
// Note: If you update this you will also need to update `create-document-from-template.ts`
// since there is custom work there which allows 3 overrides.
return {
language: meta.language || settings.documentLanguage,
timezone: meta.timezone || settings.documentTimezone || DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: meta.dateFormat || settings.documentDateFormat,
message: meta.message || null,
subject: meta.subject || null,
password: meta.password || null,
redirectUrl: meta.redirectUrl || null,
signingOrder: meta.signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: meta.allowDictateNextSigner ?? false,
distributionMethod: meta.distributionMethod || DocumentDistributionMethod.EMAIL, // Todo: Make this a setting.
// Signature settings.
typedSignatureEnabled: meta.typedSignatureEnabled ?? settings.typedSignatureEnabled,
uploadSignatureEnabled: meta.uploadSignatureEnabled ?? settings.uploadSignatureEnabled,
drawSignatureEnabled: meta.drawSignatureEnabled ?? settings.drawSignatureEnabled,
// Email settings.
emailId: meta.emailId ?? settings.emailId,
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
emailSettings:
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
} satisfies Omit<DocumentMeta, 'id' | 'documentId'>;
};

View File

@ -0,0 +1,17 @@
export const generateDkimRecord = (recordName: string, publicKeyFlattened: string) => {
return {
name: recordName,
value: `v=DKIM1; k=rsa; p=${publicKeyFlattened}`,
type: 'TXT',
};
};
export const AWS_SES_SPF_RECORD = {
name: `@`,
value: 'v=spf1 include:amazonses.com -all',
type: 'TXT',
};
export const generateEmailDomainRecords = (recordName: string, publicKeyFlattened: string) => {
return [generateDkimRecord(recordName, publicKeyFlattened), AWS_SES_SPF_RECORD];
};

View File

@ -1,5 +1,7 @@
import { type TransportTargetOptions, pino } from 'pino';
import type { BaseApiLog } from '../types/api-logs';
import { extractRequestMetadata } from '../universal/extract-request-metadata';
import { env } from './env';
const transports: TransportTargetOptions[] = [];
@ -33,3 +35,31 @@ export const logger = pino({
}
: undefined,
});
export const logDocumentAccess = ({
request,
documentId,
userId,
}: {
request: Request;
documentId: number;
userId: number;
}) => {
const metadata = extractRequestMetadata(request);
const data: BaseApiLog = {
ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent,
path: new URL(request.url).pathname,
auth: 'session',
source: 'app',
userId,
};
logger.info({
...data,
input: {
documentId,
},
});
};

View File

@ -7,11 +7,13 @@ import {
import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats';
import {
LOWEST_ORGANISATION_ROLE,
ORGANISATION_MEMBER_ROLE_HIERARCHY,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
} from '../constants/organisations';
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
export const isPersonalLayout = (organisations: Pick<Organisation, 'type'>[]) => {
return organisations.length === 1 && organisations[0].type === 'PERSONAL';
@ -113,8 +115,12 @@ export const generateDefaultOrganisationSettings = (): Omit<
return {
documentVisibility: DocumentVisibility.EVERYONE,
documentLanguage: 'en',
documentTimezone: null, // Null means local timezone.
documentDateFormat: DEFAULT_DOCUMENT_DATE_FORMAT,
includeSenderDetails: true,
includeSigningCertificate: true,
includeAuditLog: false,
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
@ -124,5 +130,10 @@ export const generateDefaultOrganisationSettings = (): Omit<
brandingLogo: '',
brandingUrl: '',
brandingCompanyDetails: '',
emailId: null,
emailReplyTo: null,
// emailReplyToName: null,
emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS,
};
};

View File

@ -165,8 +165,12 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
return {
documentVisibility: null,
documentLanguage: null,
documentTimezone: null,
documentDateFormat: null,
includeSenderDetails: null,
includeSigningCertificate: null,
includeAuditLog: null,
typedSignatureEnabled: null,
uploadSignatureEnabled: null,
@ -176,6 +180,11 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
brandingLogo: null,
brandingUrl: null,
brandingCompanyDetails: null,
emailDocumentSettings: null,
emailId: null,
emailReplyTo: null,
// emailReplyToName: null,
};
};

View File

@ -0,0 +1,94 @@
-- [CUSTOM_MIGRATION] Required to fill in missing rows for `emailDocumentSettings` column.
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "emailDocumentSettings" JSONB;
-- [CUSTOM_CHANGE] Insert default values for `emailDocumentSettings` column.
UPDATE "OrganisationGlobalSettings"
SET "emailDocumentSettings" = '{
"recipientSigningRequest": true,
"recipientRemoved": true,
"recipientSigned": true,
"documentPending": true,
"documentCompleted": true,
"documentDeleted": true,
"ownerDocumentCompleted": true
}'::jsonb
WHERE "emailDocumentSettings" IS NULL;
-- CreateEnum
CREATE TYPE "EmailDomainStatus" AS ENUM ('PENDING', 'ACTIVE');
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "emailId" TEXT,
ADD COLUMN "emailReplyTo" TEXT;
-- AlterTable
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "documentDateFormat" TEXT NOT NULL DEFAULT 'yyyy-MM-dd hh:mm a',
ADD COLUMN "documentTimezone" TEXT,
ADD COLUMN "emailId" TEXT,
ADD COLUMN "emailReplyTo" TEXT;
-- [CUSTOM_MIGRATION] Set the `emailDocumentSettings` column back to not null.
ALTER TABLE "OrganisationGlobalSettings" ALTER COLUMN "emailDocumentSettings" SET NOT NULL;
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "documentDateFormat" TEXT,
ADD COLUMN "documentTimezone" TEXT,
ADD COLUMN "emailDocumentSettings" JSONB,
ADD COLUMN "emailId" TEXT,
ADD COLUMN "emailReplyTo" TEXT;
-- AlterTable
ALTER TABLE "TemplateMeta" ADD COLUMN "emailId" TEXT,
ADD COLUMN "emailReplyTo" TEXT;
-- CreateTable
CREATE TABLE "EmailDomain" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"status" "EmailDomainStatus" NOT NULL DEFAULT 'PENDING',
"selector" TEXT NOT NULL,
"domain" TEXT NOT NULL,
"publicKey" TEXT NOT NULL,
"privateKey" TEXT NOT NULL,
"organisationId" TEXT NOT NULL,
CONSTRAINT "EmailDomain_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrganisationEmail" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"email" TEXT NOT NULL,
"emailName" TEXT NOT NULL,
"emailDomainId" TEXT NOT NULL,
"organisationId" TEXT NOT NULL,
CONSTRAINT "OrganisationEmail_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "EmailDomain_selector_key" ON "EmailDomain"("selector");
-- CreateIndex
CREATE UNIQUE INDEX "EmailDomain_domain_key" ON "EmailDomain"("domain");
-- CreateIndex
CREATE UNIQUE INDEX "OrganisationEmail_email_key" ON "OrganisationEmail"("email");
-- AddForeignKey
ALTER TABLE "OrganisationGlobalSettings" ADD CONSTRAINT "OrganisationGlobalSettings_emailId_fkey" FOREIGN KEY ("emailId") REFERENCES "OrganisationEmail"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamGlobalSettings" ADD CONSTRAINT "TeamGlobalSettings_emailId_fkey" FOREIGN KEY ("emailId") REFERENCES "OrganisationEmail"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmailDomain" ADD CONSTRAINT "EmailDomain_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "Organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrganisationEmail" ADD CONSTRAINT "OrganisationEmail_emailDomainId_fkey" FOREIGN KEY ("emailDomainId") REFERENCES "EmailDomain"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrganisationEmail" ADD CONSTRAINT "OrganisationEmail_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "Organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,11 @@
-- DropForeignKey
ALTER TABLE "Document" DROP CONSTRAINT "Document_folderId_fkey";
-- DropForeignKey
ALTER TABLE "Template" DROP CONSTRAINT "Template_folderId_fkey";
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "includeAuditLog" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "includeAuditLog" BOOLEAN;

View File

@ -397,7 +397,7 @@ model Document {
template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull)
auditLogs DocumentAuditLog[]
folder Folder? @relation(fields: [folderId], references: [id], onDelete: Cascade)
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId String?
@@unique([documentDataId])
@ -468,7 +468,10 @@ model DocumentMeta {
language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL)
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
emailReplyTo String?
emailId String?
}
enum ReadStatus {
@ -619,6 +622,9 @@ model Organisation {
teams Team[]
emailDomains EmailDomain[]
organisationEmails OrganisationEmail[]
avatarImage AvatarImage? @relation(fields: [avatarImageId], references: [id], onDelete: SetNull)
ownerUserId Int
@ -723,6 +729,7 @@ enum OrganisationMemberInviteStatus {
DECLINED
}
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
model OrganisationGlobalSettings {
id String @id
organisation Organisation?
@ -731,30 +738,52 @@ model OrganisationGlobalSettings {
documentLanguage String @default("en")
includeSenderDetails Boolean @default(true)
includeSigningCertificate Boolean @default(true)
includeAuditLog Boolean @default(false)
documentTimezone String? // Nullable to allow using local timezones if not set.
documentDateFormat String @default("yyyy-MM-dd hh:mm a")
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
drawSignatureEnabled Boolean @default(true)
emailId String?
email OrganisationEmail? @relation(fields: [emailId], references: [id])
emailReplyTo String?
// emailReplyToName String? // Placeholder for future feature.
emailDocumentSettings Json /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
brandingEnabled Boolean @default(false)
brandingLogo String @default("")
brandingUrl String @default("")
brandingCompanyDetails String @default("")
}
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
model TeamGlobalSettings {
id String @id
team Team?
documentVisibility DocumentVisibility?
documentLanguage String?
documentVisibility DocumentVisibility?
documentLanguage String?
documentTimezone String?
documentDateFormat String?
includeSenderDetails Boolean?
includeSigningCertificate Boolean?
includeAuditLog Boolean?
typedSignatureEnabled Boolean?
uploadSignatureEnabled Boolean?
drawSignatureEnabled Boolean?
emailId String?
email OrganisationEmail? @relation(fields: [emailId], references: [id])
emailReplyTo String?
// emailReplyToName String? // Placeholder for future feature.
emailDocumentSettings Json? /// [DocumentEmailSettingsNullable] @zod.custom.use(ZDocumentEmailSettingsSchema)
brandingEnabled Boolean?
brandingLogo String?
brandingUrl String?
@ -829,11 +858,14 @@ model TemplateMeta {
uploadSignatureEnabled Boolean @default(true)
drawSignatureEnabled Boolean @default(true)
templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
redirectUrl String?
language String @default("en")
templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
redirectUrl String?
language String @default("en")
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
emailReplyTo String?
emailId String?
}
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
@ -866,7 +898,7 @@ model Template {
directLink TemplateDirectLink?
documents Document[]
folder Folder? @relation(fields: [folderId], references: [id], onDelete: Cascade)
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId String?
@@unique([templateDocumentDataId])
@ -952,3 +984,45 @@ model AvatarImage {
user User[]
organisation Organisation[]
}
enum EmailDomainStatus {
PENDING
ACTIVE
}
model EmailDomain {
id String @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status EmailDomainStatus @default(PENDING)
selector String @unique
domain String @unique
publicKey String
privateKey String
organisationId String
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
emails OrganisationEmail[]
}
model OrganisationEmail {
id String @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
email String @unique
emailName String
// replyTo String?
emailDomainId String
emailDomain EmailDomain @relation(fields: [emailDomainId], references: [id], onDelete: Cascade)
organisationId String
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
organisationGlobalSettings OrganisationGlobalSettings[]
teamGlobalSettings TeamGlobalSettings[]
}

View File

@ -207,7 +207,9 @@ export const seedTeamTemplateWithMeta = async (team: Team) => {
const ownerUser = organisation.owner;
const template = await createTemplate({
title: `[TEST] Template ${nanoid(8)} - Draft`,
data: {
title: `[TEST] Template ${nanoid(8)} - Draft`,
},
userId: ownerUser.id,
teamId: team.id,
templateDocumentDataId: documentData.id,

View File

@ -18,6 +18,7 @@ declare global {
type DocumentFormValues = TDocumentFormValues;
type DocumentAuthOptions = TDocumentAuthOptions;
type DocumentEmailSettings = TDocumentEmailSettings;
type DocumentEmailSettingsNullable = TDocumentEmailSettings | null;
type RecipientAuthOptions = TRecipientAuthOptions;

View File

@ -1,20 +0,0 @@
import { router } from '../trpc';
import { createSubscriptionRoute } from './create-subscription';
import { getInvoicesRoute } from './get-invoices';
import { getPlansRoute } from './get-plans';
import { getSubscriptionRoute } from './get-subscription';
import { manageSubscriptionRoute } from './manage-subscription';
export const billingRouter = router({
plans: {
get: getPlansRoute,
},
subscription: {
get: getSubscriptionRoute,
create: createSubscriptionRoute,
manage: manageSubscriptionRoute,
},
invoices: {
get: getInvoicesRoute,
},
});

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