mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 02:01:33 +10:00
Merge branch 'main' into feat/public-api
This commit is contained in:
40
packages/app-tests/e2e/fixtures/authentication.ts
Normal file
40
packages/app-tests/e2e/fixtures/authentication.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
type ManualLoginOptions = {
|
||||
page: Page;
|
||||
email?: string;
|
||||
password?: string;
|
||||
|
||||
/**
|
||||
* Where to navigate after login.
|
||||
*/
|
||||
redirectPath?: string;
|
||||
};
|
||||
|
||||
export const manualLogin = async ({
|
||||
page,
|
||||
email = 'example@documenso.com',
|
||||
password = 'password',
|
||||
redirectPath,
|
||||
}: ManualLoginOptions) => {
|
||||
await page.goto(`${WEBAPP_BASE_URL}/signin`);
|
||||
|
||||
await page.getByLabel('Email').click();
|
||||
await page.getByLabel('Email').fill(email);
|
||||
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
await page.getByLabel('Password', { exact: true }).press('Enter');
|
||||
|
||||
if (redirectPath) {
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/documents`);
|
||||
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const manualSignout = async ({ page }: ManualLoginOptions) => {
|
||||
await page.getByTestId('menu-switcher').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
|
||||
};
|
||||
@ -2,6 +2,8 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents';
|
||||
|
||||
import { manualLogin, manualSignout } from './fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('[PR-711]: seeded documents should be visible', async ({ page }) => {
|
||||
@ -19,17 +21,11 @@ test('[PR-711]: seeded documents should be visible', async ({ page }) => {
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
|
||||
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
|
||||
await page.waitForURL('/signin');
|
||||
await manualSignout({ page });
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await page.goto('/signin');
|
||||
|
||||
await page.getByLabel('Email').fill(recipient.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await page.waitForURL('/signin');
|
||||
await manualLogin({ page, email: recipient.email, password: recipient.password });
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
@ -38,10 +34,7 @@ test('[PR-711]: seeded documents should be visible', async ({ page }) => {
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
|
||||
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
|
||||
await page.waitForURL('/signin');
|
||||
await manualSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
@ -74,13 +67,10 @@ test('[PR-711]: deleting a completed document should not remove it from recipien
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
|
||||
// signout
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
|
||||
await page.waitForURL('/signin');
|
||||
await manualSignout({ page });
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await page.waitForURL('/signin');
|
||||
await page.goto('/signin');
|
||||
|
||||
// sign in
|
||||
@ -96,11 +86,7 @@ test('[PR-711]: deleting a completed document should not remove it from recipien
|
||||
await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
|
||||
|
||||
await page.goto('/documents');
|
||||
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
|
||||
await page.waitForURL('/signin');
|
||||
await manualSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
@ -115,11 +101,7 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
|
||||
|
||||
await page.goto('/signin');
|
||||
|
||||
// sign in
|
||||
await page.getByLabel('Email').fill(sender.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(sender.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
await manualLogin({ page, email: sender.email, password: sender.password });
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// open actions menu
|
||||
@ -133,19 +115,12 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// signout
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
|
||||
await page.waitForURL('/signin');
|
||||
await manualSignout({ page });
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await page.goto('/signin');
|
||||
|
||||
// sign in
|
||||
await page.getByLabel('Email').fill(recipient.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await page.waitForURL('/signin');
|
||||
|
||||
await manualLogin({ page, email: recipient.email, password: recipient.password });
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
|
||||
@ -154,11 +129,9 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
|
||||
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
|
||||
|
||||
await page.goto('/documents');
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
|
||||
await page.waitForURL('/signin');
|
||||
await manualSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
@ -167,13 +140,7 @@ test('[PR-711]: deleting a draft document should remove it without additional pr
|
||||
}) => {
|
||||
const [sender] = TEST_USERS;
|
||||
|
||||
await page.goto('/signin');
|
||||
|
||||
// sign in
|
||||
await page.getByLabel('Email').fill(sender.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(sender.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
await manualLogin({ page, email: sender.email, password: sender.password });
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// open actions menu
|
||||
|
||||
@ -17,12 +17,6 @@ test('[PR-713]: should see sent documents', async ({ page }) => {
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').fill('sent');
|
||||
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// signout
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
});
|
||||
|
||||
test('[PR-713]: should see received documents', async ({ page }) => {
|
||||
@ -40,12 +34,6 @@ test('[PR-713]: should see received documents', async ({ page }) => {
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').fill('received');
|
||||
await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// signout
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
});
|
||||
|
||||
test('[PR-713]: should be able to search by recipient', async ({ page }) => {
|
||||
@ -63,10 +51,4 @@ test('[PR-713]: should be able to search by recipient', async ({ page }) => {
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').fill(recipient.email);
|
||||
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// signout
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
});
|
||||
|
||||
87
packages/app-tests/e2e/teams/manage-team.spec.ts
Normal file
87
packages/app-tests/e2e/teams/manage-team.spec.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { manualLogin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[TEAMS]: create team', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/settings/teams',
|
||||
});
|
||||
|
||||
const teamId = `team-${Date.now()}`;
|
||||
|
||||
// Create team.
|
||||
await page.getByRole('button', { name: 'Create team' }).click();
|
||||
await page.getByLabel('Team Name*').fill(teamId);
|
||||
await page.getByTestId('dialog-create-team-button').click();
|
||||
|
||||
await page.getByTestId('dialog-create-team-button').waitFor({ state: 'hidden' });
|
||||
|
||||
const isCheckoutRequired = page.url().includes('pending');
|
||||
test.skip(isCheckoutRequired, 'Test skipped because billing is enabled.');
|
||||
|
||||
// Goto new team settings page.
|
||||
await page.getByRole('row').filter({ hasText: teamId }).getByRole('link').nth(1).click();
|
||||
|
||||
await unseedTeam(teamId);
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete team', async ({ page }) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
// Delete team.
|
||||
await page.getByRole('button', { name: 'Delete team' }).click();
|
||||
await page.getByLabel(`Confirm by typing delete ${team.url}`).fill(`delete ${team.url}`);
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Check that we have been redirected to the teams page.
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/settings/teams`);
|
||||
});
|
||||
|
||||
test('[TEAMS]: update team', async ({ page }) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
});
|
||||
|
||||
// Navigate to create team page.
|
||||
await page.getByTestId('menu-switcher').click();
|
||||
await page.getByRole('menuitem', { name: 'Manage teams' }).click();
|
||||
|
||||
// Goto team settings page.
|
||||
await page.getByRole('row').filter({ hasText: team.url }).getByRole('link').nth(1).click();
|
||||
|
||||
const updatedTeamId = `team-${Date.now()}`;
|
||||
|
||||
// Update team.
|
||||
await page.getByLabel('Team Name*').click();
|
||||
await page.getByLabel('Team Name*').clear();
|
||||
await page.getByLabel('Team Name*').fill(updatedTeamId);
|
||||
await page.getByLabel('Team URL*').click();
|
||||
await page.getByLabel('Team URL*').clear();
|
||||
await page.getByLabel('Team URL*').fill(updatedTeamId);
|
||||
|
||||
await page.getByRole('button', { name: 'Update team' }).click();
|
||||
|
||||
// Check we have been redirected to the new team URL and the name is updated.
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/t/${updatedTeamId}/settings`);
|
||||
|
||||
await unseedTeam(updatedTeamId);
|
||||
});
|
||||
282
packages/app-tests/e2e/teams/team-documents.spec.ts
Normal file
282
packages/app-tests/e2e/teams/team-documents.spec.ts
Normal file
@ -0,0 +1,282 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { manualLogin, manualSignout } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||
await page.getByRole('tab', { name: tabName }).click();
|
||||
|
||||
if (tabName !== 'All') {
|
||||
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
await expect(page.getByRole('main')).toContainText(`Nothing to do`);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
|
||||
};
|
||||
|
||||
test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
const { team, teamMember2 } = await seedTeamDocuments();
|
||||
|
||||
// Run the test twice, once with the team owner and once with a team member to ensure the counts are the same.
|
||||
for (const user of [team.owner, teamMember2]) {
|
||||
await manualLogin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 5);
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
await manualSignout({ page });
|
||||
}
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: check team documents count with internal team email', async ({ page }) => {
|
||||
const { team, teamMember2, teamMember4 } = await seedTeamDocuments();
|
||||
const { team: team2, teamMember2: team2Member2 } = await seedTeamDocuments();
|
||||
|
||||
const teamEmailMember = teamMember4;
|
||||
|
||||
await seedTeamEmail({
|
||||
email: teamEmailMember.email,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const testUser1 = await seedUser();
|
||||
|
||||
await seedDocuments([
|
||||
// Documents sent from the team email account.
|
||||
{
|
||||
sender: teamEmailMember,
|
||||
recipients: [testUser1],
|
||||
type: DocumentStatus.COMPLETED,
|
||||
documentOptions: {
|
||||
teamId: team.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
sender: teamEmailMember,
|
||||
recipients: [testUser1],
|
||||
type: DocumentStatus.PENDING,
|
||||
documentOptions: {
|
||||
teamId: team.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
sender: teamMember4,
|
||||
recipients: [testUser1],
|
||||
type: DocumentStatus.DRAFT,
|
||||
},
|
||||
// Documents sent to the team email account.
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmailMember],
|
||||
type: DocumentStatus.COMPLETED,
|
||||
},
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmailMember],
|
||||
type: DocumentStatus.PENDING,
|
||||
},
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmailMember],
|
||||
type: DocumentStatus.DRAFT,
|
||||
},
|
||||
// Document sent to the team email account from another team.
|
||||
{
|
||||
sender: team2Member2,
|
||||
recipients: [teamEmailMember],
|
||||
type: DocumentStatus.PENDING,
|
||||
documentOptions: {
|
||||
teamId: team2.id,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
|
||||
for (const user of [team.owner, teamEmailMember]) {
|
||||
await manualLogin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 2);
|
||||
await checkDocumentTabCount(page, 'Pending', 3);
|
||||
await checkDocumentTabCount(page, 'Completed', 3);
|
||||
await checkDocumentTabCount(page, 'Draft', 3);
|
||||
await checkDocumentTabCount(page, 'All', 11);
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
await manualSignout({ page });
|
||||
}
|
||||
|
||||
await unseedTeamEmail({ teamId: team.id });
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: check team documents count with external team email', async ({ page }) => {
|
||||
const { team, teamMember2 } = await seedTeamDocuments();
|
||||
const { team: team2, teamMember2: team2Member2 } = await seedTeamDocuments();
|
||||
|
||||
const teamEmail = `external-team-email-${team.id}@test.documenso.com`;
|
||||
|
||||
await seedTeamEmail({
|
||||
email: teamEmail,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const testUser1 = await seedUser();
|
||||
|
||||
await seedDocuments([
|
||||
// Documents sent to the team email account.
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmail],
|
||||
type: DocumentStatus.COMPLETED,
|
||||
},
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmail],
|
||||
type: DocumentStatus.PENDING,
|
||||
},
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmail],
|
||||
type: DocumentStatus.DRAFT,
|
||||
},
|
||||
// Document sent to the team email account from another team.
|
||||
{
|
||||
sender: team2Member2,
|
||||
recipients: [teamEmail],
|
||||
type: DocumentStatus.PENDING,
|
||||
documentOptions: {
|
||||
teamId: team2.id,
|
||||
},
|
||||
},
|
||||
// Document sent to the team email account from an individual user.
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmail],
|
||||
type: DocumentStatus.PENDING,
|
||||
documentOptions: {
|
||||
teamId: team2.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmail],
|
||||
type: DocumentStatus.DRAFT,
|
||||
documentOptions: {
|
||||
teamId: team2.id,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: teamMember2.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 3);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 2);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 9);
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
await unseedTeamEmail({ teamId: team.id });
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete pending team document', async ({ page }) => {
|
||||
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: currentUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await page.getByRole('row').getByRole('button').nth(1).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
});
|
||||
|
||||
test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: currentUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await page.getByRole('row').getByRole('button').nth(1).click();
|
||||
await page.getByRole('menuitem', { name: 'Resend' }).click();
|
||||
|
||||
await page.getByLabel('test.documenso.com').first().click();
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
await expect(page.getByRole('status')).toContainText('Document re-sent');
|
||||
});
|
||||
102
packages/app-tests/e2e/teams/team-email.spec.ts
Normal file
102
packages/app-tests/e2e/teams/team-email.spec.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { manualLogin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[TEAMS]: send team email request', async ({ page }) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Add email' }).click();
|
||||
await page.getByPlaceholder('eg. Legal').click();
|
||||
await page.getByPlaceholder('eg. Legal').fill('test@test.documenso.com');
|
||||
await page.getByPlaceholder('example@example.com').click();
|
||||
await page.getByPlaceholder('example@example.com').fill('test@test.documenso.com');
|
||||
await page.getByRole('button', { name: 'Add' }).click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole('status')
|
||||
.filter({ hasText: 'We have sent a confirmation email for verification.' })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: accept team email request', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const teamEmailVerification = await seedTeamEmailVerification({
|
||||
email: 'team-email-verification@test.documenso.com',
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await page.goto(`${WEBAPP_BASE_URL}/team/verify/email/${teamEmailVerification.token}`);
|
||||
await expect(page.getByRole('heading')).toContainText('Team email verified!');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete team email', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
createTeamEmail: true,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
await page.locator('section div').filter({ hasText: 'Team email' }).getByRole('button').click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
|
||||
await expect(page.getByText('Team email has been removed').first()).toBeVisible();
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: team email owner removes access', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
createTeamEmail: true,
|
||||
});
|
||||
|
||||
if (!team.teamEmail) {
|
||||
throw new Error('Not possible');
|
||||
}
|
||||
|
||||
const teamEmailOwner = await seedUser({
|
||||
email: team.teamEmail.email,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: teamEmailOwner.email,
|
||||
redirectPath: `/settings/teams`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Revoke access' }).click();
|
||||
await page.getByRole('button', { name: 'Revoke' }).click();
|
||||
|
||||
await expect(page.getByText('You have successfully revoked').first()).toBeVisible();
|
||||
|
||||
await unseedTeam(team.url);
|
||||
await unseedUser(teamEmailOwner.id);
|
||||
});
|
||||
110
packages/app-tests/e2e/teams/team-members.spec.ts
Normal file
110
packages/app-tests/e2e/teams/team-members.spec.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { manualLogin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[TEAMS]: update team member role', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings/members`,
|
||||
});
|
||||
|
||||
const teamMemberToUpdate = team.members[1];
|
||||
|
||||
await page
|
||||
.getByRole('row')
|
||||
.filter({ hasText: teamMemberToUpdate.user.email })
|
||||
.getByRole('button')
|
||||
.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Update role' }).click();
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Manager').click();
|
||||
await page.getByRole('button', { name: 'Update' }).click();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: teamMemberToUpdate.user.email }),
|
||||
).toContainText('Manager');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: accept team invitation without account', async ({ page }) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
const teamInvite = await seedTeamInvite({
|
||||
email: `team-invite-test-${Date.now()}@test.documenso.com`,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
|
||||
await expect(page.getByRole('heading')).toContainText('Team invitation');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: accept team invitation with account', async ({ page }) => {
|
||||
const team = await seedTeam();
|
||||
const user = await seedUser();
|
||||
|
||||
const teamInvite = await seedTeamInvite({
|
||||
email: user.email,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
|
||||
await expect(page.getByRole('heading')).toContainText('Invitation accepted!');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: member can leave team', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const teamMember = team.members[1];
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: teamMember.user.email,
|
||||
password: 'password',
|
||||
redirectPath: `/settings/teams`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Leave' }).click();
|
||||
await page.getByRole('button', { name: 'Leave' }).click();
|
||||
|
||||
await expect(page.getByRole('status').first()).toContainText(
|
||||
'You have successfully left this team.',
|
||||
);
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: owner cannot leave team', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/settings/teams`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button').getByText('Leave')).toBeDisabled();
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
69
packages/app-tests/e2e/teams/transfer-team.spec.ts
Normal file
69
packages/app-tests/e2e/teams/transfer-team.spec.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||
|
||||
import { manualLogin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const teamMember = team.members[1];
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Transfer team' }).click();
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel(teamMember.user.name ?? '').click();
|
||||
await page.getByLabel('Confirm by typing transfer').click();
|
||||
await page.getByLabel('Confirm by typing transfer').fill('transfer');
|
||||
await page.getByRole('button', { name: 'Transfer' }).click();
|
||||
|
||||
await expect(page.locator('[id="\\:r2\\:-form-item-message"]')).toContainText(
|
||||
`You must enter 'transfer ${team.name}' to proceed`,
|
||||
);
|
||||
|
||||
await page.getByLabel('Confirm by typing transfer').click();
|
||||
await page.getByLabel('Confirm by typing transfer').fill(`transfer ${team.name}`);
|
||||
await page.getByRole('button', { name: 'Transfer' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Team transfer in progress' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await expect(page.getByRole('status').first()).toContainText(
|
||||
'The team transfer invitation has been successfully deleted.',
|
||||
);
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
/**
|
||||
* Current skipped until we disable billing during tests.
|
||||
*/
|
||||
test.skip('[TEAMS]: accept team transfer', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const newOwnerMember = team.members[1];
|
||||
|
||||
const teamTransferRequest = await seedTeamTransfer({
|
||||
teamId: team.id,
|
||||
newOwnerUserId: newOwnerMember.userId,
|
||||
});
|
||||
|
||||
await page.goto(`${WEBAPP_BASE_URL}/team/verify/transfer/${teamTransferRequest.token}`);
|
||||
await expect(page.getByRole('heading')).toContainText('Team ownership transferred!');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
205
packages/app-tests/e2e/templates/manage-templates.spec.ts
Normal file
205
packages/app-tests/e2e/templates/manage-templates.spec.ts
Normal file
@ -0,0 +1,205 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
|
||||
import { manualLogin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[TEMPLATES]: view templates', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const owner = team.owner;
|
||||
const teamMemberUser = team.members[1].user;
|
||||
|
||||
// Should only be visible to the owner in personal templates.
|
||||
await seedTemplate({
|
||||
title: 'Personal template',
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
// Should be visible to team members.
|
||||
await seedTemplate({
|
||||
title: 'Team template 1',
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
// Should be visible to team members.
|
||||
await seedTemplate({
|
||||
title: 'Team template 2',
|
||||
userId: teamMemberUser.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
// Owner should see both team templates.
|
||||
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
|
||||
await expect(page.getByRole('main')).toContainText('Showing 2 results');
|
||||
|
||||
// Only should only see their personal template.
|
||||
await page.goto(`${WEBAPP_BASE_URL}/templates`);
|
||||
await expect(page.getByRole('main')).toContainText('Showing 1 result');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEMPLATES]: delete template', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const owner = team.owner;
|
||||
const teamMemberUser = team.members[1].user;
|
||||
|
||||
// Should only be visible to the owner in personal templates.
|
||||
await seedTemplate({
|
||||
title: 'Personal template',
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
// Should be visible to team members.
|
||||
await seedTemplate({
|
||||
title: 'Team template 1',
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
// Should be visible to team members.
|
||||
await seedTemplate({
|
||||
title: 'Team template 2',
|
||||
userId: teamMemberUser.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
// Owner should be able to delete their personal template.
|
||||
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(page.getByText('Template deleted').first()).toBeVisible();
|
||||
|
||||
// Team member should be able to delete all templates.
|
||||
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
|
||||
|
||||
for (const template of ['Team template 1', 'Team template 2']) {
|
||||
await page
|
||||
.getByRole('row', { name: template })
|
||||
.getByRole('cell', { name: 'Use Template' })
|
||||
.getByRole('button')
|
||||
.nth(1)
|
||||
.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(page.getByText('Template deleted').first()).toBeVisible();
|
||||
}
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEMPLATES]: duplicate template', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const owner = team.owner;
|
||||
const teamMemberUser = team.members[1].user;
|
||||
|
||||
// Should only be visible to the owner in personal templates.
|
||||
await seedTemplate({
|
||||
title: 'Personal template',
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
// Should be visible to team members.
|
||||
await seedTemplate({
|
||||
title: 'Team template 1',
|
||||
userId: teamMemberUser.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
// Duplicate personal template.
|
||||
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
await expect(page.getByText('Template duplicated').first()).toBeVisible();
|
||||
await expect(page.getByRole('main')).toContainText('Showing 2 results');
|
||||
|
||||
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
|
||||
|
||||
// Duplicate team template.
|
||||
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
await expect(page.getByText('Template duplicated').first()).toBeVisible();
|
||||
await expect(page.getByRole('main')).toContainText('Showing 2 results');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEMPLATES]: use template', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const owner = team.owner;
|
||||
const teamMemberUser = team.members[1].user;
|
||||
|
||||
// Should only be visible to the owner in personal templates.
|
||||
await seedTemplate({
|
||||
title: 'Personal template',
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
// Should be visible to team members.
|
||||
await seedTemplate({
|
||||
title: 'Team template 1',
|
||||
userId: teamMemberUser.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
// Use personal template.
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.waitForURL(/documents/);
|
||||
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
||||
await page.waitForURL('/documents');
|
||||
await expect(page.getByRole('main')).toContainText('Showing 1 result');
|
||||
|
||||
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
|
||||
|
||||
// Use team template.
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.waitForURL(/\/t\/.+\/documents/);
|
||||
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
||||
await page.waitForURL(`/t/${team.url}/documents`);
|
||||
await expect(page.getByRole('main')).toContainText('Showing 1 result');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
@ -12,7 +12,7 @@ test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const username = 'Test User';
|
||||
const email = 'test-user@auth-flow.documenso.com';
|
||||
const password = 'Password123';
|
||||
const password = 'Password123#';
|
||||
|
||||
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
|
||||
await page.goto('/signup');
|
||||
@ -30,7 +30,7 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||
await page.getByRole('button', { name: 'Sign Up', exact: true }).click();
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await expect(page).toHaveURL('/documents');
|
||||
|
||||
@ -1,17 +1,23 @@
|
||||
import { APP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
import { FREE_PLAN_LIMITS } from './constants';
|
||||
import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema';
|
||||
import type { TLimitsResponseSchema } from './schema';
|
||||
import { ZLimitsResponseSchema } from './schema';
|
||||
|
||||
export type GetLimitsOptions = {
|
||||
headers?: Record<string, string>;
|
||||
teamId?: number | null;
|
||||
};
|
||||
|
||||
export const getLimits = async ({ headers }: GetLimitsOptions = {}) => {
|
||||
export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => {
|
||||
const requestHeaders = headers ?? {};
|
||||
|
||||
const url = new URL(`${APP_BASE_URL}/api/limits`);
|
||||
|
||||
if (teamId) {
|
||||
requestHeaders['team-id'] = teamId.toString();
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import { TLimitsSchema } from './schema';
|
||||
import type { TLimitsSchema } from './schema';
|
||||
|
||||
export const FREE_PLAN_LIMITS: TLimitsSchema = {
|
||||
documents: 5,
|
||||
recipients: 10,
|
||||
};
|
||||
|
||||
export const TEAM_PLAN_LIMITS: TLimitsSchema = {
|
||||
documents: Infinity,
|
||||
recipients: Infinity,
|
||||
};
|
||||
|
||||
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
|
||||
documents: Infinity,
|
||||
recipients: Infinity,
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { ERROR_CODES } from './errors';
|
||||
import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
|
||||
import type { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
|
||||
import { getServerLimits } from './server';
|
||||
|
||||
export const limitsHandler = async (
|
||||
@ -14,7 +14,19 @@ export const limitsHandler = async (
|
||||
try {
|
||||
const token = await getToken({ req });
|
||||
|
||||
const limits = await getServerLimits({ email: token?.email });
|
||||
const rawTeamId = req.headers['team-id'];
|
||||
|
||||
let teamId: number | null = null;
|
||||
|
||||
if (typeof rawTeamId === 'string' && !isNaN(parseInt(rawTeamId, 10))) {
|
||||
teamId = parseInt(rawTeamId, 10);
|
||||
}
|
||||
|
||||
if (!teamId && rawTeamId) {
|
||||
throw new Error(ERROR_CODES.INVALID_TEAM_ID);
|
||||
}
|
||||
|
||||
const limits = await getServerLimits({ email: token?.email, teamId });
|
||||
|
||||
return res.status(200).json(limits);
|
||||
} catch (err) {
|
||||
|
||||
@ -6,7 +6,7 @@ import { equals } from 'remeda';
|
||||
|
||||
import { getLimits } from '../client';
|
||||
import { FREE_PLAN_LIMITS } from '../constants';
|
||||
import { TLimitsResponseSchema } from '../schema';
|
||||
import type { TLimitsResponseSchema } from '../schema';
|
||||
|
||||
export type LimitsContextValue = TLimitsResponseSchema;
|
||||
|
||||
@ -24,19 +24,22 @@ export const useLimits = () => {
|
||||
|
||||
export type LimitsProviderProps = {
|
||||
initialValue?: LimitsContextValue;
|
||||
teamId?: number;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => {
|
||||
const defaultValue: TLimitsResponseSchema = {
|
||||
export const LimitsProvider = ({
|
||||
initialValue = {
|
||||
quota: FREE_PLAN_LIMITS,
|
||||
remaining: FREE_PLAN_LIMITS,
|
||||
};
|
||||
|
||||
const [limits, setLimits] = useState(() => initialValue ?? defaultValue);
|
||||
},
|
||||
teamId,
|
||||
children,
|
||||
}: LimitsProviderProps) => {
|
||||
const [limits, setLimits] = useState(() => initialValue);
|
||||
|
||||
const refreshLimits = async () => {
|
||||
const newLimits = await getLimits();
|
||||
const newLimits = await getLimits({ teamId });
|
||||
|
||||
setLimits((oldLimits) => {
|
||||
if (equals(oldLimits, newLimits)) {
|
||||
|
||||
@ -3,16 +3,22 @@
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { getLimits } from '../client';
|
||||
import type { LimitsContextValue } from './client';
|
||||
import { LimitsProvider as ClientLimitsProvider } from './client';
|
||||
|
||||
export type LimitsProviderProps = {
|
||||
children?: React.ReactNode;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const LimitsProvider = async ({ children }: LimitsProviderProps) => {
|
||||
export const LimitsProvider = async ({ children, teamId }: LimitsProviderProps) => {
|
||||
const requestHeaders = Object.fromEntries(headers().entries());
|
||||
|
||||
const limits = await getLimits({ headers: requestHeaders });
|
||||
const limits: LimitsContextValue = await getLimits({ headers: requestHeaders, teamId });
|
||||
|
||||
return <ClientLimitsProvider initialValue={limits}>{children}</ClientLimitsProvider>;
|
||||
return (
|
||||
<ClientLimitsProvider initialValue={limits} teamId={teamId}>
|
||||
{children}
|
||||
</ClientLimitsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { getPricesByType } from '../stripe/get-prices-by-type';
|
||||
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
|
||||
import { getPricesByPlan } from '../stripe/get-prices-by-plan';
|
||||
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
|
||||
import { ERROR_CODES } from './errors';
|
||||
import { ZLimitsSchema } from './schema';
|
||||
|
||||
export type GetServerLimitsOptions = {
|
||||
email?: string | null;
|
||||
teamId?: number | null;
|
||||
};
|
||||
|
||||
export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||
const isBillingEnabled = await getFlag('app_billing');
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => {
|
||||
if (!IS_BILLING_ENABLED) {
|
||||
return {
|
||||
quota: SELFHOSTED_PLAN_LIMITS,
|
||||
remaining: SELFHOSTED_PLAN_LIMITS,
|
||||
@ -27,6 +27,14 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||
throw new Error(ERROR_CODES.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
return teamId ? handleTeamLimits({ email, teamId }) : handleUserLimits({ email });
|
||||
};
|
||||
|
||||
type HandleUserLimitsOptions = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
@ -48,10 +56,10 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||
);
|
||||
|
||||
if (activeSubscriptions.length > 0) {
|
||||
const individualPrices = await getPricesByType('individual');
|
||||
const communityPlanPrices = await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
|
||||
|
||||
for (const subscription of activeSubscriptions) {
|
||||
const price = individualPrices.find((price) => price.id === subscription.priceId);
|
||||
const price = communityPlanPrices.find((price) => price.id === subscription.priceId);
|
||||
if (!price || typeof price.product === 'string' || price.product.deleted) {
|
||||
continue;
|
||||
}
|
||||
@ -71,6 +79,7 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||
const documents = await prisma.document.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
createdAt: {
|
||||
gte: DateTime.utc().startOf('month').toJSDate(),
|
||||
},
|
||||
@ -84,3 +93,50 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||
remaining,
|
||||
};
|
||||
};
|
||||
|
||||
type HandleTeamLimitsOptions = {
|
||||
email: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
const handleTeamLimits = async ({ email, teamId }: HandleTeamLimitsOptions) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
user: {
|
||||
email,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new Error('Team not found');
|
||||
}
|
||||
|
||||
const { subscription } = team;
|
||||
|
||||
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
|
||||
return {
|
||||
quota: {
|
||||
documents: 0,
|
||||
recipients: 0,
|
||||
},
|
||||
remaining: {
|
||||
documents: 0,
|
||||
recipients: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
quota: structuredClone(TEAM_PLAN_LIMITS),
|
||||
remaining: structuredClone(TEAM_PLAN_LIMITS),
|
||||
};
|
||||
};
|
||||
|
||||
20
packages/ee/server-only/stripe/create-team-customer.ts
Normal file
20
packages/ee/server-only/stripe/create-team-customer.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
type CreateTeamCustomerOptions = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Stripe customer for a given team.
|
||||
*/
|
||||
export const createTeamCustomer = async ({ name, email }: CreateTeamCustomerOptions) => {
|
||||
return await stripe.customers.create({
|
||||
name,
|
||||
email,
|
||||
metadata: {
|
||||
type: STRIPE_CUSTOMER_TYPE.TEAM,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
type DeleteCustomerPaymentMethodsOptions = {
|
||||
customerId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete all attached payment methods for a given customer.
|
||||
*/
|
||||
export const deleteCustomerPaymentMethods = async ({
|
||||
customerId,
|
||||
}: DeleteCustomerPaymentMethodsOptions) => {
|
||||
const paymentMethods = await stripe.paymentMethods.list({
|
||||
customer: customerId,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
paymentMethods.data.map(async (paymentMethod) =>
|
||||
stripe.paymentMethods.detach(paymentMethod.id),
|
||||
),
|
||||
);
|
||||
};
|
||||
@ -1,17 +1,21 @@
|
||||
'use server';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type GetCheckoutSessionOptions = {
|
||||
customerId: string;
|
||||
priceId: string;
|
||||
returnUrl: string;
|
||||
subscriptionMetadata?: Stripe.Metadata;
|
||||
};
|
||||
|
||||
export const getCheckoutSession = async ({
|
||||
customerId,
|
||||
priceId,
|
||||
returnUrl,
|
||||
subscriptionMetadata,
|
||||
}: GetCheckoutSessionOptions) => {
|
||||
'use server';
|
||||
|
||||
@ -26,6 +30,9 @@ export const getCheckoutSession = async ({
|
||||
],
|
||||
success_url: `${returnUrl}?success=true`,
|
||||
cancel_url: `${returnUrl}?canceled=true`,
|
||||
subscription_data: {
|
||||
metadata: subscriptionMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
return session.url;
|
||||
|
||||
13
packages/ee/server-only/stripe/get-community-plan-prices.ts
Normal file
13
packages/ee/server-only/stripe/get-community-plan-prices.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
export const getCommunityPlanPrices = async () => {
|
||||
return await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
|
||||
};
|
||||
|
||||
export const getCommunityPlanPriceIds = async () => {
|
||||
const prices = await getCommunityPlanPrices();
|
||||
|
||||
return prices.map((price) => price.id);
|
||||
};
|
||||
@ -1,15 +1,19 @@
|
||||
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
|
||||
import { onSubscriptionUpdated } from './webhook/on-subscription-updated';
|
||||
|
||||
/**
|
||||
* Get a non team Stripe customer by email.
|
||||
*/
|
||||
export const getStripeCustomerByEmail = async (email: string) => {
|
||||
const foundStripeCustomers = await stripe.customers.list({
|
||||
email,
|
||||
});
|
||||
|
||||
return foundStripeCustomers.data[0] ?? null;
|
||||
return foundStripeCustomers.data.find((customer) => customer.metadata.type !== 'team') ?? null;
|
||||
};
|
||||
|
||||
export const getStripeCustomerById = async (stripeCustomerId: string) => {
|
||||
@ -51,6 +55,7 @@ export const getStripeCustomerByUser = async (user: User) => {
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
type: STRIPE_CUSTOMER_TYPE.INDIVIDUAL,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -78,6 +83,14 @@ export const getStripeCustomerByUser = async (user: User) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getStripeCustomerIdByUser = async (user: User) => {
|
||||
if (user.customerId !== null) {
|
||||
return user.customerId;
|
||||
}
|
||||
|
||||
return await getStripeCustomerByUser(user).then((session) => session.stripeCustomer.id);
|
||||
};
|
||||
|
||||
const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => {
|
||||
const stripeSubscriptions = await stripe.subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
|
||||
11
packages/ee/server-only/stripe/get-invoices.ts
Normal file
11
packages/ee/server-only/stripe/get-invoices.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type GetInvoicesOptions = {
|
||||
customerId: string;
|
||||
};
|
||||
|
||||
export const getInvoices = async ({ customerId }: GetInvoicesOptions) => {
|
||||
return await stripe.invoices.list({
|
||||
customer: customerId,
|
||||
});
|
||||
};
|
||||
@ -4,7 +4,7 @@ import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type GetPortalSessionOptions = {
|
||||
customerId: string;
|
||||
returnUrl: string;
|
||||
returnUrl?: string;
|
||||
};
|
||||
|
||||
export const getPortalSession = async ({ customerId, returnUrl }: GetPortalSessionOptions) => {
|
||||
|
||||
@ -9,12 +9,12 @@ export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithPr
|
||||
|
||||
export type GetPricesByIntervalOptions = {
|
||||
/**
|
||||
* Filter products by their meta 'type' attribute.
|
||||
* Filter products by their meta 'plan' attribute.
|
||||
*/
|
||||
type?: 'individual';
|
||||
plan?: 'community';
|
||||
};
|
||||
|
||||
export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions = {}) => {
|
||||
export const getPricesByInterval = async ({ plan }: GetPricesByIntervalOptions = {}) => {
|
||||
let { data: prices } = await stripe.prices.search({
|
||||
query: `active:'true' type:'recurring'`,
|
||||
expand: ['data.product'],
|
||||
@ -26,7 +26,7 @@ export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions =
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const product = price.product as Stripe.Product;
|
||||
|
||||
const filter = !type || product.metadata?.type === type;
|
||||
const filter = !plan || product.metadata?.plan === plan;
|
||||
|
||||
// Filter out prices for products that are not active.
|
||||
return product.active && filter;
|
||||
|
||||
14
packages/ee/server-only/stripe/get-prices-by-plan.ts
Normal file
14
packages/ee/server-only/stripe/get-prices-by-plan.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export const getPricesByPlan = async (
|
||||
plan: (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE],
|
||||
) => {
|
||||
const { data: prices } = await stripe.prices.search({
|
||||
query: `metadata['plan']:'${plan}' type:'recurring'`,
|
||||
expand: ['data.product'],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return prices;
|
||||
};
|
||||
@ -1,11 +0,0 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export const getPricesByType = async (type: 'individual') => {
|
||||
const { data: prices } = await stripe.prices.search({
|
||||
query: `metadata['type']:'${type}' type:'recurring'`,
|
||||
expand: ['data.product'],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return prices;
|
||||
};
|
||||
43
packages/ee/server-only/stripe/get-team-prices.ts
Normal file
43
packages/ee/server-only/stripe/get-team-prices.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
export const getTeamPrices = async () => {
|
||||
const prices = (await getPricesByPlan(STRIPE_PLAN_TYPE.TEAM)).filter((price) => price.active);
|
||||
|
||||
const monthlyPrice = prices.find((price) => price.recurring?.interval === 'month');
|
||||
const yearlyPrice = prices.find((price) => price.recurring?.interval === 'year');
|
||||
const priceIds = prices.map((price) => price.id);
|
||||
|
||||
if (!monthlyPrice || !yearlyPrice) {
|
||||
throw new AppError('INVALID_CONFIG', 'Missing monthly or yearly price');
|
||||
}
|
||||
|
||||
return {
|
||||
monthly: {
|
||||
friendlyInterval: 'Monthly',
|
||||
interval: 'monthly',
|
||||
...extractPriceData(monthlyPrice),
|
||||
},
|
||||
yearly: {
|
||||
friendlyInterval: 'Yearly',
|
||||
interval: 'yearly',
|
||||
...extractPriceData(yearlyPrice),
|
||||
},
|
||||
priceIds,
|
||||
} as const;
|
||||
};
|
||||
|
||||
const extractPriceData = (price: Stripe.Price) => {
|
||||
const product =
|
||||
typeof price.product !== 'string' && !price.product.deleted ? price.product : null;
|
||||
|
||||
return {
|
||||
priceId: price.id,
|
||||
description: product?.description ?? '',
|
||||
features: product?.features ?? [],
|
||||
};
|
||||
};
|
||||
126
packages/ee/server-only/stripe/transfer-team-subscription.ts
Normal file
126
packages/ee/server-only/stripe/transfer-team-subscription.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { type Subscription, type Team, type User } from '@documenso/prisma/client';
|
||||
|
||||
import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
|
||||
import { getCommunityPlanPriceIds } from './get-community-plan-prices';
|
||||
import { getTeamPrices } from './get-team-prices';
|
||||
|
||||
type TransferStripeSubscriptionOptions = {
|
||||
/**
|
||||
* The user to transfer the subscription to.
|
||||
*/
|
||||
user: User & { Subscription: Subscription[] };
|
||||
|
||||
/**
|
||||
* The team the subscription is associated with.
|
||||
*/
|
||||
team: Team & { subscription?: Subscription | null };
|
||||
|
||||
/**
|
||||
* Whether to clear any current payment methods attached to the team.
|
||||
*/
|
||||
clearPaymentMethods: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transfer the Stripe Team seats subscription from one user to another.
|
||||
*
|
||||
* Will create a new subscription for the new owner and cancel the old one.
|
||||
*
|
||||
* Returns the subscription that should be associated with the team, null if
|
||||
* no subscription is needed (for community plan).
|
||||
*/
|
||||
export const transferTeamSubscription = async ({
|
||||
user,
|
||||
team,
|
||||
clearPaymentMethods,
|
||||
}: TransferStripeSubscriptionOptions) => {
|
||||
const teamCustomerId = team.customerId;
|
||||
|
||||
if (!teamCustomerId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.');
|
||||
}
|
||||
|
||||
const [communityPlanIds, teamSeatPrices] = await Promise.all([
|
||||
getCommunityPlanPriceIds(),
|
||||
getTeamPrices(),
|
||||
]);
|
||||
|
||||
const teamSubscriptionRequired = !subscriptionsContainsActiveCommunityPlan(
|
||||
user.Subscription,
|
||||
communityPlanIds,
|
||||
);
|
||||
|
||||
let teamSubscription: Stripe.Subscription | null = null;
|
||||
|
||||
if (team.subscription) {
|
||||
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
|
||||
|
||||
if (!teamSubscription) {
|
||||
throw new Error('Could not find the current subscription.');
|
||||
}
|
||||
|
||||
if (clearPaymentMethods) {
|
||||
await deleteCustomerPaymentMethods({ customerId: teamCustomerId });
|
||||
}
|
||||
}
|
||||
|
||||
await stripe.customers.update(teamCustomerId, {
|
||||
name: user.name ?? team.name,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
// If team subscription is required and the team does not have a subscription, create one.
|
||||
if (teamSubscriptionRequired && !teamSubscription) {
|
||||
const numberOfSeats = await prisma.teamMember.count({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const teamSeatPriceId = teamSeatPrices.monthly.priceId;
|
||||
|
||||
teamSubscription = await stripe.subscriptions.create({
|
||||
customer: teamCustomerId,
|
||||
items: [
|
||||
{
|
||||
price: teamSeatPriceId,
|
||||
quantity: numberOfSeats,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
teamId: team.id.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// If no team subscription is required, cancel the current team subscription if it exists.
|
||||
if (!teamSubscriptionRequired && teamSubscription) {
|
||||
try {
|
||||
// Set the quantity to 0 so we can refund/charge the old Stripe customer the prorated amount.
|
||||
await stripe.subscriptions.update(teamSubscription.id, {
|
||||
items: teamSubscription.items.data.map((item) => ({
|
||||
id: item.id,
|
||||
quantity: 0,
|
||||
})),
|
||||
});
|
||||
|
||||
await stripe.subscriptions.cancel(teamSubscription.id, {
|
||||
invoice_now: true,
|
||||
prorate: false,
|
||||
});
|
||||
} catch (e) {
|
||||
// Do not error out since we can't easily undo the transfer.
|
||||
// Todo: Teams - Alert us.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return teamSubscription;
|
||||
};
|
||||
18
packages/ee/server-only/stripe/update-customer.ts
Normal file
18
packages/ee/server-only/stripe/update-customer.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
type UpdateCustomerOptions = {
|
||||
customerId: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export const updateCustomer = async ({ customerId, name, email }: UpdateCustomerOptions) => {
|
||||
if (!name && !email) {
|
||||
return;
|
||||
}
|
||||
|
||||
return await stripe.customers.update(customerId, {
|
||||
name,
|
||||
email,
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,44 @@
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type UpdateSubscriptionItemQuantityOptions = {
|
||||
subscriptionId: string;
|
||||
quantity: number;
|
||||
priceId: string;
|
||||
};
|
||||
|
||||
export const updateSubscriptionItemQuantity = async ({
|
||||
subscriptionId,
|
||||
quantity,
|
||||
priceId,
|
||||
}: UpdateSubscriptionItemQuantityOptions) => {
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
const items = subscription.items.data.filter((item) => item.price.id === priceId);
|
||||
|
||||
if (items.length !== 1) {
|
||||
throw new Error('Subscription does not contain required item');
|
||||
}
|
||||
|
||||
const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year');
|
||||
const oldQuantity = items[0].quantity;
|
||||
|
||||
if (oldQuantity === quantity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionUpdatePayload: Stripe.SubscriptionUpdateParams = {
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
quantity,
|
||||
})),
|
||||
};
|
||||
|
||||
// Only invoice immediately when changing the quantity of yearly item.
|
||||
if (hasYearlyItem) {
|
||||
subscriptionUpdatePayload.proration_behavior = 'always_invoice';
|
||||
}
|
||||
|
||||
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
|
||||
};
|
||||
@ -3,8 +3,10 @@ import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { buffer } from 'micro';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { createTeamFromPendingTeam } from '@documenso/lib/server-only/team/create-team';
|
||||
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@ -84,14 +86,9 @@ export const stripeWebhookHandler = async (
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.id) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
if (result?.id) {
|
||||
userId = result.id;
|
||||
}
|
||||
|
||||
userId = result.id;
|
||||
}
|
||||
|
||||
const subscriptionId =
|
||||
@ -99,7 +96,7 @@ export const stripeWebhookHandler = async (
|
||||
? session.subscription
|
||||
: session.subscription?.id;
|
||||
|
||||
if (!subscriptionId || Number.isNaN(userId)) {
|
||||
if (!subscriptionId) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Invalid session',
|
||||
@ -108,6 +105,24 @@ export const stripeWebhookHandler = async (
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
// Handle team creation after seat checkout.
|
||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||
await handleTeamSeatCheckout({ subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate user ID.
|
||||
if (!userId || Number.isNaN(userId)) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Invalid session or missing user ID',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
@ -124,6 +139,28 @@ export const stripeWebhookHandler = async (
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'No team associated with subscription found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
@ -182,6 +219,28 @@ export const stripeWebhookHandler = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'No team associated with subscription found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
@ -233,6 +292,28 @@ export const stripeWebhookHandler = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'No team associated with subscription found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
@ -282,3 +363,21 @@ export const stripeWebhookHandler = async (
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export type HandleTeamSeatCheckoutOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
const handleTeamSeatCheckout = async ({ subscription }: HandleTeamSeatCheckoutOptions) => {
|
||||
if (subscription.metadata?.pendingTeamId === undefined) {
|
||||
throw new Error('Missing pending team ID');
|
||||
}
|
||||
|
||||
const pendingTeamId = Number(subscription.metadata.pendingTeamId);
|
||||
|
||||
if (Number.isNaN(pendingTeamId)) {
|
||||
throw new Error('Invalid pending team ID');
|
||||
}
|
||||
|
||||
return await createTeamFromPendingTeam({ pendingTeamId, subscription }).then((team) => team.id);
|
||||
};
|
||||
|
||||
@ -2,23 +2,40 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type OnSubscriptionUpdatedOptions = {
|
||||
userId: number;
|
||||
userId?: number;
|
||||
teamId?: number;
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
export const onSubscriptionUpdated = async ({
|
||||
userId,
|
||||
teamId,
|
||||
subscription,
|
||||
}: OnSubscriptionUpdatedOptions) => {
|
||||
await prisma.subscription.upsert(
|
||||
mapStripeSubscriptionToPrismaUpsertAction(subscription, userId, teamId),
|
||||
);
|
||||
};
|
||||
|
||||
export const mapStripeSubscriptionToPrismaUpsertAction = (
|
||||
subscription: Stripe.Subscription,
|
||||
userId?: number,
|
||||
teamId?: number,
|
||||
): Prisma.SubscriptionUpsertArgs => {
|
||||
if ((!userId && !teamId) || (userId && teamId)) {
|
||||
throw new Error('Either userId or teamId must be provided.');
|
||||
}
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
return {
|
||||
where: {
|
||||
planId: subscription.id,
|
||||
},
|
||||
@ -27,7 +44,8 @@ export const onSubscriptionUpdated = async ({
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
userId,
|
||||
userId: userId ?? null,
|
||||
teamId: teamId ?? null,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
update: {
|
||||
@ -37,5 +55,5 @@ export const onSubscriptionUpdated = async ({
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
BIN
packages/email/static/add-user.png
Normal file
BIN
packages/email/static/add-user.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
packages/email/static/mail-open-alert.png
Normal file
BIN
packages/email/static/mail-open-alert.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
packages/email/static/mail-open.png
Normal file
BIN
packages/email/static/mail-open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@ -1,3 +1,6 @@
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
@ -7,6 +10,7 @@ export interface TemplateDocumentInviteProps {
|
||||
documentName: string;
|
||||
signDocumentLink: string;
|
||||
assetBaseUrl: string;
|
||||
role: RecipientRole;
|
||||
}
|
||||
|
||||
export const TemplateDocumentInvite = ({
|
||||
@ -14,19 +18,22 @@ export const TemplateDocumentInvite = ({
|
||||
documentName,
|
||||
signDocumentLink,
|
||||
assetBaseUrl,
|
||||
role,
|
||||
}: TemplateDocumentInviteProps) => {
|
||||
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
{inviterName} has invited you to sign
|
||||
{inviterName} has invited you to {actionVerb.toLowerCase()}
|
||||
<br />"{documentName}"
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
Continue by signing the document.
|
||||
Continue by {progressiveVerb.toLowerCase()} the document.
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
@ -34,7 +41,7 @@ export const TemplateDocumentInvite = ({
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={signDocumentLink}
|
||||
>
|
||||
Sign Document
|
||||
{actionVerb} Document
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
@ -10,7 +10,7 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||
{isDocument && (
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
This document was sent using{' '}
|
||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
||||
<Link className="text-[#7AC455]" href="https://documen.so/mail-footer">
|
||||
Documenso.
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
17
packages/email/template-components/template-image.tsx
Normal file
17
packages/email/template-components/template-image.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Img } from '../components';
|
||||
|
||||
export interface TemplateImageProps {
|
||||
assetBaseUrl: string;
|
||||
className?: string;
|
||||
staticAsset: string;
|
||||
}
|
||||
|
||||
export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: TemplateImageProps) => {
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return <Img className={className} src={getAssetUrl(`/static/${staticAsset}`)} />;
|
||||
};
|
||||
|
||||
export default TemplateImage;
|
||||
@ -7,7 +7,7 @@ import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export const ConfirmEmailTemplate = ({
|
||||
confirmationLink,
|
||||
assetBaseUrl,
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: TemplateConfirmationEmailProps) => {
|
||||
const previewText = `Please confirm your email address`;
|
||||
|
||||
@ -55,3 +55,5 @@ export const ConfirmEmailTemplate = ({
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmEmailTemplate;
|
||||
|
||||
127
packages/email/templates/confirm-team-email.tsx
Normal file
127
packages/email/templates/confirm-team-email.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '../components';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
export type ConfirmTeamEmailProps = {
|
||||
assetBaseUrl: string;
|
||||
baseUrl: string;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const ConfirmTeamEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
baseUrl = 'https://documenso.com',
|
||||
teamName = 'Team Name',
|
||||
teamUrl = 'demo',
|
||||
token = '',
|
||||
}: ConfirmTeamEmailProps) => {
|
||||
const previewText = `Accept team email request for ${teamName} on Documenso`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
className="mx-auto"
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
staticAsset="mail-open.png"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
Verify your team email address
|
||||
</Text>
|
||||
|
||||
<Text className="text-center text-base">
|
||||
<span className="font-bold">{teamName}</span> has requested to use your email
|
||||
address for their team on Documenso.
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto mt-6 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
|
||||
{formatTeamUrl(teamUrl, baseUrl)}
|
||||
</div>
|
||||
|
||||
<Section className="mt-6">
|
||||
<Text className="my-0 text-sm">
|
||||
By accepting this request, you will be granting <strong>{teamName}</strong>{' '}
|
||||
access to:
|
||||
</Text>
|
||||
|
||||
<ul className="mb-0 mt-2">
|
||||
<li className="text-sm">
|
||||
View all documents sent to and from this email address
|
||||
</li>
|
||||
<li className="mt-1 text-sm">
|
||||
Allow document recipients to reply directly to this email address
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Text className="mt-2 text-sm">
|
||||
You can revoke access at any time in your team settings on Documenso{' '}
|
||||
<Link href={`${baseUrl}/settings/teams`}>here.</Link>
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={`${baseUrl}/team/verify/email/${token}`}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Text className="text-center text-xs text-slate-500">Link expires in 1 hour.</Text>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmTeamEmailTemplate;
|
||||
@ -1,3 +1,5 @@
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { RecipientRole } from '@documenso/prisma/client';
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
@ -19,6 +21,7 @@ import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
||||
customBody?: string;
|
||||
role: RecipientRole;
|
||||
};
|
||||
|
||||
export const DocumentInviteEmailTemplate = ({
|
||||
@ -28,8 +31,11 @@ export const DocumentInviteEmailTemplate = ({
|
||||
signDocumentLink = 'https://documenso.com',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
customBody,
|
||||
role,
|
||||
}: DocumentInviteEmailTemplateProps) => {
|
||||
const previewText = `${inviterName} has invited you to sign ${documentName}`;
|
||||
const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
|
||||
|
||||
const previewText = `${inviterName} has invited you to ${action} ${documentName}`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
@ -64,6 +70,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
documentName={documentName}
|
||||
signDocumentLink={signDocumentLink}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
role={role}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
@ -81,7 +88,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
{customBody ? (
|
||||
<pre className="font-sans text-base text-slate-400">{customBody}</pre>
|
||||
) : (
|
||||
`${inviterName} has invited you to sign the document "${documentName}".`
|
||||
`${inviterName} has invited you to ${action} the document "${documentName}".`
|
||||
)}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
83
packages/email/templates/team-email-removed.tsx
Normal file
83
packages/email/templates/team-email-removed.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Preview, Section, Tailwind, Text } from '../components';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
export type TeamEmailRemovedTemplateProps = {
|
||||
assetBaseUrl: string;
|
||||
baseUrl: string;
|
||||
teamEmail: string;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
export const TeamEmailRemovedTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
baseUrl = 'https://documenso.com',
|
||||
teamEmail = 'example@documenso.com',
|
||||
teamName = 'Team Name',
|
||||
teamUrl = 'demo',
|
||||
}: TeamEmailRemovedTemplateProps) => {
|
||||
const previewText = `Team email removed for ${teamName} on Documenso`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
className="mx-auto"
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
staticAsset="mail-open-alert.png"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
Team email removed
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
The team email <span className="font-bold">{teamEmail}</span> has been removed
|
||||
from the following team
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto mb-6 mt-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
|
||||
{formatTeamUrl(teamUrl, baseUrl)}
|
||||
</div>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamEmailRemovedTemplate;
|
||||
108
packages/email/templates/team-invite.tsx
Normal file
108
packages/email/templates/team-invite.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '../components';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
export type TeamInviteEmailProps = {
|
||||
assetBaseUrl: string;
|
||||
baseUrl: string;
|
||||
senderName: string;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const TeamInviteEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
baseUrl = 'https://documenso.com',
|
||||
senderName = 'John Doe',
|
||||
teamName = 'Team Name',
|
||||
teamUrl = 'demo',
|
||||
token = '',
|
||||
}: TeamInviteEmailProps) => {
|
||||
const previewText = `Accept invitation to join a team on Documenso`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
className="mx-auto"
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
staticAsset="add-user.png"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
Join {teamName} on Documenso
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
You have been invited to join the following team
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
|
||||
{formatTeamUrl(teamUrl, baseUrl)}
|
||||
</div>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
by <span className="text-slate-900">{senderName}</span>
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-6 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={`${baseUrl}/team/invite/${token}`}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamInviteEmailTemplate;
|
||||
112
packages/email/templates/team-transfer-request.tsx
Normal file
112
packages/email/templates/team-transfer-request.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '../components';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
export type TeamTransferRequestTemplateProps = {
|
||||
assetBaseUrl: string;
|
||||
baseUrl: string;
|
||||
senderName: string;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const TeamTransferRequestTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
baseUrl = 'https://documenso.com',
|
||||
senderName = 'John Doe',
|
||||
teamName = 'Team Name',
|
||||
teamUrl = 'demo',
|
||||
token = '',
|
||||
}: TeamTransferRequestTemplateProps) => {
|
||||
const previewText = 'Accept team transfer request on Documenso';
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
className="mx-auto"
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
staticAsset="add-user.png"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
{teamName} ownership transfer request
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
<span className="font-bold">{senderName}</span> has requested that you take
|
||||
ownership of the following team
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
|
||||
{formatTeamUrl(teamUrl, baseUrl)}
|
||||
</div>
|
||||
|
||||
<Text className="text-center text-sm">
|
||||
By accepting this request, you will take responsibility for any billing items
|
||||
associated with this team.
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-6 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 ml-2 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={`${baseUrl}/team/verify/transfer/${token}`}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Text className="text-center text-xs">Link expires in 1 hour.</Text>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamTransferRequestTemplate;
|
||||
@ -13,7 +13,7 @@
|
||||
"eslint-config-next": "13.4.19",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-config-turbo": "^1.9.3",
|
||||
"eslint-plugin-package-json": "^0.1.4",
|
||||
"eslint-plugin-package-json": "^0.2.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
|
||||
29
packages/lib/client-only/download-pdf.ts
Normal file
29
packages/lib/client-only/download-pdf.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { DocumentData } from '@documenso/prisma/client';
|
||||
|
||||
import { getFile } from '../universal/upload/get-file';
|
||||
|
||||
type DownloadPDFProps = {
|
||||
documentData: DocumentData;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps) => {
|
||||
const bytes = await getFile(documentData);
|
||||
|
||||
const blob = new Blob([bytes], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
|
||||
const [baseTitle] = fileName?.includes('.pdf')
|
||||
? fileName.split('.pdf')
|
||||
: [fileName ?? 'document'];
|
||||
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = `${baseTitle}_signed.pdf`;
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
};
|
||||
@ -1,9 +1,10 @@
|
||||
import { ReadStatus, Recipient, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export const getRecipientType = (recipient: Recipient) => {
|
||||
if (
|
||||
recipient.sendStatus === SendStatus.SENT &&
|
||||
recipient.signingStatus === SigningStatus.SIGNED
|
||||
recipient.role === RecipientRole.CC ||
|
||||
(recipient.sendStatus === SendStatus.SENT && recipient.signingStatus === SigningStatus.SIGNED)
|
||||
) {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
|
||||
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
|
||||
export const IS_BILLING_ENABLED = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true';
|
||||
|
||||
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
|
||||
Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50;
|
||||
|
||||
export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web';
|
||||
|
||||
export const APP_BASE_URL = IS_APP_WEB
|
||||
? process.env.NEXT_PUBLIC_WEBAPP_URL
|
||||
: process.env.NEXT_PUBLIC_MARKETING_URL;
|
||||
|
||||
export const WEBAPP_BASE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000';
|
||||
|
||||
export const MARKETING_BASE_URL = process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001';
|
||||
|
||||
@ -1 +1,25 @@
|
||||
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
export const SALT_ROUNDS = 12;
|
||||
|
||||
export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = {
|
||||
[IdentityProvider.DOCUMENSO]: 'Documenso',
|
||||
[IdentityProvider.GOOGLE]: 'Google',
|
||||
};
|
||||
|
||||
export const IS_GOOGLE_SSO_ENABLED = Boolean(
|
||||
process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
|
||||
);
|
||||
|
||||
export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: string } = {
|
||||
[UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO',
|
||||
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
|
||||
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
|
||||
[UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
|
||||
[UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset',
|
||||
[UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
|
||||
[UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
|
||||
[UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
|
||||
[UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed',
|
||||
[UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed',
|
||||
};
|
||||
|
||||
11
packages/lib/constants/billing.ts
Normal file
11
packages/lib/constants/billing.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export enum STRIPE_CUSTOMER_TYPE {
|
||||
INDIVIDUAL = 'individual',
|
||||
TEAM = 'team',
|
||||
}
|
||||
|
||||
export enum STRIPE_PLAN_TYPE {
|
||||
TEAM = 'team',
|
||||
COMMUNITY = 'community',
|
||||
}
|
||||
|
||||
export const TEAM_BILLING_DOMAIN = 'billing.team.documenso.com';
|
||||
@ -1 +1,25 @@
|
||||
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
|
||||
|
||||
export const DOCUMENSO_ENCRYPTION_SECONDARY_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
|
||||
}
|
||||
|
||||
if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||
throw new Error(
|
||||
'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (DOCUMENSO_ENCRYPTION_KEY === 'CAFEBABE') {
|
||||
console.warn('*********************************************************************');
|
||||
console.warn('*');
|
||||
console.warn('*');
|
||||
console.warn('Please change the encryption key from the default value of "CAFEBABE"');
|
||||
console.warn('*');
|
||||
console.warn('*');
|
||||
console.warn('*********************************************************************');
|
||||
}
|
||||
|
||||
79
packages/lib/constants/date-formats.ts
Normal file
79
packages/lib/constants/date-formats.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from './time-zones';
|
||||
|
||||
export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a';
|
||||
|
||||
export const DATE_FORMATS = [
|
||||
{
|
||||
key: 'yyyy-MM-dd_hh:mm_a',
|
||||
label: 'YYYY-MM-DD HH:mm a',
|
||||
value: DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
},
|
||||
{
|
||||
key: 'YYYYMMDD',
|
||||
label: 'YYYY-MM-DD',
|
||||
value: 'YYYY-MM-DD',
|
||||
},
|
||||
{
|
||||
key: 'DDMMYYYY',
|
||||
label: 'DD/MM/YYYY',
|
||||
value: 'dd/MM/yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'MMDDYYYY',
|
||||
label: 'MM/DD/YYYY',
|
||||
value: 'MM/dd/yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'YYYYMMDDHHmm',
|
||||
label: 'YYYY-MM-DD HH:mm',
|
||||
value: 'yyyy-MM-dd HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'YYMMDD',
|
||||
label: 'YY-MM-DD',
|
||||
value: 'yy-MM-dd hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'YYYYMMDDhhmmss',
|
||||
label: 'YYYY-MM-DD HH:mm:ss',
|
||||
value: 'yyyy-MM-dd HH:mm:ss',
|
||||
},
|
||||
{
|
||||
key: 'MonthDateYear',
|
||||
label: 'Month Date, Year',
|
||||
value: 'MMMM dd, yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'DayMonthYear',
|
||||
label: 'Day, Month Year',
|
||||
value: 'EEEE, MMMM dd, yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'ISO8601',
|
||||
label: 'ISO 8601',
|
||||
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||
},
|
||||
];
|
||||
|
||||
export const convertToLocalSystemFormat = (
|
||||
customText: string,
|
||||
dateFormat: string | null = DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
timeZone: string | null = DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
): string => {
|
||||
const coalescedDateFormat = dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT;
|
||||
const coalescedTimeZone = timeZone ?? DEFAULT_DOCUMENT_TIME_ZONE;
|
||||
|
||||
const parsedDate = DateTime.fromFormat(customText, coalescedDateFormat, {
|
||||
zone: coalescedTimeZone,
|
||||
});
|
||||
|
||||
if (!parsedDate.isValid) {
|
||||
return 'Invalid date';
|
||||
}
|
||||
|
||||
const formattedDate = parsedDate.toLocal().toFormat(coalescedDateFormat);
|
||||
|
||||
return formattedDate;
|
||||
};
|
||||
@ -17,6 +17,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
|
||||
*/
|
||||
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
|
||||
app_teams: true,
|
||||
marketing_header_single_player_mode: false,
|
||||
} as const;
|
||||
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export const SETTINGS_PAGE_SHORTCUT = 'N+S';
|
||||
export const DOCUMENTS_PAGE_SHORTCUT = 'N+D';
|
||||
export const TEMPLATES_PAGE_SHORTCUT = 'N+T';
|
||||
|
||||
26
packages/lib/constants/recipient-roles.ts
Normal file
26
packages/lib/constants/recipient-roles.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
export const RECIPIENT_ROLES_DESCRIPTION: {
|
||||
[key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string };
|
||||
} = {
|
||||
[RecipientRole.APPROVER]: {
|
||||
actionVerb: 'Approve',
|
||||
progressiveVerb: 'Approving',
|
||||
roleName: 'Approver',
|
||||
},
|
||||
[RecipientRole.CC]: {
|
||||
actionVerb: 'CC',
|
||||
progressiveVerb: 'CC',
|
||||
roleName: 'CC',
|
||||
},
|
||||
[RecipientRole.SIGNER]: {
|
||||
actionVerb: 'Sign',
|
||||
progressiveVerb: 'Signing',
|
||||
roleName: 'Signer',
|
||||
},
|
||||
[RecipientRole.VIEWER]: {
|
||||
actionVerb: 'View',
|
||||
progressiveVerb: 'Viewing',
|
||||
roleName: 'Viewer',
|
||||
},
|
||||
};
|
||||
103
packages/lib/constants/teams.ts
Normal file
103
packages/lib/constants/teams.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
export const TEAM_URL_ROOT_REGEX = new RegExp('^/t/[^/]+$');
|
||||
export const TEAM_URL_REGEX = new RegExp('^/t/[^/]+');
|
||||
|
||||
export const TEAM_MEMBER_ROLE_MAP: Record<keyof typeof TeamMemberRole, string> = {
|
||||
ADMIN: 'Admin',
|
||||
MANAGER: 'Manager',
|
||||
MEMBER: 'Member',
|
||||
};
|
||||
|
||||
export const TEAM_MEMBER_ROLE_PERMISSIONS_MAP = {
|
||||
/**
|
||||
* Includes permissions to:
|
||||
* - Manage team members
|
||||
* - Manage team settings, changing name, url, etc.
|
||||
*/
|
||||
MANAGE_TEAM: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
|
||||
MANAGE_BILLING: [TeamMemberRole.ADMIN],
|
||||
DELETE_TEAM_TRANSFER_REQUEST: [TeamMemberRole.ADMIN],
|
||||
} satisfies Record<string, TeamMemberRole[]>;
|
||||
|
||||
/**
|
||||
* A hierarchy of team member roles to determine which role has higher permission than another.
|
||||
*/
|
||||
export const TEAM_MEMBER_ROLE_HIERARCHY = {
|
||||
[TeamMemberRole.ADMIN]: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER, TeamMemberRole.MEMBER],
|
||||
[TeamMemberRole.MANAGER]: [TeamMemberRole.MANAGER, TeamMemberRole.MEMBER],
|
||||
[TeamMemberRole.MEMBER]: [TeamMemberRole.MEMBER],
|
||||
} satisfies Record<TeamMemberRole, TeamMemberRole[]>;
|
||||
|
||||
export const PROTECTED_TEAM_URLS = [
|
||||
'403',
|
||||
'404',
|
||||
'500',
|
||||
'502',
|
||||
'503',
|
||||
'504',
|
||||
'about',
|
||||
'account',
|
||||
'admin',
|
||||
'administrator',
|
||||
'api',
|
||||
'app',
|
||||
'archive',
|
||||
'auth',
|
||||
'backup',
|
||||
'config',
|
||||
'configure',
|
||||
'contact',
|
||||
'contact-us',
|
||||
'copyright',
|
||||
'crime',
|
||||
'criminal',
|
||||
'dashboard',
|
||||
'docs',
|
||||
'documenso',
|
||||
'documentation',
|
||||
'document',
|
||||
'documents',
|
||||
'error',
|
||||
'exploit',
|
||||
'exploitation',
|
||||
'exploiter',
|
||||
'feedback',
|
||||
'finance',
|
||||
'forgot-password',
|
||||
'fraud',
|
||||
'fraudulent',
|
||||
'hack',
|
||||
'hacker',
|
||||
'harassment',
|
||||
'help',
|
||||
'helpdesk',
|
||||
'illegal',
|
||||
'internal',
|
||||
'legal',
|
||||
'login',
|
||||
'logout',
|
||||
'maintenance',
|
||||
'malware',
|
||||
'newsletter',
|
||||
'policy',
|
||||
'privacy',
|
||||
'profile',
|
||||
'public',
|
||||
'reset-password',
|
||||
'scam',
|
||||
'scammer',
|
||||
'settings',
|
||||
'setup',
|
||||
'sign',
|
||||
'signin',
|
||||
'signout',
|
||||
'signup',
|
||||
'spam',
|
||||
'support',
|
||||
'system',
|
||||
'team',
|
||||
'terms',
|
||||
'virus',
|
||||
'webhook',
|
||||
];
|
||||
44
packages/lib/constants/time-zones.ts
Normal file
44
packages/lib/constants/time-zones.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { rawTimeZones, timeZonesNames } from '@vvo/tzdb';
|
||||
|
||||
export const TIME_ZONE_DATA = rawTimeZones;
|
||||
|
||||
export const DEFAULT_DOCUMENT_TIME_ZONE = 'Etc/UTC';
|
||||
|
||||
export type TimeZone = {
|
||||
name: string;
|
||||
rawOffsetInMinutes: number;
|
||||
};
|
||||
|
||||
export const minutesToHours = (minutes: number): string => {
|
||||
const hours = Math.abs(Math.floor(minutes / 60));
|
||||
const min = Math.abs(minutes % 60);
|
||||
const sign = minutes >= 0 ? '+' : '-';
|
||||
|
||||
return `${sign}${String(hours).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getGMTOffsets = (timezones: TimeZone[]): string[] => {
|
||||
const gmtOffsets: string[] = [];
|
||||
|
||||
for (const timezone of timezones) {
|
||||
const offsetValue = minutesToHours(timezone.rawOffsetInMinutes);
|
||||
const gmtText = `(${offsetValue})`;
|
||||
|
||||
gmtOffsets.push(`${timezone.name} ${gmtText}`);
|
||||
}
|
||||
|
||||
return gmtOffsets;
|
||||
};
|
||||
|
||||
export const splitTimeZone = (input: string | null): string => {
|
||||
if (input === null) {
|
||||
return '';
|
||||
}
|
||||
const [timeZone] = input.split('(');
|
||||
|
||||
return timeZone.trim();
|
||||
};
|
||||
|
||||
export const TIME_ZONES_FULL = getGMTOffsets(TIME_ZONE_DATA);
|
||||
|
||||
export const TIME_ZONES = ['Etc/UTC', ...timeZonesNames];
|
||||
144
packages/lib/errors/app-error.ts
Normal file
144
packages/lib/errors/app-error.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
|
||||
/**
|
||||
* Generic application error codes.
|
||||
*/
|
||||
export enum AppErrorCode {
|
||||
'ALREADY_EXISTS' = 'AlreadyExists',
|
||||
'EXPIRED_CODE' = 'ExpiredCode',
|
||||
'INVALID_BODY' = 'InvalidBody',
|
||||
'INVALID_REQUEST' = 'InvalidRequest',
|
||||
'NOT_FOUND' = 'NotFound',
|
||||
'NOT_SETUP' = 'NotSetup',
|
||||
'UNAUTHORIZED' = 'Unauthorized',
|
||||
'UNKNOWN_ERROR' = 'UnknownError',
|
||||
'RETRY_EXCEPTION' = 'RetryException',
|
||||
'SCHEMA_FAILED' = 'SchemaFailed',
|
||||
'TOO_MANY_REQUESTS' = 'TooManyRequests',
|
||||
}
|
||||
|
||||
const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
|
||||
[AppErrorCode.ALREADY_EXISTS]: 'BAD_REQUEST',
|
||||
[AppErrorCode.EXPIRED_CODE]: 'BAD_REQUEST',
|
||||
[AppErrorCode.INVALID_BODY]: 'BAD_REQUEST',
|
||||
[AppErrorCode.INVALID_REQUEST]: 'BAD_REQUEST',
|
||||
[AppErrorCode.NOT_FOUND]: 'NOT_FOUND',
|
||||
[AppErrorCode.NOT_SETUP]: 'BAD_REQUEST',
|
||||
[AppErrorCode.UNAUTHORIZED]: 'UNAUTHORIZED',
|
||||
[AppErrorCode.UNKNOWN_ERROR]: 'INTERNAL_SERVER_ERROR',
|
||||
[AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR',
|
||||
[AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR',
|
||||
[AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS',
|
||||
};
|
||||
|
||||
export const ZAppErrorJsonSchema = z.object({
|
||||
code: z.string(),
|
||||
message: z.string().optional(),
|
||||
userMessage: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TAppErrorJsonSchema = z.infer<typeof ZAppErrorJsonSchema>;
|
||||
|
||||
export class AppError extends Error {
|
||||
/**
|
||||
* The error code.
|
||||
*/
|
||||
code: string;
|
||||
|
||||
/**
|
||||
* An error message which can be displayed to the user.
|
||||
*/
|
||||
userMessage?: string;
|
||||
|
||||
/**
|
||||
* Create a new AppError.
|
||||
*
|
||||
* @param errorCode A string representing the error code.
|
||||
* @param message An internal error message.
|
||||
* @param userMessage A error message which can be displayed to the user.
|
||||
*/
|
||||
public constructor(errorCode: string, message?: string, userMessage?: string) {
|
||||
super(message || errorCode);
|
||||
this.code = errorCode;
|
||||
this.userMessage = userMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an unknown value into an AppError.
|
||||
*
|
||||
* @param error An unknown type.
|
||||
*/
|
||||
static parseError(error: unknown): AppError {
|
||||
if (error instanceof AppError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Handle TRPC errors.
|
||||
if (error instanceof TRPCClientError) {
|
||||
const parsedJsonError = AppError.parseFromJSONString(error.message);
|
||||
return parsedJsonError || new AppError('UnknownError', error.message);
|
||||
}
|
||||
|
||||
// Handle completely unknown errors.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const { code, message, userMessage } = error as {
|
||||
code: unknown;
|
||||
message: unknown;
|
||||
status: unknown;
|
||||
userMessage: unknown;
|
||||
};
|
||||
|
||||
const validCode: string | null = typeof code === 'string' ? code : AppErrorCode.UNKNOWN_ERROR;
|
||||
const validMessage: string | undefined = typeof message === 'string' ? message : undefined;
|
||||
const validUserMessage: string | undefined =
|
||||
typeof userMessage === 'string' ? userMessage : undefined;
|
||||
|
||||
return new AppError(validCode, validMessage, validUserMessage);
|
||||
}
|
||||
|
||||
static parseErrorToTRPCError(error: unknown): TRPCError {
|
||||
const appError = AppError.parseError(error);
|
||||
|
||||
return new TRPCError({
|
||||
code: genericErrorCodeToTrpcErrorCodeMap[appError.code] || 'BAD_REQUEST',
|
||||
message: AppError.toJSONString(appError),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an AppError into a JSON object which represents the error.
|
||||
*
|
||||
* @param appError The AppError to convert to JSON.
|
||||
* @returns A JSON object representing the AppError.
|
||||
*/
|
||||
static toJSON({ code, message, userMessage }: AppError): TAppErrorJsonSchema {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
userMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an AppError into a JSON string containing the relevant information.
|
||||
*
|
||||
* @param appError The AppError to stringify.
|
||||
* @returns A JSON string representing the AppError.
|
||||
*/
|
||||
static toJSONString(appError: AppError): string {
|
||||
return JSON.stringify(AppError.toJSON(appError));
|
||||
}
|
||||
|
||||
static parseFromJSONString(jsonString: string): AppError | null {
|
||||
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
|
||||
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
|
||||
}
|
||||
}
|
||||
@ -9,10 +9,12 @@ import type { GoogleProfile } from 'next-auth/providers/google';
|
||||
import GoogleProvider from 'next-auth/providers/google';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
|
||||
import { ErrorCode } from './error-codes';
|
||||
|
||||
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
@ -34,7 +36,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
},
|
||||
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
|
||||
},
|
||||
authorize: async (credentials, _req) => {
|
||||
authorize: async (credentials, req) => {
|
||||
if (!credentials) {
|
||||
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
||||
}
|
||||
@ -50,8 +52,18 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
}
|
||||
|
||||
const isPasswordsSame = await compare(password, user.password);
|
||||
const requestMetadata = extractNextAuthRequestMetadata(req);
|
||||
|
||||
if (!isPasswordsSame) {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
userAgent: requestMetadata.userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN_FAIL,
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||
}
|
||||
|
||||
@ -61,6 +73,15 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
|
||||
|
||||
if (!isValid) {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
userAgent: requestMetadata.userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL,
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
totpCode
|
||||
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
|
||||
@ -93,7 +114,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
async jwt({ token, user, trigger, account }) {
|
||||
const merged = {
|
||||
...token,
|
||||
...user,
|
||||
@ -138,6 +159,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
merged.emailVerified = user.emailVerified?.toISOString() ?? null;
|
||||
}
|
||||
|
||||
if ((trigger === 'signIn' || trigger === 'signUp') && account?.provider === 'google') {
|
||||
merged.emailVerified = user?.emailVerified
|
||||
? new Date(user.emailVerified).toISOString()
|
||||
: new Date().toISOString();
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: Number(merged.id),
|
||||
},
|
||||
data: {
|
||||
emailVerified: merged.emailVerified,
|
||||
identityProvider: IdentityProvider.GOOGLE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: merged.id,
|
||||
name: merged.name,
|
||||
@ -175,4 +212,5 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
// Note: `events` are handled in `apps/web/src/pages/api/auth/[...nextauth].ts` to allow access to the request.
|
||||
};
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
"@scure/base": "^1.1.3",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@upstash/redis": "^1.20.6",
|
||||
"@vvo/tzdb": "^6.117.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"luxon": "^3.4.0",
|
||||
"nanoid": "^4.0.2",
|
||||
|
||||
@ -1,21 +1,25 @@
|
||||
import { compare } from 'bcrypt';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { ErrorCode } from '../../next-auth/error-codes';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||
|
||||
type DisableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
backupCode: string;
|
||||
password: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const disableTwoFactorAuthentication = async ({
|
||||
backupCode,
|
||||
user,
|
||||
password,
|
||||
requestMetadata,
|
||||
}: DisableTwoFactorAuthenticationOptions) => {
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||
@ -33,15 +37,26 @@ export const disableTwoFactorAuthentication = async ({
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorBackupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorBackupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: UserSecurityAuditLogType.AUTH_2FA_DISABLE,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
@ -1,18 +1,21 @@
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||
|
||||
type EnableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
code: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const enableTwoFactorAuthentication = async ({
|
||||
user,
|
||||
code,
|
||||
requestMetadata,
|
||||
}: EnableTwoFactorAuthenticationOptions) => {
|
||||
if (user.identityProvider !== 'DOCUMENSO') {
|
||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||
@ -32,13 +35,24 @@ export const enableTwoFactorAuthentication = async ({
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
const updatedUser = await prisma.$transaction(async (tx) => {
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: UserSecurityAuditLogType.AUTH_2FA_ENABLE,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
||||
|
||||
@ -5,7 +5,7 @@ import { createTOTPKeyURI } from 'oslo/otp';
|
||||
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import { type User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricEncrypt } from '../../universal/crypto';
|
||||
|
||||
@ -19,9 +19,11 @@ export const getRecipientsStats = async () => {
|
||||
|
||||
results.forEach((result) => {
|
||||
const { readStatus, signingStatus, sendStatus, _count } = result;
|
||||
|
||||
stats[readStatus] += _count;
|
||||
stats[signingStatus] += _count;
|
||||
stats[sendStatus] += _count;
|
||||
|
||||
stats.TOTAL_RECIPIENTS += _count;
|
||||
});
|
||||
|
||||
|
||||
37
packages/lib/server-only/crypto/decrypt.ts
Normal file
37
packages/lib/server-only/crypto/decrypt.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { ZEncryptedDataSchema } from '@documenso/lib/server-only/crypto/encrypt';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
|
||||
/**
|
||||
* Decrypt the passed in data. This uses the secondary encrypt key for miscellaneous data.
|
||||
*
|
||||
* @param encryptedData The data encrypted with the `encryptSecondaryData` function.
|
||||
* @returns The decrypted value, or `null` if the data is invalid or expired.
|
||||
*/
|
||||
export const decryptSecondaryData = (encryptedData: string): string | null => {
|
||||
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||
throw new Error('Missing encryption key');
|
||||
}
|
||||
|
||||
try {
|
||||
const decryptedBufferValue = symmetricDecrypt({
|
||||
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
|
||||
data: encryptedData,
|
||||
});
|
||||
|
||||
const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8');
|
||||
const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue));
|
||||
|
||||
if (!result.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
42
packages/lib/server-only/crypto/encrypt.ts
Normal file
42
packages/lib/server-only/crypto/encrypt.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||
import type { TEncryptSecondaryDataMutationSchema } from '@documenso/trpc/server/crypto/schema';
|
||||
|
||||
export const ZEncryptedDataSchema = z.object({
|
||||
data: z.string(),
|
||||
expiresAt: z.number().optional(),
|
||||
});
|
||||
|
||||
export type EncryptDataOptions = {
|
||||
data: string;
|
||||
|
||||
/**
|
||||
* When the data should no longer be allowed to be decrypted.
|
||||
*
|
||||
* Leave this empty to never expire the data.
|
||||
*/
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encrypt the passed in data. This uses the secondary encrypt key for miscellaneous data.
|
||||
*
|
||||
* @returns The encrypted data.
|
||||
*/
|
||||
export const encryptSecondaryData = ({ data, expiresAt }: TEncryptSecondaryDataMutationSchema) => {
|
||||
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||
throw new Error('Missing encryption key');
|
||||
}
|
||||
|
||||
const dataToEncrypt: z.infer<typeof ZEncryptedDataSchema> = {
|
||||
data,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
return symmetricEncrypt({
|
||||
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
|
||||
data: JSON.stringify(dataToEncrypt),
|
||||
});
|
||||
};
|
||||
@ -4,15 +4,43 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type CreateDocumentMetaOptions = {
|
||||
documentId: number;
|
||||
subject: string;
|
||||
message: string;
|
||||
subject?: string;
|
||||
message?: string;
|
||||
timezone?: string;
|
||||
password?: string;
|
||||
dateFormat?: string;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const upsertDocumentMeta = async ({
|
||||
subject,
|
||||
message,
|
||||
timezone,
|
||||
dateFormat,
|
||||
documentId,
|
||||
userId,
|
||||
password,
|
||||
}: CreateDocumentMetaOptions) => {
|
||||
await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return await prisma.documentMeta.upsert({
|
||||
where: {
|
||||
documentId,
|
||||
@ -20,11 +48,17 @@ export const upsertDocumentMeta = async ({
|
||||
create: {
|
||||
subject,
|
||||
message,
|
||||
dateFormat,
|
||||
timezone,
|
||||
password,
|
||||
documentId,
|
||||
},
|
||||
update: {
|
||||
subject,
|
||||
message,
|
||||
dateFormat,
|
||||
password,
|
||||
timezone,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -5,15 +5,37 @@ import { prisma } from '@documenso/prisma';
|
||||
export type CreateDocumentOptions = {
|
||||
title: string;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
documentDataId: string;
|
||||
};
|
||||
|
||||
export const createDocument = async ({ userId, title, documentDataId }: CreateDocumentOptions) => {
|
||||
return await prisma.document.create({
|
||||
data: {
|
||||
title,
|
||||
documentDataId,
|
||||
userId,
|
||||
},
|
||||
export const createDocument = async ({
|
||||
userId,
|
||||
title,
|
||||
documentDataId,
|
||||
teamId,
|
||||
}: CreateDocumentOptions) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
if (teamId !== undefined) {
|
||||
await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.document.create({
|
||||
data: {
|
||||
title,
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,16 +1,27 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export interface DuplicateDocumentByIdOptions {
|
||||
id: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByIdOptions) => {
|
||||
export const duplicateDocumentById = async ({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
}: DuplicateDocumentByIdOptions) => {
|
||||
const documentWhereInput = await getDocumentWhereInput({
|
||||
documentId: id,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findUniqueOrThrow({
|
||||
where: {
|
||||
id,
|
||||
userId: userId,
|
||||
},
|
||||
where: documentWhereInput,
|
||||
select: {
|
||||
title: true,
|
||||
userId: true,
|
||||
@ -25,12 +36,15 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI
|
||||
select: {
|
||||
message: true,
|
||||
subject: true,
|
||||
dateFormat: true,
|
||||
password: true,
|
||||
timezone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdDocument = await prisma.document.create({
|
||||
const createDocumentArguments: Prisma.DocumentCreateArgs = {
|
||||
data: {
|
||||
title: document.title,
|
||||
User: {
|
||||
@ -50,7 +64,17 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (teamId !== undefined) {
|
||||
createDocumentArguments.data.team = {
|
||||
connect: {
|
||||
id: teamId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const createdDocument = await prisma.document.create(createDocumentArguments);
|
||||
|
||||
return createdDocument.id;
|
||||
};
|
||||
|
||||
@ -2,14 +2,18 @@ import { DateTime } from 'luxon';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Document, Prisma } from '@documenso/prisma/client';
|
||||
import { SigningStatus } from '@documenso/prisma/client';
|
||||
import { RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Document, Prisma, Team, TeamEmail, User } from '@documenso/prisma/client';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
import type { FindResultSet } from '../../types/find-result-set';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
|
||||
export interface FindDocumentsOptions {
|
||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||
|
||||
export type FindDocumentsOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
term?: string;
|
||||
status?: ExtendedDocumentStatus;
|
||||
page?: number;
|
||||
@ -18,22 +22,50 @@ export interface FindDocumentsOptions {
|
||||
column: keyof Omit<Document, 'document'>;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
period?: '' | '7d' | '14d' | '30d';
|
||||
}
|
||||
period?: PeriodSelectorValue;
|
||||
senderIds?: number[];
|
||||
};
|
||||
|
||||
export const findDocuments = async ({
|
||||
userId,
|
||||
teamId,
|
||||
term,
|
||||
status = ExtendedDocumentStatus.ALL,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
period,
|
||||
senderIds,
|
||||
}: FindDocumentsOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
const { user, team } = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
let team = null;
|
||||
|
||||
if (teamId !== undefined) {
|
||||
team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
teamEmail: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
team,
|
||||
};
|
||||
});
|
||||
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
@ -50,90 +82,34 @@ export const findDocuments = async ({
|
||||
})
|
||||
.otherwise(() => undefined);
|
||||
|
||||
const filters = match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.INBOX, () => ({
|
||||
status: {
|
||||
not: ExtendedDocumentStatus.DRAFT,
|
||||
},
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||
userId,
|
||||
status: ExtendedDocumentStatus.DRAFT,
|
||||
deletedAt: null,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.PENDING, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
.exhaustive();
|
||||
const filters = team ? findTeamDocumentsFilter(status, team) : findDocumentsFilter(status, user);
|
||||
|
||||
const whereClause = {
|
||||
if (filters === null) {
|
||||
return {
|
||||
data: [],
|
||||
count: 0,
|
||||
currentPage: 1,
|
||||
perPage,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const whereClause: Prisma.DocumentWhereInput = {
|
||||
...termFilters,
|
||||
...filters,
|
||||
AND: {
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
},
|
||||
{
|
||||
status: {
|
||||
not: ExtendedDocumentStatus.COMPLETED,
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
if (period) {
|
||||
@ -146,6 +122,12 @@ export const findDocuments = async ({
|
||||
};
|
||||
}
|
||||
|
||||
if (senderIds && senderIds.length > 0) {
|
||||
whereClause.userId = {
|
||||
in: senderIds,
|
||||
};
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.document.findMany({
|
||||
where: whereClause,
|
||||
@ -163,21 +145,296 @@ export const findDocuments = async ({
|
||||
},
|
||||
},
|
||||
Recipient: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.document.count({
|
||||
where: {
|
||||
...termFilters,
|
||||
...filters,
|
||||
},
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
const maskedData = data.map((document) =>
|
||||
maskRecipientTokensForDocument({
|
||||
document,
|
||||
user,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
data: maskedData,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultSet<typeof data>;
|
||||
};
|
||||
|
||||
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.INBOX, () => ({
|
||||
status: {
|
||||
not: ExtendedDocumentStatus.DRAFT,
|
||||
},
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.DRAFT,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.PENDING, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Prisma filter for the Document schema to find documents for a team.
|
||||
*
|
||||
* Status All:
|
||||
* - Documents that belong to the team
|
||||
* - Documents that have been sent by the team email
|
||||
* - Non draft documents that have been sent to the team email
|
||||
*
|
||||
* Status Inbox:
|
||||
* - Non draft documents that have been sent to the team email that have not been signed
|
||||
*
|
||||
* Status Draft:
|
||||
* - Documents that belong to the team that are draft
|
||||
* - Documents that belong to the team email that are draft
|
||||
*
|
||||
* Status Pending:
|
||||
* - Documents that belong to the team that are pending
|
||||
* - Documents that have been sent by the team email that is pending to be signed by someone else
|
||||
* - Documents that have been sent to the team email that is pending to be signed by someone else
|
||||
*
|
||||
* Status Completed:
|
||||
* - Documents that belong to the team that are completed
|
||||
* - Documents that have been sent to the team email that are completed
|
||||
* - Documents that have been sent by the team email that are completed
|
||||
*
|
||||
* @param status The status of the documents to find.
|
||||
* @param team The team to find the documents for.
|
||||
* @returns A filter which can be applied to the Prisma Document schema.
|
||||
*/
|
||||
const findTeamDocumentsFilter = (
|
||||
status: ExtendedDocumentStatus,
|
||||
team: Team & { teamEmail: TeamEmail | null },
|
||||
) => {
|
||||
const teamEmail = team.teamEmail?.email ?? null;
|
||||
|
||||
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput | null>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
// Filter to display all documents that belong to the team.
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (teamEmail && filter.OR) {
|
||||
// Filter to display all documents received by the team email that are not draft.
|
||||
filter.OR.push({
|
||||
status: {
|
||||
not: ExtendedDocumentStatus.DRAFT,
|
||||
},
|
||||
Recipient: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Filter to display all documents that have been sent by the team email.
|
||||
filter.OR.push({
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return filter;
|
||||
})
|
||||
.with(ExtendedDocumentStatus.INBOX, () => {
|
||||
// Return a filter that will return nothing.
|
||||
if (!teamEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
status: {
|
||||
not: ExtendedDocumentStatus.DRAFT,
|
||||
},
|
||||
Recipient: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
})
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
status: ExtendedDocumentStatus.DRAFT,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (teamEmail && filter.OR) {
|
||||
filter.OR.push({
|
||||
status: ExtendedDocumentStatus.DRAFT,
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return filter;
|
||||
})
|
||||
.with(ExtendedDocumentStatus.PENDING, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (teamEmail && filter.OR) {
|
||||
filter.OR.push({
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
OR: [
|
||||
{
|
||||
Recipient: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return filter;
|
||||
})
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (teamEmail && filter.OR) {
|
||||
filter.OR.push(
|
||||
{
|
||||
Recipient: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return filter;
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@ -1,19 +1,106 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
export interface GetDocumentByIdOptions {
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type GetDocumentByIdOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
}
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOptions) => {
|
||||
const documentWhereInput = await getDocumentWhereInput({
|
||||
documentId: id,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) => {
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
},
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export type GetDocumentWhereInputOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
|
||||
/**
|
||||
* Whether to return a filter that allows access to both the user and team documents.
|
||||
* This only applies if `teamId` is passed in.
|
||||
*
|
||||
* If true, and `teamId` is passed in, the filter will allow both team and user documents.
|
||||
* If false, and `teamId` is passed in, the filter will only allow team documents.
|
||||
*
|
||||
* Defaults to false.
|
||||
*/
|
||||
overlapUserTeamScope?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the where input for a given Prisma document query.
|
||||
*
|
||||
* This will return a query that allows a user to get a document if they have valid access to it.
|
||||
*/
|
||||
export const getDocumentWhereInput = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
overlapUserTeamScope = false,
|
||||
}: GetDocumentWhereInputOptions) => {
|
||||
const documentWhereInput: Prisma.DocumentWhereUniqueInput = {
|
||||
id: documentId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (teamId === undefined || !documentWhereInput.OR) {
|
||||
return documentWhereInput;
|
||||
}
|
||||
|
||||
const team = await getTeamById({ teamId, userId });
|
||||
|
||||
// Allow access to team and user documents.
|
||||
if (overlapUserTeamScope) {
|
||||
documentWhereInput.OR.push({
|
||||
teamId: team.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Allow access to only team documents.
|
||||
if (!overlapUserTeamScope) {
|
||||
documentWhereInput.OR = [
|
||||
{
|
||||
teamId: team.id,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Allow access to documents sent to or from the team email.
|
||||
if (team.teamEmail) {
|
||||
documentWhereInput.OR.push(
|
||||
{
|
||||
Recipient: {
|
||||
some: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
User: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return documentWhereInput;
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
|
||||
export interface GetDocumentAndSenderByTokenOptions {
|
||||
token: string;
|
||||
@ -58,7 +58,11 @@ export const getDocumentAndRecipientByToken = async ({
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
Recipient: {
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface GetDocumentMetaByDocumentIdOptions {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export const getDocumentMetaByDocumentId = async ({ id }: GetDocumentMetaByDocumentIdOptions) => {
|
||||
return await prisma.documentMeta.findFirstOrThrow({
|
||||
where: {
|
||||
documentId: id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,71 +1,34 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import type { Prisma, User } from '@documenso/prisma/client';
|
||||
import { SigningStatus } from '@documenso/prisma/client';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
export type GetStatsInput = {
|
||||
user: User;
|
||||
team?: Omit<GetTeamCountsOption, 'createdAt'>;
|
||||
period?: PeriodSelectorValue;
|
||||
};
|
||||
|
||||
export const getStats = async ({ user }: GetStatsInput) => {
|
||||
const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
|
||||
let createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
createdAt = {
|
||||
gte: startOfPeriod.toJSDate(),
|
||||
};
|
||||
}
|
||||
|
||||
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
|
||||
? getTeamCounts({ ...options.team, createdAt })
|
||||
: getCounts({ user, createdAt }));
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
@ -101,3 +64,189 @@ export const getStats = async ({ user }: GetStatsInput) => {
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
type GetCountsOption = {
|
||||
user: User;
|
||||
createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
};
|
||||
|
||||
const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
||||
return Promise.all([
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
createdAt,
|
||||
teamId: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
},
|
||||
createdAt,
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
createdAt,
|
||||
User: {
|
||||
email: {
|
||||
not: user.email,
|
||||
},
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
type GetTeamCountsOption = {
|
||||
teamId: number;
|
||||
teamEmail?: string;
|
||||
senderIds?: number[];
|
||||
createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
};
|
||||
|
||||
const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
const { createdAt, teamId, teamEmail } = options;
|
||||
|
||||
const senderIds = options.senderIds ?? [];
|
||||
|
||||
const userIdWhereClause: Prisma.DocumentWhereInput['userId'] =
|
||||
senderIds.length > 0
|
||||
? {
|
||||
in: senderIds,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let ownerCountsWhereInput: Prisma.DocumentWhereInput = {
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
teamId,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
let notSignedCountsGroupByArgs = null;
|
||||
let hasSignedCountsGroupByArgs = null;
|
||||
|
||||
if (teamEmail) {
|
||||
ownerCountsWhereInput = {
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
OR: [
|
||||
{
|
||||
teamId,
|
||||
},
|
||||
{
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
],
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
notSignedCountsGroupByArgs = {
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
} satisfies Prisma.DocumentGroupByArgs;
|
||||
|
||||
hasSignedCountsGroupByArgs = {
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Prisma.DocumentGroupByArgs;
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
prisma.document.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: ownerCountsWhereInput,
|
||||
}),
|
||||
notSignedCountsGroupByArgs ? prisma.document.groupBy(notSignedCountsGroupByArgs) : [],
|
||||
hasSignedCountsGroupByArgs ? prisma.document.groupBy(hasSignedCountsGroupByArgs) : [],
|
||||
]);
|
||||
};
|
||||
|
||||
@ -6,26 +6,39 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export type ResendDocumentOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
recipients: number[];
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const resendDocument = async ({ documentId, userId, recipients }: ResendDocumentOptions) => {
|
||||
export const resendDocument = async ({
|
||||
documentId,
|
||||
userId,
|
||||
recipients,
|
||||
teamId,
|
||||
}: ResendDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const documentWhereInput: Prisma.DocumentWhereUniqueInput = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
},
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
Recipient: {
|
||||
where: {
|
||||
@ -59,6 +72,10 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
||||
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
if (recipient.role === RecipientRole.CC) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { email, name } = recipient;
|
||||
|
||||
const customEmailTemplate = {
|
||||
@ -77,8 +94,11 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||
role: recipient.role,
|
||||
});
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
@ -90,7 +110,7 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: 'Please sign this document',
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
@ -6,7 +6,7 @@ import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import { signPdf } from '@documenso/signing';
|
||||
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
@ -44,6 +44,9 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
|
||||
export type SearchDocumentsWithKeywordOptions = {
|
||||
query: string;
|
||||
userId: number;
|
||||
@ -77,5 +79,12 @@ export const searchDocumentsWithKeyword = async ({
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return documents;
|
||||
const maskedDocuments = documents.map((document) =>
|
||||
maskRecipientTokensForDocument({
|
||||
document,
|
||||
user,
|
||||
}),
|
||||
);
|
||||
|
||||
return maskedDocuments;
|
||||
};
|
||||
|
||||
@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
|
||||
|
||||
export type SendDocumentOptions = {
|
||||
documentId: number;
|
||||
@ -23,7 +25,20 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
@ -47,6 +62,10 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { email, name } = recipient;
|
||||
|
||||
const customEmailTemplate = {
|
||||
@ -55,10 +74,6 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
'document.name': document.title,
|
||||
};
|
||||
|
||||
if (recipient.sendStatus === SendStatus.SENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
|
||||
|
||||
@ -69,8 +84,11 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||
role: recipient.role,
|
||||
});
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
@ -82,7 +100,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: 'Please sign this document',
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
'use server';
|
||||
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type UpdateDocumentOptions = {
|
||||
documentId: number;
|
||||
data: Prisma.DocumentUpdateInput;
|
||||
userId: number;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const updateDocument = async ({ documentId, data }: UpdateDocumentOptions) => {
|
||||
export const updateDocument = async ({ documentId, userId, data }: UpdateDocumentOptions) => {
|
||||
return await prisma.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
|
||||
@ -12,7 +12,20 @@ export const updateTitle = async ({ userId, documentId, title }: UpdateTitleOpti
|
||||
return await prisma.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
data: {
|
||||
title,
|
||||
|
||||
@ -10,7 +10,20 @@ export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForD
|
||||
where: {
|
||||
documentId,
|
||||
Document: {
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
|
||||
35
packages/lib/server-only/field/get-fields-for-template.ts
Normal file
35
packages/lib/server-only/field/get-fields-for-template.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface GetFieldsForTemplateOptions {
|
||||
templateId: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForTemplateOptions) => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: {
|
||||
templateId,
|
||||
Template: {
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return fields;
|
||||
};
|
||||
@ -27,6 +27,10 @@ export const removeSignedFieldWithToken = async ({
|
||||
|
||||
const { Document: document, Recipient: recipient } = field;
|
||||
|
||||
if (!document) {
|
||||
throw new Error(`Document not found for field ${field.id}`);
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
throw new Error(`Document ${document.id} has already been completed`);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { FieldType } from '@documenso/prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export interface SetFieldsForDocumentOptions {
|
||||
userId: number;
|
||||
@ -24,7 +25,20 @@ export const setFieldsForDocument = async ({
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@ -32,6 +46,10 @@ export const setFieldsForDocument = async ({
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (document.completedAt) {
|
||||
throw new Error('Document already complete');
|
||||
}
|
||||
|
||||
const existingFields = await prisma.field.findMany({
|
||||
where: {
|
||||
documentId,
|
||||
@ -42,11 +60,7 @@ export const setFieldsForDocument = async ({
|
||||
});
|
||||
|
||||
const removedFields = existingFields.filter(
|
||||
(existingField) =>
|
||||
!fields.find(
|
||||
(field) =>
|
||||
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
|
||||
),
|
||||
(existingField) => !fields.find((field) => field.id === existingField.id),
|
||||
);
|
||||
|
||||
const linkedFields = fields
|
||||
|
||||
131
packages/lib/server-only/field/set-fields-for-template.ts
Normal file
131
packages/lib/server-only/field/set-fields-for-template.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export type Field = {
|
||||
id?: number | null;
|
||||
type: FieldType;
|
||||
signerEmail: string;
|
||||
signerId?: number;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
};
|
||||
|
||||
export type SetFieldsForTemplateOptions = {
|
||||
userId: number;
|
||||
templateId: number;
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
export const setFieldsForTemplate = async ({
|
||||
userId,
|
||||
templateId,
|
||||
fields,
|
||||
}: SetFieldsForTemplateOptions) => {
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const existingFields = await prisma.field.findMany({
|
||||
where: {
|
||||
templateId,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
const removedFields = existingFields.filter(
|
||||
(existingField) =>
|
||||
!fields.find(
|
||||
(field) =>
|
||||
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
|
||||
),
|
||||
);
|
||||
|
||||
const linkedFields = fields.map((field) => {
|
||||
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
||||
|
||||
return {
|
||||
...field,
|
||||
_persisted: existing,
|
||||
};
|
||||
});
|
||||
|
||||
const persistedFields = await prisma.$transaction(
|
||||
// Disabling as wrapping promises here causes type issues
|
||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||
linkedFields.map((field) =>
|
||||
prisma.field.upsert({
|
||||
where: {
|
||||
id: field._persisted?.id ?? -1,
|
||||
templateId,
|
||||
},
|
||||
update: {
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.pageWidth,
|
||||
height: field.pageHeight,
|
||||
},
|
||||
create: {
|
||||
type: field.type,
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.pageWidth,
|
||||
height: field.pageHeight,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
Template: {
|
||||
connect: {
|
||||
id: templateId,
|
||||
},
|
||||
},
|
||||
Recipient: {
|
||||
connect: {
|
||||
templateId_email: {
|
||||
templateId,
|
||||
email: field.signerEmail.toLowerCase(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (removedFields.length > 0) {
|
||||
await prisma.field.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: removedFields.map((field) => field.id),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return persistedFields;
|
||||
};
|
||||
@ -5,6 +5,9 @@ import { DateTime } from 'luxon';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
||||
|
||||
export type SignFieldWithTokenOptions = {
|
||||
token: string;
|
||||
fieldId: number;
|
||||
@ -33,6 +36,10 @@ export const signFieldWithToken = async ({
|
||||
|
||||
const { Document: document, Recipient: recipient } = field;
|
||||
|
||||
if (!document) {
|
||||
throw new Error(`Document not found for field ${field.id}`);
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
throw new Error(`Document ${document.id} has already been completed`);
|
||||
}
|
||||
@ -54,6 +61,12 @@ export const signFieldWithToken = async ({
|
||||
throw new Error(`Field ${fieldId} has no recipientId`);
|
||||
}
|
||||
|
||||
const documentMeta = await prisma.documentMeta.findFirst({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
const isSignatureField =
|
||||
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
|
||||
|
||||
@ -63,7 +76,9 @@ export const signFieldWithToken = async ({
|
||||
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
|
||||
|
||||
if (field.type === FieldType.DATE) {
|
||||
customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a');
|
||||
customText = DateTime.now()
|
||||
.setZone(documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
|
||||
.toFormat(documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
|
||||
}
|
||||
|
||||
if (isSignatureField && !signatureImageAsBase64 && !typedSignature) {
|
||||
|
||||
@ -13,7 +13,20 @@ export const getRecipientsForDocument = async ({
|
||||
where: {
|
||||
documentId,
|
||||
Document: {
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface GetRecipientsForTemplateOptions {
|
||||
templateId: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const getRecipientsForTemplate = async ({
|
||||
templateId,
|
||||
userId,
|
||||
}: GetRecipientsForTemplateOptions) => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
templateId,
|
||||
Template: {
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return recipients;
|
||||
};
|
||||
@ -1,4 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { nanoid } from '../../universal/id';
|
||||
@ -10,6 +11,7 @@ export interface SetRecipientsForDocumentOptions {
|
||||
id?: number | null;
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
}[];
|
||||
}
|
||||
|
||||
@ -21,7 +23,20 @@ export const setRecipientsForDocument = async ({
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@ -29,6 +44,10 @@ export const setRecipientsForDocument = async ({
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (document.completedAt) {
|
||||
throw new Error('Document already complete');
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
email: recipient.email.toLowerCase(),
|
||||
@ -62,8 +81,9 @@ export const setRecipientsForDocument = async ({
|
||||
})
|
||||
.filter((recipient) => {
|
||||
return (
|
||||
recipient._persisted?.sendStatus !== SendStatus.SENT &&
|
||||
recipient._persisted?.signingStatus !== SigningStatus.SIGNED
|
||||
recipient._persisted?.role === RecipientRole.CC ||
|
||||
(recipient._persisted?.sendStatus !== SendStatus.SENT &&
|
||||
recipient._persisted?.signingStatus !== SigningStatus.SIGNED)
|
||||
);
|
||||
});
|
||||
|
||||
@ -79,13 +99,21 @@ export const setRecipientsForDocument = async ({
|
||||
update: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
documentId,
|
||||
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||
signingStatus:
|
||||
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
create: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
token: nanoid(),
|
||||
documentId,
|
||||
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||
signingStatus:
|
||||
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { nanoid } from '../../universal/id';
|
||||
|
||||
export type SetRecipientsForTemplateOptions = {
|
||||
userId: number;
|
||||
templateId: number;
|
||||
recipients: {
|
||||
id?: number;
|
||||
email: string;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const setRecipientsForTemplate = async ({
|
||||
userId,
|
||||
templateId,
|
||||
recipients,
|
||||
}: SetRecipientsForTemplateOptions) => {
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
email: recipient.email.toLowerCase(),
|
||||
}));
|
||||
|
||||
const existingRecipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
templateId,
|
||||
},
|
||||
});
|
||||
|
||||
const removedRecipients = existingRecipients.filter(
|
||||
(existingRecipient) =>
|
||||
!normalizedRecipients.find(
|
||||
(recipient) =>
|
||||
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
||||
),
|
||||
);
|
||||
|
||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
);
|
||||
|
||||
return {
|
||||
...recipient,
|
||||
_persisted: existing,
|
||||
};
|
||||
});
|
||||
|
||||
const persistedRecipients = await prisma.$transaction(
|
||||
// Disabling as wrapping promises here causes type issues
|
||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||
linkedRecipients.map((recipient) =>
|
||||
prisma.recipient.upsert({
|
||||
where: {
|
||||
id: recipient._persisted?.id ?? -1,
|
||||
templateId,
|
||||
},
|
||||
update: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
templateId,
|
||||
},
|
||||
create: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
token: nanoid(),
|
||||
templateId,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (removedRecipients.length > 0) {
|
||||
await prisma.recipient.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: removedRecipients.map((recipient) => recipient.id),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return persistedRecipients;
|
||||
};
|
||||
63
packages/lib/server-only/team/accept-team-invitation.ts
Normal file
63
packages/lib/server-only/team/accept-team-invitation.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
|
||||
export type AcceptTeamInvitationOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitationOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
|
||||
where: {
|
||||
teamId,
|
||||
email: user.email,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { team } = teamMemberInvite;
|
||||
|
||||
await tx.teamMember.create({
|
||||
data: {
|
||||
teamId: teamMemberInvite.teamId,
|
||||
userId: user.id,
|
||||
role: teamMemberInvite.role,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMemberInvite.delete({
|
||||
where: {
|
||||
id: teamMemberInvite.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId: teamMemberInvite.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
47
packages/lib/server-only/team/create-team-billing-portal.ts
Normal file
47
packages/lib/server-only/team/create-team-billing-portal.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type CreateTeamBillingPortalOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const createTeamBillingPortal = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: CreateTeamBillingPortalOptions) => {
|
||||
if (!IS_BILLING_ENABLED) {
|
||||
throw new Error('Billing is not enabled');
|
||||
}
|
||||
|
||||
const team = await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_BILLING'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team.subscription) {
|
||||
throw new Error('Team has no subscription');
|
||||
}
|
||||
|
||||
if (!team.customerId) {
|
||||
throw new Error('Team has no customerId');
|
||||
}
|
||||
|
||||
return getPortalSession({
|
||||
customerId: team.customerId,
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,52 @@
|
||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||
import { getTeamPrices } from '@documenso/ee/server-only/stripe/get-team-prices';
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type CreateTeamPendingCheckoutSession = {
|
||||
userId: number;
|
||||
pendingTeamId: number;
|
||||
interval: 'monthly' | 'yearly';
|
||||
};
|
||||
|
||||
export const createTeamPendingCheckoutSession = async ({
|
||||
userId,
|
||||
pendingTeamId,
|
||||
interval,
|
||||
}: CreateTeamPendingCheckoutSession) => {
|
||||
const teamPendingCreation = await prisma.teamPending.findFirstOrThrow({
|
||||
where: {
|
||||
id: pendingTeamId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
include: {
|
||||
owner: true,
|
||||
},
|
||||
});
|
||||
|
||||
const prices = await getTeamPrices();
|
||||
const priceId = prices[interval].priceId;
|
||||
|
||||
try {
|
||||
const stripeCheckoutSession = await getCheckoutSession({
|
||||
customerId: teamPendingCreation.customerId,
|
||||
priceId,
|
||||
returnUrl: `${WEBAPP_BASE_URL}/settings/teams`,
|
||||
subscriptionMetadata: {
|
||||
pendingTeamId: pendingTeamId.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!stripeCheckoutSession) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
|
||||
}
|
||||
|
||||
return stripeCheckoutSession;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
// Absorb all the errors incase Stripe throws something sensitive.
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, 'Something went wrong.');
|
||||
}
|
||||
};
|
||||
132
packages/lib/server-only/team/create-team-email-verification.ts
Normal file
132
packages/lib/server-only/team/create-team-email-verification.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
export type CreateTeamEmailVerificationOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
data: {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const createTeamEmailVerification = async ({
|
||||
userId,
|
||||
teamId,
|
||||
data,
|
||||
}: CreateTeamEmailVerificationOptions) => {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
teamEmail: true,
|
||||
emailVerification: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (team.teamEmail || team.emailVerification) {
|
||||
throw new AppError(
|
||||
AppErrorCode.INVALID_REQUEST,
|
||||
'Team already has an email or existing email verification.',
|
||||
);
|
||||
}
|
||||
|
||||
const existingTeamEmail = await tx.teamEmail.findFirst({
|
||||
where: {
|
||||
email: data.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTeamEmail) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
|
||||
}
|
||||
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
|
||||
await tx.teamEmailVerification.create({
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (!(err instanceof Prisma.PrismaClientKnownRequestError)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const target = z.array(z.string()).safeParse(err.meta?.target);
|
||||
|
||||
if (err.code === 'P2002' && target.success && target.data.includes('email')) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send an email to a user asking them to accept a team email request.
|
||||
*
|
||||
* @param email The email address to use for the team.
|
||||
* @param token The token used to authenticate that the user has granted access.
|
||||
* @param teamName The name of the team the user is being invited to.
|
||||
* @param teamUrl The url of the team the user is being invited to.
|
||||
*/
|
||||
export const sendTeamEmailVerificationEmail = async (
|
||||
email: string,
|
||||
token: string,
|
||||
teamName: string,
|
||||
teamUrl: string,
|
||||
) => {
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(ConfirmTeamEmailTemplate, {
|
||||
assetBaseUrl,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
teamName,
|
||||
teamUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `A request to use your email has been initiated by ${teamName} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
};
|
||||
161
packages/lib/server-only/team/create-team-member-invites.ts
Normal file
161
packages/lib/server-only/team/create-team-member-invites.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import type { TeamInviteEmailProps } from '@documenso/email/templates/team-invite';
|
||||
import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite';
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
|
||||
import type { TCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
|
||||
export type CreateTeamMemberInvitesOptions = {
|
||||
userId: number;
|
||||
userName: string;
|
||||
teamId: number;
|
||||
invitations: TCreateTeamMemberInvitesMutationSchema['invitations'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Invite team members via email to join a team.
|
||||
*/
|
||||
export const createTeamMemberInvites = async ({
|
||||
userId,
|
||||
userName,
|
||||
teamId,
|
||||
invitations,
|
||||
}: CreateTeamMemberInvitesOptions) => {
|
||||
const team = await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
role: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invites: true,
|
||||
},
|
||||
});
|
||||
|
||||
const teamMemberEmails = team.members.map((member) => member.user.email);
|
||||
const teamMemberInviteEmails = team.invites.map((invite) => invite.email);
|
||||
const currentTeamMember = team.members.find((member) => member.user.id === userId);
|
||||
|
||||
if (!currentTeamMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'User not part of team.');
|
||||
}
|
||||
|
||||
const usersToInvite = invitations.filter((invitation) => {
|
||||
// Filter out users that are already members of the team.
|
||||
if (teamMemberEmails.includes(invitation.email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out users that have already been invited to the team.
|
||||
if (teamMemberInviteEmails.includes(invitation.email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const unauthorizedRoleAccess = usersToInvite.some(
|
||||
({ role }) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, role),
|
||||
);
|
||||
|
||||
if (unauthorizedRoleAccess) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'User does not have permission to set high level roles',
|
||||
);
|
||||
}
|
||||
|
||||
const teamMemberInvites = usersToInvite.map(({ email, role }) => ({
|
||||
email,
|
||||
teamId,
|
||||
role,
|
||||
status: TeamMemberInviteStatus.PENDING,
|
||||
token: nanoid(32),
|
||||
}));
|
||||
|
||||
await prisma.teamMemberInvite.createMany({
|
||||
data: teamMemberInvites,
|
||||
});
|
||||
|
||||
const sendEmailResult = await Promise.allSettled(
|
||||
teamMemberInvites.map(async ({ email, token }) =>
|
||||
sendTeamMemberInviteEmail({
|
||||
email,
|
||||
token,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
senderName: userName,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const sendEmailResultErrorList = sendEmailResult.filter(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||
);
|
||||
|
||||
if (sendEmailResultErrorList.length > 0) {
|
||||
console.error(JSON.stringify(sendEmailResultErrorList));
|
||||
|
||||
throw new AppError(
|
||||
'EmailDeliveryFailed',
|
||||
'Failed to send invite emails to one or more users.',
|
||||
`Failed to send invites to ${sendEmailResultErrorList.length}/${teamMemberInvites.length} users.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type SendTeamMemberInviteEmailOptions = Omit<TeamInviteEmailProps, 'baseUrl' | 'assetBaseUrl'> & {
|
||||
email: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send an email to a user inviting them to join a team.
|
||||
*/
|
||||
export const sendTeamMemberInviteEmail = async ({
|
||||
email,
|
||||
...emailTemplateOptions
|
||||
}: SendTeamMemberInviteEmailOptions) => {
|
||||
const template = createElement(TeamInviteEmailTemplate, {
|
||||
assetBaseUrl: WEBAPP_BASE_URL,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
...emailTemplateOptions,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `You have been invited to join ${emailTemplateOptions.teamName} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
};
|
||||
207
packages/lib/server-only/team/create-team.ts
Normal file
207
packages/lib/server-only/team/create-team.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import type Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
|
||||
import { getCommunityPlanPriceIds } from '@documenso/ee/server-only/stripe/get-community-plan-prices';
|
||||
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma, TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
import { stripe } from '../stripe';
|
||||
|
||||
export type CreateTeamOptions = {
|
||||
/**
|
||||
* ID of the user creating the Team.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* Name of the team to display.
|
||||
*/
|
||||
teamName: string;
|
||||
|
||||
/**
|
||||
* Unique URL of the team.
|
||||
*
|
||||
* Used as the URL path, example: https://documenso.com/t/{teamUrl}/settings
|
||||
*/
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
export type CreateTeamResponse =
|
||||
| {
|
||||
paymentRequired: false;
|
||||
}
|
||||
| {
|
||||
paymentRequired: true;
|
||||
pendingTeamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a team or pending team depending on the user's subscription or application's billing settings.
|
||||
*/
|
||||
export const createTeam = async ({
|
||||
userId,
|
||||
teamName,
|
||||
teamUrl,
|
||||
}: CreateTeamOptions): Promise<CreateTeamResponse> => {
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
Subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
let isPaymentRequired = IS_BILLING_ENABLED;
|
||||
let customerId: string | null = null;
|
||||
|
||||
if (IS_BILLING_ENABLED) {
|
||||
const communityPlanPriceIds = await getCommunityPlanPriceIds();
|
||||
|
||||
isPaymentRequired = !subscriptionsContainsActiveCommunityPlan(
|
||||
user.Subscription,
|
||||
communityPlanPriceIds,
|
||||
);
|
||||
|
||||
customerId = await createTeamCustomer({
|
||||
name: user.name ?? teamName,
|
||||
email: user.email,
|
||||
}).then((customer) => customer.id);
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the team directly if no payment is required.
|
||||
if (!isPaymentRequired) {
|
||||
await prisma.team.create({
|
||||
data: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
ownerUserId: user.id,
|
||||
customerId,
|
||||
members: {
|
||||
create: [
|
||||
{
|
||||
userId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
paymentRequired: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Create a pending team if payment is required.
|
||||
const pendingTeam = await prisma.$transaction(async (tx) => {
|
||||
const existingTeamWithUrl = await tx.team.findUnique({
|
||||
where: {
|
||||
url: teamUrl,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTeamWithUrl) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, 'Missing customer ID for pending teams.');
|
||||
}
|
||||
|
||||
return await tx.teamPending.create({
|
||||
data: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
ownerUserId: user.id,
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
paymentRequired: true,
|
||||
pendingTeamId: pendingTeam.id,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (!(err instanceof Prisma.PrismaClientKnownRequestError)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const target = z.array(z.string()).safeParse(err.meta?.target);
|
||||
|
||||
if (err.code === 'P2002' && target.success && target.data.includes('url')) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export type CreateTeamFromPendingTeamOptions = {
|
||||
pendingTeamId: number;
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
export const createTeamFromPendingTeam = async ({
|
||||
pendingTeamId,
|
||||
subscription,
|
||||
}: CreateTeamFromPendingTeamOptions) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const pendingTeam = await tx.teamPending.findUniqueOrThrow({
|
||||
where: {
|
||||
id: pendingTeamId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamPending.delete({
|
||||
where: {
|
||||
id: pendingTeamId,
|
||||
},
|
||||
});
|
||||
|
||||
const team = await tx.team.create({
|
||||
data: {
|
||||
name: pendingTeam.name,
|
||||
url: pendingTeam.url,
|
||||
ownerUserId: pendingTeam.ownerUserId,
|
||||
customerId: pendingTeam.customerId,
|
||||
members: {
|
||||
create: [
|
||||
{
|
||||
userId: pendingTeam.ownerUserId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.subscription.upsert(
|
||||
mapStripeSubscriptionToPrismaUpsertAction(subscription, undefined, team.id),
|
||||
);
|
||||
|
||||
// Attach the team ID to the subscription metadata for sanity reasons.
|
||||
await stripe.subscriptions
|
||||
.update(subscription.id, {
|
||||
metadata: {
|
||||
teamId: team.id.toString(),
|
||||
},
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
// Non-critical error, but we want to log it so we can rectify it.
|
||||
// Todo: Teams - Alert us.
|
||||
});
|
||||
|
||||
return team;
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type DeleteTeamEmailVerificationOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const deleteTeamEmailVerification = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: DeleteTeamEmailVerificationOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamEmailVerification.delete({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
93
packages/lib/server-only/team/delete-team-email.ts
Normal file
93
packages/lib/server-only/team/delete-team-email.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type DeleteTeamEmailOptions = {
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a team email.
|
||||
*
|
||||
* The user must either be part of the team with the required permissions, or the owner of the email.
|
||||
*/
|
||||
export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamEmailOptions) => {
|
||||
const team = await prisma.$transaction(async (tx) => {
|
||||
const foundTeam = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
OR: [
|
||||
{
|
||||
teamEmail: {
|
||||
email: userEmail,
|
||||
},
|
||||
},
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
teamEmail: true,
|
||||
owner: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamEmail.delete({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
return foundTeam;
|
||||
});
|
||||
|
||||
try {
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(TeamEmailRemovedTemplate, {
|
||||
assetBaseUrl,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
teamEmail: team.teamEmail?.email ?? '',
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: team.owner.email,
|
||||
name: team.owner.name ?? '',
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `Team email has been revoked for ${team.name}`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
} catch (e) {
|
||||
// Todo: Teams - Alert us.
|
||||
// We don't want to prevent a user from revoking access because an email could not be sent.
|
||||
}
|
||||
};
|
||||
47
packages/lib/server-only/team/delete-team-invitations.ts
Normal file
47
packages/lib/server-only/team/delete-team-invitations.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
|
||||
export type DeleteTeamMemberInvitationsOptions = {
|
||||
/**
|
||||
* The ID of the user who is initiating this action.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The ID of the team to remove members from.
|
||||
*/
|
||||
teamId: number;
|
||||
|
||||
/**
|
||||
* The IDs of the invitations to remove.
|
||||
*/
|
||||
invitationIds: number[];
|
||||
};
|
||||
|
||||
export const deleteTeamMemberInvitations = async ({
|
||||
userId,
|
||||
teamId,
|
||||
invitationIds,
|
||||
}: DeleteTeamMemberInvitationsOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.teamMember.findFirstOrThrow({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMemberInvite.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: invitationIds,
|
||||
},
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
102
packages/lib/server-only/team/delete-team-members.ts
Normal file
102
packages/lib/server-only/team/delete-team-members.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type DeleteTeamMembersOptions = {
|
||||
/**
|
||||
* The ID of the user who is initiating this action.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The ID of the team to remove members from.
|
||||
*/
|
||||
teamId: number;
|
||||
|
||||
/**
|
||||
* The IDs of the team members to remove.
|
||||
*/
|
||||
teamMemberIds: number[];
|
||||
};
|
||||
|
||||
export const deleteTeamMembers = async ({
|
||||
userId,
|
||||
teamId,
|
||||
teamMemberIds,
|
||||
}: DeleteTeamMembersOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Find the team and validate that the user is allowed to remove members.
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
const currentTeamMember = team.members.find((member) => member.userId === userId);
|
||||
const teamMembersToRemove = team.members.filter((member) => teamMemberIds.includes(member.id));
|
||||
|
||||
if (!currentTeamMember) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist');
|
||||
}
|
||||
|
||||
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner');
|
||||
}
|
||||
|
||||
const isMemberToRemoveHigherRole = teamMembersToRemove.some(
|
||||
(member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role),
|
||||
);
|
||||
|
||||
if (isMemberToRemoveHigherRole) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
|
||||
}
|
||||
|
||||
// Remove the team members.
|
||||
await tx.teamMember.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: teamMemberIds,
|
||||
},
|
||||
teamId,
|
||||
userId: {
|
||||
not: team.ownerUserId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user