feat: add folders (#1711)

This commit is contained in:
Catalin Pit
2025-05-01 19:46:59 +03:00
committed by GitHub
parent 12ada567f5
commit 17370749b4
91 changed files with 10497 additions and 183 deletions

View File

@ -45,11 +45,14 @@ test('[DOCUMENT_FLOW]: should be able to upload a PDF document', async ({ page }
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
page
.locator('input[type=file]')
.nth(1)
.evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
@ -641,7 +644,7 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
}) => {
const user = await seedUser();
const { document, recipients } = await seedPendingDocumentWithFullFields({
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['user1@example.com', 'user2@example.com', 'user3@example.com'],
fields: [FieldType.SIGNATURE],

View File

@ -0,0 +1,842 @@
import { expect, test } from '@playwright/test';
import path from 'node:path';
import { FolderType } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('create folder button is visible on documents page', async ({ page }) => {
const user = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: '/',
});
await expect(page.getByRole('button', { name: 'Create Folder' })).toBeVisible();
});
test('user can create a document folder', async ({ page }) => {
const user = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: '/',
});
await page.getByRole('button', { name: 'Create Folder' }).click();
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
await page.getByLabel('Folder name').fill('My folder');
await page.getByRole('button', { name: 'Create' }).click();
await page.waitForTimeout(1000);
await expect(page.getByText('My folder')).toBeVisible();
await page.goto('/documents');
await expect(page.locator('div').filter({ hasText: 'My folder' }).nth(3)).toBeVisible();
});
test('user can create a document subfolder inside a document folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Contracts',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/f/${folder.id}`,
});
await expect(page.getByText('Client Contracts')).toBeVisible();
await page.getByRole('button', { name: 'Create Folder' }).click();
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
await page.getByLabel('Folder name').fill('Invoices');
await page.getByRole('button', { name: 'Create' }).click();
await page.waitForTimeout(1000);
await expect(page.getByText('Invoices')).toBeVisible();
});
test('user can create a document inside a document folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Contracts',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/f/${folder.id}`,
});
const fileInput = page.locator('input[type="file"]').nth(1);
await fileInput.waitFor({ state: 'attached' });
await fileInput.setInputFiles(
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
);
await page.waitForTimeout(3000);
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
await page.goto(`/documents/f/${folder.id}`);
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
});
test('user can pin a document folder', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contracts',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
await page.reload();
await expect(page.locator('svg.text-documenso.h-3.w-3')).toBeVisible();
});
test('user can unpin a document folder', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contracts',
pinned: true,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Unpin' }).click();
await page.reload();
await expect(page.locator('svg.text-documenso.h-3.w-3')).not.toBeVisible();
});
test('user can rename a document folder', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contracts',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await page.getByLabel('Name').fill('Archive');
await page.getByRole('button', { name: 'Save Changes' }).click();
await expect(page.getByText('Archive')).toBeVisible();
});
test('document folder visibility is not visible to user', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contracts',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await expect(page.getByRole('menuitem', { name: 'Visibility' })).not.toBeVisible();
});
test('document folder can be moved to another document folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Clients',
},
});
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contracts',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByRole('button', { name: '•••' }).nth(0).click();
await page.getByRole('menuitem', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Clients' }).click();
await page.getByRole('button', { name: 'Move Folder' }).click();
await page.waitForTimeout(1000);
await page.goto(`/documents/f/${folder.id}`);
await expect(page.getByText('Contracts')).toBeVisible();
});
test('document folder can be moved to the root', async ({ page }) => {
const user = await seedUser();
const parentFolder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Clients',
},
});
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contracts',
parentId: parentFolder.id,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByText('Clients').click();
await page.getByRole('button', { name: '•••' }).nth(0).click();
await page.getByRole('menuitem', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Root' }).click();
await page.getByRole('button', { name: 'Move Folder' }).click();
await page.waitForTimeout(1000);
await page.goto('/documents');
await expect(page.getByText('Clients')).toBeVisible();
});
test('document folder and its contents can be deleted', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Proposals',
},
});
const proposal = await seedBlankDocument(user, {
createDocumentOptions: {
title: 'Proposal 1',
folderId: folder.id,
},
});
const reportsFolder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Reports',
parentId: folder.id,
},
});
const report = await seedBlankDocument(user, {
createDocumentOptions: {
title: 'Report 1',
folderId: reportsFolder.id,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('textbox').fill(`delete ${folder.name}`);
await page.getByRole('button', { name: 'Delete' }).click();
await page.goto('/documents');
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
await expect(page.getByText(proposal.title)).not.toBeVisible();
await page.goto(`/documents/f/${folder.id}`);
await expect(page.getByText(report.title)).not.toBeVisible();
await expect(page.locator('div').filter({ hasText: reportsFolder.name })).not.toBeVisible();
});
test('user can move a document to a document folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Proposals',
},
});
await seedBlankDocument(user, {
createDocumentOptions: {
title: 'Proposal 1',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Proposals' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.waitForTimeout(1000);
await page.goto(`/documents/f/${folder.id}`);
await expect(page.getByText('Proposal 1')).toBeVisible();
});
test('user can move a document from folder to the root', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Proposals',
},
});
await seedBlankDocument(user, {
createDocumentOptions: {
title: 'Proposal 1',
folderId: folder.id,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByText('Proposals').click();
await page.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Root' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.waitForTimeout(1000);
await page.goto('/documents');
await expect(page.getByText('Proposal 1')).toBeVisible();
});
test('create folder button is visible on templates page', async ({ page }) => {
const user = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await expect(page.getByRole('button', { name: 'Create folder' })).toBeVisible();
});
test('user can create a template folder', async ({ page }) => {
const user = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByRole('button', { name: 'Create folder' }).click();
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
await page.getByLabel('Folder name').fill('My template folder');
await page.getByRole('button', { name: 'Create' }).click();
await page.waitForTimeout(1000);
await expect(page.getByText('My template folder')).toBeVisible();
await page.goto('/templates');
await expect(page.locator('div').filter({ hasText: 'My template folder' }).nth(3)).toBeVisible();
});
test('user can create a template subfolder inside a template folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Templates',
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/f/${folder.id}`,
});
await expect(page.getByText('Client Templates')).toBeVisible();
await page.getByRole('button', { name: 'Create folder' }).click();
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
await page.getByLabel('Folder name').fill('Contract Templates');
await page.getByRole('button', { name: 'Create' }).click();
await page.waitForTimeout(1000);
await expect(page.getByText('Contract Templates')).toBeVisible();
});
test('user can create a template inside a template folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Templates',
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/f/${folder.id}`,
});
await expect(page.getByText('Client Templates')).toBeVisible();
await page.getByRole('button', { name: 'New Template' }).click();
// await expect(page.getByRole('dialog', { name: 'New Template' })).toBeVisible();
await page
.locator('div')
.filter({ hasText: /^Upload Template DocumentDrag & drop your PDF here\.$/ })
.nth(2)
.click();
await page.locator('input[type="file"]').waitFor({ state: 'attached' });
await page
.locator('input[type="file"]')
.setInputFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Create' }).click();
await page.waitForTimeout(1000);
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
await page.goto(`/templates/f/${folder.id}`);
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
});
test('user can pin a template folder', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contract Templates',
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
await page.reload();
await expect(page.locator('svg.text-documenso.h-3.w-3')).toBeVisible();
});
test('user can unpin a template folder', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contract Templates',
pinned: true,
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Unpin' }).click();
await page.reload();
await expect(page.locator('svg.text-documenso.h-3.w-3')).not.toBeVisible();
});
test('user can rename a template folder', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contract Templates',
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await page.getByLabel('Name').fill('Updated Template Folder');
await page.getByRole('button', { name: 'Save Changes' }).click();
await expect(page.getByText('Updated Template Folder')).toBeVisible();
});
test('template folder visibility is not visible to user', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contract Templates',
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await expect(page.getByRole('menuitem', { name: 'Visibility' })).not.toBeVisible();
});
test('template folder can be moved to another template folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Templates',
type: FolderType.TEMPLATE,
},
});
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contract Templates',
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByRole('button', { name: '•••' }).nth(0).click();
await page.getByRole('menuitem', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Client Templates' }).click();
await page.getByRole('button', { name: 'Move Folder' }).click();
await page.waitForTimeout(1000);
await page.goto(`/templates/f/${folder.id}`);
await expect(page.getByText('Contract Templates')).toBeVisible();
});
test('template folder can be moved to the root', async ({ page }) => {
const user = await seedUser();
const parentFolder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Templates',
type: FolderType.TEMPLATE,
},
});
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contract Templates',
parentId: parentFolder.id,
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByText('Client Templates').click();
await page.getByRole('button', { name: '•••' }).nth(0).click();
await page.getByRole('menuitem', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Root' }).click();
await page.getByRole('button', { name: 'Move Folder' }).click();
await page.waitForTimeout(1000);
await page.goto('/templates');
await expect(page.getByText('Contract Templates')).toBeVisible();
});
test('template folder and its contents can be deleted', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Proposal Templates',
type: FolderType.TEMPLATE,
},
});
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Proposal Template 1',
folderId: folder.id,
},
});
const subfolder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Report Templates',
parentId: folder.id,
type: FolderType.TEMPLATE,
},
});
const reportTemplate = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Report Template 1',
folderId: subfolder.id,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('textbox').fill(`delete ${folder.name}`);
await page.getByRole('button', { name: 'Delete' }).click();
await page.goto('/templates');
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
await expect(page.getByText(template.title)).not.toBeVisible();
await page.goto(`/templates/f/${folder.id}`);
await expect(page.getByText(reportTemplate.title)).not.toBeVisible();
await expect(page.locator('div').filter({ hasText: subfolder.name })).not.toBeVisible();
});
test('user can navigate between template folders', async ({ page }) => {
const user = await seedUser();
const parentFolder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Templates',
type: FolderType.TEMPLATE,
},
});
const subfolder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contract Templates',
parentId: parentFolder.id,
type: FolderType.TEMPLATE,
},
});
await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Contract Template 1',
folderId: subfolder.id,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByText('Client Templates').click();
await expect(page.getByText('Contract Templates')).toBeVisible();
await page.getByText('Contract Templates').click();
await expect(page.getByText('Contract Template 1')).toBeVisible();
await page.getByRole('button', { name: parentFolder.name }).click();
await expect(page.getByText('Contract Templates')).toBeVisible();
await page.getByRole('button', { name: subfolder.name }).click();
await expect(page.getByText('Contract Template 1')).toBeVisible();
});
test('user can move a template to a template folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Templates',
type: FolderType.TEMPLATE,
},
});
await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Proposal Template 1',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByTestId('template-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Client Templates' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.goto(`/templates/f/${folder.id}`);
await page.waitForTimeout(1000);
await expect(page.getByText('Proposal Template 1')).toBeVisible();
});
test('user can move a template from a folder to the root', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Templates',
type: FolderType.TEMPLATE,
},
});
await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Proposal Template 1',
folderId: folder.id,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByText('Client Templates').click();
await page.getByTestId('template-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Root' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.waitForTimeout(1000);
await page.goto('/templates');
await expect(page.getByText('Proposal Template 1')).toBeVisible();
});

File diff suppressed because it is too large Load Diff

View File

@ -21,4 +21,4 @@
"dependencies": {
"start-server-and-test": "^2.0.1"
}
}
}

View File

@ -233,6 +233,7 @@ export const createDocumentV2 = async ({
documentMeta: true,
recipients: true,
fields: true,
folder: true,
},
});

View File

@ -1,5 +1,5 @@
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import type { DocumentVisibility, Team, TeamGlobalSettings } from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@ -29,6 +29,7 @@ export type CreateDocumentOptions = {
normalizePdf?: boolean;
timezone?: string;
requestMetadata: ApiRequestMetadata;
folderId?: string;
};
export const createDocument = async ({
@ -41,6 +42,7 @@ export const createDocument = async ({
formValues,
requestMetadata,
timezone,
folderId,
}: CreateDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@ -89,6 +91,29 @@ export const createDocument = async ({
userTeamRole = teamWithUserRole.members[0]?.role;
}
let folderVisibility: DocumentVisibility | undefined;
if (folderId) {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
},
select: {
visibility: true,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
folderVisibility = folder.visibility;
}
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
@ -121,10 +146,13 @@ export const createDocument = async ({
documentDataId,
userId,
teamId,
visibility: determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
folderId,
visibility:
folderVisibility ??
determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {

View File

@ -27,6 +27,7 @@ export type FindDocumentsOptions = {
period?: PeriodSelectorValue;
senderIds?: number[];
query?: string;
folderId?: string;
};
export const findDocuments = async ({
@ -41,6 +42,7 @@ export const findDocuments = async ({
period,
senderIds,
query = '',
folderId,
}: FindDocumentsOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@ -120,10 +122,10 @@ export const findDocuments = async ({
},
];
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user);
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user, folderId);
if (team) {
filters = findTeamDocumentsFilter(status, team, visibilityFilters);
filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId);
}
if (filters === null) {
@ -227,6 +229,12 @@ export const findDocuments = async ({
};
}
if (folderId !== undefined) {
whereClause.folderId = folderId;
} else {
whereClause.folderId = null;
}
const [data, count] = await Promise.all([
prisma.document.findMany({
where: whereClause,
@ -273,13 +281,18 @@ export const findDocuments = async ({
} satisfies FindResultResponse<typeof data>;
};
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
const findDocumentsFilter = (
status: ExtendedDocumentStatus,
user: User,
folderId?: string | null,
) => {
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
.with(ExtendedDocumentStatus.ALL, () => ({
OR: [
{
userId: user.id,
teamId: null,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.COMPLETED,
@ -288,6 +301,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
email: user.email,
},
},
folderId: folderId,
},
{
status: ExtendedDocumentStatus.PENDING,
@ -296,6 +310,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
email: user.email,
},
},
folderId: folderId,
},
],
}))
@ -324,6 +339,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.PENDING,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.PENDING,
@ -336,6 +352,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
},
},
},
folderId: folderId,
},
],
}))
@ -345,6 +362,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.COMPLETED,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.COMPLETED,
@ -353,6 +371,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
email: user.email,
},
},
folderId: folderId,
},
],
}))
@ -362,6 +381,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.REJECTED,
folderId: folderId,
},
{
status: ExtendedDocumentStatus.REJECTED,
@ -371,6 +391,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
signingStatus: SigningStatus.REJECTED,
},
},
folderId: folderId,
},
],
}))
@ -410,6 +431,7 @@ const findTeamDocumentsFilter = (
status: ExtendedDocumentStatus,
team: Team & { teamEmail: TeamEmail | null },
visibilityFilters: Prisma.DocumentWhereInput[],
folderId?: string,
) => {
const teamEmail = team.teamEmail?.email ?? null;
@ -420,6 +442,7 @@ const findTeamDocumentsFilter = (
OR: [
{
teamId: team.id,
folderId: folderId,
OR: visibilityFilters,
},
],
@ -437,6 +460,7 @@ const findTeamDocumentsFilter = (
},
},
OR: visibilityFilters,
folderId: folderId,
});
// Filter to display all documents that have been sent by the team email.
@ -445,6 +469,7 @@ const findTeamDocumentsFilter = (
email: teamEmail,
},
OR: visibilityFilters,
folderId: folderId,
});
}
@ -470,6 +495,7 @@ const findTeamDocumentsFilter = (
},
},
OR: visibilityFilters,
folderId: folderId,
};
})
.with(ExtendedDocumentStatus.DRAFT, () => {
@ -479,6 +505,7 @@ const findTeamDocumentsFilter = (
teamId: team.id,
status: ExtendedDocumentStatus.DRAFT,
OR: visibilityFilters,
folderId: folderId,
},
],
};
@ -490,6 +517,7 @@ const findTeamDocumentsFilter = (
email: teamEmail,
},
OR: visibilityFilters,
folderId: folderId,
});
}
@ -502,6 +530,7 @@ const findTeamDocumentsFilter = (
teamId: team.id,
status: ExtendedDocumentStatus.PENDING,
OR: visibilityFilters,
folderId: folderId,
},
],
};
@ -521,12 +550,14 @@ const findTeamDocumentsFilter = (
},
},
OR: visibilityFilters,
folderId: folderId,
},
{
user: {
email: teamEmail,
},
OR: visibilityFilters,
folderId: folderId,
},
],
});

View File

@ -12,9 +12,15 @@ export type GetDocumentByIdOptions = {
documentId: number;
userId: number;
teamId?: number;
folderId?: string;
};
export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumentByIdOptions) => {
export const getDocumentById = async ({
documentId,
userId,
teamId,
folderId,
}: GetDocumentByIdOptions) => {
const documentWhereInput = await getDocumentWhereInput({
documentId,
userId,
@ -22,7 +28,10 @@ export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumen
});
const document = await prisma.document.findFirst({
where: documentWhereInput,
where: {
...documentWhereInput,
folderId,
},
include: {
documentData: true,
documentMeta: true,

View File

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

View File

@ -15,9 +15,16 @@ export type GetStatsInput = {
team?: Omit<GetTeamCountsOption, 'createdAt'>;
period?: PeriodSelectorValue;
search?: string;
folderId?: string;
};
export const getStats = async ({ user, period, search = '', ...options }: GetStatsInput) => {
export const getStats = async ({
user,
period,
search = '',
folderId,
...options
}: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) {
@ -37,8 +44,9 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
currentUserEmail: user.email,
userId: user.id,
search,
folderId,
})
: getCounts({ user, createdAt, search }));
: getCounts({ user, createdAt, search, folderId }));
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
@ -84,9 +92,10 @@ type GetCountsOption = {
user: User;
createdAt: Prisma.DocumentWhereInput['createdAt'];
search?: string;
folderId?: string | null;
};
const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption) => {
const searchFilter: Prisma.DocumentWhereInput = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
@ -95,6 +104,8 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
],
};
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
return Promise.all([
// Owner counts.
prisma.document.groupBy({
@ -107,7 +118,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
createdAt,
teamId: null,
deletedAt: null,
AND: [searchFilter],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
// Not signed counts.
@ -126,7 +137,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
},
},
createdAt,
AND: [searchFilter],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
// Has signed counts.
@ -164,7 +175,7 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
},
},
],
AND: [searchFilter],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
]);
@ -179,10 +190,11 @@ type GetTeamCountsOption = {
createdAt: Prisma.DocumentWhereInput['createdAt'];
currentTeamMemberRole?: TeamMemberRole;
search?: string;
folderId?: string | null;
};
const getTeamCounts = async (options: GetTeamCountsOption) => {
const { createdAt, teamId, teamEmail } = options;
const { createdAt, teamId, teamEmail, folderId } = options;
const senderIds = options.senderIds ?? [];
@ -206,6 +218,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
createdAt,
teamId,
deletedAt: null,
folderId,
};
let notSignedCountsGroupByArgs = null;
@ -278,6 +291,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
where: {
userId: userIdWhereClause,
createdAt,
folderId,
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
@ -298,6 +312,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
where: {
userId: userIdWhereClause,
createdAt,
folderId,
OR: [
{
status: ExtendedDocumentStatus.PENDING,

View File

@ -0,0 +1,86 @@
import { TeamMemberRole } from '@prisma/client';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
import { determineDocumentVisibility } from '../../utils/document-visibility';
export interface CreateFolderOptions {
userId: number;
teamId?: number;
name: string;
parentId?: string | null;
type?: TFolderType;
}
export const createFolder = async ({
userId,
teamId,
name,
parentId,
type = FolderType.DOCUMENT,
}: CreateFolderOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
teamMembers: {
select: {
teamId: true,
},
},
},
});
if (
teamId !== undefined &&
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;
let userTeamRole: TeamMemberRole | undefined;
if (teamId) {
const teamWithUserRole = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
},
include: {
teamGlobalSettings: true,
members: {
where: {
userId: userId,
},
select: {
role: true,
},
},
},
});
team = teamWithUserRole;
userTeamRole = teamWithUserRole.members[0]?.role;
}
return await prisma.folder.create({
data: {
name,
userId,
teamId,
parentId,
type,
visibility: determineDocumentVisibility(
team?.teamGlobalSettings?.documentVisibility,
userTeamRole ?? TeamMemberRole.MEMBER,
),
},
});
};

View File

@ -0,0 +1,85 @@
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
export interface DeleteFolderOptions {
userId: number;
teamId?: number;
folderId: string;
}
export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOptions) => {
let teamMemberRole: TeamMemberRole | null = null;
if (teamId) {
const team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
teamMemberRole = team.members[0]?.role ?? null;
}
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
},
include: {
documents: true,
subfolders: true,
templates: true,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
if (teamId && teamMemberRole) {
const hasPermission = match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => folder.visibility !== DocumentVisibility.ADMIN)
.with(TeamMemberRole.MEMBER, () => folder.visibility === DocumentVisibility.EVERYONE)
.otherwise(() => false);
if (!hasPermission) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to delete this folder',
});
}
}
return await prisma.folder.delete({
where: {
id: folderId,
},
});
};

View File

@ -0,0 +1,152 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
export interface FindFoldersOptions {
userId: number;
teamId?: number;
parentId?: string | null;
type?: TFolderType;
}
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => {
let team = null;
let teamMemberRole = null;
if (teamId !== undefined) {
try {
team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
console.error('Error finding team:', error);
throw error;
}
}
const visibilityFilters = match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const whereClause = {
AND: [
{ parentId },
teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null },
],
};
try {
const folders = await prisma.folder.findMany({
where: {
...whereClause,
...(type ? { type } : {}),
},
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
});
const foldersWithDetails = await Promise.all(
folders.map(async (folder) => {
try {
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
prisma.folder.findMany({
where: {
parentId: folder.id,
...(teamId ? { teamId, ...visibilityFilters } : { userId, teamId: null }),
},
orderBy: {
createdAt: 'desc',
},
}),
prisma.document.count({
where: {
folderId: folder.id,
},
}),
prisma.template.count({
where: {
folderId: folder.id,
},
}),
prisma.folder.count({
where: {
parentId: folder.id,
...(teamId ? { teamId, ...visibilityFilters } : { userId, teamId: null }),
},
}),
]);
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
...subfolder,
subfolders: [],
_count: {
documents: 0,
templates: 0,
subfolders: 0,
},
}));
return {
...folder,
subfolders: subfoldersWithEmptySubfolders,
_count: {
documents: documentCount,
templates: templateCount,
subfolders: subfolderCount,
},
};
} catch (error) {
console.error('Error processing folder:', folder.id, error);
throw error;
}
}),
);
return foldersWithDetails;
} catch (error) {
console.error('Error in findFolders:', error);
throw error;
}
};

View File

@ -0,0 +1,112 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
export interface GetFolderBreadcrumbsOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const getFolderBreadcrumbs = async ({
userId,
teamId,
folderId,
type,
}: GetFolderBreadcrumbsOptions) => {
let teamMemberRole = null;
if (teamId !== undefined) {
try {
const team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
console.error('Error finding team:', error);
return [];
}
}
const visibilityFilters = match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const whereClause = (folderId: string) => ({
id: folderId,
...(type ? { type } : {}),
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
});
const breadcrumbs = [];
let currentFolderId = folderId;
const currentFolder = await prisma.folder.findFirst({
where: whereClause(currentFolderId),
});
if (!currentFolder) {
return [];
}
breadcrumbs.push(currentFolder);
while (currentFolder?.parentId) {
const parentFolder = await prisma.folder.findFirst({
where: whereClause(currentFolder.parentId),
});
if (!parentFolder) {
break;
}
breadcrumbs.unshift(parentFolder);
currentFolderId = parentFolder.id;
currentFolder.parentId = parentFolder.parentId;
}
return breadcrumbs;
};

View File

@ -0,0 +1,92 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
export interface GetFolderByIdOptions {
userId: number;
teamId?: number;
folderId?: string;
type?: TFolderType;
}
export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => {
let teamMemberRole = null;
if (teamId !== undefined) {
try {
const team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
}
const visibilityFilters = match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const whereClause = {
id: folderId,
...(type ? { type } : {}),
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
};
const folder = await prisma.folder.findFirst({
where: whereClause,
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return folder;
};

View File

@ -0,0 +1,130 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { FolderType } from '@documenso/lib/types/folder-type';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
export interface MoveDocumentToFolderOptions {
userId: number;
teamId?: number;
documentId: number;
folderId?: string | null;
requestMetadata?: ApiRequestMetadata;
}
export const moveDocumentToFolder = async ({
userId,
teamId,
documentId,
folderId,
}: MoveDocumentToFolderOptions) => {
let teamMemberRole = null;
if (teamId !== undefined) {
try {
const team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
teamMemberRole = team.members[0].role;
} catch (error) {
console.error('Error finding team:', error);
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
}
const visibilityFilters = match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const documentWhereClause = {
id: documentId,
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
};
const document = await prisma.document.findFirst({
where: documentWhereClause,
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (folderId) {
const folderWhereClause = {
id: folderId,
type: FolderType.DOCUMENT,
...(teamId
? {
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
}
: { userId, teamId: null }),
};
const folder = await prisma.folder.findFirst({
where: folderWhereClause,
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
return await prisma.document.update({
where: {
id: documentId,
},
data: {
folderId,
},
});
};

View File

@ -0,0 +1,85 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
export interface MoveFolderOptions {
userId: number;
teamId?: number;
folderId?: string;
parentId?: string | null;
requestMetadata?: ApiRequestMetadata;
}
export const moveFolder = async ({ userId, teamId, folderId, parentId }: MoveFolderOptions) => {
return await prisma.$transaction(async (tx) => {
const folder = await tx.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
if (parentId) {
const parentFolder = await tx.folder.findFirst({
where: {
id: parentId,
userId,
teamId,
type: folder.type,
},
});
if (!parentFolder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
if (parentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into itself',
});
}
let currentParentId = parentFolder.parentId;
while (currentParentId) {
if (currentParentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into its descendant',
});
}
const currentParent = await tx.folder.findUnique({
where: {
id: currentParentId,
},
select: {
parentId: true,
},
});
if (!currentParent) {
break;
}
currentParentId = currentParent.parentId;
}
}
return await tx.folder.update({
where: {
id: folderId,
},
data: {
parentId,
},
});
});
};

View File

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

View File

@ -0,0 +1,37 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
export interface PinFolderOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const pinFolder = async ({ userId, teamId, folderId, type }: PinFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
pinned: true,
},
});
};

View File

@ -0,0 +1,37 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
export interface UnpinFolderOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const unpinFolder = async ({ userId, teamId, folderId, type }: UnpinFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
pinned: false,
},
});
};

View File

@ -0,0 +1,53 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '@documenso/prisma/generated/types';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
export interface UpdateFolderOptions {
userId: number;
teamId?: number;
folderId: string;
name: string;
visibility: DocumentVisibility;
type?: TFolderType;
}
export const updateFolder = async ({
userId,
teamId,
folderId,
name,
visibility,
type,
}: UpdateFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
userId,
teamId,
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
const isTemplateFolder = folder.type === FolderType.TEMPLATE;
const effectiveVisibility =
isTemplateFolder && teamId !== null ? DocumentVisibility.EVERYONE : visibility;
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
name,
visibility: effectiveVisibility,
},
});
};

View File

@ -20,6 +20,7 @@ export const createTemplate = async ({
userId,
teamId,
templateDocumentDataId,
folderId,
}: CreateTemplateOptions) => {
let team = null;
@ -43,12 +44,38 @@ export const createTemplate = async ({
}
}
const folder = await prisma.folder.findFirstOrThrow({
where: {
id: folderId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return await prisma.template.create({
data: {
title,
userId,
templateDocumentDataId,
teamId,
folderId: folder.id,
templateMeta: {
create: {
language: team?.teamGlobalSettings?.documentLanguage,

View File

@ -12,6 +12,7 @@ export type FindTemplatesOptions = {
type?: Template['type'];
page?: number;
perPage?: number;
folderId?: string;
};
export const findTemplates = async ({
@ -20,6 +21,7 @@ export const findTemplates = async ({
type,
page = 1,
perPage = 10,
folderId,
}: FindTemplatesOptions) => {
const whereFilter: Prisma.TemplateWhereInput[] = [];
@ -67,6 +69,12 @@ export const findTemplates = async ({
);
}
if (folderId) {
whereFilter.push({ folderId });
} else {
whereFilter.push({ folderId: null });
}
const [data, count] = await Promise.all([
prisma.template.findMany({
where: {

View File

@ -6,9 +6,15 @@ export type GetTemplateByIdOptions = {
id: number;
userId: number;
teamId?: number;
folderId?: string | null;
};
export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOptions) => {
export const getTemplateById = async ({
id,
userId,
teamId,
folderId = null,
}: GetTemplateByIdOptions) => {
const template = await prisma.template.findFirst({
where: {
id,
@ -27,6 +33,7 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
userId,
teamId: null,
}),
...(folderId ? { folderId } : {}),
},
include: {
directLink: true,
@ -41,6 +48,7 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
email: true,
},
},
folder: true,
},
});

View File

@ -3,6 +3,7 @@ import type { z } from 'zod';
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import { DocumentSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentSchema';
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
@ -31,6 +32,7 @@ export const ZDocumentSchema = DocumentSchema.pick({
deletedAt: true,
teamId: true,
templateId: true,
folderId: true,
}).extend({
// Todo: Maybe we want to alter this a bit since this returns a lot of data.
documentData: DocumentDataSchema.pick({
@ -57,6 +59,18 @@ export const ZDocumentSchema = DocumentSchema.pick({
language: true,
emailSettings: true,
}).nullable(),
folder: FolderSchema.pick({
id: true,
name: true,
type: true,
visibility: true,
userId: true,
teamId: true,
pinned: true,
parentId: true,
createdAt: true,
updatedAt: true,
}).nullable(),
recipients: ZRecipientLiteSchema.array(),
fields: ZFieldSchema.array(),
});
@ -83,6 +97,7 @@ export const ZDocumentLiteSchema = DocumentSchema.pick({
deletedAt: true,
teamId: true,
templateId: true,
folderId: true,
useLegacyFieldInsertion: true,
});
@ -108,6 +123,7 @@ export const ZDocumentManySchema = DocumentSchema.pick({
deletedAt: true,
teamId: true,
templateId: true,
folderId: true,
useLegacyFieldInsertion: true,
}).extend({
user: UserSchema.pick({

View File

@ -0,0 +1,9 @@
import { z } from 'zod';
export const FolderType = {
DOCUMENT: 'DOCUMENT',
TEMPLATE: 'TEMPLATE',
} as const;
export const ZFolderTypeSchema = z.enum([FolderType.DOCUMENT, FolderType.TEMPLATE]);
export type TFolderType = z.infer<typeof ZFolderTypeSchema>;

View File

@ -1,6 +1,7 @@
import type { z } from 'zod';
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateDirectLinkSchema';
import { TemplateMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateMetaSchema';
@ -29,6 +30,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
updatedAt: true,
publicTitle: true,
publicDescription: true,
folderId: true,
}).extend({
// Todo: Maybe we want to alter this a bit since this returns a lot of data.
templateDocumentData: DocumentDataSchema.pick({
@ -62,6 +64,18 @@ export const ZTemplateSchema = TemplateSchema.pick({
}),
recipients: ZRecipientLiteSchema.array(),
fields: ZFieldSchema.array(),
folder: FolderSchema.pick({
id: true,
name: true,
type: true,
visibility: true,
userId: true,
teamId: true,
pinned: true,
parentId: true,
createdAt: true,
updatedAt: true,
}).nullable(),
});
export type TTemplate = z.infer<typeof ZTemplateSchema>;
@ -83,6 +97,7 @@ export const ZTemplateLiteSchema = TemplateSchema.pick({
updatedAt: true,
publicTitle: true,
publicDescription: true,
folderId: true,
useLegacyFieldInsertion: true,
});
@ -103,6 +118,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
updatedAt: true,
publicTitle: true,
publicDescription: true,
folderId: true,
useLegacyFieldInsertion: true,
}).extend({
team: TeamSchema.pick({

View File

@ -0,0 +1,5 @@
export function formatFolderCount(count: number, singular: string, plural?: string): string {
const itemLabel = count === 1 ? singular : plural || `${singular}s`;
return `${count} ${itemLabel}`;
}

View File

@ -0,0 +1,39 @@
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "folderId" INTEGER;
-- CreateTable
CREATE TABLE "Folder" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"teamId" INTEGER,
"parentId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Folder_userId_idx" ON "Folder"("userId");
-- CreateIndex
CREATE INDEX "Folder_teamId_idx" ON "Folder"("teamId");
-- CreateIndex
CREATE INDEX "Folder_parentId_idx" ON "Folder"("parentId");
-- CreateIndex
CREATE INDEX "Document_folderId_idx" ON "Document"("folderId");
-- AddForeignKey
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Folder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,29 @@
/*
Warnings:
- The primary key for the `Folder` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- DropForeignKey
ALTER TABLE "Document" DROP CONSTRAINT "Document_folderId_fkey";
-- DropForeignKey
ALTER TABLE "Folder" DROP CONSTRAINT "Folder_parentId_fkey";
-- AlterTable
ALTER TABLE "Document" ALTER COLUMN "folderId" SET DATA TYPE TEXT;
-- AlterTable
ALTER TABLE "Folder" DROP CONSTRAINT "Folder_pkey",
ADD COLUMN "pinned" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "id" SET DATA TYPE TEXT,
ALTER COLUMN "parentId" SET DATA TYPE TEXT,
ADD CONSTRAINT "Folder_pkey" PRIMARY KEY ("id");
DROP SEQUENCE "Folder_id_seq";
-- AddForeignKey
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Folder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Folder" ADD COLUMN "visibility" "DocumentVisibility" NOT NULL DEFAULT 'EVERYONE';

View File

@ -0,0 +1,20 @@
/*
Warnings:
- Added the required column `type` to the `Folder` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "FolderType" AS ENUM ('DOCUMENT', 'TEMPLATE');
-- AlterTable
ALTER TABLE "Folder" ADD COLUMN "type" "FolderType" NOT NULL;
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "folderId" TEXT;
-- CreateIndex
CREATE INDEX "Folder_type_idx" ON "Folder"("type");
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

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

View File

@ -56,6 +56,7 @@ model User {
accounts Account[]
sessions Session[]
documents Document[]
folders Folder[]
subscriptions Subscription[]
passwordResetTokens PasswordResetToken[]
ownedTeams Team[]
@ -312,6 +313,35 @@ enum DocumentVisibility {
ADMIN
}
enum FolderType {
DOCUMENT
TEMPLATE
}
model Folder {
id String @id @default(cuid())
name String
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
teamId Int?
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
pinned Boolean @default(false)
parentId String?
parent Folder? @relation("FolderToFolder", fields: [parentId], references: [id], onDelete: Cascade)
documents Document[]
templates Template[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
subfolders Folder[] @relation("FolderToFolder")
visibility DocumentVisibility @default(EVERYONE)
type FolderType
@@index([userId])
@@index([teamId])
@@index([parentId])
@@index([type])
}
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';", "import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';"])
model Document {
id Int @id @default(autoincrement())
@ -344,10 +374,13 @@ model Document {
template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull)
auditLogs DocumentAuditLog[]
folder Folder? @relation(fields: [folderId], references: [id], onDelete: Cascade)
folderId String?
@@unique([documentDataId])
@@index([userId])
@@index([status])
@@index([folderId])
}
model DocumentAuditLog {
@ -593,6 +626,7 @@ model Team {
documents Document[]
templates Template[]
folders Folder[]
apiTokens ApiToken[]
webhooks Webhook[]
}
@ -721,6 +755,8 @@ model Template {
fields Field[]
directLink TemplateDirectLink?
documents Document[]
folder Folder? @relation(fields: [folderId], references: [id], onDelete: Cascade)
folderId String?
@@unique([templateDocumentDataId])
}

View File

@ -0,0 +1,33 @@
import type { User } from '@prisma/client';
import { DocumentStatus, FolderType } from '@prisma/client';
import { prisma } from '..';
import type { Prisma } from '../client';
import { seedDocuments } from './documents';
type CreateFolderOptions = {
type?: string;
createFolderOptions?: Partial<Prisma.FolderUncheckedCreateInput>;
};
export const seedBlankFolder = async (user: User, options: CreateFolderOptions = {}) => {
return await prisma.folder.create({
data: {
name: 'My folder',
userId: user.id,
type: FolderType.DOCUMENT,
...options.createFolderOptions,
},
});
};
export const seedFolderWithDocuments = async (user: User, options: CreateFolderOptions = {}) => {
const folder = await seedBlankFolder(user, options);
await seedDocuments([
{
sender: user,
recipients: [user],
type: DocumentStatus.DRAFT,
},
]);
};

View File

@ -107,8 +107,17 @@ export const documentRouter = router({
.query(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const { query, templateId, page, perPage, orderByDirection, orderByColumn, source, status } =
input;
const {
query,
templateId,
page,
perPage,
orderByDirection,
orderByColumn,
source,
status,
folderId,
} = input;
const documents = await findDocuments({
userId: user.id,
@ -119,6 +128,7 @@ export const documentRouter = router({
status,
page,
perPage,
folderId,
orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined,
});
@ -147,12 +157,14 @@ export const documentRouter = router({
status,
period,
senderIds,
folderId,
} = input;
const getStatOptions: GetStatsInput = {
user,
period,
search: query,
folderId,
};
if (teamId) {
@ -181,6 +193,7 @@ export const documentRouter = router({
status,
period,
senderIds,
folderId,
orderBy: orderByColumn
? { column: orderByColumn, direction: orderByDirection }
: undefined,
@ -212,12 +225,13 @@ export const documentRouter = router({
.output(ZGetDocumentWithDetailsByIdResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { documentId } = input;
const { documentId, folderId } = input;
return await getDocumentWithDetailsById({
userId: user.id,
teamId,
documentId,
folderId,
});
}),
@ -290,6 +304,7 @@ export const documentRouter = router({
return {
document: createdDocument,
folder: createdDocument.folder,
uploadUrl: url,
};
}),
@ -311,7 +326,7 @@ export const documentRouter = router({
.input(ZCreateDocumentRequestSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { title, documentDataId, timezone } = input;
const { title, documentDataId, timezone, folderId } = input;
const { remaining } = await getServerLimits({ email: ctx.user.email, teamId });
@ -330,6 +345,7 @@ export const documentRouter = router({
normalizePdf: true,
timezone,
requestMetadata: ctx.metadata,
folderId,
});
}),

View File

@ -130,6 +130,7 @@ export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({
.nativeEnum(DocumentStatus)
.describe('Filter documents by the current status')
.optional(),
folderId: z.string().describe('Filter documents by folder ID').optional(),
orderByColumn: z.enum(['createdAt']).optional(),
orderByDirection: z.enum(['asc', 'desc']).describe('').default('desc'),
});
@ -144,6 +145,7 @@ export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.e
period: z.enum(['7d', '14d', '30d']).optional(),
senderIds: z.array(z.number()).optional(),
status: z.nativeEnum(ExtendedDocumentStatus).optional(),
folderId: z.string().optional(),
});
export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({
@ -188,6 +190,7 @@ export type TGetDocumentByTokenQuerySchema = z.infer<typeof ZGetDocumentByTokenQ
export const ZGetDocumentWithDetailsByIdRequestSchema = z.object({
documentId: z.number(),
folderId: z.string().describe('Filter documents by folder ID').optional(),
});
export const ZGetDocumentWithDetailsByIdResponseSchema = ZDocumentSchema;
@ -196,6 +199,7 @@ export const ZCreateDocumentRequestSchema = z.object({
title: ZDocumentTitleSchema,
documentDataId: z.string().min(1),
timezone: ZDocumentMetaTimezoneSchema.optional(),
folderId: z.string().describe('The ID of the folder to create the document in').optional(),
});
export const ZCreateDocumentV2RequestSchema = z.object({

View File

@ -0,0 +1,354 @@
import { TRPCError } from '@trpc/server';
import { createFolder } from '@documenso/lib/server-only/folder/create-folder';
import { deleteFolder } from '@documenso/lib/server-only/folder/delete-folder';
import { findFolders } from '@documenso/lib/server-only/folder/find-folders';
import { getFolderBreadcrumbs } from '@documenso/lib/server-only/folder/get-folder-breadcrumbs';
import { getFolderById } from '@documenso/lib/server-only/folder/get-folder-by-id';
import { moveDocumentToFolder } from '@documenso/lib/server-only/folder/move-document-to-folder';
import { moveFolder } from '@documenso/lib/server-only/folder/move-folder';
import { moveTemplateToFolder } from '@documenso/lib/server-only/folder/move-template-to-folder';
import { pinFolder } from '@documenso/lib/server-only/folder/pin-folder';
import { unpinFolder } from '@documenso/lib/server-only/folder/unpin-folder';
import { updateFolder } from '@documenso/lib/server-only/folder/update-folder';
import { FolderType } from '@documenso/lib/types/folder-type';
import { authenticatedProcedure, router } from '../trpc';
import {
ZCreateFolderSchema,
ZDeleteFolderSchema,
ZFindFoldersRequestSchema,
ZFindFoldersResponseSchema,
ZGenericSuccessResponse,
ZGetFoldersResponseSchema,
ZGetFoldersSchema,
ZMoveDocumentToFolderSchema,
ZMoveFolderSchema,
ZMoveTemplateToFolderSchema,
ZPinFolderSchema,
ZSuccessResponseSchema,
ZUnpinFolderSchema,
ZUpdateFolderSchema,
} from './schema';
export const folderRouter = router({
/**
* @private
*/
getFolders: authenticatedProcedure
.input(ZGetFoldersSchema)
.output(ZGetFoldersResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { parentId, type } = input;
const folders = await findFolders({
userId: user.id,
teamId,
parentId,
type,
});
const breadcrumbs = parentId
? await getFolderBreadcrumbs({
userId: user.id,
teamId,
folderId: parentId,
type,
})
: [];
return {
folders,
breadcrumbs,
type,
};
}),
/**
* @private
*/
findFolders: authenticatedProcedure
.input(ZFindFoldersRequestSchema)
.output(ZFindFoldersResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { parentId, type } = input;
const folders = await findFolders({
userId: user.id,
teamId,
parentId,
type,
});
const breadcrumbs = parentId
? await getFolderBreadcrumbs({
userId: user.id,
teamId,
folderId: parentId,
type,
})
: [];
return {
data: folders,
breadcrumbs,
type,
};
}),
/**
* @private
*/
createFolder: authenticatedProcedure
.input(ZCreateFolderSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { name, parentId, type } = input;
if (parentId) {
try {
await getFolderById({
userId: user.id,
teamId,
folderId: parentId,
type,
});
} catch (error) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Parent folder not found',
});
}
}
const result = await createFolder({
userId: user.id,
teamId,
name,
parentId,
type,
});
return {
...result,
type,
};
}),
/**
* @private
*/
updateFolder: authenticatedProcedure
.input(ZUpdateFolderSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { id, name, visibility } = input;
const currentFolder = await getFolderById({
userId: user.id,
teamId,
folderId: id,
});
const result = await updateFolder({
userId: user.id,
teamId,
folderId: id,
name,
visibility,
type: currentFolder.type,
});
return {
...result,
type: currentFolder.type,
};
}),
/**
* @private
*/
deleteFolder: authenticatedProcedure
.input(ZDeleteFolderSchema)
.output(ZSuccessResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { id } = input;
await deleteFolder({
userId: user.id,
teamId,
folderId: id,
});
return ZGenericSuccessResponse;
}),
/**
* @private
*/
moveFolder: authenticatedProcedure.input(ZMoveFolderSchema).mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { id, parentId } = input;
const currentFolder = await getFolderById({
userId: user.id,
teamId,
folderId: id,
});
if (parentId !== null) {
try {
await getFolderById({
userId: user.id,
teamId,
folderId: parentId,
type: currentFolder.type,
});
} catch (error) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Parent folder not found',
});
}
}
const result = await moveFolder({
userId: user.id,
teamId,
folderId: id,
parentId,
requestMetadata: ctx.metadata,
});
return {
...result,
type: currentFolder.type,
};
}),
/**
* @private
*/
moveDocumentToFolder: authenticatedProcedure
.input(ZMoveDocumentToFolderSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { documentId, folderId } = input;
if (folderId !== null) {
try {
await getFolderById({
userId: user.id,
teamId,
folderId,
type: FolderType.DOCUMENT,
});
} catch (error) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Folder not found',
});
}
}
const result = await moveDocumentToFolder({
userId: user.id,
teamId,
documentId,
folderId,
requestMetadata: ctx.metadata,
});
return {
...result,
type: FolderType.DOCUMENT,
};
}),
/**
* @private
*/
moveTemplateToFolder: authenticatedProcedure
.input(ZMoveTemplateToFolderSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { templateId, folderId } = input;
if (folderId !== null) {
try {
await getFolderById({
userId: user.id,
teamId,
folderId,
type: FolderType.TEMPLATE,
});
} catch (error) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Folder not found',
});
}
}
const result = await moveTemplateToFolder({
userId: user.id,
teamId,
templateId,
folderId,
});
return {
...result,
type: FolderType.TEMPLATE,
};
}),
/**
* @private
*/
pinFolder: authenticatedProcedure.input(ZPinFolderSchema).mutation(async ({ ctx, input }) => {
const currentFolder = await getFolderById({
userId: ctx.user.id,
teamId: ctx.teamId,
folderId: input.folderId,
});
const result = await pinFolder({
userId: ctx.user.id,
teamId: ctx.teamId,
folderId: input.folderId,
type: currentFolder.type,
});
return {
...result,
type: currentFolder.type,
};
}),
/**
* @private
*/
unpinFolder: authenticatedProcedure.input(ZUnpinFolderSchema).mutation(async ({ ctx, input }) => {
const currentFolder = await getFolderById({
userId: ctx.user.id,
teamId: ctx.teamId,
folderId: input.folderId,
});
const result = await unpinFolder({
userId: ctx.user.id,
teamId: ctx.teamId,
folderId: input.folderId,
type: currentFolder.type,
});
return {
...result,
type: currentFolder.type,
};
}),
});

View File

@ -0,0 +1,132 @@
import { z } from 'zod';
import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type';
import { DocumentVisibility } from '@documenso/prisma/generated/types';
/**
* Required for empty responses since we currently can't 201 requests for our openapi setup.
*
* Without this it will throw an error in Speakeasy SDK when it tries to parse an empty response.
*/
export const ZSuccessResponseSchema = z.object({
success: z.boolean(),
type: ZFolderTypeSchema.optional(),
});
export const ZGenericSuccessResponse = {
success: true,
} satisfies z.infer<typeof ZSuccessResponseSchema>;
export const ZFolderSchema = z.object({
id: z.string(),
name: z.string(),
userId: z.number(),
teamId: z.number().nullable(),
parentId: z.string().nullable(),
pinned: z.boolean(),
createdAt: z.date(),
updatedAt: z.date(),
visibility: z.nativeEnum(DocumentVisibility),
type: ZFolderTypeSchema,
});
export type TFolder = z.infer<typeof ZFolderSchema>;
const ZFolderCountSchema = z.object({
documents: z.number(),
templates: z.number(),
subfolders: z.number(),
});
const ZSubfolderSchema = ZFolderSchema.extend({
subfolders: z.array(z.any()),
_count: ZFolderCountSchema,
});
export const ZFolderWithSubfoldersSchema = ZFolderSchema.extend({
subfolders: z.array(ZSubfolderSchema),
_count: ZFolderCountSchema,
});
export type TFolderWithSubfolders = z.infer<typeof ZFolderWithSubfoldersSchema>;
export const ZCreateFolderSchema = z.object({
name: z.string(),
parentId: z.string().optional(),
type: ZFolderTypeSchema.optional(),
});
export const ZUpdateFolderSchema = z.object({
id: z.string(),
name: z.string(),
visibility: z.nativeEnum(DocumentVisibility),
type: ZFolderTypeSchema.optional(),
});
export type TUpdateFolderSchema = z.infer<typeof ZUpdateFolderSchema>;
export const ZDeleteFolderSchema = z.object({
id: z.string(),
type: ZFolderTypeSchema.optional(),
});
export const ZMoveFolderSchema = z.object({
id: z.string(),
parentId: z.string().nullable(),
type: ZFolderTypeSchema.optional(),
});
export const ZMoveDocumentToFolderSchema = z.object({
documentId: z.number(),
folderId: z.string().nullable().optional(),
type: z.enum(['DOCUMENT']).optional(),
});
export const ZMoveTemplateToFolderSchema = z.object({
templateId: z.number(),
folderId: z.string().nullable().optional(),
type: z.enum(['TEMPLATE']).optional(),
});
export const ZPinFolderSchema = z.object({
folderId: z.string(),
type: ZFolderTypeSchema.optional(),
});
export const ZUnpinFolderSchema = z.object({
folderId: z.string(),
type: ZFolderTypeSchema.optional(),
});
export const ZGetFoldersSchema = z.object({
parentId: z.string().nullable().optional(),
type: ZFolderTypeSchema.optional(),
});
export const ZGetFoldersResponseSchema = z.object({
folders: z.array(ZFolderWithSubfoldersSchema),
breadcrumbs: z.array(ZFolderSchema),
type: ZFolderTypeSchema.optional(),
});
export type TGetFoldersResponse = z.infer<typeof ZGetFoldersResponseSchema>;
export const ZFindSearchParamsSchema = z.object({
query: z.string().optional(),
page: z.number().optional(),
perPage: z.number().optional(),
type: ZFolderTypeSchema.optional(),
});
export const ZFindFoldersRequestSchema = ZFindSearchParamsSchema.extend({
parentId: z.string().nullable().optional(),
type: ZFolderTypeSchema.optional(),
});
export const ZFindFoldersResponseSchema = z.object({
data: z.array(ZFolderWithSubfoldersSchema),
breadcrumbs: z.array(ZFolderSchema),
type: ZFolderTypeSchema.optional(),
});
export type TFindFoldersResponse = z.infer<typeof ZFindFoldersResponseSchema>;

View File

@ -4,6 +4,7 @@ import { authRouter } from './auth-router/router';
import { documentRouter } from './document-router/router';
import { embeddingPresignRouter } from './embedding-router/_router';
import { fieldRouter } from './field-router/router';
import { folderRouter } from './folder-router/router';
import { profileRouter } from './profile-router/router';
import { recipientRouter } from './recipient-router/router';
import { shareLinkRouter } from './share-link-router/router';
@ -17,6 +18,7 @@ export const appRouter = router({
profile: profileRouter,
document: documentRouter,
field: fieldRouter,
folder: folderRouter,
recipient: recipientRouter,
admin: adminRouter,
shareLink: shareLinkRouter,

View File

@ -121,13 +121,14 @@ export const templateRouter = router({
.output(ZCreateTemplateResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { title, templateDocumentDataId } = input;
const { title, templateDocumentDataId, folderId } = input;
return await createTemplate({
userId: ctx.user.id,
teamId,
title,
templateDocumentDataId,
folderId,
});
}),

View File

@ -33,6 +33,7 @@ import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema';
export const ZCreateTemplateMutationSchema = z.object({
title: z.string().min(1).trim(),
templateDocumentDataId: z.string().min(1),
folderId: z.string().optional(),
});
export const ZCreateDocumentFromDirectTemplateRequestSchema = z.object({
@ -179,6 +180,7 @@ export const ZUpdateTemplateResponseSchema = ZTemplateLiteSchema;
export const ZFindTemplatesRequestSchema = ZFindSearchParamsSchema.extend({
type: z.nativeEnum(TemplateType).describe('Filter templates by type.').optional(),
folderId: z.string().describe('The ID of the folder to filter templates by.').optional(),
});
export const ZFindTemplatesResponseSchema = ZFindResultResponse.extend({

View File

@ -0,0 +1,88 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Upload } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { Link } from 'react-router';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { Button } from './button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip';
export type DocumentDropzoneProps = {
className?: string;
disabled?: boolean;
disabledMessage?: MessageDescriptor;
onDrop?: (_file: File) => void | Promise<void>;
onDropRejected?: () => void | Promise<void>;
type?: 'document' | 'template';
[key: string]: unknown;
};
export const DocumentDropzone = ({
className,
onDrop,
onDropRejected,
disabled,
disabledMessage = msg`You cannot upload documents at this time.`,
type = 'document',
...props
}: DocumentDropzoneProps) => {
const { _ } = useLingui();
const { getRootProps, getInputProps } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
multiple: false,
disabled,
onDrop: ([acceptedFile]) => {
if (acceptedFile && onDrop) {
void onDrop(acceptedFile);
}
},
onDropRejected: () => {
if (onDropRejected) {
void onDropRejected();
}
},
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
});
const heading = {
document: msg`Upload Document`,
template: msg`Upload Template Document`,
};
if (disabled && IS_BILLING_ENABLED()) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button className="hover:bg-warning/80 bg-warning" asChild>
<Link to="/settings/billing">
<Trans>Upgrade</Trans>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="text-sm">{_(disabledMessage)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return (
<Button aria-disabled={disabled} {...getRootProps()} {...props}>
<div className="flex items-center gap-2">
<input {...getInputProps()} />
<Upload className="h-4 w-4" />
{disabled ? _(disabledMessage) : _(heading[type])}
</div>
</Button>
);
};