Skip to content
Merged
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
13 changes: 10 additions & 3 deletions src/api/threadsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { buildScheduleName } from "../scheduler/schedule-name.js";
import { durationToSeconds } from "../retention.js";
import { isCalendarEventSignal, isEmailSignal } from "../types/index.js";
import type { EmailContentStore } from "./content-store.js";
import type { Signal, AnySignal, InboundEmailSignalData, PageParams, ThreadStatus, Workflow } from "../types/index.js";
import type { Signal, AnySignal, InboundEmailSignalData, PageParams, Thread, ThreadStatus, Workflow } from "../types/index.js";
import type { CalendarResponseData, DomainMisconfigurationData, Pagination } from "../types/index.js";
import type { UpdateThreadFields, ThreadDatabase } from "../database/thread-database.js";
import type { AccountDatabase } from "../database/account-database.js";
Expand Down Expand Up @@ -51,6 +51,13 @@ function page<K extends string, T>(key: K, items: T[], nextCursor?: string): Rec
return { [key]: items, pagination: { cursor: nextCursor ?? null } } as Record<K, T[]> & { pagination: Pagination };
}

// Threads with a stale/placeholder lastSignalAt (e.g. never-updated legacy records) are
// excluded from list responses — they don't represent real activity.
const MIN_LAST_SIGNAL_AT = "2000-01-01T00:00:00.000Z";
function hasRecentSignal(thread: Thread): boolean {
return thread.lastSignalAt >= MIN_LAST_SIGNAL_AT;
}

export class ThreadsApi {
constructor(
private readonly threadDb: ThreadDatabase,
Expand Down Expand Up @@ -129,7 +136,7 @@ export class ThreadsApi {
return err(c, 500, "Internal Server Error");
}

return c.json(page("threads", threadsResult.value.map(toApiThread), undefined), 200);
return c.json(page("threads", threadsResult.value.filter(hasRecentSignal).map(toApiThread), undefined), 200);
}
const params: ListThreadsParams = {
...(query["workflow"] ? { workflow: query["workflow"] as Workflow } : {}),
Expand All @@ -143,7 +150,7 @@ export class ThreadsApi {
logger.error(`Failed to list threads: ${result.error.message}`, { code: "api.threads.list_failed", error: result.error });
return err(c, 500, "Internal Server Error");
}
return c.json(page("threads", result.value.items.map(toApiThread), result.value.nextCursor), 200);
return c.json(page("threads", result.value.items.filter(hasRecentSignal).map(toApiThread), result.value.nextCursor), 200);
});

// -------------------------------------------------------------------------
Expand Down
12 changes: 12 additions & 0 deletions tests/api/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,18 @@ describe("API", () => {
const body = await res.json() as { pagination: { cursor: string } };
expect(body.pagination.cursor).toBe("cursor-abc");
});

it("excludes threads whose last signal predates Jan 1 2000", async () => {
vi.mocked(threadDb.listThreads).mockResolvedValueOnce(ok({
items: [
makeThread({ id: "arc-stale", lastSignalAt: "1999-12-31T23:59:59.000Z" }),
makeThread({ id: "arc-fresh", lastSignalAt: "2024-01-01T00:00:00.000Z" }),
],
}));
const res = await req(app, "GET", `${A}/threads`);
const body = await res.json() as { threads: { threadId: string }[] };
expect(body.threads.map(t => t.threadId)).toEqual(["arc-fresh"]);
});
});

describe("GET /accounts/:accountId/threads/:threadId", () => {
Expand Down