diff --git a/server/src/__tests__/issue-list-updatedsince-filter-routes.test.ts b/server/src/__tests__/issue-list-updatedsince-filter-routes.test.ts new file mode 100644 index 0000000000..2b9e56eba0 --- /dev/null +++ b/server/src/__tests__/issue-list-updatedsince-filter-routes.test.ts @@ -0,0 +1,176 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { companies, companyMemberships, createDb, issues, principalPermissionGrants } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; +import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres issue list route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("issue list routes updatedSince filter", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-list-routes-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issues); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createApp(companyId: string) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "cloud-user-1", + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "owner", status: "active" }], + source: "cloud_tenant", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", issueRoutes(db, {} as any)); + app.use(errorHandler); + return app; + } + + function uniqueIssuePrefix() { + return `P${randomUUID().replace(/-/g, "").slice(0, 4).toUpperCase()}`; + } + + async function seedCloudTenantMember(companyId: string) { + await db.insert(companyMemberships).values({ + companyId, + principalType: "user", + principalId: "cloud-user-1", + status: "active", + membershipRole: "owner", + updatedAt: new Date(), + }); + await ensureHumanRoleDefaultGrants(db, { + companyId, + principalId: "cloud-user-1", + membershipRole: "owner", + grantedByUserId: null, + }); + } + + it("returns 0 issues when updatedSince is in the future", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }); + await seedCloudTenantMember(companyId); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Existing issue", + status: "todo", + priority: "medium", + }); + + const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + + const app = createApp(companyId); + const res = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ updatedSince: futureDate, limit: "20" }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toEqual([]); + }); + + it("returns only issues updated after a past updatedSince timestamp", async () => { + const companyId = randomUUID(); + const staleIssueId = randomUUID(); + const freshIssueId = randomUUID(); + const since = new Date("2026-07-01T00:00:00.000Z"); + const staleUpdatedAt = new Date("2026-06-30T00:00:00.000Z"); + const freshUpdatedAt = new Date("2026-07-02T00:00:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }); + await seedCloudTenantMember(companyId); + await db.insert(issues).values([ + { + id: staleIssueId, + companyId, + title: "Stale issue", + status: "todo", + priority: "medium", + updatedAt: staleUpdatedAt, + }, + { + id: freshIssueId, + companyId, + title: "Fresh issue", + status: "todo", + priority: "medium", + updatedAt: freshUpdatedAt, + }, + ]); + + const app = createApp(companyId); + const res = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ updatedSince: since.toISOString(), limit: "20" }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.map((issue: { id: string }) => issue.id)).toEqual([freshIssueId]); + }); + + it("returns 400 for a malformed updatedSince timestamp", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }); + await seedCloudTenantMember(companyId); + + const app = createApp(companyId); + const res = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ updatedSince: "not-a-date", limit: "20" }); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + error: "updatedSince must be a valid ISO 8601 timestamp when provided", + }); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index c0295a2186..43ac00e6e4 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -5364,6 +5364,7 @@ export function issueRoutes( const includeLiveDescendantSummary = parseOptionalBooleanQuery(req.query.includeLiveDescendantSummary); const assigneeAgentFilterRaw = req.query.assigneeAgentId; let assigneeAgentId: string | null | undefined; + const rawUpdatedSince = req.query.updatedSince as string | undefined; if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); @@ -5430,6 +5431,10 @@ export function issueRoutes( return; } } + if (rawUpdatedSince !== undefined && !Number.isFinite(new Date(rawUpdatedSince).getTime())) { + res.status(400).json({ error: "updatedSince must be a valid ISO 8601 timestamp when provided" }); + return; + } const offset = parsedOffset ?? 0; const listFilters: IssueFilters = { @@ -5466,6 +5471,7 @@ export function issueRoutes( offset, sortField: sortField === "updated" ? "updated" : undefined, sortDir: sortDir === "asc" || sortDir === "desc" ? sortDir : undefined, + updatedSince: rawUpdatedSince, }; const requestKey = issueListRequestKey({ req, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 75f1f4ee92..dab47b0717 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -572,6 +572,8 @@ export interface IssueFilters { offset?: number; sortField?: "updated"; sortDir?: "asc" | "desc"; + /** ISO 8601 timestamp — only return issues with updatedAt strictly after this value. */ + updatedSince?: string; } type IssueRow = typeof issues.$inferSelect; @@ -5531,6 +5533,12 @@ export function issueService(db: Db) { )!, ); } + if (filters?.updatedSince) { + const since = new Date(filters.updatedSince); + if (Number.isFinite(since.getTime())) { + conditions.push(gt(issues.updatedAt, since)); + } + } if (filters?.excludeRoutineExecutions && !filters?.originKind && !filters?.originId) { conditions.push(ne(issues.originKind, "routine_execution")); }