Skip to content

Add cursor based pagination to Links, Customers, and Commissions#3184

Merged
steven-tey merged 54 commits intomainfrom
cursor-based-pagination
Mar 12, 2026
Merged

Add cursor based pagination to Links, Customers, and Commissions#3184
steven-tey merged 54 commits intomainfrom
cursor-based-pagination

Conversation

@devkiran
Copy link
Copy Markdown
Collaborator

@devkiran devkiran commented Dec 2, 2025

Summary by CodeRabbit

  • New Features

    • Cursor-based pagination alongside page-based navigation; responses include next-cursor hints.
    • Routes default to page=1 when omitted.
  • Bug Fixes

    • Stronger validation and clearer errors for invalid, conflicting, or non-existent pagination cursors.
    • Improved UI error handling to surface specific error messages for lists.
  • Performance

    • Database indexes enhanced to speed pagination queries.
  • Tests

    • Integration tests and helpers added to validate pagination behaviors and error paths.
  • Documentation

    • API docs updated to describe cursor pagination.

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Dec 2, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Mar 11, 2026 11:45pm

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Dec 2, 2025

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a cursor+offset pagination utility and integrates it into commissions, customers, and links APIs with cursor validation; updates Zod schemas and OpenAPI metadata; adds deterministic Prisma composite indexes; introduces pagination integration tests and helpers; and defaults many page params to 1 across routes.

Changes

Cohort / File(s) Summary
Pagination Core Utility
apps/web/lib/api/pagination.ts
New buildPaginationQuery(filters) implements cursor and offset pagination, validates input combinations, enforces cursor restrictions (createdAt sort), and returns Prisma-friendly pagination params or throws DubApiError.
API Handlers (pagination + cursor validation)
apps/web/lib/api/commissions/get-commissions.ts, apps/web/lib/customers/api/get-customers.ts, apps/web/lib/api/links/get-links-for-workspace.ts
Handlers now use buildPaginationQuery, validate startingAfter/endingBefore by DB lookup (throw DubApiError for invalid/mismatched cursor), and replace manual skip/take/orderBy with the returned pagination query; get-links-for-workspace signature changed to accept a single filters param.
Zod Schemas (cursor + deprecated offset)
apps/web/lib/zod/schemas/misc.ts, apps/web/lib/zod/schemas/commissions.ts, apps/web/lib/zod/schemas/customers.ts, apps/web/lib/zod/schemas/links.ts
Adds getCursorPaginationQuerySchema, extends query schemas to include cursor fields and marks offset pagination schema as deprecated: true; introduces LINKS_MAX_PAGE_SIZE.
OpenAPI metadata
apps/web/lib/openapi/commissions/list-commissions.ts, apps/web/lib/openapi/customers/get-customers.ts
Adds x-speakeasy-pagination descriptors declaring cursor-type inputs (startingAfter) and next-cursor outputs.
Prisma Index Changes
packages/prisma/schema/commission.prisma, packages/prisma/schema/customer.prisma, packages/prisma/schema/link.prisma
Adds id to composite indexes (or creates new index) to ensure deterministic ordering when paginating.
Tests & Test Helpers
apps/web/tests/commissions/pagination.test.ts, apps/web/tests/customers/pagination.test.ts, apps/web/tests/links/list-links.test.ts, apps/web/tests/utils/helpers.ts
Adds comprehensive integration tests covering offset and cursor pagination, error cases, and ordering; adds helpers expectSortedById, expectSortedByCreatedAt, expectSortedByCreatedAtAsc, and expectNoOverlap.
Default page params across routes & libs
multiple files under apps/web/app/.../route.ts, apps/web/lib/... (e.g., .../campaigns/route.ts, .../fraud/..., .../partners/..., .../payouts/...)
Many routes and API functions now default page = 1 in parsing/destructuring to ensure defined pagination behavior.
UI & hook adjustments
apps/web/ui/links/links-container.tsx, apps/web/ui/customers/customers-table/customers-table.tsx, various client pages
Hooks/components now surface error (and message when error is Error) to surface specific messages instead of generic strings.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant API as "API Handler"
    participant Validator as "Cursor Validator"
    participant Pager as "buildPaginationQuery"
    participant DB as Database

    Client->>API: GET /resource?startingAfter=cursor_id&pageSize=10
    API->>Validator: validate cursor (startingAfter / endingBefore)
    Validator->>DB: SELECT ... WHERE id = cursor_id
    DB-->>Validator: record / null
    alt cursor exists
        Validator-->>API: valid
        API->>Pager: buildPaginationQuery(filters)
        Pager-->>API: { cursor?, orderBy, take, skip }
        API->>DB: Prisma findMany with pagination params
        DB-->>API: paginated rows
        API-->>Client: 200 results + nextCursor
    else cursor missing
        Validator-->>API: invalid
        API-->>Client: 422 DubApiError (unprocessable_entity)
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • devkiran

"🐰 I hopped through code with eager paws,
cursors traced paths and ordered laws,
indexes tucked neat beneath each tree,
tests hopped in — now pages are free! 🥕"

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.70% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the primary change: adding cursor-based pagination support to three key resources.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch cursor-based-pagination

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 11, 2026

@devkiran Sure! Triggering a full review of PR #3184 now.

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/lib/zod/schemas/misc.ts (1)

54-65: ⚠️ Potential issue | 🟡 Minor

Mark pageSize as deprecated too.

When deprecated: true, only page is annotated as deprecated right now. pageSize still shows up as current in the shared schema metadata, so the public API surface for links/customers/commissions is only half-deprecated.

Suggested tweak
  pageSize: z.coerce
    .number({ error: "Page size must be a number." })
    .positive({ message: "Page size must be greater than 0." })
    .max(pageSize, {
      message: `Max page size is ${pageSize}.`,
    })
    .optional()
    .default(pageSize)
-   .describe("The number of items per page.")
+   .describe(
+     deprecated
+       ? "DEPRECATED. Use cursor-based pagination instead."
+       : "The number of items per page.",
+   )
    .meta({
      example: 50,
+     deprecated,
    }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/lib/zod/schemas/misc.ts` around lines 54 - 65, The pageSize schema
in apps/web/lib/zod/schemas/misc.ts is not marked deprecated like page is;
update the pageSize definition (the symbol pageSize) to include the deprecation
metadata by adding deprecated: true to its .meta({ ... }) call (merge with
existing keys such as example if present) so the shared schema metadata marks
pageSize as deprecated as well.
♻️ Duplicate comments (1)
apps/web/tests/links/list-links.test.ts (1)

50-76: ⚠️ Potential issue | 🟠 Major

Make the describe.concurrent callback synchronous.

beforeAll already does the async setup, so the async suite callback is just extra risk here and still trips Vitest's invalid-describe-callback rules/patterns.

Suggested fix
-describe.concurrent("/links/** - pagination", async () => {
+describe.concurrent("/links/** - pagination", () => {
Does Vitest v4 allow an async callback passed directly to describe() or describe.concurrent(), or must suite callbacks be synchronous?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/tests/links/list-links.test.ts` around lines 50 - 76, The suite
callback passed to describe.concurrent is async but should be synchronous;
remove the async keyword from the describe.concurrent callback declaration so
the suite is a normal synchronous function and keep the existing async beforeAll
setup (which performs the async initialization using IntegrationHarness, http,
baseline, baselineIds and expectSortedByCreatedAt). Ensure you only change the
describe.concurrent declaration (not beforeAll, IntegrationHarness usage, or
assertions) so Vitest's describe callback rules are satisfied.
🧹 Nitpick comments (2)
apps/web/tests/customers/pagination.test.ts (1)

1-2: Avoid hardcoding the max-page constant in the assertion.

This will drift if CUSTOMERS_MAX_PAGE_SIZE changes. Import the schema constant and build the expected message from it so the test stays aligned with validation.

Suggested tweak
 import { Customer } from "@/lib/types";
+import { CUSTOMERS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/customers";
 import { beforeAll, describe, expect, test } from "vitest";
@@
         code: "unprocessable_entity",
         message:
-          "Page is too big (cannot be more than 100), recommend using cursor-based pagination instead.",
+          `Page is too big (cannot be more than ${CUSTOMERS_MAX_PAGE_SIZE}), recommend using cursor-based pagination instead.`,
         doc_url:
           "https://dub.co/docs/api-reference/errors#unprocessable-entity",

Also applies to: 103-119

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/tests/customers/pagination.test.ts` around lines 1 - 2, The test
currently hardcodes the max-page value in its assertion; import the schema
constant CUSTOMERS_MAX_PAGE_SIZE (or the exported name where it's defined) into
apps/web/tests/customers/pagination.test.ts and construct the expected
validation message using that constant instead of a literal number; update both
occurrences (around the initial assertion and lines ~103-119) to reference
CUSTOMERS_MAX_PAGE_SIZE so the test stays in sync with the schema validation.
apps/web/tests/commissions/pagination.test.ts (1)

202-250: Add an ascending cursor case.

This block only validates offset pagination for sortOrder: "asc". Cursor pagination changes its comparison direction in ascending mode, so that branch can regress without any test failing here.

Example of an asc cursor assertion to add nearby
   test("Offset pagination with sort order asc works correctly", async () => {
     // Get baseline in ascending order
     const { status: baselineStatus, data: ascBaseline } = await http.get<
       CommissionResponse[]
     >({
       path: "/commissions",
       query: {
         pageSize: "25",
         sortBy: "createdAt",
         sortOrder: "asc",
       },
     });

     expect(baselineStatus).toEqual(200);
     expectSortedByCreatedAtAsc(ascBaseline);

     const ascBaselineIds = ascBaseline.map((c) => c.id);

     // Test offset pagination with asc
     const page1 = await http.get<CommissionResponse[]>({
       path: "/commissions",
       query: {
         page: "1",
         pageSize: "5",
         sortBy: "createdAt",
         sortOrder: "asc",
       },
     });

     const page2 = await http.get<CommissionResponse[]>({
       path: "/commissions",
       query: {
         page: "2",
         pageSize: "5",
         sortBy: "createdAt",
         sortOrder: "asc",
       },
     });

     expect(page1.status).toEqual(200);
     expect(page2.status).toEqual(200);

     expect(page1.data.map((c) => c.id)).toEqual(ascBaselineIds.slice(0, 5));
     expect(page2.data.map((c) => c.id)).toEqual(ascBaselineIds.slice(5, 10));

     expectSortedByCreatedAtAsc(page1.data);
     expectSortedByCreatedAtAsc(page2.data);
     expectNoOverlap(page1.data, page2.data);
+
+    const ascCursorPage = await http.get<CommissionResponse[]>({
+      path: "/commissions",
+      query: {
+        pageSize: "5",
+        sortBy: "createdAt",
+        sortOrder: "asc",
+        startingAfter: ascBaselineIds[4],
+      },
+    });
+
+    expect(ascCursorPage.status).toEqual(200);
+    expect(ascCursorPage.data.map((c) => c.id)).toEqual(
+      ascBaselineIds.slice(5, 10),
+    );
+    expectSortedByCreatedAtAsc(ascCursorPage.data);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/tests/commissions/pagination.test.ts` around lines 202 - 250, Add a
cursor-pagination assertion for ascending order next to the existing offset
test: after the offset checks in the "Offset pagination with sort order asc
works correctly" test, call the same endpoint via http.get with sortBy:
"createdAt", sortOrder: "asc" and pageSize (e.g., "5") using cursor-based params
(first fetch without cursor, then fetch using the returned cursor from the last
item of page1), then assert responses are 200, that page1.data and page2.data
IDs equal ascBaselineIds.slice(0,5) and ascBaselineIds.slice(5,10) respectively,
and reuse expectSortedByCreatedAtAsc and expectNoOverlap to validate ordering
and no overlap; reference the existing http.get calls, CommissionResponse
typing, and helper functions expectSortedByCreatedAtAsc / expectNoOverlap to
implement the new assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/lib/api/commissions/get-commissions.ts`:
- Around line 35-54: The cursor validation currently uses
prisma.commission.findUnique to check global existence of cursorId (from
startingAfter/endingBefore); instead, build and reuse the same filtered "where"
object used by the main query (including program, status, user, etc.), then
validate the cursor by querying with that filter plus the id (e.g.,
prisma.commission.findFirst or findUnique with an ANDed where) so the cursor
must belong to the current filtered result set; if no record is found, throw the
same DubApiError for an invalid cursor. Ensure you reference cursorId,
startingAfter/endingBefore, the shared where object, and
prisma.commission.findFirst/findUnique in the change.

In `@apps/web/lib/api/links/get-links-for-workspace.ts`:
- Around line 50-69: The cursor validation currently only checks global
existence (cursorId via prisma.link.findUnique) but must ensure the cursor
record matches the same filters used for the main list query; change the
validation to query using the same where-clause as the main findMany (include
workspaceId, folder/tag/search/domain/date/other filters) and include id:
cursorId (use prisma.link.findFirst or prisma.link.findMany with id plus the
existing where) so it only succeeds when the cursor belongs to the filtered
result set; if not found, throw the same DubApiError as before. Ensure you
reference filters.startingAfter and filters.endingBefore for cursorId and reuse
the exact where object used by the main findMany to avoid drift.

In `@apps/web/lib/api/pagination.ts`:
- Around line 55-85: The cursor and offset branches must use a stable two-column
ordering (createdAt then id) to preserve requested sort and avoid duplicates;
update the useCursorPagination branch (where useCursorPagination, startingAfter,
endingBefore, sortOrder are used) to set orderBy to include createdAt: sortOrder
and id: sortOrder (as a tiebreaker) and adapt the cursor payload to use a
composite cursor (createdAt + id) instead of relying on id alone, and update the
offset branch (where sortBy, sortOrder, take, skip are returned) to return
orderBy: { createdAt: sortOrder, id: sortOrder } rather than only id so offset
pagination uses the same stable ordering.

In `@apps/web/lib/customers/api/get-customers.ts`:
- Around line 29-48: The cursor validation currently checks global existence;
change the prisma.customer.findUnique call that looks up cursorRecord to include
the same filtered WHERE clause used for the main query (i.e. combine the
constructed filter object used later for listing customers with the id check,
e.g. where: { ...finalWhere, id: cursorId }) so the cursor must belong to the
filtered customer set before allowing pagination; keep the DubApiError throw on
failure.

In `@apps/web/lib/zod/schemas/misc.ts`:
- Around line 40-53: Remove the .default(1) from the page zod schema (the `page`
field in the Zod schema in misc.ts) so omitted vs explicit page=1 can be
distinguished; then apply the default only in the offset-pagination code path by
changing the pagination helper to use a nullish fallback (e.g., replace direct
use of filters.page with `const page = filters.page ?? 1` inside the
offset/limit branch where offset-pagination is selected) so validation in the
useCursorPagination check (`if (useCursorPagination && page > 1)`) correctly
rejects mixed pagination modes.

In `@apps/web/tests/commissions/pagination.test.ts`:
- Around line 57-82: The tests "Cursor forward (startingAfter)" and "Cursor
backward (endingBefore)" only check length and monotonic ID order; tighten them
to assert the exact cursor window by comparing returned IDs to the expected
slice from baseline using the same sort parameters. For the forward test
(variables firstPage, lastId) compute the expected IDs =
baseline.slice(0,5).map(b => b.id) and assert data.map(d => d.id) equals that
array; for the backward test (beforeId) compute expected IDs =
baseline.slice(1,6).map(b => b.id) (or the equivalent slice used by the API) and
assert equality instead of only checking length and expectSortedById; keep the
http.get call and CommissionResponse type and ensure the sort param matches the
baseline when forming the query.

In `@apps/web/tests/links/list-links.test.ts`:
- Around line 97-121: The cursor tests ("Cursor forward (startingAfter)" and
"Cursor backward (endingBefore)") currently only assert local id ordering;
update each test to request the same sort params used to build the baseline
(e.g., include sortBy: "createdAt" and sortDir: "desc" in the http.get query)
and assert the returned ids exactly match the expected window from baselineIds
(compute expectedIds via baselineIds.slice(...) for the forward/backward window
and compare to data.map(d => d.id)). This ensures pagination is validated
against the createdAt-ordered baseline rather than just id ordering.

---

Outside diff comments:
In `@apps/web/lib/zod/schemas/misc.ts`:
- Around line 54-65: The pageSize schema in apps/web/lib/zod/schemas/misc.ts is
not marked deprecated like page is; update the pageSize definition (the symbol
pageSize) to include the deprecation metadata by adding deprecated: true to its
.meta({ ... }) call (merge with existing keys such as example if present) so the
shared schema metadata marks pageSize as deprecated as well.

---

Duplicate comments:
In `@apps/web/tests/links/list-links.test.ts`:
- Around line 50-76: The suite callback passed to describe.concurrent is async
but should be synchronous; remove the async keyword from the describe.concurrent
callback declaration so the suite is a normal synchronous function and keep the
existing async beforeAll setup (which performs the async initialization using
IntegrationHarness, http, baseline, baselineIds and expectSortedByCreatedAt).
Ensure you only change the describe.concurrent declaration (not beforeAll,
IntegrationHarness usage, or assertions) so Vitest's describe callback rules are
satisfied.

---

Nitpick comments:
In `@apps/web/tests/commissions/pagination.test.ts`:
- Around line 202-250: Add a cursor-pagination assertion for ascending order
next to the existing offset test: after the offset checks in the "Offset
pagination with sort order asc works correctly" test, call the same endpoint via
http.get with sortBy: "createdAt", sortOrder: "asc" and pageSize (e.g., "5")
using cursor-based params (first fetch without cursor, then fetch using the
returned cursor from the last item of page1), then assert responses are 200,
that page1.data and page2.data IDs equal ascBaselineIds.slice(0,5) and
ascBaselineIds.slice(5,10) respectively, and reuse expectSortedByCreatedAtAsc
and expectNoOverlap to validate ordering and no overlap; reference the existing
http.get calls, CommissionResponse typing, and helper functions
expectSortedByCreatedAtAsc / expectNoOverlap to implement the new assertions.

In `@apps/web/tests/customers/pagination.test.ts`:
- Around line 1-2: The test currently hardcodes the max-page value in its
assertion; import the schema constant CUSTOMERS_MAX_PAGE_SIZE (or the exported
name where it's defined) into apps/web/tests/customers/pagination.test.ts and
construct the expected validation message using that constant instead of a
literal number; update both occurrences (around the initial assertion and lines
~103-119) to reference CUSTOMERS_MAX_PAGE_SIZE so the test stays in sync with
the schema validation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9a89b269-351d-4bc4-b1ee-52d595f6318f

📥 Commits

Reviewing files that changed from the base of the PR and between f9b9e50 and 4c33563.

📒 Files selected for processing (17)
  • apps/web/lib/api/commissions/get-commissions.ts
  • apps/web/lib/api/links/get-links-for-workspace.ts
  • apps/web/lib/api/pagination.ts
  • apps/web/lib/customers/api/get-customers.ts
  • apps/web/lib/openapi/commissions/list-commissions.ts
  • apps/web/lib/openapi/customers/get-customers.ts
  • apps/web/lib/zod/schemas/commissions.ts
  • apps/web/lib/zod/schemas/customers.ts
  • apps/web/lib/zod/schemas/links.ts
  • apps/web/lib/zod/schemas/misc.ts
  • apps/web/tests/commissions/pagination.test.ts
  • apps/web/tests/customers/pagination.test.ts
  • apps/web/tests/links/list-links.test.ts
  • apps/web/tests/utils/helpers.ts
  • packages/prisma/schema/commission.prisma
  • packages/prisma/schema/customer.prisma
  • packages/prisma/schema/link.prisma

Comment thread apps/web/lib/api/commissions/get-commissions.ts
Comment thread apps/web/lib/api/links/get-links-for-workspace.ts
Comment thread apps/web/lib/api/pagination.ts
Comment thread apps/web/lib/customers/api/get-customers.ts
Comment thread apps/web/lib/zod/schemas/misc.ts
Comment thread apps/web/tests/commissions/pagination.test.ts
Comment thread apps/web/tests/links/list-links.test.ts
@devkiran
Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review please

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 11, 2026

@devkiran Sure! Triggering a full review of PR #3184 now.

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (3)
apps/web/tests/links/list-links.test.ts (2)

49-75: ⚠️ Potential issue | 🟠 Major

Keep the suite callback synchronous.

describe.concurrent should not be wrapped in an async callback here. All async setup is already in beforeAll, so the async suite wrapper is just invalid/fragile Vitest usage.

🔧 Minimal fix
-describe.concurrent("/links/** - pagination", async () => {
+describe.concurrent("/links/** - pagination", () => {
Does Vitest allow an async callback for `describe` / `describe.concurrent`, or should async suite setup live in `beforeAll` with a synchronous describe callback?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/tests/links/list-links.test.ts` around lines 49 - 75, The test suite
callback passed to describe.concurrent is declared async which is incorrect;
make the describe.concurrent callback synchronous and move all asynchronous work
into beforeAll (which already contains the http init and baseline setup). Locate
the describe.concurrent(...) function wrapper and change its callback to a
normal synchronous function, leaving beforeAll(async () => { ... }) unchanged so
all async initialization stays in beforeAll and tests run correctly.

96-120: ⚠️ Potential issue | 🟡 Minor

Assert the exact cursor window.

These two tests still pass if /links skips or duplicates rows, because they only check length and descending ids. Since baseline is built from commonQuery, use the same query shape for the cursor requests and compare the returned ids to the expected slice.

🔧 Suggested tightening
   test("Cursor forward (startingAfter)", async () => {
     const firstPage = baseline.slice(0, 5);
     const lastId = firstPage[4].id;

     const { status, data } = await http.get<Link[]>({
       path: "/links",
-      query: { pageSize: "5", startingAfter: lastId },
+      query: { ...commonQuery, startingAfter: lastId },
     });

     expect(status).toEqual(200);
-    expect(data).toHaveLength(5);
-    expectSortedById(data, "desc");
+    expect(data.map((l) => l.id)).toEqual(baselineIds.slice(5, 10));
   });

   test("Cursor backward (endingBefore)", async () => {
     const beforeId = baseline[5].id;

     const { status, data } = await http.get<Link[]>({
       path: "/links",
-      query: { pageSize: "5", endingBefore: beforeId },
+      query: { ...commonQuery, endingBefore: beforeId },
     });

     expect(status).toEqual(200);
-    expect(data).toHaveLength(5);
-    expectSortedById(data, "desc");
+    expect(data.map((l) => l.id)).toEqual(baselineIds.slice(0, 5));
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/tests/links/list-links.test.ts` around lines 96 - 120, Update the
two cursor tests ("Cursor forward (startingAfter)" and "Cursor backward
(endingBefore)") to assert the exact window of ids returned instead of only
length and sort: build the expected id slice from baseline using the same query
shape (as used when creating baseline via commonQuery), call http.get<Link[]>
with the identical query parameters (pageSize and startingAfter/endingBefore),
then compare the array of returned ids to the expected slice of baseline.ids to
ensure no rows are skipped or duplicated; reference the test names, baseline
array, and the http.get call to locate and change the assertions.
apps/web/lib/customers/api/get-customers.ts (1)

27-49: ⚠️ Potential issue | 🟠 Major

Validate the cursor against the full filtered query.

A customer from the same workspace but outside the current programId / partnerId / email / externalId / country / linkId / customerIds filter still passes this check. That makes the cursor boundary inconsistent with the result set the client is actually paging through. Build the where object once and reuse it for both the cursor lookup and findMany (with findFirst here, since findUnique can't combine the extra filters).

🔧 Suggested shape
-  const paginationQuery = buildPaginationQuery(filters);
+  const where = {
+    // reuse the existing filters currently passed to findMany
+  };
+  const paginationQuery = buildPaginationQuery(filters);

   if (cursorId) {
-    const customer = await prisma.customer.findUnique({
-      where: {
-        id: cursorId,
-      },
-      select: {
-        id: true,
-        projectId: true,
-      },
+    const customer = await prisma.customer.findFirst({
+      where: {
+        AND: [where, { id: cursorId }],
+      },
+      select: {
+        id: true,
+      },
     });

-    if (!customer || customer.projectId !== workspaceId) {
+    if (!customer) {
       throw new DubApiError({
         code: "unprocessable_entity",
-        message: "Invalid cursor: the provided ID does not exist.",
+        message: "Invalid cursor for the current query.",
       });
     }
   }

   return await prisma.customer.findMany({
-    where: {
-      // existing filters
-    },
+    where,
     ...paginationQuery,
Does Prisma Client `findUnique` support combining a unique field with arbitrary non-unique filters in `where`, or should query-scoped existence checks use `findFirst`/`findMany` with combined filters?

Based on learnings, the codebase prefers enforcing invariants directly in Prisma where clauses.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/lib/customers/api/get-customers.ts` around lines 27 - 49, The cursor
validation currently looks up the ID with prisma.customer.findUnique using only
projectId and thus can pass for a customer outside the active filters; instead
build the full Prisma where object once (the same filters used to create
paginationQuery) and reuse it for both the cursor check and the final findMany,
and use prisma.customer.findFirst (not findUnique) to validate that a record
with id === cursorId exists within that combined where (including
workspace/projectId plus programId, partnerId, email, externalId, country,
linkId, customerIds, etc.); update the validation logic around
cursorId/startingAfter/endingBefore to call findFirst with that shared where and
throw the existing DubApiError if no matching row is found.
🧹 Nitpick comments (2)
apps/web/tests/utils/helpers.ts (1)

75-93: Assert the id tie-breaker in the createdAt helpers.

These helpers only verify monotonic timestamps. If two rows share the same createdAt, the pagination tests can still pass even when the secondary id ordering regresses and pages start duplicating or skipping records. That's the exact edge case this PR is trying to stabilize.

♻️ Possible tightening
-export function expectSortedByCreatedAt<T extends { createdAt: string | Date }>(
+export function expectSortedByCreatedAt<
+  T extends { id: string; createdAt: string | Date },
+>(
   items: T[],
 ) {
   for (let i = 0; i < items.length - 1; i++) {
     const a = new Date(items[i].createdAt).getTime();
     const b = new Date(items[i + 1].createdAt).getTime();
-    expect(a).toBeGreaterThanOrEqual(b);
+    if (a === b) {
+      expect(items[i].id.localeCompare(items[i + 1].id)).toBeGreaterThanOrEqual(0);
+    } else {
+      expect(a).toBeGreaterThanOrEqual(b);
+    }
   }
 }
 
 export function expectSortedByCreatedAtAsc<
-  T extends { createdAt: string | Date },
+  T extends { id: string; createdAt: string | Date },
 >(items: T[]) {
   for (let i = 0; i < items.length - 1; i++) {
     const a = new Date(items[i].createdAt).getTime();
     const b = new Date(items[i + 1].createdAt).getTime();
-    expect(a).toBeLessThanOrEqual(b);
+    if (a === b) {
+      expect(items[i].id.localeCompare(items[i + 1].id)).toBeLessThanOrEqual(0);
+    } else {
+      expect(a).toBeLessThanOrEqual(b);
+    }
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/tests/utils/helpers.ts` around lines 75 - 93, Update the two helpers
expectSortedByCreatedAt and expectSortedByCreatedAtAsc to assert a deterministic
secondary ordering by id when createdAt timestamps are equal: after computing a
and b, if a === b compare items[i].id and items[i + 1].id (coercing to the same
type if needed) and assert the id ordering matches the overall comparator (for
expectSortedByCreatedAt use id >= next id, for expectSortedByCreatedAtAsc use id
<= next id); keep the timestamp checks as-is and only perform the id assertion
when timestamps are equal so tied createdAt values cannot regress across pages.
apps/web/lib/api/pagination.ts (1)

80-88: Consider adding id as a tiebreaker for offset pagination.

For offset pagination, when multiple records share the same sortBy value (e.g., same createdAt), the order is non-deterministic. Adding id as a secondary sort ensures stable pagination across pages.

♻️ Suggested improvement
   return {
-    // Order by id only for better query performance on large datasets (single-column PK index).
-    // Trade-off: ordering is by id rather than createdAt, so order may not strictly match creation time.
-    orderBy: {
-      [sortBy]: sortOrder,
-    },
+    orderBy: [{ [sortBy]: sortOrder }, { id: sortOrder }],
     take: pageSize,
     skip: (page - 1) * pageSize,
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/lib/api/pagination.ts` around lines 80 - 88, The current offset
pagination returns orderBy: { [sortBy]: sortOrder } which is non-deterministic
when multiple rows share the same sortBy value; update the return to include id
as a secondary tiebreaker so ordering is stable—e.g. set orderBy to include both
the primary key and id (for Prisma: [{ [sortBy]: sortOrder }, { id: sortOrder
}], or if using an object form add id: sortOrder) while keeping take: pageSize
and skip: (page - 1) * pageSize unchanged; use the existing identifiers sortBy,
sortOrder, orderBy, pageSize, and page when applying the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/lib/zod/schemas/commissions.ts`:
- Around line 124-132: The derived schemas (via the .extend that spreads
getCursorPaginationQuerySchema and getPaginationQuerySchema) are currently
adding cursor fields (startingAfter, endingBefore) into endpoints that shouldn't
paginate; update the derivations used to build getCommissionsCountQuerySchema
and commissionsExportQuerySchema so they omit cursor params: remove or avoid
spreading getCursorPaginationQuerySchema into those extends (or explicitly pick
only page/pageSize from getPaginationQuerySchema and exclude
startingAfter/endingBefore) so that getCommissionsCountQuerySchema and
commissionsExportQuerySchema do not include startingAfter/endingBefore; look for
the .extend call that combines getCursorPaginationQuerySchema and
getPaginationQuerySchema and change it for the count/export schema constructions
accordingly.

In `@apps/web/tests/commissions/pagination.test.ts`:
- Around line 10-35: The test suite callback passed to describe.concurrent is
marked async but should be synchronous; remove the async keyword from the
describe.concurrent callback (leaving the body and hooks like beforeAll, the
IntegrationHarness initialization, and variables http, baseline, baselineIds
untouched) so the suite definition is registered synchronously while keeping
async setup inside beforeAll.

In `@apps/web/tests/customers/pagination.test.ts`:
- Around line 57-81: The cursor tests ("Cursor forward (startingAfter)" and
"Cursor backward (endingBefore)") only check ordering; update each http.get call
to include the shared commonQuery and then assert the returned ids exactly match
the expected window from baselineIds (e.g., for startingAfter use
baselineIds.slice(5,10) or for endingBefore use the corresponding slice) instead
of only checking length/order; locate the tests and modify the requests that
call http.get<Customer[]> and the assertions that use expectSortedById to
compare the returned id array to the exact expected slice from baselineIds.
- Around line 10-36: The test suite callback passed to describe.concurrent is
marked async but Vitest expects a synchronous suite body; remove the async
keyword from the describe.concurrent callback (the anonymous function that
contains the IntegrationHarness setup, http, baseline variables and the
beforeAll block) so the suite callback is synchronous—leave the existing async
work inside beforeAll as-is (where http is initialized and baseline/baselineIds
are assigned) and ensure only the callback signature for describe.concurrent is
changed.

---

Duplicate comments:
In `@apps/web/lib/customers/api/get-customers.ts`:
- Around line 27-49: The cursor validation currently looks up the ID with
prisma.customer.findUnique using only projectId and thus can pass for a customer
outside the active filters; instead build the full Prisma where object once (the
same filters used to create paginationQuery) and reuse it for both the cursor
check and the final findMany, and use prisma.customer.findFirst (not findUnique)
to validate that a record with id === cursorId exists within that combined where
(including workspace/projectId plus programId, partnerId, email, externalId,
country, linkId, customerIds, etc.); update the validation logic around
cursorId/startingAfter/endingBefore to call findFirst with that shared where and
throw the existing DubApiError if no matching row is found.

In `@apps/web/tests/links/list-links.test.ts`:
- Around line 49-75: The test suite callback passed to describe.concurrent is
declared async which is incorrect; make the describe.concurrent callback
synchronous and move all asynchronous work into beforeAll (which already
contains the http init and baseline setup). Locate the describe.concurrent(...)
function wrapper and change its callback to a normal synchronous function,
leaving beforeAll(async () => { ... }) unchanged so all async initialization
stays in beforeAll and tests run correctly.
- Around line 96-120: Update the two cursor tests ("Cursor forward
(startingAfter)" and "Cursor backward (endingBefore)") to assert the exact
window of ids returned instead of only length and sort: build the expected id
slice from baseline using the same query shape (as used when creating baseline
via commonQuery), call http.get<Link[]> with the identical query parameters
(pageSize and startingAfter/endingBefore), then compare the array of returned
ids to the expected slice of baseline.ids to ensure no rows are skipped or
duplicated; reference the test names, baseline array, and the http.get call to
locate and change the assertions.

---

Nitpick comments:
In `@apps/web/lib/api/pagination.ts`:
- Around line 80-88: The current offset pagination returns orderBy: { [sortBy]:
sortOrder } which is non-deterministic when multiple rows share the same sortBy
value; update the return to include id as a secondary tiebreaker so ordering is
stable—e.g. set orderBy to include both the primary key and id (for Prisma: [{
[sortBy]: sortOrder }, { id: sortOrder }], or if using an object form add id:
sortOrder) while keeping take: pageSize and skip: (page - 1) * pageSize
unchanged; use the existing identifiers sortBy, sortOrder, orderBy, pageSize,
and page when applying the change.

In `@apps/web/tests/utils/helpers.ts`:
- Around line 75-93: Update the two helpers expectSortedByCreatedAt and
expectSortedByCreatedAtAsc to assert a deterministic secondary ordering by id
when createdAt timestamps are equal: after computing a and b, if a === b compare
items[i].id and items[i + 1].id (coercing to the same type if needed) and assert
the id ordering matches the overall comparator (for expectSortedByCreatedAt use
id >= next id, for expectSortedByCreatedAtAsc use id <= next id); keep the
timestamp checks as-is and only perform the id assertion when timestamps are
equal so tied createdAt values cannot regress across pages.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e9a56197-3196-4617-bbee-2802e3705f52

📥 Commits

Reviewing files that changed from the base of the PR and between f9b9e50 and 414d7d6.

📒 Files selected for processing (35)
  • apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts
  • apps/web/app/(ee)/api/campaigns/route.ts
  • apps/web/app/(ee)/api/fraud/events/route.ts
  • apps/web/app/(ee)/api/fraud/groups/route.ts
  • apps/web/app/(ee)/api/network/programs/route.ts
  • apps/web/app/(ee)/api/partner-profile/payouts/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/referrals/route.ts
  • apps/web/app/(ee)/api/payouts/route.ts
  • apps/web/app/(ee)/api/programs/[programId]/referrals/route.ts
  • apps/web/app/api/domains/route.ts
  • apps/web/app/api/tags/route.ts
  • apps/web/lib/api/campaigns/get-campaign-events.ts
  • apps/web/lib/api/commissions/get-commissions.ts
  • apps/web/lib/api/groups/get-groups.ts
  • apps/web/lib/api/links/get-links-for-workspace.ts
  • apps/web/lib/api/network/calculate-partner-ranking.ts
  • apps/web/lib/api/pagination.ts
  • apps/web/lib/api/partners/get-partners.ts
  • apps/web/lib/api/payouts/get-eligible-payouts.ts
  • apps/web/lib/customers/api/get-customers.ts
  • apps/web/lib/openapi/commissions/list-commissions.ts
  • apps/web/lib/openapi/customers/get-customers.ts
  • apps/web/lib/zod/schemas/commissions.ts
  • apps/web/lib/zod/schemas/customers.ts
  • apps/web/lib/zod/schemas/links.ts
  • apps/web/lib/zod/schemas/misc.ts
  • apps/web/tests/commissions/pagination.test.ts
  • apps/web/tests/customers/pagination.test.ts
  • apps/web/tests/links/list-links.test.ts
  • apps/web/tests/utils/helpers.ts
  • packages/prisma/schema/commission.prisma
  • packages/prisma/schema/customer.prisma
  • packages/prisma/schema/link.prisma

Comment thread apps/web/lib/zod/schemas/commissions.ts
Comment thread apps/web/tests/commissions/pagination.test.ts
Comment thread apps/web/tests/customers/pagination.test.ts
Comment thread apps/web/tests/customers/pagination.test.ts
@steven-tey
Copy link
Copy Markdown
Collaborator

@coderabbitai full review, any breaking changes?

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 11, 2026

🧩 Analysis chain

Let me dig into the codebase to assess the breaking changes properly.


🏁 Script executed:

#!/bin/bash
# Find all callers of getLinksForWorkspace
rg -n "getLinksForWorkspace" --type ts -B2 -A2

# Read the new pagination.ts file
cat apps/web/lib/api/pagination.ts

# Check the updated getLinksForWorkspace function signature
cat apps/web/lib/api/links/get-links-for-workspace.ts

# Check MAX_PAGE_VALUE constant
rg -n "MAX_PAGE" --type ts apps/web/lib/api/pagination.ts apps/web/lib/zod/schemas/

Length of output: 12284


@steven-tey Here's the full review summary along with a focused breakdown of breaking changes:


Breaking Changes

1. 🔴 Hard Break — Offset Pagination Cap (page > 1000)

The new buildPaginationQuery enforces MAX_OFFSET_PAGE = 1000. Any existing API consumer passing page > 1000 on the Links, Customers, or Commissions endpoints will now receive a 422 Unprocessable Entity error:

"Page is too big (cannot be more than 1000), recommend using cursor-based pagination instead."

This is the most impactful breaking change for existing integrations that do deep offset pagination.

2. 🟡 Database Migrations Required

Three Prisma schema files have index changes that require migrations to be run before or alongside deployment:

Schema Change
link.prisma Added id to composite index: [projectId, folderId, archived, createdAt(Desc), id]
customer.prisma Added id to composite index: [projectId, createdAt, id]
commission.prisma New index added: [programId, createdAt, id]

These are purely additive index changes (no column drops, no uniqueness constraint changes), but the migrations must be applied to avoid query failures when the new pagination cursor logic runs.

3. 🟡 Soft Break — Deprecated page / pageSize Params

For Links, Customers, and Commissions, the page and pageSize query parameters are now marked deprecated: true in the OpenAPI schema. They still function, but SDK-generated clients (e.g., Speakeasy) will surface deprecation warnings to consumers.


Non-Breaking Notes

  • getLinksForWorkspace signature change: Internally refactored from getLinksForWorkspace({ ... }: Props) to getLinksForWorkspace(filters: Props). All 3 call sites (links/route.ts, links/export/route.ts, fetch-links-batch.ts) use object-spread syntax and are unaffected.
  • Cursor orderBy uses id, not createdAt: Even when sortBy: "createdAt" is specified with cursor mode, the actual DB ordering is orderBy: { id: sortOrder }. The code comment acknowledges this: "ordering is by id rather than createdAt, so order may not strictly match creation time." This is only relevant to new cursor pagination consumers, not existing ones.
  • New 422 validations for cursor misuse (both cursors simultaneously, cursor + page, cursor + non-createdAt sort) are all gated behind the new startingAfter/endingBefore params, so they can't affect existing consumers.

TL;DR: The main risk is the page > 1000 cap for existing deep-paginating consumers and the database migrations that must be run on deploy. Everything else is additive or soft-deprecated.

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (3)
apps/web/tests/commissions/pagination.test.ts (1)

3-7: ⚠️ Potential issue | 🟠 Major

These cursor expectations still bake in id ordering.

The expectedIds calculation re-filters and re-sorts baseline by id, so this suite still passes if /commissions paginates by id instead of the requested createdAt order. Compare against the exact window from baselineIds and keep the assertions aligned with createdAt; otherwise the test codifies the current bug.

♻️ Tighten the expectations to the baseline window
 import {
   expectNoOverlap,
   expectSortedByCreatedAt,
-  expectSortedById,
 } from "../utils/helpers";
@@
   test("Cursor forward (startingAfter)", async () => {
     const firstPage = baseline.slice(0, 5);
     const lastId = firstPage[4].id;
-    const expectedIds = baseline
-      .filter((b) => b.id < lastId)
-      .sort((a, b) => b.id.localeCompare(a.id))
-      .slice(0, 5)
-      .map((b) => b.id);
+    const expectedIds = baselineIds.slice(5, 10);

     const { status, data } = await http.get<CommissionResponse[]>({
       path: "/commissions",
       query: {
         ...commonQuery,
@@
 
     expect(status).toEqual(200);
     expect(data.map((d) => d.id)).toEqual(expectedIds);
-    expectSortedById(data, "desc");
+    expectSortedByCreatedAt(data);
   });

   test("Cursor backward (endingBefore)", async () => {
     const beforeId = baseline[5].id;
-    const expectedIds = baseline
-      .filter((b) => b.id > beforeId)
-      .sort((a, b) => b.id.localeCompare(a.id))
-      .slice(0, 5)
-      .map((b) => b.id);
+    const expectedIds = baselineIds.slice(0, 5);

     const { status, data } = await http.get<CommissionResponse[]>({
       path: "/commissions",
       query: {
         ...commonQuery,
@@
 
     expect(status).toEqual(200);
     expect(data.map((d) => d.id)).toEqual(expectedIds);
-    expectSortedById(data, "desc");
+    expectSortedByCreatedAt(data);
   });

Also applies to: 56-99

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/tests/commissions/pagination.test.ts` around lines 3 - 7, The test
currently recomputes expectedIds by re-filtering and re-sorting baseline data by
id which hides a bug where the API might be paginating by id instead of
createdAt; update the expectedIds calculation in the pagination tests (the logic
around baseline, baselineIds and expectedIds used by expectSortedByCreatedAt /
expectNoOverlap) to take the exact window slice from baselineIds (which is
already sorted by createdAt) rather than re-sorting by id, and ensure the
assertions call expectSortedByCreatedAt against that baseline window; apply the
same change to the other occurrences in the file (the block around lines 56-99)
so all cursor-window expectations are aligned to createdAt ordering.
apps/web/lib/zod/schemas/customers.ts (1)

365-370: ⚠️ Potential issue | 🟡 Minor

Omit cursor params from the export schema too.

customersExportQuerySchema still inherits startingAfter / endingBefore from getCustomersQuerySchema, even though exports are not paginated. That leaks dead query params into the public schema, and customersExportCronInputSchema picks them up as well.

♻️ Minimal fix
 export const customersExportQuerySchema = getCustomersQuerySchema
   .omit({
     page: true,
     pageSize: true,
     includeExpandedFields: true,
+    startingAfter: true,
+    endingBefore: true,
   })
   .extend({
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/lib/zod/schemas/customers.ts` around lines 365 - 370,
customersExportQuerySchema currently inherits cursor params from
getCustomersQuerySchema; update the omit call on customersExportQuerySchema to
also remove the cursor fields (startingAfter and endingBefore) so exports don't
expose pagination params, and verify customersExportCronInputSchema (which
derives from customersExportQuerySchema) no longer includes those cursor keys.
apps/web/tests/links/list-links.test.ts (1)

4-8: ⚠️ Potential issue | 🟠 Major

Assert the createdAt cursor window, not just local id order.

Both cursor tests still pass if the handler paginates by id instead of the createdAt-ordered baseline. Reuse ...commonQuery, compare against the exact baselineIds.slice(...) window, and assert createdAt ordering so the suite actually covers the cursor boundary.

♻️ Tighten the cursor assertions
 import {
   expectNoOverlap,
   expectSortedByCreatedAt,
-  expectSortedById,
 } from "../utils/helpers";
@@
   test("Cursor forward (startingAfter)", async () => {
     const firstPage = baseline.slice(0, 5);
     const lastId = firstPage[4].id;

     const { status, data } = await http.get<Link[]>({
       path: "/links",
-      query: { pageSize: "5", startingAfter: lastId },
+      query: { ...commonQuery, startingAfter: lastId },
     });

     expect(status).toEqual(200);
     expect(data).toHaveLength(5);
-    expectSortedById(data, "desc");
+    expect(data.map((l) => l.id)).toEqual(baselineIds.slice(5, 10));
+    expectSortedByCreatedAt(data);
   });

   test("Cursor backward (endingBefore)", async () => {
     const beforeId = baseline[5].id;

     const { status, data } = await http.get<Link[]>({
       path: "/links",
-      query: { pageSize: "5", endingBefore: beforeId },
+      query: { ...commonQuery, endingBefore: beforeId },
     });

     expect(status).toEqual(200);
     expect(data).toHaveLength(5);
-    expectSortedById(data, "desc");
+    expect(data.map((l) => l.id)).toEqual(baselineIds.slice(0, 5));
+    expectSortedByCreatedAt(data);
   });

Also applies to: 96-121

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/tests/links/list-links.test.ts` around lines 4 - 8, Update the
cursor tests in list-links.test.ts to validate the createdAt-based cursor window
rather than only local id order: reuse the existing commonQuery for requests,
compute the expected window from baselineIds.slice(...) and assert the handler's
returned ids exactly match that slice, and replace or augment expectSortedById
with expectSortedByCreatedAt to ensure results are ordered by createdAt; also
keep expectNoOverlap to validate pagination boundaries. Locate the test cases
that call commonQuery and reference baselineIds, then change the assertions to
compare the full returned id array to baselineIds.slice(start, end) and call
expectSortedByCreatedAt(response) instead of expectSortedById where appropriate.
🧹 Nitpick comments (1)
apps/web/lib/api/links/get-links-for-workspace.ts (1)

43-46: Mutating the filters parameter may cause unexpected side effects.

The reassignment filters = { ...filters, sortBy: sort } creates a new object, but the function signature implies filters should be treated as input-only. While this works, consider using a local variable for clarity.

♻️ Suggested refinement
   // Support legacy sort param
   if (sort && sort !== "createdAt") {
-    filters = { ...filters, sortBy: sort };
+    const effectiveFilters = { ...filters, sortBy: sort };
+    // use effectiveFilters for buildPaginationQuery
   }
+  const paginationQuery = buildPaginationQuery(
+    sort && sort !== "createdAt" ? { ...filters, sortBy: sort } : filters
+  );

Or inline the override:

-  // Support legacy sort param
-  if (sort && sort !== "createdAt") {
-    filters = { ...filters, sortBy: sort };
-  }
-
-  const paginationQuery = buildPaginationQuery(filters);
+  // Support legacy sort param
+  const paginationQuery = buildPaginationQuery(
+    sort && sort !== "createdAt" ? { ...filters, sortBy: sort } : filters
+  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/lib/api/links/get-links-for-workspace.ts` around lines 43 - 46, The
code mutates the input parameter `filters` by reassigning it when handling the
legacy `sort` param; instead create a new local variable (e.g.
`effectiveFilters` or `updatedFilters`) and assign `{ ...filters, sortBy: sort
}` to that, then use that local variable in downstream calls (or inline the
override where `filters` is consumed) so the original `filters` parameter
remains untouched; update references to use the new local variable rather than
reassigning `filters`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/lib/customers/api/get-customers.ts`:
- Line 27: The cursor pagination is using id-only ordering which can diverge
from createdAt sorting; update the pagination logic so when sortBy ===
"createdAt" (used by this endpoint) the cursor branch produces a deterministic
composite ordering and cursor position: orderBy should be [{ createdAt: dir }, {
id: dir }] and the cursor value must include both createdAt and id (not just id)
so that the object passed into findMany via paginationQuery uses createdAt+id
consistently; modify buildPaginationQuery (or override paginationQuery in
get-customers.ts) to emit this composite cursor and ordering when sortBy is
"createdAt".

In `@apps/web/lib/zod/schemas/misc.ts`:
- Around line 33-52: getPaginationQuerySchema currently allows any positive page
number while runtime enforces a hard cap; add a maxPage numeric parameter (e.g.,
getPaginationQuerySchema({ pageSize, deprecated = false, maxPage })) and apply
it to the page schema using z.coerce.number().positive().max(maxPage, { message:
`Page must be <= ${maxPage}.` }) so the OpenAPI/docs and runtime bounds match;
update the page .meta() or .describe() to include the max value and ensure
callers (places that call getPaginationQuerySchema) pass the agreed max (the
same constant used by buildPaginationQuery()).

In `@packages/prisma/schema/customer.prisma`:
- Line 40: The existing index @@index([projectId, createdAt, id]) doesn’t match
the cursor branch used by getCustomers() (built via buildPaginationQuery())
because that path orders by id only; add a composite index that has projectId
followed immediately by id (for example add @@index([projectId, id]) on the
Customer model) so MySQL can use the index for WHERE projectId = ? ORDER BY id;
update the Prisma schema (Customer model) accordingly and run prisma migrate to
apply the change.

In `@packages/prisma/schema/link.prisma`:
- Line 99: The composite index in link.prisma currently has createdAt before id
which prevents MySQL from using it for the cursor branch that orders by id
alone; update the schema to add or replace an index that places id before
createdAt so the cursor path can be satisfied — e.g., add an @@index([...]) for
the same filter prefix but with id (then createdAt(sort: Desc) if still needed)
such as [projectId, folderId, archived, id, createdAt(sort: Desc)] (or add a
separate index [projectId, folderId, archived, id]) to cover
getLinksForWorkspace() and buildPaginationQuery() cursor ordering.

---

Duplicate comments:
In `@apps/web/lib/zod/schemas/customers.ts`:
- Around line 365-370: customersExportQuerySchema currently inherits cursor
params from getCustomersQuerySchema; update the omit call on
customersExportQuerySchema to also remove the cursor fields (startingAfter and
endingBefore) so exports don't expose pagination params, and verify
customersExportCronInputSchema (which derives from customersExportQuerySchema)
no longer includes those cursor keys.

In `@apps/web/tests/commissions/pagination.test.ts`:
- Around line 3-7: The test currently recomputes expectedIds by re-filtering and
re-sorting baseline data by id which hides a bug where the API might be
paginating by id instead of createdAt; update the expectedIds calculation in the
pagination tests (the logic around baseline, baselineIds and expectedIds used by
expectSortedByCreatedAt / expectNoOverlap) to take the exact window slice from
baselineIds (which is already sorted by createdAt) rather than re-sorting by id,
and ensure the assertions call expectSortedByCreatedAt against that baseline
window; apply the same change to the other occurrences in the file (the block
around lines 56-99) so all cursor-window expectations are aligned to createdAt
ordering.

In `@apps/web/tests/links/list-links.test.ts`:
- Around line 4-8: Update the cursor tests in list-links.test.ts to validate the
createdAt-based cursor window rather than only local id order: reuse the
existing commonQuery for requests, compute the expected window from
baselineIds.slice(...) and assert the handler's returned ids exactly match that
slice, and replace or augment expectSortedById with expectSortedByCreatedAt to
ensure results are ordered by createdAt; also keep expectNoOverlap to validate
pagination boundaries. Locate the test cases that call commonQuery and reference
baselineIds, then change the assertions to compare the full returned id array to
baselineIds.slice(start, end) and call expectSortedByCreatedAt(response) instead
of expectSortedById where appropriate.

---

Nitpick comments:
In `@apps/web/lib/api/links/get-links-for-workspace.ts`:
- Around line 43-46: The code mutates the input parameter `filters` by
reassigning it when handling the legacy `sort` param; instead create a new local
variable (e.g. `effectiveFilters` or `updatedFilters`) and assign `{ ...filters,
sortBy: sort }` to that, then use that local variable in downstream calls (or
inline the override where `filters` is consumed) so the original `filters`
parameter remains untouched; update references to use the new local variable
rather than reassigning `filters`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cee50609-09e5-4c8e-8205-9336bec10c00

📥 Commits

Reviewing files that changed from the base of the PR and between 6a8ea70 and c883f15.

📒 Files selected for processing (39)
  • apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts
  • apps/web/app/(ee)/api/campaigns/route.ts
  • apps/web/app/(ee)/api/fraud/events/route.ts
  • apps/web/app/(ee)/api/fraud/groups/route.ts
  • apps/web/app/(ee)/api/network/programs/route.ts
  • apps/web/app/(ee)/api/partner-profile/payouts/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts
  • apps/web/app/(ee)/api/partner-profile/programs/[programId]/referrals/route.ts
  • apps/web/app/(ee)/api/payouts/route.ts
  • apps/web/app/(ee)/api/programs/[programId]/referrals/route.ts
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/(index)/page-client.tsx
  • apps/web/app/api/domains/route.ts
  • apps/web/app/api/tags/route.ts
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commissions-table.tsx
  • apps/web/lib/api/campaigns/get-campaign-events.ts
  • apps/web/lib/api/commissions/get-commissions.ts
  • apps/web/lib/api/groups/get-groups.ts
  • apps/web/lib/api/links/get-links-for-workspace.ts
  • apps/web/lib/api/network/calculate-partner-ranking.ts
  • apps/web/lib/api/pagination.ts
  • apps/web/lib/api/partners/get-partners.ts
  • apps/web/lib/api/payouts/get-eligible-payouts.ts
  • apps/web/lib/customers/api/get-customers.ts
  • apps/web/lib/openapi/commissions/list-commissions.ts
  • apps/web/lib/openapi/customers/get-customers.ts
  • apps/web/lib/zod/schemas/commissions.ts
  • apps/web/lib/zod/schemas/customers.ts
  • apps/web/lib/zod/schemas/links.ts
  • apps/web/lib/zod/schemas/misc.ts
  • apps/web/tests/commissions/pagination.test.ts
  • apps/web/tests/customers/pagination.test.ts
  • apps/web/tests/links/list-links.test.ts
  • apps/web/tests/utils/helpers.ts
  • apps/web/ui/customers/customers-table/customers-table.tsx
  • apps/web/ui/links/links-container.tsx
  • packages/prisma/schema/commission.prisma
  • packages/prisma/schema/customer.prisma
  • packages/prisma/schema/link.prisma

Comment thread apps/web/lib/customers/api/get-customers.ts
Comment thread apps/web/lib/zod/schemas/misc.ts
Comment thread packages/prisma/schema/customer.prisma Outdated
Comment thread packages/prisma/schema/link.prisma Outdated
@steven-tey steven-tey merged commit 8de071b into main Mar 12, 2026
10 of 11 checks passed
@steven-tey steven-tey deleted the cursor-based-pagination branch March 12, 2026 00:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants