From 32cede4cdd6195c3dd8c04265e2ce8b84192bfd7 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Tue, 15 Sep 2026 14:35:12 +0530 Subject: [PATCH] Paginate list endpoints and scope dashboard stats to the selected month --- apps/web/src/__tests__/broadcasts-api.test.ts | 37 +++++++++++++++++++ apps/web/src/__tests__/dashboard-api.test.ts | 25 +++++++++++++ apps/web/src/__tests__/docs-api.test.ts | 23 ++++++++++++ apps/web/src/app/api/broadcasts/route.ts | 27 ++++++++++---- apps/web/src/app/api/dashboard/stats/route.ts | 14 ++++++- apps/web/src/app/api/docs/articles/route.ts | 29 +++++++++++---- .../src/app/broadcasts/broadcasts-content.tsx | 4 +- apps/web/src/app/docs/[category]/page.tsx | 2 +- apps/web/src/app/docs/page.tsx | 2 +- 9 files changed, 142 insertions(+), 21 deletions(-) diff --git a/apps/web/src/__tests__/broadcasts-api.test.ts b/apps/web/src/__tests__/broadcasts-api.test.ts index 7adecede..1a21c0c8 100644 --- a/apps/web/src/__tests__/broadcasts-api.test.ts +++ b/apps/web/src/__tests__/broadcasts-api.test.ts @@ -6,6 +6,7 @@ const mockBroadcastFindMany = vi.fn(); const mockBroadcastFindUnique = vi.fn(); const mockBroadcastCreate = vi.fn(); const mockBroadcastUpdate = vi.fn(); +const mockBroadcastCount = vi.fn(); vi.mock('@copilotkit/outpost/db', () => ({ prisma: { @@ -14,6 +15,7 @@ vi.mock('@copilotkit/outpost/db', () => ({ findUnique: (...args: unknown[]) => mockBroadcastFindUnique(...args), create: (...args: unknown[]) => mockBroadcastCreate(...args), update: (...args: unknown[]) => mockBroadcastUpdate(...args), + count: (...args: unknown[]) => mockBroadcastCount(...args), }, }, })); @@ -93,6 +95,7 @@ describe('GET /api/broadcasts', () => { it('returns all broadcasts', async () => { mockBroadcastFindMany.mockResolvedValue([MOCK_BROADCAST]); + mockBroadcastCount.mockResolvedValue(1); const req = makeGetRequest('http://localhost:3000/api/broadcasts'); const res = await GET(req as never); @@ -100,10 +103,13 @@ describe('GET /api/broadcasts', () => { expect(body.broadcasts).toHaveLength(1); expect(body.total).toBe(1); + expect(body.page).toBe(1); + expect(body.pageSize).toBe(25); }); it('returns empty array when no broadcasts exist', async () => { mockBroadcastFindMany.mockResolvedValue([]); + mockBroadcastCount.mockResolvedValue(0); const req = makeGetRequest('http://localhost:3000/api/broadcasts'); const res = await GET(req as never); @@ -114,6 +120,7 @@ describe('GET /api/broadcasts', () => { it('filters by status', async () => { mockBroadcastFindMany.mockResolvedValue([]); + mockBroadcastCount.mockResolvedValue(0); const req = makeGetRequest('http://localhost:3000/api/broadcasts?status=DRAFT'); await GET(req as never); @@ -124,6 +131,36 @@ describe('GET /api/broadcasts', () => { }), ); }); + + it('paginates with take/skip and reports the total from count', async () => { + mockBroadcastFindMany.mockResolvedValue([MOCK_BROADCAST]); + mockBroadcastCount.mockResolvedValue(42); + + const req = makeGetRequest('http://localhost:3000/api/broadcasts?page=3&pageSize=10'); + const res = await GET(req as never); + const body = await res.json(); + + expect(mockBroadcastFindMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 10, skip: 20 }), + ); + expect(body.total).toBe(42); + expect(body.page).toBe(3); + expect(body.pageSize).toBe(10); + }); + + it('clamps pageSize to 100', async () => { + mockBroadcastFindMany.mockResolvedValue([]); + mockBroadcastCount.mockResolvedValue(0); + + const req = makeGetRequest('http://localhost:3000/api/broadcasts?pageSize=9999'); + const res = await GET(req as never); + const body = await res.json(); + + expect(mockBroadcastFindMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 100 }), + ); + expect(body.pageSize).toBe(100); + }); }); describe('POST /api/broadcasts', () => { diff --git a/apps/web/src/__tests__/dashboard-api.test.ts b/apps/web/src/__tests__/dashboard-api.test.ts index 5d2c7a31..93965cc2 100644 --- a/apps/web/src/__tests__/dashboard-api.test.ts +++ b/apps/web/src/__tests__/dashboard-api.test.ts @@ -244,6 +244,31 @@ describe('Dashboard API', () => { expect(countArgs?.where?.createdAt?.lte).toEqual(new Date(2026, 5, 30, 23, 59, 59, 999)); }); + it('scopes the first-response and resolution scans to the selected month', async () => { + mockTicketFindFirst.mockResolvedValue({ createdAt: new Date(2026, 5, 4) }); + mockTicketCount + .mockResolvedValueOnce(4) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(0); + mockTicketFindMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + const res = await statsGet(statsRequest('2026-06')); + expect(res.status).toBe(200); + + // findMany calls: [ticketsWithFirstResponse, resolvedTickets, monthlyTickets] + expect(mockTicketFindMany).toHaveBeenCalledTimes(3); + for (const index of [0, 1]) { + const args = mockTicketFindMany.mock.calls[index][0] as { + where?: { createdAt?: { gte: Date; lte: Date } }; + }; + expect(args?.where?.createdAt?.gte).toEqual(new Date(2026, 5, 1, 0, 0, 0, 0)); + expect(args?.where?.createdAt?.lte).toEqual(new Date(2026, 5, 30, 23, 59, 59, 999)); + } + }); + it('falls back to the newest month with tickets for a malformed month param', async () => { // Both findFirst calls (oldest, newest) resolve to January 2026, so // the newest month with data IS January — not the calendar month. diff --git a/apps/web/src/__tests__/docs-api.test.ts b/apps/web/src/__tests__/docs-api.test.ts index d3c4c954..fa075c51 100644 --- a/apps/web/src/__tests__/docs-api.test.ts +++ b/apps/web/src/__tests__/docs-api.test.ts @@ -6,6 +6,7 @@ const mockDocArticleFindMany = vi.fn(); const mockDocArticleFindUnique = vi.fn(); const mockDocArticleCreate = vi.fn(); const mockDocArticleUpdate = vi.fn(); +const mockDocArticleCount = vi.fn(); const mockDocCategoryFindMany = vi.fn(); const mockDocCategoryFindUnique = vi.fn(); const mockDocCategoryFindFirst = vi.fn(); @@ -17,6 +18,7 @@ vi.mock('@copilotkit/outpost/db', () => ({ findUnique: (...args: unknown[]) => mockDocArticleFindUnique(...args), create: (...args: unknown[]) => mockDocArticleCreate(...args), update: (...args: unknown[]) => mockDocArticleUpdate(...args), + count: (...args: unknown[]) => mockDocArticleCount(...args), }, docCategory: { findMany: (...args: unknown[]) => mockDocCategoryFindMany(...args), @@ -111,6 +113,7 @@ describe('GET /api/docs/articles', () => { it('returns all articles', async () => { mockDocArticleFindMany.mockResolvedValue([MOCK_ARTICLE]); + mockDocArticleCount.mockResolvedValue(1); const req = makeGetRequest('http://localhost:3000/api/docs/articles'); const res = await getArticles(req as never); @@ -118,10 +121,13 @@ describe('GET /api/docs/articles', () => { expect(body.articles).toHaveLength(1); expect(body.total).toBe(1); + expect(body.page).toBe(1); + expect(body.pageSize).toBe(25); }); it('returns empty when no articles exist', async () => { mockDocArticleFindMany.mockResolvedValue([]); + mockDocArticleCount.mockResolvedValue(0); const req = makeGetRequest('http://localhost:3000/api/docs/articles'); const res = await getArticles(req as never); @@ -132,6 +138,7 @@ describe('GET /api/docs/articles', () => { it('filters by search', async () => { mockDocArticleFindMany.mockResolvedValue([]); + mockDocArticleCount.mockResolvedValue(0); const req = makeGetRequest('http://localhost:3000/api/docs/articles?search=quick'); await getArticles(req as never); @@ -146,6 +153,22 @@ describe('GET /api/docs/articles', () => { }), ); }); + + it('paginates with take/skip and reports the total from count', async () => { + mockDocArticleFindMany.mockResolvedValue([MOCK_ARTICLE]); + mockDocArticleCount.mockResolvedValue(57); + + const req = makeGetRequest('http://localhost:3000/api/docs/articles?page=2&pageSize=10'); + const res = await getArticles(req as never); + const body = await res.json(); + + expect(mockDocArticleFindMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 10, skip: 10 }), + ); + expect(body.total).toBe(57); + expect(body.page).toBe(2); + expect(body.pageSize).toBe(10); + }); }); describe('POST /api/docs/articles', () => { diff --git a/apps/web/src/app/api/broadcasts/route.ts b/apps/web/src/app/api/broadcasts/route.ts index 9965b85f..1838bc14 100644 --- a/apps/web/src/app/api/broadcasts/route.ts +++ b/apps/web/src/app/api/broadcasts/route.ts @@ -2,14 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireSession, requireAdmin } from '@/lib/require-admin'; import { prisma } from '@copilotkit/outpost/db'; import type { Prisma, BroadcastStatus } from '@copilotkit/outpost/db'; +import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '@copilotkit/outpost/shared'; const MAX_BROADCAST_LENGTH = 500; /** * GET /api/broadcasts * - * List broadcasts with optional status filter. - * Query params: status (DRAFT | SENT) + * List broadcasts with optional status filter and pagination. + * Query params: status (DRAFT | SENT), page, pageSize */ export async function GET(request: NextRequest) { const { error } = await requireSession(); @@ -18,17 +19,29 @@ export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; const status = searchParams.get('status')?.toUpperCase() as BroadcastStatus | null; + const rawPage = Number.parseInt(searchParams.get('page') ?? '', 10); + const rawPageSize = Number.parseInt(searchParams.get('pageSize') ?? '', 10); + const page = Number.isFinite(rawPage) ? Math.max(1, rawPage) : 1; + const pageSize = Number.isFinite(rawPageSize) + ? Math.min(MAX_PAGE_SIZE, Math.max(1, rawPageSize)) + : DEFAULT_PAGE_SIZE; + const where: Prisma.BroadcastWhereInput = {}; if (status && (status === 'DRAFT' || status === 'SENT')) { where.status = status; } - const broadcasts = await prisma.broadcast.findMany({ - where, - orderBy: { createdAt: 'desc' }, - }); + const [broadcasts, total] = await Promise.all([ + prisma.broadcast.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: pageSize, + skip: (page - 1) * pageSize, + }), + prisma.broadcast.count({ where }), + ]); - return NextResponse.json({ broadcasts, total: broadcasts.length }); + return NextResponse.json({ broadcasts, total, page, pageSize }); } /** diff --git a/apps/web/src/app/api/dashboard/stats/route.ts b/apps/web/src/app/api/dashboard/stats/route.ts index b57d0f1c..572ab7e5 100644 --- a/apps/web/src/app/api/dashboard/stats/route.ts +++ b/apps/web/src/app/api/dashboard/stats/route.ts @@ -77,8 +77,14 @@ export async function GET(request: Request) { prisma.ticket.count({ where: { slaBreachedAt: { not: null } }, }), - // Get tickets with their first non-system, non-user-authored message for avg first response + // Get tickets with their first non-system, non-user-authored message for avg first response. + // Scoped to the selected month: previously this loaded the entire + // ticket table on every dashboard view and the average ignored + // the month picker beside it. prisma.ticket.findMany({ + where: { + createdAt: { gte: monthStart, lte: monthEnd }, + }, select: { createdAt: true, user: { select: { name: true } }, @@ -91,10 +97,14 @@ export async function GET(request: Request) { }, }, }), - // Resolved/closed tickets for avg resolution time + // Resolved/closed tickets created in the selected month for avg + // resolution time. Previously unbounded: every resolved ticket + // ever was loaded to compute an "all-time" number that did not + // match the selected month. prisma.ticket.findMany({ where: { status: { in: [TicketStatus.RESOLVED, TicketStatus.CLOSED] }, + createdAt: { gte: monthStart, lte: monthEnd }, }, select: { createdAt: true, diff --git a/apps/web/src/app/api/docs/articles/route.ts b/apps/web/src/app/api/docs/articles/route.ts index d051f9a6..ce06c52d 100644 --- a/apps/web/src/app/api/docs/articles/route.ts +++ b/apps/web/src/app/api/docs/articles/route.ts @@ -2,12 +2,13 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireSession, requireAdmin } from '@/lib/require-admin'; import { prisma } from '@copilotkit/outpost/db'; import type { Prisma } from '@copilotkit/outpost/db'; +import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '@copilotkit/outpost/shared'; /** * GET /api/docs/articles * - * List articles with optional filters. - * Query params: category, status, search + * List articles with optional filters and pagination. + * Query params: category, status, search, page, pageSize */ export async function GET(request: NextRequest) { const { error } = await requireSession(); @@ -18,6 +19,13 @@ export async function GET(request: NextRequest) { const status = searchParams.get('status')?.toUpperCase() as 'DRAFT' | 'PUBLISHED' | null; const search = searchParams.get('search'); + const rawPage = Number.parseInt(searchParams.get('page') ?? '', 10); + const rawPageSize = Number.parseInt(searchParams.get('pageSize') ?? '', 10); + const page = Number.isFinite(rawPage) ? Math.max(1, rawPage) : 1; + const pageSize = Number.isFinite(rawPageSize) + ? Math.min(MAX_PAGE_SIZE, Math.max(1, rawPageSize)) + : DEFAULT_PAGE_SIZE; + const where: Prisma.DocArticleWhereInput = {}; if (categoryId) { @@ -35,13 +43,18 @@ export async function GET(request: NextRequest) { ]; } - const articles = await prisma.docArticle.findMany({ - where, - include: { category: true }, - orderBy: { updatedAt: 'desc' }, - }); + const [articles, total] = await Promise.all([ + prisma.docArticle.findMany({ + where, + include: { category: true }, + orderBy: { updatedAt: 'desc' }, + take: pageSize, + skip: (page - 1) * pageSize, + }), + prisma.docArticle.count({ where }), + ]); - return NextResponse.json({ articles, total: articles.length }); + return NextResponse.json({ articles, total, page, pageSize }); } /** diff --git a/apps/web/src/app/broadcasts/broadcasts-content.tsx b/apps/web/src/app/broadcasts/broadcasts-content.tsx index e6f566a8..c10ad2a4 100644 --- a/apps/web/src/app/broadcasts/broadcasts-content.tsx +++ b/apps/web/src/app/broadcasts/broadcasts-content.tsx @@ -29,8 +29,8 @@ export default function BroadcastsContent() { setError(null); try { const url = status - ? `/api/broadcasts?status=${status}` - : '/api/broadcasts'; + ? `/api/broadcasts?status=${status}&pageSize=100` + : '/api/broadcasts?pageSize=100'; const res = await apiFetch(url); if (res.ok) { const data = await res.json(); diff --git a/apps/web/src/app/docs/[category]/page.tsx b/apps/web/src/app/docs/[category]/page.tsx index 5727d31f..b5f587bd 100644 --- a/apps/web/src/app/docs/[category]/page.tsx +++ b/apps/web/src/app/docs/[category]/page.tsx @@ -33,7 +33,7 @@ export default function CategoryPage({ params }: CategoryPageProps) { async function fetchData() { try { // Fetch articles for this category - const artRes = await fetch(`/api/docs/articles?category=${categoryId}`); + const artRes = await fetch(`/api/docs/articles?category=${categoryId}&pageSize=100`); if (!artRes.ok) { setNotFound(true); return; diff --git a/apps/web/src/app/docs/page.tsx b/apps/web/src/app/docs/page.tsx index 539fbad4..6a97d544 100644 --- a/apps/web/src/app/docs/page.tsx +++ b/apps/web/src/app/docs/page.tsx @@ -41,7 +41,7 @@ export default function DocsPage() { try { const [catRes, artRes] = await Promise.all([ fetch('/api/docs/categories'), - fetch('/api/docs/articles'), + fetch('/api/docs/articles?pageSize=100'), ]); if (catRes.ok) { const catData = await catRes.json();