Merge branch 'main' into feat/add-runtime-env

This commit is contained in:
Lucas Smith
2024-02-08 22:06:59 +11:00
committed by GitHub
294 changed files with 15355 additions and 1451 deletions

View 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`);
};

View File

@ -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

View File

@ -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();
});

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

View 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');
});

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

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

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

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

View File

@ -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');

View File

@ -6,13 +6,18 @@ 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('/api/limits', APP_BASE_URL() ?? 'http://localhost:3000');
if (teamId) {
requestHeaders['team-id'] = teamId.toString();
}
return fetch(url, {
headers: {
...requestHeaders,

View File

@ -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,

View File

@ -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) {

View File

@ -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)) {

View File

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

View File

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

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

View File

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

View File

@ -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;

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

View File

@ -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,

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

View File

@ -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) => {

View File

@ -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;

View 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;
};

View File

@ -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;
};

View 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 ?? [],
};
};

View 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;
};

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -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>

View 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;

View File

@ -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;

View 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;

View File

@ -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>

View 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;

View 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;

View 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;

View File

@ -1,10 +1,10 @@
import type { Recipient } from '@documenso/prisma/client';
import { ReadStatus, SendStatus, SigningStatus } 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';
}

View File

@ -1,5 +1,8 @@
import { env } from 'next-runtime-env';
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50;
export const NEXT_PUBLIC_PROJECT = () => env('NEXT_PUBLIC_PROJECT');
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL');
export const NEXT_PUBLIC_MARKETING_URL = () => env('NEXT_PUBLIC_MARKETING_URL');
@ -11,3 +14,6 @@ export const APP_FOLDER = () => (IS_APP_MARKETING() ? 'marketing' : 'web');
export const APP_BASE_URL = () =>
IS_APP_WEB() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PUBLIC_MARKETING_URL();
export const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000';
export const MARKETING_BASE_URL = NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001';

View File

@ -1,4 +1,4 @@
import { IdentityProvider } from '@documenso/prisma/client';
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
export const SALT_ROUNDS = 12;
@ -10,3 +10,16 @@ export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = {
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',
};

View 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';

View File

@ -2,15 +2,17 @@ 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 (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
// throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
// }
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 === 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('*********************************************************************');

View 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',
},
};

View 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',
];

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

View File

@ -10,11 +10,12 @@ import GoogleProvider from 'next-auth/providers/google';
import { env } from 'next-runtime-env';
import { prisma } from '@documenso/prisma';
import { IdentityProvider } from '@documenso/prisma/client';
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 = {
@ -36,7 +37,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);
}
@ -52,8 +53,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);
}
@ -63,6 +74,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
@ -193,4 +213,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.
};

View File

@ -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;

View File

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

View File

@ -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';

View File

@ -13,21 +13,25 @@ export const decryptSecondaryData = (encryptedData: string): string | null => {
throw new Error('Missing encryption key');
}
const decryptedBufferValue = symmetricDecrypt({
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
data: encryptedData,
});
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));
const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8');
const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue));
if (!result.success) {
if (!result.success) {
return null;
}
if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) {
return null;
}
return result.data.data;
} catch {
return null;
}
if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) {
return null;
}
return result.data.data;
};

View File

@ -24,7 +24,20 @@ export const upsertDocumentMeta = async ({
await prisma.document.findFirstOrThrow({
where: {
id: documentId,
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});

View File

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

View File

@ -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,
@ -33,7 +44,7 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI
},
});
const createdDocument = await prisma.document.create({
const createDocumentArguments: Prisma.DocumentCreateArgs = {
data: {
title: document.title,
User: {
@ -53,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;
};

View File

@ -2,15 +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 type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
export type FindDocumentsOptions = {
userId: number;
teamId?: number;
term?: string;
status?: ExtendedDocumentStatus;
page?: number;
@ -19,22 +22,50 @@ export type 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';
@ -51,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) {
@ -147,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,
@ -164,13 +145,16 @@ export const findDocuments = async ({
},
},
Recipient: true,
team: {
select: {
id: true,
url: true,
},
},
},
}),
prisma.document.count({
where: {
...termFilters,
...filters,
},
where: whereClause,
}),
]);
@ -189,3 +173,268 @@ export const findDocuments = async ({
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();
};

View File

@ -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;
};

View File

@ -1,76 +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: {
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,
},
},
},
],
},
}),
]);
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,
@ -106,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) : [],
]);
};

View File

@ -6,7 +6,11 @@ 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';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@ -14,20 +18,29 @@ 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: {
@ -61,6 +74,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 = {
@ -79,8 +96,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,
@ -92,7 +112,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 }),
});

View File

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

View File

@ -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';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@ -25,7 +27,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,
@ -49,6 +64,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 = {
@ -71,8 +90,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,
@ -84,7 +106,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 }),
});

View File

@ -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,

View File

@ -10,7 +10,20 @@ export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForD
where: {
documentId,
Document: {
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
orderBy: {

View File

@ -10,7 +10,20 @@ export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForT
where: {
templateId,
Template: {
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
orderBy: {

View File

@ -25,7 +25,20 @@ export const setFieldsForDocument = async ({
const document = await prisma.document.findFirst({
where: {
id: documentId,
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
@ -43,11 +56,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

View File

@ -27,7 +27,20 @@ export const setFieldsForTemplate = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});

View File

@ -13,7 +13,20 @@ export const getRecipientsForDocument = async ({
where: {
documentId,
Document: {
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
orderBy: {

View File

@ -13,7 +13,20 @@ export const getRecipientsForTemplate = async ({
where: {
templateId,
Template: {
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
orderBy: {

View File

@ -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,
},
},
},
},
],
},
});
@ -79,13 +94,20 @@ export const setRecipientsForDocument = async ({
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
documentId,
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,
},
}),
),

View File

@ -20,7 +20,20 @@ export const setRecipientsForTemplate = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});

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

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

View File

@ -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.');
}
};

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

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

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

View File

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

View 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.
}
};

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

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

View File

@ -0,0 +1,15 @@
import { prisma } from '@documenso/prisma';
export type DeleteTeamPendingOptions = {
userId: number;
pendingTeamId: number;
};
export const deleteTeamPending = async ({ userId, pendingTeamId }: DeleteTeamPendingOptions) => {
await prisma.teamPending.delete({
where: {
id: pendingTeamId,
ownerUserId: userId,
},
});
};

View File

@ -0,0 +1,42 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
export type DeleteTeamTransferRequestOptions = {
/**
* The ID of the user deleting the transfer.
*/
userId: number;
/**
* The ID of the team whose team transfer request should be deleted.
*/
teamId: number;
};
export const deleteTeamTransferRequest = async ({
userId,
teamId,
}: DeleteTeamTransferRequestOptions) => {
await prisma.$transaction(async (tx) => {
await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_TEAM_TRANSFER_REQUEST'],
},
},
},
},
});
await tx.teamTransferVerification.delete({
where: {
teamId,
},
});
});
};

View File

@ -0,0 +1,42 @@
import { prisma } from '@documenso/prisma';
import { AppError } from '../../errors/app-error';
import { stripe } from '../stripe';
export type DeleteTeamOptions = {
userId: number;
teamId: number;
};
export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: userId,
},
include: {
subscription: true,
},
});
if (team.subscription) {
await stripe.subscriptions
.cancel(team.subscription.planId, {
prorate: false,
invoice_now: true,
})
.catch((err) => {
console.error(err);
throw AppError.parseError(err);
});
}
await tx.team.delete({
where: {
id: teamId,
ownerUserId: userId,
},
});
});
};

View File

@ -0,0 +1,52 @@
import { getInvoices } from '@documenso/ee/server-only/stripe/get-invoices';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
export interface FindTeamInvoicesOptions {
userId: number;
teamId: number;
}
export const findTeamInvoices = async ({ userId, teamId }: FindTeamInvoicesOptions) => {
const team = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
});
if (!team.customerId) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Team has no customer ID.');
}
const results = await getInvoices({ customerId: team.customerId });
if (!results) {
return null;
}
return {
...results,
data: results.data.map((invoice) => ({
invoicePdf: invoice.invoice_pdf,
hostedInvoicePdf: invoice.hosted_invoice_url,
status: invoice.status,
subtotal: invoice.subtotal,
total: invoice.total,
amountPaid: invoice.amount_paid,
amountDue: invoice.amount_due,
created: invoice.created,
paid: invoice.paid,
quantity: invoice.lines.data[0].quantity ?? 0,
currency: invoice.currency,
})),
};
};

View File

@ -0,0 +1,91 @@
import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { TeamMemberInvite } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import type { FindResultSet } from '../../types/find-result-set';
export interface FindTeamMemberInvitesOptions {
userId: number;
teamId: number;
term?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof TeamMemberInvite;
direction: 'asc' | 'desc';
};
}
export const findTeamMemberInvites = async ({
userId,
teamId,
term,
page = 1,
perPage = 10,
orderBy,
}: FindTeamMemberInvitesOptions) => {
const orderByColumn = orderBy?.column ?? 'email';
const orderByDirection = orderBy?.direction ?? 'desc';
// Check that the user belongs to the team they are trying to find invites in.
const userTeam = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
});
const termFilters: Prisma.TeamMemberInviteWhereInput | undefined = match(term)
.with(P.string.minLength(1), () => ({
email: {
contains: term,
mode: Prisma.QueryMode.insensitive,
},
}))
.otherwise(() => undefined);
const whereClause: Prisma.TeamMemberInviteWhereInput = {
...termFilters,
teamId: userTeam.id,
};
const [data, count] = await Promise.all([
prisma.teamMemberInvite.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
// Exclude token attribute.
select: {
id: true,
teamId: true,
email: true,
role: true,
createdAt: true,
},
}),
prisma.teamMemberInvite.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
};

View File

@ -0,0 +1,100 @@
import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { TeamMember } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
import type { FindResultSet } from '../../types/find-result-set';
export interface FindTeamMembersOptions {
userId: number;
teamId: number;
term?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof TeamMember | 'name';
direction: 'asc' | 'desc';
};
}
export const findTeamMembers = async ({
userId,
teamId,
term,
page = 1,
perPage = 10,
orderBy,
}: FindTeamMembersOptions) => {
const orderByColumn = orderBy?.column ?? 'name';
const orderByDirection = orderBy?.direction ?? 'desc';
// Check that the user belongs to the team they are trying to find members in.
const userTeam = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
const termFilters: Prisma.TeamMemberWhereInput | undefined = match(term)
.with(P.string.minLength(1), () => ({
user: {
name: {
contains: term,
mode: Prisma.QueryMode.insensitive,
},
},
}))
.otherwise(() => undefined);
const whereClause: Prisma.TeamMemberWhereInput = {
...termFilters,
teamId: userTeam.id,
};
let orderByClause: Prisma.TeamMemberOrderByWithRelationInput = {
[orderByColumn]: orderByDirection,
};
// Name field is nested in the user so we have to handle it differently.
if (orderByColumn === 'name') {
orderByClause = {
user: {
name: orderByDirection,
},
};
}
const [data, count] = await Promise.all([
prisma.teamMember.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: orderByClause,
include: {
user: {
select: {
name: true,
email: true,
},
},
},
}),
prisma.teamMember.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
};

View File

@ -0,0 +1,58 @@
import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
export interface FindTeamsPendingOptions {
userId: number;
term?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof Team;
direction: 'asc' | 'desc';
};
}
export const findTeamsPending = async ({
userId,
term,
page = 1,
perPage = 10,
orderBy,
}: FindTeamsPendingOptions) => {
const orderByColumn = orderBy?.column ?? 'name';
const orderByDirection = orderBy?.direction ?? 'desc';
const whereClause: Prisma.TeamPendingWhereInput = {
ownerUserId: userId,
};
if (term && term.length > 0) {
whereClause.name = {
contains: term,
mode: Prisma.QueryMode.insensitive,
};
}
const [data, count] = await Promise.all([
prisma.teamPending.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
}),
prisma.teamPending.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
};
};

View File

@ -0,0 +1,76 @@
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
export interface FindTeamsOptions {
userId: number;
term?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof Team;
direction: 'asc' | 'desc';
};
}
export const findTeams = async ({
userId,
term,
page = 1,
perPage = 10,
orderBy,
}: FindTeamsOptions) => {
const orderByColumn = orderBy?.column ?? 'name';
const orderByDirection = orderBy?.direction ?? 'desc';
const whereClause: Prisma.TeamWhereInput = {
members: {
some: {
userId,
},
},
};
if (term && term.length > 0) {
whereClause.name = {
contains: term,
mode: Prisma.QueryMode.insensitive,
};
}
const [data, count] = await Promise.all([
prisma.team.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
include: {
members: {
where: {
userId,
},
},
},
}),
prisma.team.count({
where: whereClause,
}),
]);
const maskedData = data.map((team) => ({
...team,
currentTeamMember: team.members[0],
members: undefined,
}));
return {
data: maskedData,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof maskedData>;
};

View File

@ -0,0 +1,22 @@
import { prisma } from '@documenso/prisma';
export type GetTeamEmailByEmailOptions = {
email: string;
};
export const getTeamEmailByEmail = async ({ email }: GetTeamEmailByEmailOptions) => {
return await prisma.teamEmail.findFirst({
where: {
email,
},
include: {
team: {
select: {
id: true,
name: true,
url: true,
},
},
},
});
};

View File

@ -0,0 +1,22 @@
import { prisma } from '@documenso/prisma';
export type GetTeamInvitationsOptions = {
email: string;
};
export const getTeamInvitations = async ({ email }: GetTeamInvitationsOptions) => {
return await prisma.teamMemberInvite.findMany({
where: {
email,
},
include: {
team: {
select: {
id: true,
name: true,
url: true,
},
},
},
});
};

View File

@ -0,0 +1,33 @@
import { prisma } from '@documenso/prisma';
export type GetTeamMembersOptions = {
userId: number;
teamId: number;
};
/**
* Get all team members for a given team.
*/
export const getTeamMembers = async ({ userId, teamId }: GetTeamMembersOptions) => {
return await prisma.teamMember.findMany({
where: {
team: {
id: teamId,
members: {
some: {
userId: userId,
},
},
},
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});
};

View File

@ -0,0 +1,95 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
export type GetTeamByIdOptions = {
userId?: number;
teamId: number;
};
/**
* Get a team given a teamId.
*
* Provide an optional userId to check that the user is a member of the team.
*/
export const getTeamById = async ({ userId, teamId }: GetTeamByIdOptions) => {
const whereFilter: Prisma.TeamWhereUniqueInput = {
id: teamId,
};
if (userId !== undefined) {
whereFilter['members'] = {
some: {
userId,
},
};
}
const result = await prisma.team.findUniqueOrThrow({
where: whereFilter,
include: {
teamEmail: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
const { members, ...team } = result;
return {
...team,
currentTeamMember: userId !== undefined ? members[0] : null,
};
};
export type GetTeamByUrlOptions = {
userId: number;
teamUrl: string;
};
/**
* Get a team given a team URL.
*/
export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) => {
const whereFilter: Prisma.TeamWhereUniqueInput = {
url: teamUrl,
};
if (userId !== undefined) {
whereFilter['members'] = {
some: {
userId,
},
};
}
const result = await prisma.team.findUniqueOrThrow({
where: whereFilter,
include: {
teamEmail: true,
emailVerification: true,
transferVerification: true,
subscription: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
const { members, ...team } = result;
return {
...team,
currentTeamMember: members[0],
};
};

View File

@ -0,0 +1,33 @@
import { prisma } from '@documenso/prisma';
export type GetTeamsOptions = {
userId: number;
};
export type GetTeamsResponse = Awaited<ReturnType<typeof getTeams>>;
export const getTeams = async ({ userId }: GetTeamsOptions) => {
const teams = await prisma.team.findMany({
where: {
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
return teams.map(({ members, ...team }) => ({
...team,
currentTeamMember: members[0],
}));
};

View File

@ -0,0 +1,59 @@
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
export type LeaveTeamOptions = {
/**
* The ID of the user who is leaving the team.
*/
userId: number;
/**
* The ID of the team the user is leaving.
*/
teamId: number;
};
export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: {
not: userId,
},
},
include: {
subscription: true,
},
});
await tx.teamMember.delete({
where: {
userId_teamId: {
userId,
teamId,
},
team: {
ownerUserId: {
not: userId,
},
},
},
});
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,
});
}
});
};

View File

@ -0,0 +1,106 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { TeamTransferRequestTemplate } from '@documenso/email/templates/team-transfer-request';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
export type RequestTeamOwnershipTransferOptions = {
/**
* The ID of the user initiating the transfer.
*/
userId: number;
/**
* The name of the user initiating the transfer.
*/
userName: string;
/**
* The ID of the team whose ownership is being transferred.
*/
teamId: number;
/**
* The user ID of the new owner.
*/
newOwnerUserId: number;
/**
* Whether to clear any current payment methods attached to the team.
*/
clearPaymentMethods: boolean;
};
export const requestTeamOwnershipTransfer = async ({
userId,
userName,
teamId,
newOwnerUserId,
}: RequestTeamOwnershipTransferOptions) => {
// Todo: Clear payment methods disabled for now.
const clearPaymentMethods = false;
await prisma.$transaction(async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: userId,
members: {
some: {
userId: newOwnerUserId,
},
},
},
});
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
},
});
const { token, expiresAt } = createTokenVerification({ minute: 10 });
const teamVerificationPayload = {
teamId,
token,
expiresAt,
userId: newOwnerUserId,
name: newOwnerUser.name ?? '',
email: newOwnerUser.email,
clearPaymentMethods,
};
await tx.teamTransferVerification.upsert({
where: {
teamId,
},
create: teamVerificationPayload,
update: teamVerificationPayload,
});
const template = createElement(TeamTransferRequestTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
senderName: userName,
teamName: team.name,
teamUrl: team.url,
token,
});
await mailer.sendMail({
to: newOwnerUser.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
});
};

View File

@ -0,0 +1,65 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError } from '@documenso/lib/errors/app-error';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { sendTeamEmailVerificationEmail } from './create-team-email-verification';
export type ResendTeamMemberInvitationOptions = {
userId: number;
teamId: number;
};
/**
* Resend a team email verification with a new token.
*/
export const resendTeamEmailVerification = async ({
userId,
teamId,
}: ResendTeamMemberInvitationOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
include: {
emailVerification: true,
},
});
if (!team) {
throw new AppError('TeamNotFound', 'User is not a member of the team.');
}
const { emailVerification } = team;
if (!emailVerification) {
throw new AppError(
'VerificationNotFound',
'No team email verification exists for this team.',
);
}
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.update({
where: {
teamId,
},
data: {
token,
expiresAt,
},
});
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
});
};

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