Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/web/src/__tests__/broadcasts-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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),
},
},
}));
Expand Down Expand Up @@ -93,17 +95,21 @@ 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);
const body = await res.json();

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);
Expand All @@ -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);
Expand All @@ -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', () => {
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/__tests__/dashboard-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/__tests__/docs-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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),
Expand Down Expand Up @@ -111,17 +113,21 @@ 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);
const body = await res.json();

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);
Expand All @@ -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);
Expand All @@ -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', () => {
Expand Down
27 changes: 20 additions & 7 deletions apps/web/src/app/api/broadcasts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 });
}

/**
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/app/api/dashboard/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand All @@ -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,
Expand Down
29 changes: 21 additions & 8 deletions apps/web/src/app/api/docs/articles/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) {
Expand All @@ -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 });
}

/**
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/broadcasts/broadcasts-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/docs/[category]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down