From 7a57f740596c81b86fc53287edcfe398a9bb8403 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Sat, 29 Aug 2026 01:16:11 +0300 Subject: [PATCH 1/2] fix(server): paginate getDocs, exclude content and add title search - add ListDocumentsQuerySchema (q, limit, offset) with validation - getDocs now selects only dashboard fields (omits content), applies case-insensitive title filter and take/skip, returns pagination totals - validate GET /document query and update Swagger Fixes #56 --- server/src/controllers/document.controller.ts | 72 ++++++++++++++----- server/src/routers/document.router.ts | 3 +- .../src/validations/documentParams.schema.ts | 11 +++ server/swagger.yaml | 61 ++++++++++++++-- 4 files changed, 124 insertions(+), 23 deletions(-) diff --git a/server/src/controllers/document.controller.ts b/server/src/controllers/document.controller.ts index b8fdea8..f56ee32 100644 --- a/server/src/controllers/document.controller.ts +++ b/server/src/controllers/document.controller.ts @@ -136,35 +136,68 @@ export const getDoc = asyncErrorWrapper(async (req: AuthenticatedRequest, res: R } }); +/** + * Fields returned by the dashboard list — excludes `content` (Yjs-owned, + * potentially large) and `YjsDocumentState`. Only metadata needed to render + * cards. + */ +const documentListSelect = { + id: true, + title: true, + isPublic: true, + shareId: true, + allowSelfJoin: true, + authorId: true, + createdAt: true, + updatedAt: true, +} as const; + export const getDocs = asyncErrorWrapper(async (req: AuthenticatedRequest, res: Response) => { const clientInfo = getClientInfo(req); const userId = req.user?.userId; + const { q, limit, offset } = req.query as unknown as { q?: string; limit: number; offset: number }; logger.debug('Documents list request', { action: 'GET_DOCUMENTS_ATTEMPT', ...clientInfo, userId, + q, + limit, + offset, }); try { - const ownedDocs = await prisma.document.findMany({ - where: { authorId: userId }, - orderBy: { updatedAt: 'desc' }, - }); - - const collaboratedDocs = await prisma.document.findMany({ - where: { - Collaborator: { - some: { - userId, - }, - }, - NOT: { - authorId: userId, // exclude owned docs - }, - }, - orderBy: { updatedAt: 'desc' }, - }); + const titleFilter = q ? { title: { contains: q, mode: 'insensitive' as const } } : {}; + + const ownedWhere: Prisma.DocumentWhereInput = { + authorId: userId, + ...titleFilter, + }; + + const collaboratedWhere: Prisma.DocumentWhereInput = { + Collaborator: { some: { userId } }, + NOT: { authorId: userId }, + ...titleFilter, + }; + + const [ownedDocs, collaboratedDocs, totalOwned, totalCollaborated] = await Promise.all([ + prisma.document.findMany({ + where: ownedWhere, + orderBy: { updatedAt: 'desc' }, + select: documentListSelect, + take: limit, + skip: offset, + }), + prisma.document.findMany({ + where: collaboratedWhere, + orderBy: { updatedAt: 'desc' }, + select: documentListSelect, + take: limit, + skip: offset, + }), + prisma.document.count({ where: ownedWhere }), + prisma.document.count({ where: collaboratedWhere }), + ]); logger.debug('Documents list retrieved successfully', { action: 'GET_DOCUMENTS_SUCCESS', @@ -172,11 +205,14 @@ export const getDocs = asyncErrorWrapper(async (req: AuthenticatedRequest, res: userId, ownedCount: ownedDocs.length, collaboratedCount: collaboratedDocs.length, + totalOwned, + totalCollaborated, }); res.status(StatusCodes.OK).json({ owned: ownedDocs, collaborated: collaboratedDocs, + pagination: { limit, offset, totalOwned, totalCollaborated }, }); } catch (error) { logger.error('Documents list retrieval failed', { diff --git a/server/src/routers/document.router.ts b/server/src/routers/document.router.ts index 8f9a32c..9714e79 100644 --- a/server/src/routers/document.router.ts +++ b/server/src/routers/document.router.ts @@ -23,6 +23,7 @@ import { CreateDocumentSchema } from '@/validations/createDocument.schema'; import { CollaboratorParamsSchema, IdParamsSchema, + ListDocumentsQuerySchema, RequestIdParamsSchema, ShareLinkQuerySchema, } from '@/validations/documentParams.schema'; @@ -35,7 +36,7 @@ docRouter.use(authenticate); docRouter.get('/share/:token', getDocByToken); docRouter.post('/', validate({ body: CreateDocumentSchema }), createDoc); -docRouter.get('/', getDocs); +docRouter.get('/', validate({ query: ListDocumentsQuerySchema }), getDocs); docRouter.get('/:id', validate({ params: IdParamsSchema }), getDoc); docRouter.put('/:id', validate({ params: IdParamsSchema, body: UpdateDocumentSchema }), updateDoc); docRouter.delete('/:id', validate({ params: IdParamsSchema }), deleteDoc); diff --git a/server/src/validations/documentParams.schema.ts b/server/src/validations/documentParams.schema.ts index 5e19448..f8d2ea3 100644 --- a/server/src/validations/documentParams.schema.ts +++ b/server/src/validations/documentParams.schema.ts @@ -25,3 +25,14 @@ export const ShareLinkQuerySchema = z.object({ }); export type ShareLinkQuerySchema = z.infer; + +export const ListDocumentsQuerySchema = z.object({ + q: z.preprocess( + v => (typeof v === 'string' && v.trim() === '' ? undefined : v), + z.string().trim().min(1).max(100).optional() + ), + limit: z.coerce.number().int().min(1).max(50).default(20), + offset: z.coerce.number().int().min(0).default(0), +}); + +export type ListDocumentsQuerySchema = z.infer; diff --git a/server/swagger.yaml b/server/swagger.yaml index af1b182..875a61c 100644 --- a/server/swagger.yaml +++ b/server/swagger.yaml @@ -442,18 +442,71 @@ paths: tags: - Documents summary: Get user's documents - description: Retrieve all documents created by the authenticated user + description: Retrieve owned and collaborated documents (paginated, searchable). The list omits `content` — fetch `GET /document/{id}` for the full body. security: - BearerAuth: [] + parameters: + - name: q + in: query + required: false + description: Case-insensitive title filter + schema: + type: string + minLength: 1 + maxLength: 100 + example: meeting notes + - name: limit + in: query + required: false + description: Page size + schema: + type: integer + minimum: 1 + maximum: 50 + default: 20 + - name: offset + in: query + required: false + description: Pagination offset + schema: + type: integer + minimum: 0 + default: 0 responses: 200: description: Documents retrieved successfully content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Document' + type: object + properties: + owned: + type: array + items: + $ref: '#/components/schemas/Document' + description: Owned documents (content omitted) + collaborated: + type: array + items: + $ref: '#/components/schemas/Document' + description: Collaborated documents (content omitted) + pagination: + type: object + properties: + limit: + type: integer + offset: + type: integer + totalOwned: + type: integer + totalCollaborated: + type: integer + 400: + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' 401: description: Unauthorized content: From 76e64080bfe0dc7a4beb8d612bdf8f060daa523c Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Sat, 29 Aug 2026 01:16:25 +0300 Subject: [PATCH 2/2] feat(client): add dashboard search and pagination - dashboard route debounces title search (300ms) and drives limit/offset pagination via GET /document query - DashboardMain adds search input and prev/next pagination bar driven by server totals; resets offset on search Fixes #56 --- client/src/app/routes/app/dashboard.tsx | 49 ++++++++-- .../DashboardMain/dashboard-main.tsx | 95 +++++++++++++++++-- 2 files changed, 129 insertions(+), 15 deletions(-) diff --git a/client/src/app/routes/app/dashboard.tsx b/client/src/app/routes/app/dashboard.tsx index 8dc9bf1..0b7afd0 100644 --- a/client/src/app/routes/app/dashboard.tsx +++ b/client/src/app/routes/app/dashboard.tsx @@ -1,10 +1,12 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { DashboardLayout } from '@/components/layouts/DashboardLayout'; import DashboardMain from '@/features/Dashboard/components/DashboardMain/dashboard-main'; import { api } from '@/lib/api'; import { Document } from '@/types/api'; +const DEFAULT_LIMIT = 20; + /** * Dashboard page listing owned documents alongside shared ones with view-mode controls. */ @@ -12,23 +14,52 @@ export default function Dashboard() { const [ownedDocs, setOwnedDocs] = useState([]); const [collaboratedDocs, setCollaboratedDocs] = useState([]); const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [limit] = useState(DEFAULT_LIMIT); + const [offset, setOffset] = useState(0); + const [pagination, setPagination] = useState<{ + totalOwned: number; + totalCollaborated: number; + } | null>(null); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(search.trim()), 300); + return () => clearTimeout(timer); + }, [search]); + + useEffect(() => { + setOffset(0); + }, [debouncedSearch]); - const fetchDocs = async () => { + const fetchDocs = useCallback(async () => { try { - const res = await api.get('/document'); - // console.log(res); + setLoading(true); + const res = await api.get('/document', { + params: { + q: debouncedSearch || undefined, + limit, + offset, + }, + }); setOwnedDocs(res.data.owned || []); setCollaboratedDocs(res.data.collaborated || []); + if (res.data.pagination) { + setPagination({ + totalOwned: res.data.pagination.totalOwned, + totalCollaborated: res.data.pagination.totalCollaborated, + }); + } } catch (err) { console.error('Failed to fetch documents', err); } finally { setLoading(false); } - }; + }, [debouncedSearch, limit, offset]); useEffect(() => { fetchDocs(); - }, []); + }, [fetchDocs]); return ( @@ -37,6 +68,12 @@ export default function Dashboard() { collaboratedDocs={collaboratedDocs} loading={loading} setDocuments={setOwnedDocs} + search={search} + onSearchChange={setSearch} + limit={limit} + offset={offset} + onOffsetChange={setOffset} + pagination={pagination} /> ); diff --git a/client/src/features/Dashboard/components/DashboardMain/dashboard-main.tsx b/client/src/features/Dashboard/components/DashboardMain/dashboard-main.tsx index e370ac8..c08d510 100644 --- a/client/src/features/Dashboard/components/DashboardMain/dashboard-main.tsx +++ b/client/src/features/Dashboard/components/DashboardMain/dashboard-main.tsx @@ -1,10 +1,14 @@ import { useEffect, useMemo, useState } from 'react'; import { + LuChevronLeft as ChevronLeftIcon, + LuChevronRight as ChevronRightIcon, LuFileText as FileIcon, LuPin as PinIcon, + LuSearch as SearchIcon, LuShare as ShareIcon, } from 'react-icons/lu'; +import { Button } from '@/components/ui/Button'; import { Document } from '@/types/api'; import NewDocumentModal from '../NewDocumentModal/new-document-modal'; @@ -33,6 +37,18 @@ type DashboardMainProps = { loading: boolean; /** State setter used to apply card-level updates and deletions. */ setDocuments: React.Dispatch>; + /** Current search query. */ + search: string; + /** Called when search query changes. */ + onSearchChange: (value: string) => void; + /** Page size. */ + limit: number; + /** Current offset. */ + offset: number; + /** Called when offset changes. */ + onOffsetChange: (offset: number) => void; + /** Total counts returned by the server. */ + pagination: { totalOwned: number; totalCollaborated: number } | null; }; /** @@ -67,6 +83,12 @@ export default function DashboardMain({ collaboratedDocs, loading, setDocuments, + search, + onSearchChange, + limit, + offset, + onOffsetChange, + pagination, }: DashboardMainProps) { const [view, setView] = useState<'grid' | 'row'>('grid'); const [showSkeletons, setShowSkeletons] = useState(true); @@ -107,18 +129,44 @@ export default function DashboardMain({ setDocuments((docs) => docs.filter((doc) => doc.id !== id)); }; + const totalOwned = pagination?.totalOwned ?? ownedDocs.length; + const totalCollaborated = + pagination?.totalCollaborated ?? collaboratedDocs.length; + const maxTotal = Math.max(totalOwned, totalCollaborated); + const canPrev = offset > 0; + const canNext = offset + limit < maxTotal; + const page = Math.floor(offset / limit) + 1; + const totalPages = Math.max(1, Math.ceil(maxTotal / limit)); + return ( <> -
-

Dashboard

-
- - setSort(val)} - options={sortOptions} +
+
+

Dashboard

+
+ + setSort(val)} + options={sortOptions} + /> + +
+
+
+
@@ -191,6 +239,35 @@ export default function DashboardMain({ /> )} + {!showSkeletons && + (ownedDocs.length > 0 || collaboratedDocs.length > 0) && ( +
+

+ Page {page} of {totalPages} · {totalOwned} owned ·{' '} + {totalCollaborated} shared +

+
+ + +
+
+ )} )}