diff --git a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx index c4c85c051..45ec1465a 100644 --- a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx @@ -71,7 +71,7 @@ export const DocumentMoveToFolderDialog = ({ }, }); - const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery( + const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery( { parentId: currentFolderId, type: FolderType.DOCUMENT, diff --git a/apps/remix/app/components/dialogs/folder-delete-dialog.tsx b/apps/remix/app/components/dialogs/folder-delete-dialog.tsx index 51dedb72d..cec8b1da0 100644 --- a/apps/remix/app/components/dialogs/folder-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-delete-dialog.tsx @@ -63,7 +63,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet const onFormSubmit = async () => { try { await deleteFolder({ - id: folder.id, + folderId: folder.id, }); onOpenChange(false); diff --git a/apps/remix/app/components/dialogs/folder-move-dialog.tsx b/apps/remix/app/components/dialogs/folder-move-dialog.tsx index 28673c4b3..b12922a6a 100644 --- a/apps/remix/app/components/dialogs/folder-move-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -53,7 +53,7 @@ export const FolderMoveDialog = ({ const { toast } = useToast(); const [searchTerm, setSearchTerm] = useState(''); - const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation(); + const { mutateAsync: moveFolder } = trpc.folder.updateFolder.useMutation(); const form = useForm({ resolver: zodResolver(ZMoveFolderFormSchema), @@ -63,12 +63,16 @@ export const FolderMoveDialog = ({ }); const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => { - if (!folder) return; + if (!folder) { + return; + } try { await moveFolder({ - id: folder.id, - parentId: targetFolderId || null, + folderId: folder.id, + data: { + parentId: targetFolderId || null, + }, }); onOpenChange(false); diff --git a/apps/remix/app/components/dialogs/folder-update-dialog.tsx b/apps/remix/app/components/dialogs/folder-update-dialog.tsx index ddc219201..145defbcd 100644 --- a/apps/remix/app/components/dialogs/folder-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-update-dialog.tsx @@ -61,8 +61,6 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat const { toast } = useToast(); const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation(); - const isTeamContext = !!team; - const form = useForm>({ resolver: zodResolver(ZUpdateFolderFormSchema), defaultValues: { @@ -87,11 +85,11 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat try { await updateFolder({ - id: folder.id, - name: data.name, - visibility: isTeamContext - ? (data.visibility ?? DocumentVisibility.EVERYONE) - : DocumentVisibility.EVERYONE, + folderId: folder.id, + data: { + name: data.name, + visibility: data.visibility, + }, }); toast({ @@ -140,38 +138,36 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat )} /> - {isTeamContext && ( - ( - - - Visibility - - - - - )} - /> - )} + ( + + + Visibility + + + + + )} + /> diff --git a/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx index d6ccfa07d..fc8149d25 100644 --- a/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx @@ -73,7 +73,7 @@ export function TemplateMoveToFolderDialog({ }, }); - const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery( + const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery( { parentId: currentFolderId ?? null, type: FolderType.TEMPLATE, diff --git a/apps/remix/app/components/general/folder/folder-card.tsx b/apps/remix/app/components/general/folder/folder-card.tsx index db88ebb7f..0c6cc6781 100644 --- a/apps/remix/app/components/general/folder/folder-card.tsx +++ b/apps/remix/app/components/general/folder/folder-card.tsx @@ -12,6 +12,7 @@ import { import { Link } from 'react-router'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -28,22 +29,15 @@ import { useCurrentTeam } from '~/providers/team'; export type FolderCardProps = { folder: TFolderWithSubfolders; onMove: (folder: TFolderWithSubfolders) => void; - onPin: (folderId: string) => void; - onUnpin: (folderId: string) => void; onSettings: (folder: TFolderWithSubfolders) => void; onDelete: (folder: TFolderWithSubfolders) => void; }; -export const FolderCard = ({ - folder, - onMove, - onPin, - onUnpin, - onSettings, - onDelete, -}: FolderCardProps) => { +export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardProps) => { const team = useCurrentTeam(); + const { mutateAsync: updateFolderMutation } = trpc.folder.updateFolder.useMutation(); + const formatPath = () => { const rootPath = folder.type === FolderType.DOCUMENT @@ -53,6 +47,15 @@ export const FolderCard = ({ return `${rootPath}/f/${folder.id}`; }; + const updateFolder = async ({ pinned }: { pinned: boolean }) => { + await updateFolderMutation({ + folderId: folder.id, + data: { + pinned, + }, + }); + }; + return ( @@ -112,9 +115,7 @@ export const FolderCard = ({ Move - (folder.pinned ? onUnpin(folder.id) : onPin(folder.id))} - > + updateFolder({ pinned: !folder.pinned })}> {folder.pinned ? Unpin : Pin} diff --git a/apps/remix/app/components/general/folder/folder-grid.tsx b/apps/remix/app/components/general/folder/folder-grid.tsx index 6d6e6a2f4..05afc90cd 100644 --- a/apps/remix/app/components/general/folder/folder-grid.tsx +++ b/apps/remix/app/components/general/folder/folder-grid.tsx @@ -34,9 +34,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => { const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false); const [folderToSettings, setFolderToSettings] = useState(null); - const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation(); - const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation(); - const { data: foldersData, isPending } = trpc.folder.getFolders.useQuery({ type, parentId, @@ -155,8 +152,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => { setFolderToMove(folder); setIsMovingFolder(true); }} - onPin={(folderId) => void pinFolder({ folderId })} - onUnpin={(folderId) => void unpinFolder({ folderId })} onSettings={(folder) => { setFolderToSettings(folder); setIsSettingsFolderOpen(true); @@ -180,8 +175,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => { setFolderToMove(folder); setIsMovingFolder(true); }} - onPin={(folderId) => void pinFolder({ folderId })} - onUnpin={(folderId) => void unpinFolder({ folderId })} onSettings={(folder) => { setFolderToSettings(folder); setIsSettingsFolderOpen(true); diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx index 958e0dc29..ca18849ce 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx @@ -42,9 +42,6 @@ export default function DocumentsFoldersPage() { parentId: null, }); - const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation(); - const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation(); - const navigateToFolder = (folderId?: string | null) => { const documentsPath = formatDocumentsPath(team.url); @@ -113,8 +110,6 @@ export default function DocumentsFoldersPage() { setFolderToMove(folder); setIsMovingFolder(true); }} - onPin={(folderId) => void pinFolder({ folderId })} - onUnpin={(folderId) => void unpinFolder({ folderId })} onSettings={(folder) => { setFolderToSettings(folder); setIsSettingsFolderOpen(true); @@ -147,8 +142,6 @@ export default function DocumentsFoldersPage() { setFolderToMove(folder); setIsMovingFolder(true); }} - onPin={(folderId) => void pinFolder({ folderId })} - onUnpin={(folderId) => void unpinFolder({ folderId })} onSettings={(folder) => { setFolderToSettings(folder); setIsSettingsFolderOpen(true); diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.folders._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.folders._index.tsx index 67f22d4b8..f23d18b42 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.folders._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.folders._index.tsx @@ -42,9 +42,6 @@ export default function TemplatesFoldersPage() { parentId: null, }); - const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation(); - const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation(); - const navigateToFolder = (folderId?: string | null) => { const templatesPath = formatTemplatesPath(team.url); @@ -113,8 +110,6 @@ export default function TemplatesFoldersPage() { setFolderToMove(folder); setIsMovingFolder(true); }} - onPin={(folderId) => void pinFolder({ folderId })} - onUnpin={(folderId) => void unpinFolder({ folderId })} onSettings={(folder) => { setFolderToSettings(folder); setIsSettingsFolderOpen(true); @@ -147,8 +142,6 @@ export default function TemplatesFoldersPage() { setFolderToMove(folder); setIsMovingFolder(true); }} - onPin={(folderId) => void pinFolder({ folderId })} - onUnpin={(folderId) => void unpinFolder({ folderId })} onSettings={(folder) => { setFolderToSettings(folder); setIsSettingsFolderOpen(true); diff --git a/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts b/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts index d45ea4512..77686346e 100644 --- a/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts +++ b/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts @@ -24,6 +24,7 @@ import { seedDraftDocument, seedPendingDocument, } 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'; @@ -326,11 +327,6 @@ test.describe('Document API V2', () => { data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) }, }); - const asdf = await res.json(); - console.log({ - asdf, - }); - expect(res.ok()).toBeTruthy(); expect(res.status()).toBe(200); }); @@ -407,11 +403,6 @@ test.describe('Document API V2', () => { headers: { Authorization: `Bearer ${tokenA}` }, }); - const asdf = await res.json(); - console.log({ - asdf, - }); - expect(res.ok()).toBeTruthy(); expect(res.status()).toBe(200); }); @@ -2715,4 +2706,154 @@ test.describe('Document API V2', () => { expect(res.status()).toBe(200); }); }); + + test.describe('Folder list endpoint', () => { + test('should block unauthorized access to folder list endpoint', async ({ request }) => { + await seedBlankFolder(userA, teamA.id); + await seedBlankFolder(userA, teamA.id); + + const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/folder`, { + headers: { Authorization: `Bearer ${tokenB}` }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const { data } = await res.json(); + expect(data.every((folder: { userId: number }) => folder.userId !== userA.id)).toBe(true); + expect(data.length).toBe(0); + }); + + test('should allow authorized access to folder list endpoint', async ({ request }) => { + await seedBlankFolder(userA, teamA.id); + await seedBlankFolder(userA, teamA.id); + + // Other team folders should not be visible. + await seedBlankFolder(userA, teamB.id); + await seedBlankFolder(userA, teamB.id); + + // Other team and user folders should not be visible. + await seedBlankFolder(userB, teamB.id); + await seedBlankFolder(userB, teamB.id); + + const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/folder`, { + headers: { Authorization: `Bearer ${tokenA}` }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const { data } = await res.json(); + + expect(data.length).toBe(2); + expect(data.every((folder: { userId: number }) => folder.userId === userA.id)).toBe(true); + }); + }); + + test.describe('Folder create endpoint', () => { + test('should block unauthorized access to folder create endpoint', async ({ request }) => { + const unauthorizedFolder = await seedBlankFolder(userB, teamB.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + parentId: unauthorizedFolder.id, + name: 'Test Folder', + type: 'DOCUMENT', + }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to folder create endpoint', async ({ request }) => { + const authorizedFolder = await seedBlankFolder(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + parentId: authorizedFolder.id, + name: 'Test Folder', + type: 'DOCUMENT', + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const noParentRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + name: 'Test Folder', + type: 'DOCUMENT', + }, + }); + + expect(noParentRes.ok()).toBeTruthy(); + expect(noParentRes.status()).toBe(200); + }); + }); + + test.describe('Folder update endpoint', () => { + test('should block unauthorized access to folder update endpoint', async ({ request }) => { + const folder = await seedBlankFolder(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/update`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + folderId: folder.id, + data: { + name: 'Updated Folder Name', + }, + }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to folder update endpoint', async ({ request }) => { + const folder = await seedBlankFolder(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/update`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + folderId: folder.id, + data: { + name: 'Updated Folder Name', + }, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Folder delete endpoint', () => { + test('should block unauthorized access to folder delete endpoint', async ({ request }) => { + const folder = await seedBlankFolder(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/delete`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { folderId: folder.id }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to folder delete endpoint', async ({ request }) => { + const folder = await seedBlankFolder(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/delete`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { folderId: folder.id }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); }); diff --git a/packages/lib/server-only/folder/create-folder.ts b/packages/lib/server-only/folder/create-folder.ts index 9939f9634..9967fdd81 100644 --- a/packages/lib/server-only/folder/create-folder.ts +++ b/packages/lib/server-only/folder/create-folder.ts @@ -1,7 +1,9 @@ import { prisma } from '@documenso/prisma'; +import { AppError, AppErrorCode } from '../../errors/app-error'; import type { TFolderType } from '../../types/folder-type'; import { FolderType } from '../../types/folder-type'; +import { buildTeamWhereQuery } from '../../utils/teams'; import { getTeamSettings } from '../team/get-team-settings'; export interface CreateFolderOptions { @@ -22,6 +24,27 @@ export const createFolder = async ({ // This indirectly verifies whether the user has access to the team. const settings = await getTeamSettings({ userId, teamId }); + if (parentId) { + const parentFolder = await prisma.folder.findFirst({ + where: { + id: parentId, + team: buildTeamWhereQuery({ teamId, userId }), + }, + }); + + if (!parentFolder) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Parent folder not found', + }); + } + + if (parentFolder.type !== type) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'Parent folder type does not match the folder type', + }); + } + } + return await prisma.folder.create({ data: { name, diff --git a/packages/lib/server-only/folder/delete-folder.ts b/packages/lib/server-only/folder/delete-folder.ts index c7e9615af..edcb50e30 100644 --- a/packages/lib/server-only/folder/delete-folder.ts +++ b/packages/lib/server-only/folder/delete-folder.ts @@ -1,6 +1,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; +import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams'; import { getTeamById } from '../team/get-team'; @@ -20,6 +21,9 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt teamId, userId, }), + visibility: { + in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole], + }, }, }); @@ -39,7 +43,7 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt return await prisma.folder.delete({ where: { - id: folderId, + id: folder.id, }, }); }; diff --git a/packages/lib/server-only/folder/find-folders-internal.ts b/packages/lib/server-only/folder/find-folders-internal.ts new file mode 100644 index 000000000..ccd894bd1 --- /dev/null +++ b/packages/lib/server-only/folder/find-folders-internal.ts @@ -0,0 +1,117 @@ +import { EnvelopeType } from '@prisma/client'; + +import { prisma } from '@documenso/prisma'; + +import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; +import type { TFolderType } from '../../types/folder-type'; +import { getTeamById } from '../team/get-team'; + +export interface FindFoldersInternalOptions { + userId: number; + teamId: number; + parentId?: string | null; + type?: TFolderType; +} + +export const findFoldersInternal = async ({ + userId, + teamId, + parentId, + type, +}: FindFoldersInternalOptions) => { + const team = await getTeamById({ userId, teamId }); + + const visibilityFilters = { + visibility: { + in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole], + }, + }; + + const whereClause = { + AND: [ + { parentId }, + { + OR: [ + { teamId, ...visibilityFilters }, + { userId, teamId }, + ], + }, + ], + }; + + 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, + ...visibilityFilters, + }, + orderBy: { + createdAt: 'desc', + }, + }), + prisma.envelope.count({ + where: { + type: EnvelopeType.DOCUMENT, + folderId: folder.id, + }, + }), + prisma.envelope.count({ + where: { + type: EnvelopeType.TEMPLATE, + folderId: folder.id, + }, + }), + prisma.folder.count({ + where: { + parentId: folder.id, + teamId, + ...visibilityFilters, + }, + }), + ]); + + 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; + } +}; diff --git a/packages/lib/server-only/folder/find-folders.ts b/packages/lib/server-only/folder/find-folders.ts index 957870ef9..404bfc285 100644 --- a/packages/lib/server-only/folder/find-folders.ts +++ b/packages/lib/server-only/folder/find-folders.ts @@ -1,9 +1,11 @@ -import { EnvelopeType } from '@prisma/client'; +import type { Prisma } from '@prisma/client'; import { prisma } from '@documenso/prisma'; import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; import type { TFolderType } from '../../types/folder-type'; +import type { FindResultResponse } from '../../types/search-params'; +import { buildTeamWhereQuery } from '../../utils/teams'; import { getTeamById } from '../team/get-team'; export interface FindFoldersOptions { @@ -11,102 +13,48 @@ export interface FindFoldersOptions { teamId: number; parentId?: string | null; type?: TFolderType; + page?: number; + perPage?: number; } -export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => { +export const findFolders = async ({ + userId, + teamId, + parentId, + type, + page = 1, + perPage = 10, +}: FindFoldersOptions) => { const team = await getTeamById({ userId, teamId }); - const visibilityFilters = { + const whereClause: Prisma.FolderWhereInput = { + parentId, + team: buildTeamWhereQuery({ teamId, userId }), + type, visibility: { in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole], }, }; - const whereClause = { - AND: [ - { parentId }, - { - OR: [ - { teamId, ...visibilityFilters }, - { userId, teamId }, - ], + const [data, count] = await Promise.all([ + prisma.folder.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + createdAt: 'desc', }, - ], - }; + }), + prisma.folder.count({ + where: whereClause, + }), + ]); - 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, - ...visibilityFilters, - }, - orderBy: { - createdAt: 'desc', - }, - }), - prisma.envelope.count({ - where: { - type: EnvelopeType.DOCUMENT, - folderId: folder.id, - }, - }), - prisma.envelope.count({ - where: { - type: EnvelopeType.TEMPLATE, - folderId: folder.id, - }, - }), - prisma.folder.count({ - where: { - parentId: folder.id, - teamId, - ...visibilityFilters, - }, - }), - ]); - - 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; - } + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultResponse; }; diff --git a/packages/lib/server-only/folder/get-folder-by-id.ts b/packages/lib/server-only/folder/get-folder-by-id.ts index 64cb563c6..845a6e1a1 100644 --- a/packages/lib/server-only/folder/get-folder-by-id.ts +++ b/packages/lib/server-only/folder/get-folder-by-id.ts @@ -1,51 +1,30 @@ -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 { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; import type { TFolderType } from '../../types/folder-type'; +import { buildTeamWhereQuery } from '../../utils/teams'; import { getTeamById } from '../team/get-team'; export interface GetFolderByIdOptions { userId: number; teamId: number; - folderId?: string; + folderId: string; type?: TFolderType; } export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => { const team = await getTeamById({ userId, teamId }); - const visibilityFilters = match(team.currentTeamRole) - .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 } : {}), - OR: [ - { teamId, ...visibilityFilters }, - { userId, teamId }, - ], - }; - const folder = await prisma.folder.findFirst({ - where: whereClause, + where: { + id: folderId, + team: buildTeamWhereQuery({ teamId, userId }), + type, + visibility: { + in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole], + }, + }, }); if (!folder) { diff --git a/packages/lib/server-only/folder/move-folder.ts b/packages/lib/server-only/folder/move-folder.ts deleted file mode 100644 index c42319173..000000000 --- a/packages/lib/server-only/folder/move-folder.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; -import { prisma } from '@documenso/prisma'; - -import { buildTeamWhereQuery } from '../../utils/teams'; - -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, - team: buildTeamWhereQuery({ - teamId, - userId, - }), - }, - }); - - 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, - }, - }); - }); -}; diff --git a/packages/lib/server-only/folder/pin-folder.ts b/packages/lib/server-only/folder/pin-folder.ts deleted file mode 100644 index 10e48dbb9..000000000 --- a/packages/lib/server-only/folder/pin-folder.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { prisma } from '@documenso/prisma'; - -import type { TFolderType } from '../../types/folder-type'; -import { buildTeamWhereQuery } from '../../utils/teams'; - -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, - team: buildTeamWhereQuery({ - teamId, - userId, - }), - type, - }, - }); - - if (!folder) { - throw new AppError(AppErrorCode.NOT_FOUND, { - message: 'Folder not found', - }); - } - - return await prisma.folder.update({ - where: { - id: folderId, - }, - data: { - pinned: true, - }, - }); -}; diff --git a/packages/lib/server-only/folder/unpin-folder.ts b/packages/lib/server-only/folder/unpin-folder.ts deleted file mode 100644 index 9adf1c11e..000000000 --- a/packages/lib/server-only/folder/unpin-folder.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { prisma } from '@documenso/prisma'; - -import type { TFolderType } from '../../types/folder-type'; -import { buildTeamWhereQuery } from '../../utils/teams'; - -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, - team: buildTeamWhereQuery({ - teamId, - userId, - }), - type, - }, - }); - - if (!folder) { - throw new AppError(AppErrorCode.NOT_FOUND, { - message: 'Folder not found', - }); - } - - return await prisma.folder.update({ - where: { - id: folderId, - }, - data: { - pinned: false, - }, - }); -}; diff --git a/packages/lib/server-only/folder/update-folder.ts b/packages/lib/server-only/folder/update-folder.ts index d3fbeaba9..198b72047 100644 --- a/packages/lib/server-only/folder/update-folder.ts +++ b/packages/lib/server-only/folder/update-folder.ts @@ -1,28 +1,28 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; -import { DocumentVisibility } from '@documenso/prisma/generated/types'; +import type { DocumentVisibility } from '@documenso/prisma/generated/types'; -import type { TFolderType } from '../../types/folder-type'; -import { FolderType } from '../../types/folder-type'; +import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; import { buildTeamWhereQuery } from '../../utils/teams'; +import { getTeamById } from '../team/get-team'; export interface UpdateFolderOptions { userId: number; - teamId?: number; + teamId: number; folderId: string; - name: string; - visibility: DocumentVisibility; - type?: TFolderType; + data: { + parentId?: string | null; + name?: string; + visibility?: DocumentVisibility; + pinned?: boolean; + }; } -export const updateFolder = async ({ - userId, - teamId, - folderId, - name, - visibility, - type, -}: UpdateFolderOptions) => { +export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFolderOptions) => { + const { parentId, name, visibility, pinned } = data; + + const team = await getTeamById({ userId, teamId }); + const folder = await prisma.folder.findFirst({ where: { id: folderId, @@ -30,7 +30,9 @@ export const updateFolder = async ({ teamId, userId, }), - type, + visibility: { + in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole], + }, }, }); @@ -40,17 +42,66 @@ export const updateFolder = async ({ }); } - const isTemplateFolder = folder.type === FolderType.TEMPLATE; - const effectiveVisibility = - isTemplateFolder && teamId !== null ? DocumentVisibility.EVERYONE : visibility; + if (parentId) { + const parentFolder = await prisma.folder.findFirst({ + where: { + id: parentId, + team: buildTeamWhereQuery({ teamId, userId }), + 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 prisma.folder.findUnique({ + where: { + id: currentParentId, + }, + select: { + parentId: true, + }, + }); + + if (!currentParent) { + break; + } + + currentParentId = currentParent.parentId; + } + } return await prisma.folder.update({ where: { id: folderId, + team: buildTeamWhereQuery({ + teamId, + userId, + }), }, data: { name, - visibility: effectiveVisibility, + visibility, + parentId, + pinned, }, }); }; diff --git a/packages/trpc/server/folder-router/router.ts b/packages/trpc/server/folder-router/router.ts index 53148c43b..fa9a7e2e2 100644 --- a/packages/trpc/server/folder-router/router.ts +++ b/packages/trpc/server/folder-router/router.ts @@ -2,27 +2,26 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; 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 { findFoldersInternal } from '@documenso/lib/server-only/folder/find-folders-internal'; import { getFolderBreadcrumbs } from '@documenso/lib/server-only/folder/get-folder-breadcrumbs'; import { getFolderById } from '@documenso/lib/server-only/folder/get-folder-by-id'; -import { moveFolder } from '@documenso/lib/server-only/folder/move-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 { authenticatedProcedure, router } from '../trpc'; import { - ZCreateFolderSchema, - ZDeleteFolderSchema, + ZCreateFolderRequestSchema, + ZCreateFolderResponseSchema, + ZDeleteFolderRequestSchema, + ZFindFoldersInternalRequestSchema, + ZFindFoldersInternalResponseSchema, ZFindFoldersRequestSchema, ZFindFoldersResponseSchema, ZGenericSuccessResponse, ZGetFoldersResponseSchema, ZGetFoldersSchema, - ZMoveFolderSchema, - ZPinFolderSchema, ZSuccessResponseSchema, - ZUnpinFolderSchema, - ZUpdateFolderSchema, + ZUpdateFolderRequestSchema, + ZUpdateFolderResponseSchema, } from './schema'; export const folderRouter = router({ @@ -43,7 +42,7 @@ export const folderRouter = router({ }, }); - const folders = await findFolders({ + const folders = await findFoldersInternal({ userId: user.id, teamId, parentId, @@ -67,11 +66,47 @@ export const folderRouter = router({ }), /** - * @private + * @public */ findFolders: authenticatedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/folder', + summary: 'Find folders', + description: 'Find folders based on a search criteria', + tags: ['Folder'], + }, + }) .input(ZFindFoldersRequestSchema) .output(ZFindFoldersResponseSchema) + .query(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { parentId, type, page, perPage } = input; + + ctx.logger.info({ + input: { + parentId, + type, + }, + }); + + return await findFolders({ + userId: user.id, + teamId, + parentId, + type, + page, + perPage, + }); + }), + + /** + * @private + */ + findFoldersInternal: authenticatedProcedure + .input(ZFindFoldersInternalRequestSchema) + .output(ZFindFoldersInternalResponseSchema) .query(async ({ input, ctx }) => { const { teamId, user } = ctx; const { parentId, type } = input; @@ -83,7 +118,7 @@ export const folderRouter = router({ }, }); - const folders = await findFolders({ + const folders = await findFoldersInternal({ userId: user.id, teamId, parentId, @@ -107,10 +142,20 @@ export const folderRouter = router({ }), /** - * @private + * @public */ createFolder: authenticatedProcedure - .input(ZCreateFolderSchema) + .meta({ + openapi: { + method: 'POST', + path: '/folder/create', + summary: 'Create new folder', + description: 'Creates a new folder in your team', + tags: ['Folder'], + }, + }) + .input(ZCreateFolderRequestSchema) + .output(ZCreateFolderResponseSchema) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; const { name, parentId, type } = input; @@ -145,181 +190,77 @@ export const folderRouter = router({ type, }); - return { - ...result, - type, - }; + return result; }), /** - * @private + * @public */ updateFolder: authenticatedProcedure - .input(ZUpdateFolderSchema) + .meta({ + openapi: { + method: 'POST', + path: '/folder/update', + summary: 'Update folder', + description: 'Updates an existing folder', + tags: ['Folder'], + }, + }) + .input(ZUpdateFolderRequestSchema) + .output(ZUpdateFolderResponseSchema) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; - const { id, name, visibility } = input; + const { folderId, data } = input; ctx.logger.info({ input: { - id, + folderId, }, }); - 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, + folderId, + data, }); return { ...result, - type: currentFolder.type, }; }), /** - * @private + * @public */ deleteFolder: authenticatedProcedure - .input(ZDeleteFolderSchema) + .meta({ + openapi: { + method: 'POST', + path: '/folder/delete', + summary: 'Delete folder', + description: 'Deletes an existing folder', + tags: ['Folder'], + }, + }) + .input(ZDeleteFolderRequestSchema) .output(ZSuccessResponseSchema) .mutation(async ({ input, ctx }) => { const { teamId, user } = ctx; - const { id } = input; + const { folderId } = input; ctx.logger.info({ input: { - id, + folderId, }, }); await deleteFolder({ userId: user.id, teamId, - folderId: id, + folderId, }); return ZGenericSuccessResponse; }), - - /** - * @private - */ - moveFolder: authenticatedProcedure.input(ZMoveFolderSchema).mutation(async ({ input, ctx }) => { - const { teamId, user } = ctx; - const { id, parentId } = input; - - ctx.logger.info({ - input: { - id, - parentId, - }, - }); - - 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 AppError(AppErrorCode.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 - */ - pinFolder: authenticatedProcedure.input(ZPinFolderSchema).mutation(async ({ ctx, input }) => { - const { folderId } = input; - - ctx.logger.info({ - input: { - folderId, - }, - }); - - const currentFolder = await getFolderById({ - userId: ctx.user.id, - teamId: ctx.teamId, - folderId, - }); - - const result = await pinFolder({ - userId: ctx.user.id, - teamId: ctx.teamId, - folderId, - type: currentFolder.type, - }); - - return { - ...result, - type: currentFolder.type, - }; - }), - - /** - * @private - */ - unpinFolder: authenticatedProcedure.input(ZUnpinFolderSchema).mutation(async ({ ctx, input }) => { - const { folderId } = input; - - ctx.logger.info({ - input: { - folderId, - }, - }); - - const currentFolder = await getFolderById({ - userId: ctx.user.id, - teamId: ctx.teamId, - folderId, - }); - - const result = await unpinFolder({ - userId: ctx.user.id, - teamId: ctx.teamId, - folderId, - type: currentFolder.type, - }); - - return { - ...result, - type: currentFolder.type, - }; - }), }); diff --git a/packages/trpc/server/folder-router/schema.ts b/packages/trpc/server/folder-router/schema.ts index 53e512dc9..8873add30 100644 --- a/packages/trpc/server/folder-router/schema.ts +++ b/packages/trpc/server/folder-router/schema.ts @@ -1,8 +1,9 @@ import { z } from 'zod'; import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type'; -import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; import { DocumentVisibility } from '@documenso/prisma/generated/types'; +import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema'; /** * Required for empty responses since we currently can't 201 requests for our openapi setup. @@ -11,24 +12,23 @@ import { DocumentVisibility } from '@documenso/prisma/generated/types'; */ export const ZSuccessResponseSchema = z.object({ success: z.boolean(), - type: ZFolderTypeSchema.optional(), }); export const ZGenericSuccessResponse = { success: true, } satisfies z.infer; -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 const ZFolderSchema = FolderSchema.pick({ + id: true, + name: true, + userId: true, + teamId: true, + parentId: true, + pinned: true, + createdAt: true, + updatedAt: true, + visibility: true, + type: true, }); export type TFolder = z.infer; @@ -51,40 +51,39 @@ export const ZFolderWithSubfoldersSchema = ZFolderSchema.extend({ export type TFolderWithSubfolders = z.infer; -export const ZCreateFolderSchema = z.object({ +const ZFolderParentIdSchema = z + .string() + .describe( + 'The folder ID to place this folder within. Leave empty to place folder at the root level.', + ); + +export const ZCreateFolderRequestSchema = z.object({ name: z.string(), - parentId: z.string().optional(), + parentId: ZFolderParentIdSchema.optional(), type: ZFolderTypeSchema.optional(), }); -export const ZUpdateFolderSchema = z.object({ - id: z.string(), - name: z.string(), - visibility: z.nativeEnum(DocumentVisibility), - type: ZFolderTypeSchema.optional(), +export const ZCreateFolderResponseSchema = ZFolderSchema; + +export const ZUpdateFolderRequestSchema = z.object({ + folderId: z.string().describe('The ID of the folder to update'), + data: z.object({ + name: z.string().optional().describe('The name of the folder'), + parentId: ZFolderParentIdSchema.optional().nullable(), + visibility: z + .nativeEnum(DocumentVisibility) + .optional() + .describe('The visibility of the folder'), + pinned: z.boolean().optional().describe('Whether the folder should be pinned'), + }), }); -export type TUpdateFolderSchema = z.infer; +export type TUpdateFolderRequestSchema = z.infer; -export const ZDeleteFolderSchema = z.object({ - id: z.string(), - type: ZFolderTypeSchema.optional(), -}); +export const ZUpdateFolderResponseSchema = ZFolderSchema; -export const ZMoveFolderSchema = z.object({ - id: z.string(), - parentId: z.string().nullable(), - type: ZFolderTypeSchema.optional(), -}); - -export const ZPinFolderSchema = z.object({ +export const ZDeleteFolderRequestSchema = z.object({ folderId: z.string(), - type: ZFolderTypeSchema.optional(), -}); - -export const ZUnpinFolderSchema = z.object({ - folderId: z.string(), - type: ZFolderTypeSchema.optional(), }); export const ZGetFoldersSchema = z.object({ @@ -101,11 +100,20 @@ export const ZGetFoldersResponseSchema = z.object({ export type TGetFoldersResponse = z.infer; export const ZFindFoldersRequestSchema = ZFindSearchParamsSchema.extend({ + parentId: z.string().optional().describe('Filter folders by the parent folder ID'), + type: ZFolderTypeSchema.optional().describe('Filter folders by the folder type'), +}); + +export const ZFindFoldersResponseSchema = ZFindResultResponse.extend({ + data: z.array(ZFolderSchema), +}); + +export const ZFindFoldersInternalRequestSchema = ZFindSearchParamsSchema.extend({ parentId: z.string().nullable().optional(), type: ZFolderTypeSchema.optional(), }); -export const ZFindFoldersResponseSchema = z.object({ +export const ZFindFoldersInternalResponseSchema = z.object({ data: z.array(ZFolderWithSubfoldersSchema), breadcrumbs: z.array(ZFolderSchema), type: ZFolderTypeSchema.optional(),