From 87b73ce702eed8c39b7bd411040a91ec121d597d Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 17 May 2026 16:19:52 -0500 Subject: [PATCH] Fix updated issue list pagination sort Co-Authored-By: Paperclip --- server/src/__tests__/issues-service.test.ts | 40 +++++++++++++++ server/src/routes/issues.ts | 12 +++++ server/src/services/issues.ts | 56 +++++++++++++++++---- ui/src/api/issues.test.ts | 12 +++++ ui/src/api/issues.ts | 4 ++ ui/src/pages/Issues.tsx | 2 + 6 files changed, 117 insertions(+), 9 deletions(-) diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 22a5b3cea95..ef328416d01 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -380,6 +380,46 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]); }); + it("can page issues by most recently updated before priority", async () => { + const companyId = randomUUID(); + const oldCriticalIssueId = randomUUID(); + const recentMediumIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values([ + { + id: oldCriticalIssueId, + companyId, + title: "Old critical issue", + status: "todo", + priority: "critical", + updatedAt: new Date("2026-05-01T10:00:00.000Z"), + }, + { + id: recentMediumIssueId, + companyId, + title: "Recent medium issue", + status: "todo", + priority: "medium", + updatedAt: new Date("2026-05-17T21:12:29.993Z"), + }, + ]); + + const result = await svc.list(companyId, { + limit: 1, + sortField: "updated", + sortDir: "desc", + }); + + expect(result.map((issue) => issue.id)).toEqual([recentMediumIssueId]); + }); + it("ranks comment matches ahead of description-only matches", async () => { const companyId = randomUUID(); const commentMatchId = randomUUID(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index ab93132aeda..1a5c738e467 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1762,6 +1762,8 @@ export function issueRoutes( ? Number.parseInt(rawOffset, 10) : null; const attention = req.query.attention as string | undefined; + const sortField = req.query.sortField as string | undefined; + const sortDir = req.query.sortDir as string | undefined; if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); @@ -1791,6 +1793,14 @@ export function issueRoutes( res.status(400).json({ error: "offset must be a non-negative integer" }); return; } + if (sortField !== undefined && sortField !== "updated") { + res.status(400).json({ error: "sortField must be 'updated' when provided" }); + return; + } + if (sortDir !== undefined && sortDir !== "asc" && sortDir !== "desc") { + res.status(400).json({ error: "sortDir must be 'asc' or 'desc' when provided" }); + return; + } const offset = parsedOffset ?? 0; const result = await svc.list(companyId, { @@ -1823,6 +1833,8 @@ export function issueRoutes( q: req.query.q as string | undefined, limit, offset, + sortField: sortField === "updated" ? "updated" : undefined, + sortDir: sortDir === "asc" || sortDir === "desc" ? sortDir : undefined, }); const issueIds = result.map((issue) => issue.id); const [handoffStates, recoveryActionByIssue] = await Promise.all([ diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index ae349906a60..0ab88d3eebc 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { and, asc, desc, eq, gt, inArray, isNull, like, lt, ne, notInArray, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, inArray, isNull, like, lt, ne, notInArray, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, @@ -239,6 +239,8 @@ export interface IssueFilters { q?: string; limit?: number; offset?: number; + sortField?: "updated"; + sortDir?: "asc" | "desc"; } type IssueRow = typeof issues.$inferSelect; @@ -782,6 +784,43 @@ function latestIssueActivityAt(...values: Array> { const map = new Map(); if (issueIds.length === 0) return map; @@ -3521,18 +3560,17 @@ export function issueService(db: Db) { ELSE 6 END `; - const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId); const baseQuery = db .select(issueListSelect) .from(issues) .where(and(...conditions)) - .orderBy( - hasSearch ? asc(searchOrder) : asc(priorityOrder), - asc(priorityOrder), - desc(canonicalLastActivityAt), - desc(issues.updatedAt), - desc(issues.id), - ); + .orderBy(...issueListOrderBy(companyId, { + hasSearch, + priorityOrder, + searchOrder, + sortField: filters?.sortField, + sortDir: filters?.sortDir, + })); const pageQuery = offset > 0 ? (limit === undefined ? baseQuery.offset(offset) : baseQuery.limit(limit).offset(offset)) : (limit === undefined ? baseQuery : baseQuery.limit(limit)); diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 4013f67d792..56bdcf476c6 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -51,6 +51,18 @@ describe("issuesApi.list", () => { ); }); + it("passes issue list sort options through to the company issues endpoint", async () => { + await issuesApi.list("company-1", { + limit: 500, + sortField: "updated", + sortDir: "desc", + }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/issues?limit=500&sortField=updated&sortDir=desc", + ); + }); + it("posts recovery action resolution to the source issue endpoint", async () => { await issuesApi.resolveRecoveryAction("issue-1", { actionId: "00000000-0000-0000-0000-0000000000aa", diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 14def41b396..2e35416a3be 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -60,6 +60,8 @@ export const issuesApi = { q?: string; limit?: number; offset?: number; + sortField?: "updated"; + sortDir?: "asc" | "desc"; }, ) => { const params = new URLSearchParams(); @@ -86,6 +88,8 @@ export const issuesApi = { if (filters?.q) params.set("q", filters.q); if (filters?.limit) params.set("limit", String(filters.limit)); if (filters?.offset !== undefined) params.set("offset", String(filters.offset)); + if (filters?.sortField) params.set("sortField", filters.sortField); + if (filters?.sortDir) params.set("sortDir", filters.sortDir); const qs = params.toString(); return api.get(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`); }, diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index c8be8cedce0..08273694162 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -145,6 +145,8 @@ export function Issues() { includeRoutineExecutions: true, limit: issuePageSize, offset: pageParam, + sortField: "updated", + sortDir: "desc", }), initialPageParam: 0, getNextPageParam: (lastPage, _allPages, lastPageParam) =>