Merge branch 'main' into feat/org-insights

This commit is contained in:
Ephraim Duncan
2025-09-02 11:28:40 +00:00
committed by GitHub
199 changed files with 5102 additions and 2007 deletions

View File

@@ -34,6 +34,7 @@ import { createTemplate } from '@documenso/lib/server-only/template/create-templ
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';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { extractDerivedDocumentEmailSettings } from '@documenso/lib/types/document-email';
import {
ZCheckboxFieldMeta,
@@ -330,6 +331,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
userId: user.id,
teamId: team?.id,
formValues: body.formValues,
folderId: body.folderId,
documentDataId: documentData.id,
requestMetadata: metadata,
});
@@ -736,6 +738,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
teamId: team?.id,
recipients: body.recipients,
prefillFields: body.prefillFields,
folderId: body.folderId,
override: {
title: body.title,
...body.meta,
@@ -978,10 +981,12 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
userId: user.id,
teamId: team?.id,
recipients: [
...recipients.map(({ email, name }) => ({
email,
name,
role,
...recipients.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? [],
})),
{
email,

View File

@@ -33,7 +33,7 @@ export const ZNoBodyMutationSchema = null;
*/
export const ZGetDocumentsQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(10),
});
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
@@ -136,6 +136,12 @@ export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSucc
export const ZCreateDocumentMutationSchema = z.object({
title: z.string().min(1),
externalId: z.string().nullish(),
folderId: z
.string()
.describe(
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
)
.optional(),
recipients: z.array(
z.object({
name: z.string().min(1),
@@ -287,6 +293,12 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
title: z.string().optional(),
externalId: z.string().optional(),
folderId: z
.string()
.describe(
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
)
.optional(),
recipients: z
.array(
z.object({
@@ -625,5 +637,5 @@ export const ZSuccessfulGetTemplatesResponseSchema = z.object({
export const ZGetTemplatesQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(10),
});

View File

@@ -0,0 +1,293 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const setupDocumentAndNavigateToFieldsStep = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add signer' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, document };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Fields Step', () => {
test('should autosave the fields without advanced settings', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(3);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
expect(retrievedFields[2].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field deletion', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Text').nth(1).click();
await page.getByRole('button', { name: 'Remove' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(2);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field duplication', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Signature').nth(1).click();
await page.getByRole('button', { name: 'Duplicate', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(4);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
expect(retrievedFields[2].type).toBe('SIGNATURE');
expect(retrievedFields[3].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the fields with advanced settings', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByRole('textbox', { name: 'Field label' }).fill('Test Field');
await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder');
await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text');
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Save' })
.click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getFieldsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedFields.length).toBe(2);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
const textField = retrievedFields[1];
expect(textField.fieldMeta).toBeDefined();
if (
textField.fieldMeta &&
typeof textField.fieldMeta === 'object' &&
'type' in textField.fieldMeta
) {
expect(textField.fieldMeta.type).toBe('text');
expect(textField.fieldMeta.label).toBe('Test Field');
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
if (textField.fieldMeta.type === 'text') {
expect(textField.fieldMeta.text).toBe('Test Text');
}
} else {
throw new Error('fieldMeta should be defined and contain advanced settings');
}
}).toPass();
});
});

View File

@@ -0,0 +1,243 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupDocument = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
return { user, team, document };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Settings Step', () => {
test('should autosave the title change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newDocumentTitle = 'New Document Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newDocumentTitle);
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(retrieved.title);
}).toPass();
});
test('should autosave the language change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newDocumentLanguage = 'French';
const expectedLanguageCode = 'fr';
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: newDocumentLanguage }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.language).toBe(expectedLanguageCode);
}).toPass();
});
test('should autosave the document access change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const access = 'Require account';
const accessValue = 'ACCOUNT';
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: access }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.authOptions?.globalAccessAuth).toContain(accessValue);
}).toPass();
});
test('should autosave the external ID change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newExternalId = '1234567890';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.externalId).toBe(newExternalId);
}).toPass();
});
test('should autosave the allowed signature types change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(3).click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Type' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false);
expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false);
expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true);
}).toPass();
});
test('should autosave the date format change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(4).click();
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.dateFormat).toBe("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
}).toPass();
});
test('should autosave the timezone change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'Europe/London' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.timezone).toBe('Europe/London');
}).toPass();
});
test('should autosave the redirect URL change', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newRedirectUrl = 'https://documenso.com/test/';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'Redirect URL' }).fill(newRedirectUrl);
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.documentMeta?.redirectUrl).toBe(newRedirectUrl);
}).toPass();
});
test('should autosave multiple field changes together', async ({ page }) => {
const { user, document, team } = await setupDocument(page);
const newTitle = 'Updated Document Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newTitle);
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'German' }).click();
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: 'Require account' }).click();
await page.getByRole('button', { name: 'Advanced Options' }).click();
const newExternalId = 'MULTI-TEST-123';
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrieved = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrieved.title).toBe(newTitle);
expect(retrieved.documentMeta?.language).toBe('de');
expect(retrieved.authOptions?.globalAccessAuth).toContain('ACCOUNT');
expect(retrieved.externalId).toBe(newExternalId);
expect(retrieved.documentMeta?.timezone).toBe('Europe/Berlin');
}).toPass();
});
});

View File

@@ -0,0 +1,168 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupDocumentAndNavigateToSignersStep = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, document };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
const addSignerAndSave = async (page: Page) => {
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await triggerAutosave(page);
};
test.describe('AutoSave Signers Step', () => {
test('should autosave the signers addition', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('recipient1@documenso.com');
expect(retrievedRecipients[0].name).toBe('Recipient 1');
}).toPass();
});
test('should autosave the signer deletion', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add myself' }).click();
await triggerAutosave(page);
await page.getByTestId('remove-signer-button').first().click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe(user.email);
expect(retrievedRecipients[0].name).toBe(user.name);
}).toPass();
});
test('should autosave the signer update', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByPlaceholder('Name').fill('Documenso Manager');
await page.getByPlaceholder('Email').fill('manager@documenso.com');
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Receives copy' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('manager@documenso.com');
expect(retrievedRecipients[0].name).toBe('Documenso Manager');
expect(retrievedRecipients[0].role).toBe('CC');
}).toPass();
});
test('should autosave the signing order change', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add signer' }).click();
await page.getByTestId('signer-email-input').nth(1).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByTestId('signer-email-input').nth(2).fill('recipient3@documenso.com');
await page.getByLabel('Name').nth(2).fill('Recipient 3');
await triggerAutosave(page);
await page.getByLabel('Enable signing order').check();
await page.getByLabel('Allow signers to dictate next signer').check();
await triggerAutosave(page);
await page.getByTestId('signing-order-input').nth(0).fill('3');
await page.getByTestId('signing-order-input').nth(0).blur();
await triggerAutosave(page);
await page.getByTestId('signing-order-input').nth(1).fill('1');
await page.getByTestId('signing-order-input').nth(1).blur();
await triggerAutosave(page);
await page.getByTestId('signing-order-input').nth(2).fill('2');
await page.getByTestId('signing-order-input').nth(2).blur();
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
const retrievedRecipients = await getRecipientsForDocument({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedDocumentData.documentMeta?.signingOrder).toBe('SEQUENTIAL');
expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true);
expect(retrievedRecipients.length).toBe(3);
expect(retrievedRecipients[0].signingOrder).toBe(2);
expect(retrievedRecipients[1].signingOrder).toBe(3);
expect(retrievedRecipients[2].signingOrder).toBe(1);
}).toPass();
});
});

View File

@@ -0,0 +1,200 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
return { user, team, document };
};
export const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Subject Step', () => {
test('should autosave the subject field', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
const subject = 'Hello world!';
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Subject (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.subject ?? '',
);
}).toPass();
});
test('should autosave the message field', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
const message = 'Please review and sign this important document. Thank you!';
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Message (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.message ?? '',
);
}).toPass();
});
test('should autosave the email settings checkboxes', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
await page.getByText('Send recipient signed email').click();
await page.getByText('Send recipient removed email').click();
await page.getByText('Send document completed email', { exact: true }).click();
await page.getByText('Send document deleted email').click();
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
const emailSettings = retrievedDocumentData.documentMeta?.emailSettings;
await expect(page.getByText('Send recipient signed email')).toBeChecked({
checked: emailSettings?.recipientSigned,
});
await expect(page.getByText('Send recipient removed email')).toBeChecked({
checked: emailSettings?.recipientRemoved,
});
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
checked: emailSettings?.documentCompleted,
});
await expect(page.getByText('Send document deleted email')).toBeChecked({
checked: emailSettings?.documentDeleted,
});
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
checked: emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Send document pending email')).toBeChecked({
checked: emailSettings?.documentPending,
});
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
checked: emailSettings?.ownerDocumentCompleted,
});
}).toPass();
});
test('should autosave all fields and settings together', async ({ page }) => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
const subject = 'Combined Test Subject - Please Sign';
const message =
'This is a comprehensive test message for autosave functionality. Please review and sign at your earliest convenience.';
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
await page.getByText('Send recipient signed email').click();
await page.getByText('Send recipient removed email').click();
await page.getByText('Send document completed email', { exact: true }).click();
await page.getByText('Send document deleted email').click();
await triggerAutosave(page);
await expect(async () => {
const retrievedDocumentData = await getDocumentById({
documentId: document.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedDocumentData.documentMeta?.subject).toBe(subject);
expect(retrievedDocumentData.documentMeta?.message).toBe(message);
expect(retrievedDocumentData.documentMeta?.emailSettings).toBeDefined();
await expect(page.getByRole('textbox', { name: 'Subject (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.subject ?? '',
);
await expect(page.getByRole('textbox', { name: 'Message (Optional)' })).toHaveValue(
retrievedDocumentData.documentMeta?.message ?? '',
);
await expect(page.getByText('Send recipient signed email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigned,
});
await expect(page.getByText('Send recipient removed email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientRemoved,
});
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
});
await expect(page.getByText('Send document deleted email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
});
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Send document pending email')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
});
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
});
}).toPass();
});
});

View File

@@ -534,9 +534,6 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByLabel('Enable signing order').check();
for (let i = 1; i <= 3; i++) {
if (i > 1) {
await page.getByRole('button', { name: 'Add Signer' }).click();
@@ -558,6 +555,9 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
.fill(`User ${i}`);
}
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByLabel('Enable signing order').check();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();

View File

@@ -0,0 +1,304 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const setupTemplateAndNavigateToFieldsStep = async (page: Page) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, template };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Fields Step', () => {
test('should autosave the fields without advanced settings', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedFields.fields;
expect(fields.length).toBe(3);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('TEXT');
expect(fields[2].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field deletion', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Text').nth(1).click();
await page.getByRole('button', { name: 'Remove' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedFields.fields;
expect(fields.length).toBe(2);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the field duplication', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Cancel' })
.click();
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
},
});
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
await page.getByText('Signature').nth(1).click();
await page.getByRole('button', { name: 'Duplicate', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedFields = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedFields.fields;
expect(fields.length).toBe(4);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('TEXT');
expect(fields[2].type).toBe('SIGNATURE');
expect(fields[3].type).toBe('SIGNATURE');
}).toPass();
});
test('should autosave the fields with advanced settings', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByRole('textbox', { name: 'Field label' }).fill('Test Field');
await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder');
await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text');
await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' });
await page
.getByTestId('field-advanced-settings-footer')
.getByRole('button', { name: 'Save' })
.click();
await page.waitForTimeout(2500);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const fields = retrievedTemplate.fields;
expect(fields.length).toBe(2);
expect(fields[0].type).toBe('SIGNATURE');
expect(fields[1].type).toBe('TEXT');
const textField = fields[1];
expect(textField.fieldMeta).toBeDefined();
if (
textField.fieldMeta &&
typeof textField.fieldMeta === 'object' &&
'type' in textField.fieldMeta
) {
expect(textField.fieldMeta.type).toBe('text');
expect(textField.fieldMeta.label).toBe('Test Field');
expect(textField.fieldMeta.placeholder).toBe('Test Placeholder');
if (textField.fieldMeta.type === 'text') {
expect(textField.fieldMeta.text).toBe('Test Text');
}
} else {
throw new Error('fieldMeta should be defined and contain advanced settings');
}
}).toPass();
});
});

View File

@@ -0,0 +1,244 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupTemplate = async (page: Page) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
return { user, team, template };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
test.describe('AutoSave Settings Step - Templates', () => {
test('should autosave the title change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newTemplateTitle = 'New Template Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newTemplateTitle);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(
retrievedTemplate.title,
);
}).toPass();
});
test('should autosave the language change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newTemplateLanguage = 'French';
const expectedLanguageCode = 'fr';
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: newTemplateLanguage }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.language).toBe(expectedLanguageCode);
}).toPass();
});
test('should autosave the template access change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const access = 'Require account';
const accessValue = 'ACCOUNT';
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: access }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.authOptions?.globalAccessAuth).toContain(accessValue);
}).toPass();
});
test('should autosave the external ID change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newExternalId = '1234567890';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.externalId).toBe(newExternalId);
}).toPass();
});
test('should autosave the allowed signature types change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(4).click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Type' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false);
expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false);
expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true);
}).toPass();
});
test('should autosave the date format change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(5).click();
await page.getByRole('option', { name: 'ISO 8601', exact: true }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.dateFormat).toBe("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
}).toPass();
});
test('should autosave the timezone change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('combobox').nth(6).click();
await page.getByRole('option', { name: 'Europe/London' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.timezone).toBe('Europe/London');
}).toPass();
});
test('should autosave the redirect URL change', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newRedirectUrl = 'https://documenso.com/test/';
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByRole('textbox', { name: 'Redirect URL' }).fill(newRedirectUrl);
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.redirectUrl).toBe(newRedirectUrl);
}).toPass();
});
test('should autosave multiple field changes together', async ({ page }) => {
const { user, template, team } = await setupTemplate(page);
const newTitle = 'Updated Template Title';
await page.getByRole('textbox', { name: 'Title *' }).fill(newTitle);
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'German' }).click();
await page.getByRole('combobox').nth(1).click();
await page.getByRole('option', { name: 'Require account' }).click();
await page.getByRole('button', { name: 'Advanced Options' }).click();
const newExternalId = 'MULTI-TEST-123';
await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId);
await page.getByRole('combobox').nth(6).click();
await page.getByRole('option', { name: 'Europe/Berlin' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.title).toBe(newTitle);
expect(retrievedTemplate.templateMeta?.language).toBe('de');
expect(retrievedTemplate.authOptions?.globalAccessAuth).toContain('ACCOUNT');
expect(retrievedTemplate.externalId).toBe(newExternalId);
expect(retrievedTemplate.templateMeta?.timezone).toBe('Europe/Berlin');
}).toPass();
});
});

View File

@@ -0,0 +1,174 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const setupTemplateAndNavigateToSignersStep = async (page: Page) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
await page.getByRole('button', { name: 'Continue' }).click();
return { user, team, template };
};
const triggerAutosave = async (page: Page) => {
await page.locator('#document-flow-form-container').click();
await page.locator('#document-flow-form-container').blur();
await page.waitForTimeout(5000);
};
const addSignerAndSave = async (page: Page) => {
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await triggerAutosave(page);
};
test.describe('AutoSave Signers Step - Templates', () => {
test('should autosave the signers addition', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('recipient1@documenso.com');
expect(retrievedRecipients[0].name).toBe('Recipient 1');
}).toPass();
});
test('should autosave the signer deletion', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add myself' }).click();
await triggerAutosave(page);
await page.getByTestId('remove-placeholder-recipient-button').first().click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe(user.email);
expect(retrievedRecipients[0].name).toBe(user.name);
}).toPass();
});
test('should autosave the signer update', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByPlaceholder('Name').fill('Documenso Manager');
await page.getByPlaceholder('Email').fill('manager@documenso.com');
await triggerAutosave(page);
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Receives copy' }).click();
await triggerAutosave(page);
await expect(async () => {
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedRecipients.length).toBe(1);
expect(retrievedRecipients[0].email).toBe('manager@documenso.com');
expect(retrievedRecipients[0].name).toBe('Documenso Manager');
expect(retrievedRecipients[0].role).toBe('CC');
}).toPass();
});
test('should autosave the signing order change', async ({ page }) => {
const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page);
await addSignerAndSave(page);
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
await page
.getByTestId('placeholder-recipient-email-input')
.nth(1)
.fill('recipient2@documenso.com');
await page.getByTestId('placeholder-recipient-name-input').nth(1).fill('Recipient 2');
await page.getByRole('button', { name: 'Add placeholder recipient' }).click();
await page
.getByTestId('placeholder-recipient-email-input')
.nth(2)
.fill('recipient3@documenso.com');
await page.getByTestId('placeholder-recipient-name-input').nth(2).fill('Recipient 3');
await triggerAutosave(page);
await page.getByLabel('Enable signing order').check();
await page.getByLabel('Allow signers to dictate next signer').check();
await triggerAutosave(page);
await page.getByTestId('placeholder-recipient-signing-order-input').nth(0).fill('3');
await page.getByTestId('placeholder-recipient-signing-order-input').nth(0).blur();
await triggerAutosave(page);
await page.getByTestId('placeholder-recipient-signing-order-input').nth(1).fill('1');
await page.getByTestId('placeholder-recipient-signing-order-input').nth(1).blur();
await triggerAutosave(page);
await page.getByTestId('placeholder-recipient-signing-order-input').nth(2).fill('2');
await page.getByTestId('placeholder-recipient-signing-order-input').nth(2).blur();
await triggerAutosave(page);
await expect(async () => {
const retrievedTemplate = await getTemplateById({
id: template.id,
userId: user.id,
teamId: team.id,
});
const retrievedRecipients = await getRecipientsForTemplate({
templateId: template.id,
userId: user.id,
teamId: team.id,
});
expect(retrievedTemplate.templateMeta?.signingOrder).toBe('SEQUENTIAL');
expect(retrievedTemplate.templateMeta?.allowDictateNextSigner).toBe(true);
expect(retrievedRecipients.length).toBe(3);
expect(retrievedRecipients[0].signingOrder).toBe(2);
expect(retrievedRecipients[1].signingOrder).toBe(3);
expect(retrievedRecipients[2].signingOrder).toBe(1);
}).toPass();
});
});

View File

@@ -17,7 +17,7 @@ export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: false,
workers: 1,
workers: 4,
maxFailures: process.env.CI ? 1 : undefined,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,

View File

@@ -92,7 +92,11 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
providerAccountId: sub,
},
include: {
user: true,
user: {
select: {
id: true,
},
},
},
});
@@ -107,6 +111,10 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
where: {
email: email,
},
select: {
id: true,
emailVerified: true,
},
});
// Handle existing user but no account.

View File

@@ -0,0 +1,31 @@
import { useCallback, useEffect, useRef } from 'react';
export const useAutoSave = <T>(onSave: (data: T) => Promise<void>) => {
const saveTimeoutRef = useRef<NodeJS.Timeout>();
const saveFormData = async (data: T) => {
try {
await onSave(data);
} catch (error) {
console.error('Auto-save failed:', error);
}
};
const scheduleSave = useCallback((data: T) => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => void saveFormData(data), 2000);
}, []);
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
return { scheduleSave };
};

View File

@@ -29,7 +29,13 @@ export const run = async ({
id: documentId,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
recipients: true,
team: {

View File

@@ -39,7 +39,13 @@ export const run = async ({
},
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
},
},
@@ -51,7 +57,13 @@ export const run = async ({
organisationId: payload.organisationId,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});

View File

@@ -39,7 +39,13 @@ export const run = async ({
},
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
},
},
@@ -49,6 +55,11 @@ export const run = async ({
where: {
id: payload.memberUserId,
},
select: {
id: true,
email: true,
name: true,
},
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({

View File

@@ -38,7 +38,13 @@ export const run = async ({
id: recipientId,
},
},
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
},
});

View File

@@ -33,7 +33,13 @@ export const run = async ({
id: documentId,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
team: {
select: {

View File

@@ -42,6 +42,11 @@ export const run = async ({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
}),
prisma.document.findFirstOrThrow({
where: {

View File

@@ -10,13 +10,7 @@ export type UpdateUserOptions = {
};
export const updateUser = async ({ id, name, email, roles }: UpdateUserOptions) => {
await prisma.user.findFirstOrThrow({
where: {
id,
},
});
return await prisma.user.update({
await prisma.user.update({
where: {
id,
},

View File

@@ -1,6 +1,7 @@
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
import {
DocumentSource,
FolderType,
RecipientRole,
SendStatus,
SigningStatus,
@@ -14,7 +15,7 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { TCreateDocumentV2Request } from '@documenso/trpc/server/document-router/schema';
import type { TCreateDocumentTemporaryRequest } from '@documenso/trpc/server/document-router/create-document-temporary.types';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
@@ -44,7 +45,8 @@ export type CreateDocumentOptions = {
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
recipients: TCreateDocumentTemporaryRequest['recipients'];
folderId?: string;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata: ApiRequestMetadata;
@@ -59,7 +61,7 @@ export const createDocumentV2 = async ({
meta,
requestMetadata,
}: CreateDocumentOptions) => {
const { title, formValues } = data;
const { title, formValues, folderId } = data;
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
@@ -78,6 +80,22 @@ export const createDocumentV2 = async ({
});
}
if (folderId) {
const folder = await prisma.folder.findUnique({
where: {
id: folderId,
type: FolderType.DOCUMENT,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
const settings = await getTeamSettings({
userId,
teamId,
@@ -164,6 +182,7 @@ export const createDocumentV2 = async ({
teamId,
authOptions,
visibility,
folderId,
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {

View File

@@ -49,6 +49,11 @@ export const findDocuments = async ({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});
let team = null;
@@ -267,7 +272,7 @@ export const findDocuments = async ({
const findDocumentsFilter = (
status: ExtendedDocumentStatus,
user: User,
user: Pick<User, 'id' | 'email' | 'name'>,
folderId?: string | null,
) => {
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)

View File

@@ -73,7 +73,13 @@ export const getDocumentAndSenderByToken = async ({
},
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentData: true,
documentMeta: true,
recipients: {
@@ -90,9 +96,6 @@ export const getDocumentAndSenderByToken = async ({
},
});
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const { password: _password, ...user } = result.user;
const recipient = result.recipients[0];
// Sanity check, should not be possible.
@@ -120,7 +123,11 @@ export const getDocumentAndSenderByToken = async ({
return {
...result,
user,
user: {
id: result.user.id,
email: result.user.email,
name: result.user.name,
},
};
};

View File

@@ -7,14 +7,12 @@ export type GetDocumentWithDetailsByIdOptions = {
documentId: number;
userId: number;
teamId: number;
folderId?: string;
};
export const getDocumentWithDetailsById = async ({
documentId,
userId,
teamId,
folderId,
}: GetDocumentWithDetailsByIdOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
@@ -25,7 +23,6 @@ export const getDocumentWithDetailsById = async ({
const document = await prisma.document.findFirst({
where: {
...documentWhereInput,
folderId,
},
include: {
documentData: true,

View File

@@ -28,13 +28,7 @@ export async function rejectDocumentWithToken({
documentId,
},
include: {
document: {
include: {
user: true,
recipients: true,
documentMeta: true,
},
},
document: true,
},
});

View File

@@ -33,7 +33,13 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
documentData: true,
documentMeta: true,
recipients: true,
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
team: {
select: {
id: true,

View File

@@ -24,7 +24,13 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
id: documentId,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
},
});

View File

@@ -148,33 +148,6 @@ export const sendDocument = async ({
// throw new Error('Some signers have not been assigned a signature field.');
// }
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
// Only send email if one of the following is true:
// - It is explicitly set
// - The email is enabled for signing requests AND sendEmail is undefined
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId,
documentId,
recipientId: recipient.id,
requestMetadata: requestMetadata?.requestMetadata,
},
});
}),
);
}
const allRecipientsHaveNoActionToTake = document.recipients.every(
(recipient) =>
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
@@ -227,6 +200,33 @@ export const sendDocument = async ({
});
});
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
// Only send email if one of the following is true:
// - It is explicitly set
// - The email is enabled for signing requests AND sendEmail is undefined
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId,
documentId,
recipientId: recipient.id,
requestMetadata: requestMetadata?.requestMetadata,
},
});
}),
);
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SENT,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),

View File

@@ -30,7 +30,13 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
include: {
recipients: true,
documentMeta: true,
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});

View File

@@ -1,3 +1,5 @@
import { P, match } from 'ts-pattern';
import type { BrandingSettings } from '@documenso/email/providers/branding';
import { prisma } from '@documenso/prisma';
import type {
@@ -104,7 +106,12 @@ export const getEmailContext = async (
}
const replyToEmail = meta?.emailReplyTo || emailContext.settings.emailReplyTo || undefined;
const senderEmailId = meta?.emailId === null ? null : emailContext.settings.emailId;
const senderEmailId = match(meta?.emailId)
.with(P.string, (emailId) => emailId) // Explicit string means to use the provided email ID.
.with(undefined, () => emailContext.settings.emailId) // Undefined means to use the inherited email ID.
.with(null, () => null) // Explicit null means to use the Documenso email.
.exhaustive();
const foundSenderEmail = emailContext.allowedEmails.find((email) => email.id === senderEmailId);

View File

@@ -3,6 +3,7 @@ import type { PDFDocument } from 'pdf-lib';
import { TextAlignment, rgb, setFontAndSize } from 'pdf-lib';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { getPageSize } from './get-page-size';
/**
* Adds a rejection stamp to each page of a PDF document.
@@ -27,7 +28,7 @@ export async function addRejectionStampToPdf(
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const { width, height } = page.getSize();
const { width, height } = getPageSize(page);
// Draw the "REJECTED" text
const rejectedTitleText = 'DOCUMENT REJECTED';

View File

@@ -0,0 +1,18 @@
import type { PDFPage } from 'pdf-lib';
/**
* Gets the effective page size for PDF operations.
*
* Uses CropBox by default to handle rare cases where MediaBox is larger than CropBox.
* Falls back to MediaBox when it's smaller than CropBox, following typical PDF reader behavior.
*/
export const getPageSize = (page: PDFPage) => {
const cropBox = page.getCropBox();
const mediaBox = page.getMediaBox();
if (mediaBox.width < cropBox.width || mediaBox.height < cropBox.height) {
return mediaBox;
}
return cropBox;
};

View File

@@ -33,6 +33,7 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import { getPageSize } from './get-page-size';
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
@@ -77,7 +78,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.

View File

@@ -26,6 +26,7 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import { getPageSize } from './get-page-size';
export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
@@ -63,7 +64,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.

View File

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

View File

@@ -1,5 +1,7 @@
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface GetRecipientsForTemplateOptions {
templateId: number;
userId: number;
@@ -14,21 +16,12 @@ export const getRecipientsForTemplate = async ({
const recipients = await prisma.recipient.findMany({
where: {
templateId,
template: teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
},
template: {
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
},
orderBy: {
id: 'asc',

View File

@@ -105,7 +105,13 @@ export const createDocumentFromDirectTemplate = async ({
directLink: true,
templateDocumentData: true,
templateMeta: true,
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});

View File

@@ -2,6 +2,7 @@ import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/c
import {
DocumentSource,
type Field,
FolderType,
type Recipient,
RecipientRole,
SendStatus,
@@ -69,6 +70,7 @@ export type CreateDocumentFromTemplateOptions = {
email: string;
signingOrder?: number | null;
}[];
folderId?: string;
prefillFields?: TFieldMetaPrefillFieldsSchema[];
customDocumentDataId?: string;
@@ -274,6 +276,7 @@ export const createDocumentFromTemplate = async ({
customDocumentDataId,
override,
requestMetadata,
folderId,
prefillFields,
}: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({
@@ -298,6 +301,22 @@ export const createDocumentFromTemplate = async ({
});
}
if (folderId) {
const folder = await prisma.folder.findUnique({
where: {
id: folderId,
type: FolderType.DOCUMENT,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
const settings = await getTeamSettings({
userId,
teamId,
@@ -368,6 +387,7 @@ export const createDocumentFromTemplate = async ({
externalId: externalId || template.externalId,
templateId: template.id,
userId,
folderId,
teamId: template.teamId,
title: override?.title || template.title,
documentDataId: documentData.id,

View File

@@ -1,13 +1,31 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface GetUserByIdOptions {
id: number;
}
export const getUserById = async ({ id }: GetUserByIdOptions) => {
return await prisma.user.findFirstOrThrow({
const user = await prisma.user.findFirst({
where: {
id,
},
select: {
id: true,
name: true,
email: true,
emailVerified: true,
roles: true,
disabled: true,
twoFactorEnabled: true,
signature: true,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return user;
};

View File

@@ -24,7 +24,14 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP
token,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
password: true,
},
},
},
});

View File

@@ -24,7 +24,7 @@ export const updateProfile = async ({
},
});
return await prisma.$transaction(async (tx) => {
await prisma.$transaction(async (tx) => {
await tx.userSecurityAuditLog.create({
data: {
userId,
@@ -34,7 +34,7 @@ export const updateProfile = async ({
},
});
return await tx.user.update({
await tx.user.update({
where: {
id: userId,
},

View File

@@ -12,7 +12,13 @@ export type VerifyEmailProps = {
export const verifyEmail = async ({ token }: VerifyEmailProps) => {
const verificationToken = await prisma.verificationToken.findFirst({
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
where: {
token,

View File

@@ -4,7 +4,7 @@ import type { DocumentWithRecipients } from '@documenso/prisma/types/document-wi
export type MaskRecipientTokensForDocumentOptions<T extends DocumentWithRecipients> = {
document: T;
user?: User;
user?: Pick<User, 'id' | 'email' | 'name'>;
token?: string;
};

View File

@@ -0,0 +1,28 @@
import { sendDeleteEmail } from '@documenso/lib/server-only/document/send-delete-email';
import { superDeleteDocument } from '@documenso/lib/server-only/document/super-delete-document';
import { adminProcedure } from '../trpc';
import {
ZDeleteDocumentRequestSchema,
ZDeleteDocumentResponseSchema,
} from './delete-document.types';
export const deleteDocumentRoute = adminProcedure
.input(ZDeleteDocumentRequestSchema)
.output(ZDeleteDocumentResponseSchema)
.mutation(async ({ ctx, input }) => {
const { id, reason } = input;
ctx.logger.info({
input: {
id,
},
});
await sendDeleteEmail({ documentId: id, reason });
await superDeleteDocument({
id,
requestMetadata: ctx.metadata.requestMetadata,
});
});

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const ZDeleteDocumentRequestSchema = z.object({
id: z.number().min(1),
reason: z.string(),
});
export const ZDeleteDocumentResponseSchema = z.void();
export type TDeleteDocumentRequest = z.infer<typeof ZDeleteDocumentRequestSchema>;
export type TDeleteDocumentResponse = z.infer<typeof ZDeleteDocumentResponseSchema>;

View File

@@ -0,0 +1,19 @@
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { adminProcedure } from '../trpc';
import { ZDeleteUserRequestSchema, ZDeleteUserResponseSchema } from './delete-user.types';
export const deleteUserRoute = adminProcedure
.input(ZDeleteUserRequestSchema)
.output(ZDeleteUserResponseSchema)
.mutation(async ({ input, ctx }) => {
const { id } = input;
ctx.logger.info({
input: {
id,
},
});
await deleteUser({ id });
});

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZDeleteUserRequestSchema = z.object({
id: z.number().min(1),
});
export const ZDeleteUserResponseSchema = z.void();
export type TDeleteUserRequest = z.infer<typeof ZDeleteUserRequestSchema>;
export type TDeleteUserResponse = z.infer<typeof ZDeleteUserResponseSchema>;

View File

@@ -0,0 +1,29 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { disableUser } from '@documenso/lib/server-only/user/disable-user';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { adminProcedure } from '../trpc';
import { ZDisableUserRequestSchema, ZDisableUserResponseSchema } from './disable-user.types';
export const disableUserRoute = adminProcedure
.input(ZDisableUserRequestSchema)
.output(ZDisableUserResponseSchema)
.mutation(async ({ input, ctx }) => {
const { id } = input;
ctx.logger.info({
input: {
id,
},
});
const user = await getUserById({ id }).catch(() => null);
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
await disableUser({ id });
});

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZDisableUserRequestSchema = z.object({
id: z.number().min(1),
});
export const ZDisableUserResponseSchema = z.void();
export type TDisableUserRequest = z.infer<typeof ZDisableUserRequestSchema>;
export type TDisableUserResponse = z.infer<typeof ZDisableUserResponseSchema>;

View File

@@ -0,0 +1,29 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { enableUser } from '@documenso/lib/server-only/user/enable-user';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { adminProcedure } from '../trpc';
import { ZEnableUserRequestSchema, ZEnableUserResponseSchema } from './enable-user.types';
export const enableUserRoute = adminProcedure
.input(ZEnableUserRequestSchema)
.output(ZEnableUserResponseSchema)
.mutation(async ({ input, ctx }) => {
const { id } = input;
ctx.logger.info({
input: {
id,
},
});
const user = await getUserById({ id }).catch(() => null);
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
await enableUser({ id });
});

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZEnableUserRequestSchema = z.object({
id: z.number().min(1),
});
export const ZEnableUserResponseSchema = z.void();
export type TEnableUserRequest = z.infer<typeof ZEnableUserRequestSchema>;
export type TEnableUserResponse = z.infer<typeof ZEnableUserResponseSchema>;

View File

@@ -0,0 +1,13 @@
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
import { adminProcedure } from '../trpc';
import { ZFindDocumentsRequestSchema, ZFindDocumentsResponseSchema } from './find-documents.types';
export const findDocumentsRoute = adminProcedure
.input(ZFindDocumentsRequestSchema)
.output(ZFindDocumentsResponseSchema)
.query(async ({ input }) => {
const { query, page, perPage } = input;
return await findDocuments({ query, page, perPage });
});

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
import { ZDocumentManySchema } from '@documenso/lib/types/document';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({
perPage: z.number().optional().default(20),
});
export const ZFindDocumentsResponseSchema = ZFindResultResponse.extend({
data: ZDocumentManySchema.omit({
team: true,
}).array(),
});
export type TFindDocumentsRequest = z.infer<typeof ZFindDocumentsRequestSchema>;
export type TFindDocumentsResponse = z.infer<typeof ZFindDocumentsResponseSchema>;

View File

@@ -0,0 +1,19 @@
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { adminProcedure } from '../trpc';
import { ZGetUserRequestSchema, ZGetUserResponseSchema } from './get-user.types';
export const getUserRoute = adminProcedure
.input(ZGetUserRequestSchema)
.output(ZGetUserResponseSchema)
.query(async ({ input, ctx }) => {
const { id } = input;
ctx.logger.info({
input: {
id,
},
});
return await getUserById({ id });
});

View File

@@ -0,0 +1,21 @@
import { z } from 'zod';
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
export const ZGetUserRequestSchema = z.object({
id: z.number().min(1),
});
export const ZGetUserResponseSchema = UserSchema.pick({
id: true,
name: true,
email: true,
emailVerified: true,
roles: true,
disabled: true,
twoFactorEnabled: true,
signature: true,
});
export type TGetUserRequest = z.infer<typeof ZGetUserRequestSchema>;
export type TGetUserResponse = z.infer<typeof ZGetUserResponseSchema>;

View File

@@ -0,0 +1,28 @@
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { adminProcedure } from '../trpc';
import {
ZResealDocumentRequestSchema,
ZResealDocumentResponseSchema,
} from './reseal-document.types';
export const resealDocumentRoute = adminProcedure
.input(ZResealDocumentRequestSchema)
.output(ZResealDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { id } = input;
ctx.logger.info({
input: {
id,
},
});
const document = await getEntireDocument({ id });
const isResealing = isDocumentCompleted(document.status);
await sealDocument({ documentId: id, isResealing });
});

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZResealDocumentRequestSchema = z.object({
id: z.number().min(1),
});
export const ZResealDocumentResponseSchema = z.void();
export type TResealDocumentRequest = z.infer<typeof ZResealDocumentRequestSchema>;
export type TResealDocumentResponse = z.infer<typeof ZResealDocumentResponseSchema>;

View File

@@ -1,40 +1,24 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient';
import { updateUser } from '@documenso/lib/server-only/admin/update-user';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
import { sendDeleteEmail } from '@documenso/lib/server-only/document/send-delete-email';
import { superDeleteDocument } from '@documenso/lib/server-only/document/super-delete-document';
import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { disableUser } from '@documenso/lib/server-only/user/disable-user';
import { enableUser } from '@documenso/lib/server-only/user/enable-user';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { adminProcedure, router } from '../trpc';
import { router } from '../trpc';
import { createAdminOrganisationRoute } from './create-admin-organisation';
import { createStripeCustomerRoute } from './create-stripe-customer';
import { createSubscriptionClaimRoute } from './create-subscription-claim';
import { deleteDocumentRoute } from './delete-document';
import { deleteSubscriptionClaimRoute } from './delete-subscription-claim';
import { deleteUserRoute } from './delete-user';
import { disableUserRoute } from './disable-user';
import { enableUserRoute } from './enable-user';
import { findAdminOrganisationsRoute } from './find-admin-organisations';
import { findDocumentsRoute } from './find-documents';
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { getAdminOrganisationRoute } from './get-admin-organisation';
import { getUserRoute } from './get-user';
import { resealDocumentRoute } from './reseal-document';
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import {
ZAdminDeleteDocumentMutationSchema,
ZAdminDeleteUserMutationSchema,
ZAdminDisableUserMutationSchema,
ZAdminEnableUserMutationSchema,
ZAdminFindDocumentsQuerySchema,
ZAdminResealDocumentMutationSchema,
ZAdminUpdateProfileMutationSchema,
ZAdminUpdateRecipientMutationSchema,
ZAdminUpdateSiteSettingMutationSchema,
} from './schema';
import { updateAdminOrganisationRoute } from './update-admin-organisation';
import { updateRecipientRoute } from './update-recipient';
import { updateSiteSettingRoute } from './update-site-setting';
import { updateSubscriptionClaimRoute } from './update-subscription-claim';
import { updateUserRoute } from './update-user';
export const adminRouter = router({
organisation: {
@@ -53,156 +37,20 @@ export const adminRouter = router({
createCustomer: createStripeCustomerRoute,
},
user: {
get: getUserRoute,
update: updateUserRoute,
delete: deleteUserRoute,
enable: enableUserRoute,
disable: disableUserRoute,
resetTwoFactor: resetTwoFactorRoute,
},
// Todo: migrate old routes
findDocuments: adminProcedure.input(ZAdminFindDocumentsQuerySchema).query(async ({ input }) => {
const { query, page, perPage } = input;
return await findDocuments({ query, page, perPage });
}),
updateUser: adminProcedure
.input(ZAdminUpdateProfileMutationSchema)
.mutation(async ({ input, ctx }) => {
const { id, name, email, roles } = input;
ctx.logger.info({
input: {
id,
roles,
},
});
return await updateUser({ id, name, email, roles });
}),
updateRecipient: adminProcedure
.input(ZAdminUpdateRecipientMutationSchema)
.mutation(async ({ input, ctx }) => {
const { id, name, email } = input;
ctx.logger.info({
input: {
id,
},
});
return await updateRecipient({ id, name, email });
}),
updateSiteSetting: adminProcedure
.input(ZAdminUpdateSiteSettingMutationSchema)
.mutation(async ({ ctx, input }) => {
const { id, enabled, data } = input;
ctx.logger.info({
input: {
id,
},
});
return await upsertSiteSetting({
id,
enabled,
data,
userId: ctx.user.id,
});
}),
resealDocument: adminProcedure
.input(ZAdminResealDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
const { id } = input;
ctx.logger.info({
input: {
id,
},
});
const document = await getEntireDocument({ id });
const isResealing = isDocumentCompleted(document.status);
return await sealDocument({ documentId: id, isResealing });
}),
enableUser: adminProcedure
.input(ZAdminEnableUserMutationSchema)
.mutation(async ({ input, ctx }) => {
const { id } = input;
ctx.logger.info({
input: {
id,
},
});
const user = await getUserById({ id }).catch(() => null);
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
return await enableUser({ id });
}),
disableUser: adminProcedure
.input(ZAdminDisableUserMutationSchema)
.mutation(async ({ input, ctx }) => {
const { id } = input;
ctx.logger.info({
input: {
id,
},
});
const user = await getUserById({ id }).catch(() => null);
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
return await disableUser({ id });
}),
deleteUser: adminProcedure
.input(ZAdminDeleteUserMutationSchema)
.mutation(async ({ input, ctx }) => {
const { id } = input;
ctx.logger.info({
input: {
id,
},
});
return await deleteUser({ id });
}),
deleteDocument: adminProcedure
.input(ZAdminDeleteDocumentMutationSchema)
.mutation(async ({ ctx, input }) => {
const { id, reason } = input;
ctx.logger.info({
input: {
id,
},
});
await sendDeleteEmail({ documentId: id, reason });
return await superDeleteDocument({
id,
requestMetadata: ctx.metadata.requestMetadata,
});
}),
document: {
find: findDocumentsRoute,
delete: deleteDocumentRoute,
reseal: resealDocumentRoute,
},
recipient: {
update: updateRecipientRoute,
},
updateSiteSetting: updateSiteSettingRoute,
});

View File

@@ -1,67 +0,0 @@
import { Role } from '@prisma/client';
import z from 'zod';
import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema';
import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
export const ZAdminFindDocumentsQuerySchema = ZFindSearchParamsSchema.extend({
perPage: z.number().optional().default(20),
});
export type TAdminFindDocumentsQuerySchema = z.infer<typeof ZAdminFindDocumentsQuerySchema>;
export const ZAdminUpdateProfileMutationSchema = z.object({
id: z.number().min(1),
name: z.string().nullish(),
email: z.string().email().optional(),
roles: z.array(z.nativeEnum(Role)).optional(),
});
export type TAdminUpdateProfileMutationSchema = z.infer<typeof ZAdminUpdateProfileMutationSchema>;
export const ZAdminUpdateRecipientMutationSchema = z.object({
id: z.number().min(1),
name: z.string().optional(),
email: z.string().email().optional(),
});
export type TAdminUpdateRecipientMutationSchema = z.infer<
typeof ZAdminUpdateRecipientMutationSchema
>;
export const ZAdminUpdateSiteSettingMutationSchema = ZSiteSettingSchema;
export type TAdminUpdateSiteSettingMutationSchema = z.infer<
typeof ZAdminUpdateSiteSettingMutationSchema
>;
export const ZAdminResealDocumentMutationSchema = z.object({
id: z.number().min(1),
});
export type TAdminResealDocumentMutationSchema = z.infer<typeof ZAdminResealDocumentMutationSchema>;
export const ZAdminDeleteUserMutationSchema = z.object({
id: z.number().min(1),
});
export type TAdminDeleteUserMutationSchema = z.infer<typeof ZAdminDeleteUserMutationSchema>;
export const ZAdminEnableUserMutationSchema = z.object({
id: z.number().min(1),
});
export type TAdminEnableUserMutationSchema = z.infer<typeof ZAdminEnableUserMutationSchema>;
export const ZAdminDisableUserMutationSchema = z.object({
id: z.number().min(1),
});
export type TAdminDisableUserMutationSchema = z.infer<typeof ZAdminDisableUserMutationSchema>;
export const ZAdminDeleteDocumentMutationSchema = z.object({
id: z.number().min(1),
reason: z.string(),
});
export type TAdminDeleteDocomentMutationSchema = z.infer<typeof ZAdminDeleteDocumentMutationSchema>;

View File

@@ -0,0 +1,22 @@
import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient';
import { adminProcedure } from '../trpc';
import {
ZUpdateRecipientRequestSchema,
ZUpdateRecipientResponseSchema,
} from './update-recipient.types';
export const updateRecipientRoute = adminProcedure
.input(ZUpdateRecipientRequestSchema)
.output(ZUpdateRecipientResponseSchema)
.mutation(async ({ input, ctx }) => {
const { id, name, email } = input;
ctx.logger.info({
input: {
id,
},
});
await updateRecipient({ id, name, email });
});

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
export const ZUpdateRecipientRequestSchema = z.object({
id: z.number().min(1),
name: z.string().optional(),
email: z.string().email().optional(),
});
export const ZUpdateRecipientResponseSchema = z.void();
export type TUpdateRecipientRequest = z.infer<typeof ZUpdateRecipientRequestSchema>;
export type TUpdateRecipientResponse = z.infer<typeof ZUpdateRecipientResponseSchema>;

View File

@@ -0,0 +1,27 @@
import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting';
import { adminProcedure } from '../trpc';
import {
ZUpdateSiteSettingRequestSchema,
ZUpdateSiteSettingResponseSchema,
} from './update-site-setting.types';
export const updateSiteSettingRoute = adminProcedure
.input(ZUpdateSiteSettingRequestSchema)
.output(ZUpdateSiteSettingResponseSchema)
.mutation(async ({ ctx, input }) => {
const { id, enabled, data } = input;
ctx.logger.info({
input: {
id,
},
});
await upsertSiteSetting({
id,
enabled,
data,
userId: ctx.user.id,
});
});

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema';
export const ZUpdateSiteSettingRequestSchema = ZSiteSettingSchema;
export const ZUpdateSiteSettingResponseSchema = z.void();
export type TUpdateSiteSettingRequest = z.infer<typeof ZUpdateSiteSettingRequestSchema>;
export type TUpdateSiteSettingResponse = z.infer<typeof ZUpdateSiteSettingResponseSchema>;

View File

@@ -0,0 +1,20 @@
import { updateUser } from '@documenso/lib/server-only/admin/update-user';
import { adminProcedure } from '../trpc';
import { ZUpdateUserRequestSchema, ZUpdateUserResponseSchema } from './update-user.types';
export const updateUserRoute = adminProcedure
.input(ZUpdateUserRequestSchema)
.output(ZUpdateUserResponseSchema)
.mutation(async ({ input, ctx }) => {
const { id, name, email, roles } = input;
ctx.logger.info({
input: {
id,
roles,
},
});
await updateUser({ id, name, email, roles });
});

View File

@@ -0,0 +1,14 @@
import { Role } from '@prisma/client';
import { z } from 'zod';
export const ZUpdateUserRequestSchema = z.object({
id: z.number().min(1),
name: z.string().nullish(),
email: z.string().email().optional(),
roles: z.array(z.nativeEnum(Role)).optional(),
});
export const ZUpdateUserResponseSchema = z.void();
export type TUpdateUserRequest = z.infer<typeof ZUpdateUserRequestSchema>;
export type TUpdateUserResponse = z.infer<typeof ZUpdateUserResponseSchema>;

View File

@@ -0,0 +1,27 @@
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { authenticatedProcedure } from '../trpc';
import {
ZCreateApiTokenRequestSchema,
ZCreateApiTokenResponseSchema,
} from './create-api-token.types';
export const createApiTokenRoute = authenticatedProcedure
.input(ZCreateApiTokenRequestSchema)
.output(ZCreateApiTokenResponseSchema)
.mutation(async ({ input, ctx }) => {
const { tokenName, teamId, expirationDate } = input;
ctx.logger.info({
input: {
teamId,
},
});
return await createApiToken({
userId: ctx.user.id,
teamId,
tokenName,
expiresIn: expirationDate,
});
});

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
export const ZCreateApiTokenRequestSchema = z.object({
teamId: z.number(),
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }),
expirationDate: z.string().nullable(),
});
export const ZCreateApiTokenResponseSchema = z.object({
id: z.number(),
token: z.string(),
});

View File

@@ -0,0 +1,27 @@
import { deleteTokenById } from '@documenso/lib/server-only/public-api/delete-api-token-by-id';
import { authenticatedProcedure } from '../trpc';
import {
ZDeleteApiTokenRequestSchema,
ZDeleteApiTokenResponseSchema,
} from './delete-api-token.types';
export const deleteApiTokenRoute = authenticatedProcedure
.input(ZDeleteApiTokenRequestSchema)
.output(ZDeleteApiTokenResponseSchema)
.mutation(async ({ input, ctx }) => {
const { id, teamId } = input;
ctx.logger.info({
input: {
id,
teamId,
},
});
await deleteTokenById({
id,
teamId,
userId: ctx.user.id,
});
});

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const ZDeleteApiTokenRequestSchema = z.object({
id: z.number().min(1),
teamId: z.number(),
});
export const ZDeleteApiTokenResponseSchema = z.void();

View File

@@ -0,0 +1,19 @@
import { getApiTokens } from '@documenso/lib/server-only/public-api/get-api-tokens';
import { authenticatedProcedure } from '../trpc';
import { ZGetApiTokensRequestSchema, ZGetApiTokensResponseSchema } from './get-api-tokens.types';
export const getApiTokensRoute = authenticatedProcedure
.input(ZGetApiTokensRequestSchema)
.output(ZGetApiTokensResponseSchema)
.query(async ({ ctx }) => {
const { teamId } = ctx;
ctx.logger.info({
input: {
teamId,
},
});
return await getApiTokens({ userId: ctx.user.id, teamId });
});

View File

@@ -0,0 +1,16 @@
import { z } from 'zod';
import ApiTokenSchema from '@documenso/prisma/generated/zod/modelSchema/ApiTokenSchema';
export const ZGetApiTokensRequestSchema = z.void();
export const ZGetApiTokensResponseSchema = z.array(
ApiTokenSchema.pick({
id: true,
name: true,
createdAt: true,
expires: true,
}),
);
export type TGetApiTokensResponse = z.infer<typeof ZGetApiTokensResponseSchema>;

View File

@@ -1,50 +1,10 @@
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { deleteTokenById } from '@documenso/lib/server-only/public-api/delete-api-token-by-id';
import { getApiTokens } from '@documenso/lib/server-only/public-api/get-api-tokens';
import { authenticatedProcedure, router } from '../trpc';
import { ZCreateTokenMutationSchema, ZDeleteTokenByIdMutationSchema } from './schema';
import { router } from '../trpc';
import { createApiTokenRoute } from './create-api-token';
import { deleteApiTokenRoute } from './delete-api-token';
import { getApiTokensRoute } from './get-api-tokens';
export const apiTokenRouter = router({
getTokens: authenticatedProcedure.query(async ({ ctx }) => {
return await getApiTokens({ userId: ctx.user.id, teamId: ctx.teamId });
}),
createToken: authenticatedProcedure
.input(ZCreateTokenMutationSchema)
.mutation(async ({ input, ctx }) => {
const { tokenName, teamId, expirationDate } = input;
ctx.logger.info({
input: {
teamId,
},
});
return await createApiToken({
userId: ctx.user.id,
teamId,
tokenName,
expiresIn: expirationDate,
});
}),
deleteTokenById: authenticatedProcedure
.input(ZDeleteTokenByIdMutationSchema)
.mutation(async ({ input, ctx }) => {
const { id, teamId } = input;
ctx.logger.info({
input: {
id,
teamId,
},
});
return await deleteTokenById({
id,
teamId,
userId: ctx.user.id,
});
}),
create: createApiTokenRoute,
getMany: getApiTokensRoute,
delete: deleteApiTokenRoute,
});

View File

@@ -1,16 +0,0 @@
import { z } from 'zod';
export const ZCreateTokenMutationSchema = z.object({
teamId: z.number(),
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }),
expirationDate: z.string().nullable(),
});
export type TCreateTokenMutationSchema = z.infer<typeof ZCreateTokenMutationSchema>;
export const ZDeleteTokenByIdMutationSchema = z.object({
id: z.number().min(1),
teamId: z.number(),
});
export type TDeleteTokenByIdMutationSchema = z.infer<typeof ZDeleteTokenByIdMutationSchema>;

View File

@@ -0,0 +1,17 @@
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
import { authenticatedProcedure } from '../trpc';
import {
ZCreatePasskeyAuthenticationOptionsRequestSchema,
ZCreatePasskeyAuthenticationOptionsResponseSchema,
} from './create-passkey-authentication-options.types';
export const createPasskeyAuthenticationOptionsRoute = authenticatedProcedure
.input(ZCreatePasskeyAuthenticationOptionsRequestSchema)
.output(ZCreatePasskeyAuthenticationOptionsResponseSchema)
.mutation(async ({ ctx, input }) => {
return await createPasskeyAuthenticationOptions({
userId: ctx.user.id,
preferredPasskeyId: input?.preferredPasskeyId,
});
});

View File

@@ -0,0 +1,19 @@
import { z } from 'zod';
export const ZCreatePasskeyAuthenticationOptionsRequestSchema = z
.object({
preferredPasskeyId: z.string().optional(),
})
.optional();
export const ZCreatePasskeyAuthenticationOptionsResponseSchema = z.object({
tokenReference: z.string(),
options: z.any(), // PublicKeyCredentialRequestOptions type
});
export type TCreatePasskeyAuthenticationOptionsRequest = z.infer<
typeof ZCreatePasskeyAuthenticationOptionsRequestSchema
>;
export type TCreatePasskeyAuthenticationOptionsResponse = z.infer<
typeof ZCreatePasskeyAuthenticationOptionsResponseSchema
>;

View File

@@ -0,0 +1,16 @@
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
import { authenticatedProcedure } from '../trpc';
import {
ZCreatePasskeyRegistrationOptionsRequestSchema,
ZCreatePasskeyRegistrationOptionsResponseSchema,
} from './create-passkey-registration-options.types';
export const createPasskeyRegistrationOptionsRoute = authenticatedProcedure
.input(ZCreatePasskeyRegistrationOptionsRequestSchema)
.output(ZCreatePasskeyRegistrationOptionsResponseSchema)
.mutation(async ({ ctx }) => {
return await createPasskeyRegistrationOptions({
userId: ctx.user.id,
});
});

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
export const ZCreatePasskeyRegistrationOptionsRequestSchema = z.void();
export const ZCreatePasskeyRegistrationOptionsResponseSchema = z.any(); // PublicKeyCredentialCreationOptions type
export type TCreatePasskeyRegistrationOptionsRequest = z.infer<
typeof ZCreatePasskeyRegistrationOptionsRequestSchema
>;
export type TCreatePasskeyRegistrationOptionsResponse = z.infer<
typeof ZCreatePasskeyRegistrationOptionsResponseSchema
>;

View File

@@ -0,0 +1,24 @@
import { createPasskeySigninOptions } from '@documenso/lib/server-only/auth/create-passkey-signin-options';
import { nanoid } from '@documenso/lib/universal/id';
import { procedure } from '../trpc';
import {
ZCreatePasskeySigninOptionsRequestSchema,
ZCreatePasskeySigninOptionsResponseSchema,
} from './create-passkey-signin-options.types';
export const createPasskeySigninOptionsRoute = procedure
.input(ZCreatePasskeySigninOptionsRequestSchema)
.output(ZCreatePasskeySigninOptionsResponseSchema)
.mutation(async () => {
const sessionIdToken = nanoid(16);
const [sessionId] = decodeURI(sessionIdToken).split('|');
const options = await createPasskeySigninOptions({ sessionId });
return {
options,
sessionId,
};
});

View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
export const ZCreatePasskeySigninOptionsRequestSchema = z.void();
export const ZCreatePasskeySigninOptionsResponseSchema = z.object({
options: z.any(), // PublicKeyCredentialRequestOptions type
sessionId: z.string(),
});
export type TCreatePasskeySigninOptionsRequest = z.infer<
typeof ZCreatePasskeySigninOptionsRequestSchema
>;
export type TCreatePasskeySigninOptionsResponse = z.infer<
typeof ZCreatePasskeySigninOptionsResponseSchema
>;

View File

@@ -0,0 +1,21 @@
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
import { authenticatedProcedure } from '../trpc';
import { ZCreatePasskeyRequestSchema, ZCreatePasskeyResponseSchema } from './create-passkey.types';
export const createPasskeyRoute = authenticatedProcedure
.input(ZCreatePasskeyRequestSchema)
.output(ZCreatePasskeyResponseSchema)
.mutation(async ({ ctx, input }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const verificationResponse = input.verificationResponse as RegistrationResponseJSON;
return await createPasskey({
userId: ctx.user.id,
verificationResponse,
passkeyName: input.passkeyName,
requestMetadata: ctx.metadata.requestMetadata,
});
});

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
export const ZCreatePasskeyRequestSchema = z.object({
passkeyName: z.string().trim().min(1),
verificationResponse: ZRegistrationResponseJSONSchema,
});
export const ZCreatePasskeyResponseSchema = z.void();
export type TCreatePasskeyRequest = z.infer<typeof ZCreatePasskeyRequestSchema>;
export type TCreatePasskeyResponse = z.infer<typeof ZCreatePasskeyResponseSchema>;

View File

@@ -0,0 +1,23 @@
import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
import { authenticatedProcedure } from '../trpc';
import { ZDeletePasskeyRequestSchema, ZDeletePasskeyResponseSchema } from './delete-passkey.types';
export const deletePasskeyRoute = authenticatedProcedure
.input(ZDeletePasskeyRequestSchema)
.output(ZDeletePasskeyResponseSchema)
.mutation(async ({ ctx, input }) => {
const { passkeyId } = input;
ctx.logger.info({
input: {
passkeyId,
},
});
await deletePasskey({
userId: ctx.user.id,
passkeyId,
requestMetadata: ctx.metadata.requestMetadata,
});
});

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZDeletePasskeyRequestSchema = z.object({
passkeyId: z.string().trim().min(1),
});
export const ZDeletePasskeyResponseSchema = z.void();
export type TDeletePasskeyRequest = z.infer<typeof ZDeletePasskeyRequestSchema>;
export type TDeletePasskeyResponse = z.infer<typeof ZDeletePasskeyResponseSchema>;

View File

@@ -0,0 +1,18 @@
import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
import { authenticatedProcedure } from '../trpc';
import { ZFindPasskeysRequestSchema, ZFindPasskeysResponseSchema } from './find-passkeys.types';
export const findPasskeysRoute = authenticatedProcedure
.input(ZFindPasskeysRequestSchema)
.output(ZFindPasskeysResponseSchema)
.query(async ({ input, ctx }) => {
const { page, perPage, orderBy } = input;
return await findPasskeys({
page,
perPage,
orderBy,
userId: ctx.user.id,
});
});

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import PasskeySchema from '@documenso/prisma/generated/zod/modelSchema/PasskeySchema';
export const ZFindPasskeysRequestSchema = ZFindSearchParamsSchema.extend({
orderBy: z
.object({
column: z.enum(['createdAt', 'updatedAt', 'name']),
direction: z.enum(['asc', 'desc']),
})
.optional(),
});
export const ZFindPasskeysResponseSchema = ZFindResultResponse.extend({
data: z.array(
PasskeySchema.pick({
id: true,
userId: true,
name: true,
createdAt: true,
updatedAt: true,
lastUsedAt: true,
counter: true,
credentialDeviceType: true,
credentialBackedUp: true,
transports: true,
}),
),
});
export type TFindPasskeysRequest = z.infer<typeof ZFindPasskeysRequestSchema>;
export type TFindPasskeysResponse = z.infer<typeof ZFindPasskeysResponseSchema>;

View File

@@ -1,113 +1,20 @@
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
import { createPasskeySigninOptions } from '@documenso/lib/server-only/auth/create-passkey-signin-options';
import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
import { nanoid } from '@documenso/lib/universal/id';
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
ZCreatePasskeyAuthenticationOptionsMutationSchema,
ZCreatePasskeyMutationSchema,
ZDeletePasskeyMutationSchema,
ZFindPasskeysQuerySchema,
ZUpdatePasskeyMutationSchema,
} from './schema';
import { router } from '../trpc';
import { createPasskeyRoute } from './create-passkey';
import { createPasskeyAuthenticationOptionsRoute } from './create-passkey-authentication-options';
import { createPasskeyRegistrationOptionsRoute } from './create-passkey-registration-options';
import { createPasskeySigninOptionsRoute } from './create-passkey-signin-options';
import { deletePasskeyRoute } from './delete-passkey';
import { findPasskeysRoute } from './find-passkeys';
import { updatePasskeyRoute } from './update-passkey';
export const authRouter = router({
createPasskey: authenticatedProcedure
.input(ZCreatePasskeyMutationSchema)
.mutation(async ({ ctx, input }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const verificationResponse = input.verificationResponse as RegistrationResponseJSON;
return await createPasskey({
userId: ctx.user.id,
verificationResponse,
passkeyName: input.passkeyName,
requestMetadata: ctx.metadata.requestMetadata,
});
}),
createPasskeyAuthenticationOptions: authenticatedProcedure
.input(ZCreatePasskeyAuthenticationOptionsMutationSchema)
.mutation(async ({ ctx, input }) => {
return await createPasskeyAuthenticationOptions({
userId: ctx.user.id,
preferredPasskeyId: input?.preferredPasskeyId,
});
}),
createPasskeyRegistrationOptions: authenticatedProcedure.mutation(async ({ ctx }) => {
return await createPasskeyRegistrationOptions({
userId: ctx.user.id,
});
passkey: router({
create: createPasskeyRoute,
createAuthenticationOptions: createPasskeyAuthenticationOptionsRoute,
createRegistrationOptions: createPasskeyRegistrationOptionsRoute,
createSigninOptions: createPasskeySigninOptionsRoute,
delete: deletePasskeyRoute,
find: findPasskeysRoute,
update: updatePasskeyRoute,
}),
createPasskeySigninOptions: procedure.mutation(async () => {
const sessionIdToken = nanoid(16);
const [sessionId] = decodeURI(sessionIdToken).split('|');
const options = await createPasskeySigninOptions({ sessionId });
return {
options,
sessionId,
};
}),
deletePasskey: authenticatedProcedure
.input(ZDeletePasskeyMutationSchema)
.mutation(async ({ ctx, input }) => {
const { passkeyId } = input;
ctx.logger.info({
input: {
passkeyId,
},
});
await deletePasskey({
userId: ctx.user.id,
passkeyId,
requestMetadata: ctx.metadata.requestMetadata,
});
}),
findPasskeys: authenticatedProcedure
.input(ZFindPasskeysQuerySchema)
.query(async ({ input, ctx }) => {
const { page, perPage, orderBy } = input;
return await findPasskeys({
page,
perPage,
orderBy,
userId: ctx.user.id,
});
}),
updatePasskey: authenticatedProcedure
.input(ZUpdatePasskeyMutationSchema)
.mutation(async ({ ctx, input }) => {
const { passkeyId, name } = input;
ctx.logger.info({
input: {
passkeyId,
},
});
await updatePasskey({
userId: ctx.user.id,
passkeyId,
name,
requestMetadata: ctx.metadata.requestMetadata,
});
}),
});

View File

@@ -1,8 +1,5 @@
import { z } from 'zod';
import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
export const ZCurrentPasswordSchema = z
.string()
.min(6, { message: 'Must be at least 6 characters in length' })
@@ -24,50 +21,3 @@ export const ZPasswordSchema = z
.refine((value) => value.length > 25 || /[`~<>?,./!@#$%^&*()\-_"'+=|{}[\];:\\]/.test(value), {
message: 'One special character is required',
});
export const ZSignUpMutationSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: ZPasswordSchema,
signature: z.string().nullish(),
url: z
.string()
.trim()
.toLowerCase()
.min(1)
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
})
.optional(),
});
export const ZCreatePasskeyMutationSchema = z.object({
passkeyName: z.string().trim().min(1),
verificationResponse: ZRegistrationResponseJSONSchema,
});
export const ZCreatePasskeyAuthenticationOptionsMutationSchema = z
.object({
preferredPasskeyId: z.string().optional(),
})
.optional();
export const ZDeletePasskeyMutationSchema = z.object({
passkeyId: z.string().trim().min(1),
});
export const ZUpdatePasskeyMutationSchema = z.object({
passkeyId: z.string().trim().min(1),
name: z.string().trim().min(1),
});
export const ZFindPasskeysQuerySchema = ZFindSearchParamsSchema.extend({
orderBy: z
.object({
column: z.enum(['createdAt', 'updatedAt', 'name']),
direction: z.enum(['asc', 'desc']),
})
.optional(),
});
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;

View File

@@ -0,0 +1,24 @@
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
import { authenticatedProcedure } from '../trpc';
import { ZUpdatePasskeyRequestSchema, ZUpdatePasskeyResponseSchema } from './update-passkey.types';
export const updatePasskeyRoute = authenticatedProcedure
.input(ZUpdatePasskeyRequestSchema)
.output(ZUpdatePasskeyResponseSchema)
.mutation(async ({ ctx, input }) => {
const { passkeyId, name } = input;
ctx.logger.info({
input: {
passkeyId,
},
});
await updatePasskey({
userId: ctx.user.id,
passkeyId,
name,
requestMetadata: ctx.metadata.requestMetadata,
});
});

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const ZUpdatePasskeyRequestSchema = z.object({
passkeyId: z.string().trim().min(1),
name: z.string().trim().min(1),
});
export const ZUpdatePasskeyResponseSchema = z.void();
export type TUpdatePasskeyRequest = z.infer<typeof ZUpdatePasskeyRequestSchema>;
export type TUpdatePasskeyResponse = z.infer<typeof ZUpdatePasskeyResponseSchema>;

View File

@@ -0,0 +1,81 @@
import { DocumentDataType } from '@prisma/client';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { createDocumentV2 } from '@documenso/lib/server-only/document/create-document-v2';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { authenticatedProcedure } from '../trpc';
import {
ZCreateDocumentTemporaryRequestSchema,
ZCreateDocumentTemporaryResponseSchema,
createDocumentTemporaryMeta,
} from './create-document-temporary.types';
/**
* Temporariy endpoint for V2 Beta until we allow passthrough documents on create.
*
* @public
* @deprecated
*/
export const createDocumentTemporaryRoute = authenticatedProcedure
.meta(createDocumentTemporaryMeta)
.input(ZCreateDocumentTemporaryRequestSchema)
.output(ZCreateDocumentTemporaryResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const {
title,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
recipients,
meta,
folderId,
} = input;
const { remaining } = await getServerLimits({ userId: user.id, teamId });
if (remaining.documents <= 0) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'You have reached your document limit for this month. Please upgrade your plan.',
statusCode: 400,
});
}
const fileName = title.endsWith('.pdf') ? title : `${title}.pdf`;
const { url, key } = await getPresignPostUrl(fileName, 'application/pdf');
const documentData = await createDocumentData({
data: key,
type: DocumentDataType.S3_PATH,
});
const createdDocument = await createDocumentV2({
userId: ctx.user.id,
teamId,
documentDataId: documentData.id,
normalizePdf: false, // Not normalizing because of presigned URL.
data: {
title,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
recipients,
folderId,
},
meta,
requestMetadata: ctx.metadata,
});
return {
document: createdDocument,
folder: createdDocument.folder, // Todo: Remove this prior to api-v2 release.
uploadUrl: url,
};
});

View File

@@ -0,0 +1,120 @@
import { DocumentSigningOrder } from '@prisma/client';
import { z } from 'zod';
import { ZDocumentSchema } from '@documenso/lib/types/document';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
import {
ZFieldHeightSchema,
ZFieldPageNumberSchema,
ZFieldPageXSchema,
ZFieldPageYSchema,
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc';
import {
ZDocumentExternalIdSchema,
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
ZDocumentMetaTypedSignatureEnabledSchema,
ZDocumentMetaUploadSignatureEnabledSchema,
ZDocumentTitleSchema,
ZDocumentVisibilitySchema,
} from './schema';
/**
* Temporariy endpoint for V2 Beta until we allow passthrough documents on create.
*/
export const createDocumentTemporaryMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/document/create/beta',
summary: 'Create document',
description:
'You will need to upload the PDF to the provided URL returned. Note: Once V2 API is released, this will be removed since we will allow direct uploads, instead of using an upload URL.',
tags: ['Document'],
},
};
export const ZCreateDocumentTemporaryRequestSchema = z.object({
title: ZDocumentTitleSchema,
externalId: ZDocumentExternalIdSchema.optional(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
formValues: ZDocumentFormValuesSchema.optional(),
folderId: z
.string()
.describe(
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
)
.optional(),
recipients: z
.array(
ZCreateRecipientSchema.extend({
fields: ZFieldAndMetaSchema.and(
z.object({
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
}),
)
.array()
.optional(),
}),
)
.refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{ message: 'Recipients must have unique emails' },
)
.optional(),
meta: z
.object({
subject: ZDocumentMetaSubjectSchema.optional(),
message: ZDocumentMetaMessageSchema.optional(),
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
});
export const ZCreateDocumentTemporaryResponseSchema = z.object({
document: ZDocumentSchema,
uploadUrl: z
.string()
.describe(
'The URL to upload the document PDF to. Use a PUT request with the file via form-data',
),
});
export type TCreateDocumentTemporaryRequest = z.infer<typeof ZCreateDocumentTemporaryRequestSchema>;
export type TCreateDocumentTemporaryResponse = z.infer<
typeof ZCreateDocumentTemporaryResponseSchema
>;

View File

@@ -0,0 +1,47 @@
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { authenticatedProcedure } from '../trpc';
import {
ZCreateDocumentRequestSchema,
ZCreateDocumentResponseSchema,
} from './create-document.types';
export const createDocumentRoute = authenticatedProcedure
.input(ZCreateDocumentRequestSchema)
.output(ZCreateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const { title, documentDataId, timezone, folderId } = input;
ctx.logger.info({
input: {
folderId,
},
});
const { remaining } = await getServerLimits({ userId: user.id, teamId });
if (remaining.documents <= 0) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'You have reached your document limit for this month. Please upgrade your plan.',
statusCode: 400,
});
}
const document = await createDocument({
userId: user.id,
teamId,
title,
documentDataId,
normalizePdf: true,
userTimezone: timezone,
requestMetadata: ctx.metadata,
folderId,
});
return {
id: document.id,
};
});

View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
import { ZDocumentMetaTimezoneSchema, ZDocumentTitleSchema } from './schema';
// Currently not in use until we allow passthrough documents on create.
// export const createDocumentMeta: TrpcRouteMeta = {
// openapi: {
// method: 'POST',
// path: '/document/create',
// summary: 'Create document',
// tags: ['Document'],
// },
// };
export const ZCreateDocumentRequestSchema = z.object({
title: ZDocumentTitleSchema,
documentDataId: z.string().min(1),
timezone: ZDocumentMetaTimezoneSchema.optional(),
folderId: z.string().describe('The ID of the folder to create the document in').optional(),
});
export const ZCreateDocumentResponseSchema = z.object({
id: z.number(),
});
export type TCreateDocumentRequest = z.infer<typeof ZCreateDocumentRequestSchema>;
export type TCreateDocumentResponse = z.infer<typeof ZCreateDocumentResponseSchema>;

View File

@@ -0,0 +1,35 @@
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { authenticatedProcedure } from '../trpc';
import {
ZDeleteDocumentRequestSchema,
ZDeleteDocumentResponseSchema,
deleteDocumentMeta,
} from './delete-document.types';
import { ZGenericSuccessResponse } from './schema';
export const deleteDocumentRoute = authenticatedProcedure
.meta(deleteDocumentMeta)
.input(ZDeleteDocumentRequestSchema)
.output(ZDeleteDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId } = input;
ctx.logger.info({
input: {
documentId,
},
});
const userId = ctx.user.id;
await deleteDocument({
id: documentId,
userId,
teamId,
requestMetadata: ctx.metadata,
});
return ZGenericSuccessResponse;
});

View File

@@ -0,0 +1,22 @@
import { z } from 'zod';
import type { TrpcRouteMeta } from '../trpc';
import { ZSuccessResponseSchema } from './schema';
export const deleteDocumentMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/document/delete',
summary: 'Delete document',
tags: ['Document'],
},
};
export const ZDeleteDocumentRequestSchema = z.object({
documentId: z.number(),
});
export const ZDeleteDocumentResponseSchema = ZSuccessResponseSchema;
export type TDeleteDocumentRequest = z.infer<typeof ZDeleteDocumentRequestSchema>;
export type TDeleteDocumentResponse = z.infer<typeof ZDeleteDocumentResponseSchema>;

View File

@@ -0,0 +1,50 @@
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { authenticatedProcedure } from '../trpc';
import {
ZDistributeDocumentRequestSchema,
ZDistributeDocumentResponseSchema,
distributeDocumentMeta,
} from './distribute-document.types';
export const distributeDocumentRoute = authenticatedProcedure
.meta(distributeDocumentMeta)
.input(ZDistributeDocumentRequestSchema)
.output(ZDistributeDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId, meta = {} } = input;
ctx.logger.info({
input: {
documentId,
},
});
if (Object.values(meta).length > 0) {
await upsertDocumentMeta({
userId: ctx.user.id,
teamId,
documentId,
subject: meta.subject,
message: meta.message,
dateFormat: meta.dateFormat,
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
emailSettings: meta.emailSettings,
language: meta.language,
emailId: meta.emailId,
emailReplyTo: meta.emailReplyTo,
requestMetadata: ctx.metadata,
});
}
return await sendDocument({
userId: ctx.user.id,
documentId,
teamId,
requestMetadata: ctx.metadata,
});
});

View File

@@ -0,0 +1,48 @@
import { z } from 'zod';
import { ZDocumentLiteSchema } from '@documenso/lib/types/document';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import type { TrpcRouteMeta } from '../trpc';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
} from './schema';
export const distributeDocumentMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/document/distribute',
summary: 'Distribute document',
description: 'Send the document out to recipients based on your distribution method',
tags: ['Document'],
},
};
export const ZDistributeDocumentRequestSchema = z.object({
documentId: z.number().describe('The ID of the document to send.'),
meta: z
.object({
subject: ZDocumentMetaSubjectSchema.optional(),
message: ZDocumentMetaMessageSchema.optional(),
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
});
export const ZDistributeDocumentResponseSchema = ZDocumentLiteSchema;
export type TDistributeDocumentRequest = z.infer<typeof ZDistributeDocumentRequestSchema>;
export type TDistributeDocumentResponse = z.infer<typeof ZDistributeDocumentResponseSchema>;

View File

@@ -0,0 +1,47 @@
import { DateTime } from 'luxon';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { authenticatedProcedure } from '../trpc';
import {
ZDownloadDocumentAuditLogsRequestSchema,
ZDownloadDocumentAuditLogsResponseSchema,
} from './download-document-audit-logs.types';
export const downloadDocumentAuditLogsRoute = authenticatedProcedure
.input(ZDownloadDocumentAuditLogsRequestSchema)
.output(ZDownloadDocumentAuditLogsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId } = input;
ctx.logger.info({
input: {
documentId,
},
});
const document = await getDocumentById({
documentId,
userId: ctx.user.id,
teamId,
}).catch(() => null);
if (!document || (teamId && document.teamId !== teamId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have access to this document.',
});
}
const encrypted = encryptSecondaryData({
data: document.id.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
});
return {
url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encrypted}`,
};
});

View File

@@ -0,0 +1,16 @@
import { z } from 'zod';
export const ZDownloadDocumentAuditLogsRequestSchema = z.object({
documentId: z.number(),
});
export const ZDownloadDocumentAuditLogsResponseSchema = z.object({
url: z.string(),
});
export type TDownloadDocumentAuditLogsRequest = z.infer<
typeof ZDownloadDocumentAuditLogsRequestSchema
>;
export type TDownloadDocumentAuditLogsResponse = z.infer<
typeof ZDownloadDocumentAuditLogsResponseSchema
>;

View File

@@ -0,0 +1,46 @@
import { DateTime } from 'luxon';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { authenticatedProcedure } from '../trpc';
import {
ZDownloadDocumentCertificateRequestSchema,
ZDownloadDocumentCertificateResponseSchema,
} from './download-document-certificate.types';
export const downloadDocumentCertificateRoute = authenticatedProcedure
.input(ZDownloadDocumentCertificateRequestSchema)
.output(ZDownloadDocumentCertificateResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId } = input;
ctx.logger.info({
input: {
documentId,
},
});
const document = await getDocumentById({
documentId,
userId: ctx.user.id,
teamId,
});
if (!isDocumentCompleted(document.status)) {
throw new AppError('DOCUMENT_NOT_COMPLETE');
}
const encrypted = encryptSecondaryData({
data: document.id.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
});
return {
url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encrypted}`,
};
});

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