mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: add folders (#1711)
This commit is contained in:
@ -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],
|
||||
|
||||
@ -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();
|
||||
});
|
||||
2873
packages/app-tests/e2e/folders/team-account-folders.spec.ts
Normal file
2873
packages/app-tests/e2e/folders/team-account-folders.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -21,4 +21,4 @@
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -233,6 +233,7 @@ export const createDocumentV2 = async ({
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
fields: true,
|
||||
folder: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
86
packages/lib/server-only/folder/create-folder.ts
Normal file
86
packages/lib/server-only/folder/create-folder.ts
Normal 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,
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
85
packages/lib/server-only/folder/delete-folder.ts
Normal file
85
packages/lib/server-only/folder/delete-folder.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
152
packages/lib/server-only/folder/find-folders.ts
Normal file
152
packages/lib/server-only/folder/find-folders.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
112
packages/lib/server-only/folder/get-folder-breadcrumbs.ts
Normal file
112
packages/lib/server-only/folder/get-folder-breadcrumbs.ts
Normal 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;
|
||||
};
|
||||
92
packages/lib/server-only/folder/get-folder-by-id.ts
Normal file
92
packages/lib/server-only/folder/get-folder-by-id.ts
Normal 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;
|
||||
};
|
||||
130
packages/lib/server-only/folder/move-document-to-folder.ts
Normal file
130
packages/lib/server-only/folder/move-document-to-folder.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
85
packages/lib/server-only/folder/move-folder.ts
Normal file
85
packages/lib/server-only/folder/move-folder.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
59
packages/lib/server-only/folder/move-template-to-folder.ts
Normal file
59
packages/lib/server-only/folder/move-template-to-folder.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
37
packages/lib/server-only/folder/pin-folder.ts
Normal file
37
packages/lib/server-only/folder/pin-folder.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
37
packages/lib/server-only/folder/unpin-folder.ts
Normal file
37
packages/lib/server-only/folder/unpin-folder.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
53
packages/lib/server-only/folder/update-folder.ts
Normal file
53
packages/lib/server-only/folder/update-folder.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
9
packages/lib/types/folder-type.ts
Normal file
9
packages/lib/types/folder-type.ts
Normal 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>;
|
||||
@ -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({
|
||||
|
||||
5
packages/lib/utils/format-folder-count.ts
Normal file
5
packages/lib/utils/format-folder-count.ts
Normal 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}`;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Folder" ADD COLUMN "visibility" "DocumentVisibility" NOT NULL DEFAULT 'EVERYONE';
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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])
|
||||
}
|
||||
|
||||
33
packages/prisma/seed/folders.ts
Normal file
33
packages/prisma/seed/folders.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
354
packages/trpc/server/folder-router/router.ts
Normal file
354
packages/trpc/server/folder-router/router.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
132
packages/trpc/server/folder-router/schema.ts
Normal file
132
packages/trpc/server/folder-router/schema.ts
Normal 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>;
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
88
packages/ui/primitives/document-upload.tsx
Normal file
88
packages/ui/primitives/document-upload.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user