Fix updated issue list pagination sort

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-19 13:54:54 -05:00
1 parent bfe6369ef5
commit 0550cd1bc8
6 files changed
+117 -9

No files matched your search

@@ -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();
+12
View File
@@ -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([
+47 -9
View File
@@ -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<Date | string | null | undefined
return normalized[0] ?? null;
}
function issueListOrderBy(
companyId: string,
{
hasSearch,
priorityOrder,
searchOrder,
sortField,
sortDir,
}: {
hasSearch: boolean;
priorityOrder: SQL;
searchOrder: SQL;
sortField?: IssueFilters["sortField"];
sortDir?: IssueFilters["sortDir"];
},
) {
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
if (sortField === "updated") {
const activityOrder = sortDir === "asc"
? asc(canonicalLastActivityAt)
: desc(canonicalLastActivityAt);
const updatedOrder = sortDir === "asc" ? asc(issues.updatedAt) : desc(issues.updatedAt);
const idOrder = sortDir === "asc" ? asc(issues.id) : desc(issues.id);
return hasSearch
? [asc(searchOrder), activityOrder, updatedOrder, idOrder]
: [activityOrder, updatedOrder, idOrder];
}
return [
hasSearch ? asc(searchOrder) : asc(priorityOrder),
asc(priorityOrder),
desc(canonicalLastActivityAt),
desc(issues.updatedAt),
desc(issues.id),
];
}
async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> {
const map = new Map<string, IssueLabelRow[]>();
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));
+12
View File
@@ -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",
+4
View File
@@ -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<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
},
+2
View File
@@ -145,6 +145,8 @@ export function Issues() {
includeRoutineExecutions: true,
limit: issuePageSize,
offset: pageParam,
sortField: "updated",
sortDir: "desc",
}),
initialPageParam: 0,
getNextPageParam: (lastPage, _allPages, lastPageParam) =>