Skip to content

perf: read-path scale hardening — RCA-driven backend fixes + an authz leak found on the way#857

Merged
teetangh merged 13 commits into
devfrom
perf/read-path-scale-hardening
Jun 11, 2026
Merged

perf: read-path scale hardening — RCA-driven backend fixes + an authz leak found on the way#857
teetangh merged 13 commits into
devfrom
perf/read-path-scale-hardening

Conversation

@teetangh

@teetangh teetangh commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

What

PR-2 of the train (stacked on #854). Read-path fixes for 100s–1000s of concurrent users, driven by a 3-agent RCA over frontend/backend/DB — every agent claim was re-verified against live routes before acting (two were refuted: events count() was already parallelized; search already had s-maxage).

Security fix found during live verification (commit fcf52b62^..)

Ownership filter silently dropped for sessions with a missing profile id. Prisma ignores undefined in where clauses, so consultantProfile: { id: undefined } matched every consultant's consultations/subscriptions on the collection GETs — verified leaking cross-consultant rows (requester PII) with a real session. Root cause chain: the seed never wrote the denormalized User.*ProfileId columns the session layer reads (29/30 consultants + 40 consultees desynced in dev). Fixed with the participants-route ?? "__none__" idiom on both GETs, the seed now syncs the columns, and the 76 dev rows were repaired. Item routes were already fail-closed.

Perf fixes

  • Requests tab: deleted /api/dashboard/consultant/[id]/requests — six unbounded datasets with 4-level includes, prefetched on every dashboard load, zero data consumers (page bound it to _requestsData, never read; the tab self-fetches paginated /api/events/*). Page renders the tab immediately — one loading phase instead of two.
  • Planner: webinar participant counts computed in-memory from already-fetched rows (was a second identical query); duplicate profile fetch removed; slot users slimmed to display fields. Verified live.
  • Availability (perf: Optimize slot availability APIs with caching and query improvements #309 live half): removeBookedSlots include filtered to candidate times (was unbounded — 200-slot subscriptions returned all 200 rows per picker request); Cache-Control: s-maxage=15, swr=60 — a popular consultant's day costs one origin hit per 15s.
  • Participants routes: display-field selects, waitlist cap 500 + truncated flag, DELETE no longer loads the whole roster, rate limiters added (had none).
  • Events routes: user: true → display selects (GET+PATCH), payment slimmed to {id, paymentStatus, amount, currency} at all 4 sites.
  • Explore trending: rank step (all plans × 30-day slots, JS sort) shared across requests via unstable_cache 60s (was per-request).
  • Indexes (additive, pushed): Consultation(consultationPlanId, requestStatus, requestedAt) + Subscription mirror (requests-tab hot path), Waitlist(status, expiresAt) (cron range scans).

Ops (done, no code)

PG_POOL_MAX=1 set in Netlify production env — each serverless function opened a default 10-connection pool; at Netlify concurrency that exhausts Supabase's cap (the #852 knob exists for exactly this).

Verification

tsc clean, 1355/1355 jest, lint adds no errors; planner/events/availability smoke-tested live (200s, shapes intact, counts correct); authz leak re-tested closed (0 rows for the degenerate session).

Follow-up

PR-2.5 (next): frontend cache-first tab smoothness from the same RCA — refetchOnWindowFocus default, keepPreviousData, Stream SDK out of layouts, poller consolidation.

Part of the post-#837 hardening train. Touches the live half of #309.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Users can cancel pending checkout payments immediately via the checkout UI (Cancel button) to release appointment holds.
  • Improvements

    • Safer concurrent checkout handling to reduce duplicate/conflict outcomes.
    • Improved refund audit trail and wallet attribution.
    • Faster availability responses via short shared caching.
    • Waitlist responses capped with a "truncated" indicator when large.
  • Tests

    • Expanded race-condition and checkout cancellation test coverage.

teetangh and others added 10 commits June 11, 2026 14:03
…edits

The refund cascade's WALLET leg branch credited the org wallet with no
audit artifact. Org-wallet-funded B2C bookings (Payment.organizationId
null, wallet owned by an org) were fully invisible to the org audit
surface; org-tagged payments only got the payment-level INVOICE row,
which the wallet-filtered view misses.

walletCredit now returns the billing account's ownerOrgId (already
selected, zero extra queries) and the cascade writes a category:WALLET /
action:WALLET_REFUND row per wallet leg, org resolved via
payment.organizationId ?? ownerOrgId, with balanceAfterPaise in details.
Step 8's INVOICE row is unchanged — org-tagged wallet payments
intentionally get both the funding-level and payment-level rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/pending/[paymentId]

A user who abandoned checkout had no way to release their own tentative
hold — they waited out the 24h cleanup window (#833 residue). New
cancelPendingCheckout core (Serializable tx, withSerializableRetry):

- CAS claim PENDING→EXPIRED via guarded updateMany — the cancel-vs-webhook
  race closer per the #836/#838 doctrine; loser gets 409
- referral credits restored (reverseCreditsForPayment, same step as the
  abandoned-payments cron)
- tentative slots released with per-type scoping mirroring the webhook's
  confirm scope: class = caller's slots across all session appointments,
  webinar = caller's slot only, consultation/subscription = all tentative
- parent → CANCELLED via the CAS transition map with a NARROW fromIn
  (PENDING/APPROVED_PENDING_PAYMENT) — never cancels a parent another
  payment already confirmed; IllegalTransitionError rolls everything back
- post-commit best-effort gateway cancel (never un-cancels on failure)

Route: DELETE with getSession + new cancelPendingLimiter (10/min, own
bucket so cancelling never consumes checkout quota). Dashboard: Cancel
button beside the Processing pill on gateway_pending items in
PendingPaymentsWidget. Tests: 10 unit cases + chaos scenario
test-cancel-pending-vs-webhook (cancel-vs-capture-webhook with consistent
end-state assertions incl. the documented late-capture orphan, plus
cancel-vs-cancel double-submit leg).

Known residual (follow-up issue): a genuine late capture can resurrect an
EXPIRED payment to SUCCEEDED (handlers.ts confirm path) — orphan is
flagged by the reconciler for refund; never a half-confirmed booking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…delete scripts copy

Two implementations of the most money-critical path was drift waiting to
happen. The weekly GH job (jobs/payouts/process-payouts.ts) now drives
lib/payments/payouts directly; scripts/payouts/process-payouts.ts is
deleted (no shim — zero external importers besides the jobs wrapper and
the test, both re-pointed). Closes the TODO #620 migration note.

Deliberate behavior changes for the weekly job (decided on #850 review —
the issue's 'no behaviour change' claim was wrong):
- TDS 194-O withholding now applies (canonical compliance engine,
  TDS_ENGINE flag semantics unchanged)
- Redis lock:payout_processing + retryCount < MAX filter now apply
- org payouts (PENDING→PROCESSING advancement) ride the weekly job via
  new org-payout-service.processPendingOrgPayouts()

Hardening ported INTO the service during consolidation:
- #776 CAS claim: updateMany {id, status: APPROVED} — the service's plain
  update() could double-submit when two runners raced with Redis down;
  losers return skipped:true and never touch the gateway
- silent-skip guard in the jobs wrapper: empty result + APPROVED payouts
  waiting (lock held / Redis down) now exits 1 instead of a green no-op
- workflow env fix: the service reads RAZORPAYX_KEY_SECRET ||
  RAZORPAY_SECRET — process-payouts.yml only set RAZORPAY_KEY_SECRET,
  which would have mass-failed every payout post-migration; RAZORPAY_SECRET
  is now mapped from the same secret
- package.json scripts:process-payouts pointed at a nonexistent file;
  re-pointed at the jobs wrapper

Test retargeted to the service path: both #785 quarantine invariants
(post-gateway fail → PROCESSING + earnings linked; pre-gateway fail →
FAILED + unlinked) plus a new CAS-skip case (gateway never called).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pooler timeouts

Both waitlist scripts imported CronLockHeldError but never called
withCronLock — the catch blocks were dead code and concurrent runs could
overlap. The scripts now wrap their bodies in
withCronLock(<same names as the jobs/waitlist wrappers>, failMode 'open')
so every entry point mutually excludes.

The recurring scheduled failures (June 6/7/10) were Prisma ETIMEDOUTs on
the FIRST query from GitHub runners against the Supabase pooler — Prisma 7
surfaces them as KnownRequestError code 'ETIMEDOUT'. New
lib/db/connect-retry.ts (withDbConnectRetry, sibling of
serializable-retry) retries only connectivity-shaped failures
(ETIMEDOUT/ECONNRESET/ECONNREFUSED/P1001/P1002/init errors) with 2s/4s/8s
backoff + jitter; business errors propagate immediately. Re-running the
bodies is safe: reminderSentAt bounds re-sends, expiry processing is
CAS-guarded.

The other half of these issues — the module-load crash on missing
UPSTASH_* env — was already fixed by 433091e (PR #851). Note:
jobs/waitlist/* wrappers remain orphaned dead code (workflows call the
scripts directly); flagged for a follow-up cleanup, not deleted here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ated by this train

TESTING_PLAN.md: route matrix with complex params for the four fixes
(#849 cancel-pending authz/CAS matrix, #835 wallet-refund audit asserts,
#850 payout job incl. concurrent-dispatch and silent-skip checks,
#814/#821 cron lock-held/retry behavior), a 5-agent parallel execution
protocol with deliberately colliding pairs, and per-scenario fixture +
verification snippets (cancel-vs-cancel, cancel-vs-webhook, last-seat
storm, waitlist-expire-vs-book, idempotency replay).

Doc syncs: tentative TTL 7d→24h (#833) + self-serve release pointer in
the cron doc; payout script paths re-pointed at jobs/payouts +
lib/payments/payouts after the #850 deletion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s-core shape

Convention recheck across all 54 cron workflows: every one calls a
jobs/<domain>/<name>.ts entry except the two waitlist workflows, which
invoked scripts/ directly while jobs/waitlist/* held full DUPLICATE
implementations with zero callers — already drifted (the duplicate sent
the event id where the script sends the plan id in reminder links).

Restored the dominant shape (the cleanup-tentative-slots idiom):
- scripts/waitlist/* are now import-only cores exporting
  sendExpirationReminders() / processWaitlistExpirations() — lock +
  connect-retry inside, structured results out, no side effects on import
- jobs/waitlist/* are thin GH wrappers: abortIfMaintenance, GH outputs,
  CronLockHeldError clean-skip, exit codes
- both workflows re-pointed at the jobs wrappers

For the payout job (#850): rechecked and left as-is — jobs→lib-service
with no scripts layer is the established NEWER convention (15 precedents:
billing/*, compliance/*, contracts/*, stream/*, meetings/*) and the old
wrapper's TODO #620 explicitly requested that migration; the scripts copy
there was a divergent duplicate of lib, not the core.

Also: master-runner mkdirs its gitignored results/reports dir instead of
failing the whole run after every scenario passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… cancel-pending

Found by the multi-agent race validation (TESTING_PLAN §4.1): 8+ truly
concurrent DELETEs on one payment produced a single 500 — the Serializable
loser exhausted withSerializableRetry's 3 attempts and the final write
conflict (Prisma 7 client-engine 'TransactionWriteConflict' / P2034)
escaped the route's catch as a server error. A concurrent writer on this
payment is a conflict outcome by definition — the winner's verdict stands
and the client refetches — so the route now maps the conflict shapes to
409 alongside IllegalTransitionError.

Re-validated: 12 concurrent cancels → exactly one 200, nine 409, two 429
(per-user limiter), zero 5xx; payment EXPIRED once, slots released,
parent CANCELLED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… requests bundle + composite indexes

Read-path scale hardening for 100s-1000s of concurrent users (PR-2 of the
train; findings from the scale audit, validated against live code):

- slots/availability (#309 live half): removeBookedSlots no longer routes
  through Appointment with an UNFILTERED include (a 200-slot subscription
  returned all 200 rows + full appointment scalars per picker request) —
  the include is filtered to candidate times and nothing else is selected;
  Set lookup replaces O(n×m) Array.includes. Plus Cache-Control
  s-maxage=15/swr=60: a popular consultant's day now costs one origin hit
  per 15s instead of one per viewer; staleness is safe because checkout
  re-validates at confirm time (#788/#827).

- participants/{class,webinar}: GET responses bounded — display-field user
  selects (the old user:true shipped every User scalar per participant per
  poll), waitlist capped at 500 with a truncated flag; DELETE no longer
  loads the entire roster to remove one participant (ownership check +
  targeted slot query); participantReadLimiter (30/min) on GET and
  eventMutationLimiter on DELETE — both routes previously had NO limiter.

- requests tab: the /api/dashboard/consultant/[id]/requests bundle — six
  unbounded datasets with 4-level includes, prefetched on EVERY dashboard
  load — had ZERO data consumers (the page bound it to _requestsData and
  never read it; RequestSlotAllocationTab self-fetches the paginated
  /api/events/* endpoints and owns its loading states). Endpoint deleted
  with its fetcher/query/prefetch wiring; the page renders the tab
  immediately, removing a duplicate loading phase. The tab's real data
  source gets display-field user selects (GET+PATCH, consultations +
  subscriptions).

- schema: composite indexes (consultationPlanId, requestStatus,
  requestedAt) + subscription mirror — serve the requests-tab hot path
  ORDER BY requestedAt DESC; pushed (additive).

Search route already had s-maxage=60 (audit claim was stale — no change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…file id

Found live during PR-2 route verification: Prisma IGNORES undefined in
where clauses, so a CONSULTANT session whose denormalized
User.consultantProfileId column was null got
`consultantProfile: { id: undefined }` — the ownership filter silently
vanished and GET /api/events/{consultations,subscriptions} served EVERY
consultant's requests (requester PII included). Verified leaked cross-
consultant rows with a real session before fixing.

Blast radius in dev was near-total: the seed created profiles via nested
create but never wrote the denormalized session-facing columns — 29/30
consultants and 40 consultees had null columns, so virtually every seeded
consultant session hit the unfiltered path. Runtime onboarding does set
the columns, so production exposure is limited to users whose columns
desynced.

Fixes:
- both collection GETs now use the participants-route idiom
  (?? "__none__") so a missing profile id matches nothing instead of
  everything
- seed writes the User.*ProfileId columns after profile creation
- dev rows repaired (76 users synced from their profile rows)

The item routes were already fail-closed (ownership compares against the
id, and undefined === x is false).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…imming, shared trending cache, waitlist index

RCA-driven (3-agent investigation, findings verified against live routes
— two agent claims were refuted by measurement: events count() was
already parallelized, and the search route already had s-maxage):

- planner: getParticipantCounts re-queried webinars the route had ALREADY
  fetched with slots+users — webinar counts now computed in-memory from
  the fetched rows (FIX #556 semantics preserved: non-tentative only,
  deduped, host excluded); classes keep their one batched query (their
  include carries no slots). The duplicate consultantProfile fetch is
  gone (reuses the org-scope lookup). Slot users slimmed to display
  fields — the full User row per attendee was the payload's biggest
  over-fetch. Verified live: 200, counts intact.

- events/{consultations,subscriptions}: appointment.payment include
  slimmed from the full row (30+ cols incl. FX decimals) to
  {id, paymentStatus, amount, currency} at all 4 sites — no consumer
  reads more (verified by grep over both dashboards).

- explore trending: the rank step (ALL marketplace plans × 30-day slot
  rows, sorted in JS) ran once per REQUEST under React.cache —
  unstable_cache now shares one computation across requests for 60s;
  cached value is just the ranked id array.

- Waitlist (status, expiresAt) composite index — the reminder/expiration
  crons range-scan NOTIFIED by expiry window; pushed (additive).

Ops (no code): PG_POOL_MAX=1 set in Netlify production env — serverless
functions each opened a default 10-connection pool; at Netlify's
concurrency that exhausts Supabase's connection cap (the #852 knob).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Consolidates payout and waitlist jobs around canonical libs, adds cancelPendingCheckout with a guarded DELETE API and rate limiting, tightens Prisma selects and participant endpoints, records wallet refund org audit rows, introduces DB connect-retry utilities and tests, removes old payout scripts, and adds extensive tests and a multi-agent testing plan.

Changes

Jobs & Workflows

Layer / File(s) Summary
Waitlist job wrappers & GH Actions outputs
jobs/waitlist/*, .github/workflows/*
Jobs wrap shared waitlist cores, emit GITHUB_OUTPUT values, handle CronLockHeldError cleanly, and ensure DB disconnect in finally.
Payout job wrapper & scripts removal
jobs/payouts/process-payouts.ts, scripts/payouts/process-payouts.ts (deleted), package.json, docs/payments/payouts/*, .github/workflows/process-payouts.yml
Jobs call lib payouts services, summarize JobSummary (exclude skipped), guard empty-result skips by checking APPROVED queue depth, merge org errors, update npm/docs/workflow references, and delete the old scripts payout implementation.
Workflow Razorpay env mapping
.github/workflows/handle-stuck-payouts.yml, .github/workflows/reconcile-payout-status.yml, .github/workflows/process-payouts.yml
Adds/normalizes RAZORPAY_SECRET from secrets.RAZORPAY_KEY_SECRET with comments about expected Razorpay payouts client variable names.

Waitlist cores

Layer / File(s) Summary
Expiration core
scripts/waitlist/process-expired-notifications.ts
Exported processWaitlistExpirations() returns aggregated counts/errors and disconnectDatabase(); per-entry failures are collected and don’t abort the run.
Reminders core
scripts/waitlist/send-expiration-reminders.ts
Exported sendExpirationReminders() returns {found,sent,errors,success}, runs under withCronLock/withDbConnectRetry, and exports disconnectDatabase().

Payout Library

Layer / File(s) Summary
Payout service CAS claiming
lib/payments/payouts/payout-service.ts
Adds optional skipped on PayoutResult; uses updateMany CAS claim for APPROVED→PROCESSING and returns skipped when claim lost to avoid gateway calls.
Org payout batch processing
lib/payments/payouts/org-payout-service.ts
processOrgPayout now reports claimed; processPendingOrgPayouts batches PENDING org payouts, counts only claimed advancements, and aggregates errors.
Exports & tests
lib/payments/payouts/index.ts, __tests__/enterprise/process-payouts-false-failed.test.ts
Re-exports PayoutResult and OrgProcessingResult, retargets tests to lib service and expands mocks and assertions for claim/skipped/false-FAILED cases.

DB connect retry

Layer / File(s) Summary
Connect-retry util and tests
lib/db/connect-retry.ts, __tests__/lib/connect-retry.test.ts
Adds isDbConnectError() and withDbConnectRetry() with exponential backoff/jitter; tests validate classification and retry/no-retry behavior.

Pending Checkout Cancellation

Layer / File(s) Summary
Cancellation operation
lib/payments/operations/cancel-pending.ts
Serializable transaction: CAS updateMany PENDING→EXPIRED, reverse referral credits, delete tentative slotOfAppointment rows scoped per appointment type, transition parent requests to CANCELLED with guarded fromIn, and prepare gatewayCancel for post-commit best-effort external cancel.
DELETE API & rate limiter
app/api/checkout/pending/[paymentId]/route.ts, lib/rate-limit.ts
New DELETE handler authenticates, applies cancelPendingLimiter (10/min), maps outcomes to 200/404/409/500, detects Prisma write-conflict patterns and returns 409, and logs structured errors.
UI cancel controls
app/dashboard/consultee/.../PendingPaymentsWidget.tsx
Adds cancelling state, cancelNotice, polling without spinner, handleCancelPending calling DELETE and handling 409 refresh notice, and Cancel button with spinner for gateway-pending items.
Tests & race scenarios
__tests__/payments/cancel-pending-checkout.test.ts, tests/typescript/race-conditions/scenarios/07-real-api-booking/*, tests/typescript/race-conditions/master-runner.ts
Unit tests cover happy path, NOT_FOUND/NOT_PENDING/IllegalTransitionError, scoped slot deletions, gateway-cancel resilience; integration race test exercises cancel-vs-webhook and double-delete CAS; master runner creates reports directory.

API selects & participant endpoints

Layer / File(s) Summary
Events endpoints select tightening
app/api/events/consultations/route.ts, app/api/events/subscriptions/route.ts
Replace nested user: true with explicit select for user fields and limit payment fields; use "none" sentinel to preserve ownership filters for missing session profiles.
Participants read limits & targeted removal
app/api/participants/class/[classId]/route.ts, app/api/participants/webinar/[webinarId]/route.ts, lib/rate-limit.ts
Add participantReadLimiter (30/min), project participant user fields via constant, cap waitlist (WAITLIST_CAP) and return waitlistTruncated, and refactor DELETE to id-only auth then disconnect only matching slot rows in one transaction.
Availability & planner
app/api/slots/availability/[consultantId]/route.ts, app/api/dashboard/consultant/[consultantId]/planner/route.ts
Add Cache-Control headers for availability responses; optimize removeBookedSlots with candidateTimes + Set-based filtering; planner computes webinar counts from already-fetched rows with dedupe and batched class counts for classes.

Dashboard & explore programs

Layer / File(s) Summary
Requests removal & prefetch changes
lib/dashboard-queries.ts, app/dashboard/consultant/.../requests/page.tsx, hooks/useConsultantPrefetchDashboard.ts
Remove consultant requests fetcher and query, simplify RequestsPage to client-only org-scope, and remove requests prefetch case.
Trending ranking cache
lib/data/explore-programs.ts
Add unstable_cache-backed helpers to compute trending plan ID rankings from last-30-days slot counts and reuse cached results in getCuratedPrograms.

Wallet refund auditing & seeding

Layer / File(s) Summary
walletCredit signature & refund audit
lib/api/organizations/wallet.ts, lib/payments/operations/refund.ts
walletCredit returns {balanceAfter, ownerOrgId} and posts ledger for TOPUP; refund WALLET leg captures credit result, writes WALLET_REFUND orgAuditLog row, and uses ownerOrgId fallback for ledger/org attribution.
Refund tests & seeding
__tests__/payments/refund-operation.test.ts, prisma/seedFiles/1a-create-users.ts
seed helper supports nullable payment.organizationId and BillingAccount.ownerOrgId; tests verify WALLET/WALLET_REFUND audit row and org resolution when payment lacks organizationId; seed user creation now backfills denormalized profile IDs.

Prisma indexes

Layer / File(s) Summary
Composite indexes
prisma/schema.prisma
Add composite @@index on Consultation(consultationPlanId, requestStatus, requestedAt), Subscription(subscriptionPlanId, requestStatus, requestedAt), and Waitlist(status, expiresAt).

Misc & docs

Layer / File(s) Summary
Testing plan, docs, and infra tweaks
TESTING_PLAN.md, docs/booking/*, docs/payments/payouts/*, tests/typescript/race-conditions/master-runner.ts, .gitignore
Add TESTING_PLAN with multi-agent scenarios and reporting rules, update cron docs to 24-hour tentative-slot stale threshold and mention DELETE cancel endpoint, adjust payout docs to jobs path, ensure reports dir exists, and ignore /temp/ scratch dir.

Estimated code review effort
🎯 4 (Complex) | ⏱️ ~45 minutes

🐰 I hopped through code with nimble paws,

Claimed payouts safe from racing claws,
Waitlists tidy, retries on the way,
Refunds logged neat at end of day,
Tests and docs — a hop, then applause!

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/read-path-scale-hardening

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a self-serve cancel-pending checkout endpoint, consolidates the payout jobs into the canonical service with TDS and locking, refactors waitlist crons to use a single source with connection retries, and optimizes various read paths by reducing over-fetching and caching trending programs. The review feedback highlights opportunities to maximize cache hits by caching the full trending lists rather than caching per-limit, to execute participant slot disconnections concurrently using prisma.$transaction instead of sequential loops, and to avoid mutating outer-scope variables inside retriable transaction blocks to prevent retry bugs.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread lib/data/explore-programs.ts
Comment thread lib/data/explore-programs.ts
Comment thread app/api/participants/class/[classId]/route.ts Outdated
Comment thread app/api/participants/webinar/[webinarId]/route.ts Outdated
Comment thread lib/data/explore-programs.ts Outdated
Comment thread lib/data/explore-programs.ts Outdated
Comment thread lib/payments/operations/cancel-pending.ts Outdated
@netlify

netlify Bot commented Jun 11, 2026

Copy link
Copy Markdown

Deploy Preview for familiarise ready!

Name Link
🔨 Latest commit c13e3d9
🔍 Latest deploy log https://app.netlify.com/projects/familiarise/deploys/6a2aaf4fadf15b00087eef9a
😎 Deploy Preview https://deploy-preview-857--familiarise.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 37 (🔴 down 15 from production)
Accessibility: 90 (no change from production)
Best Practices: 83 (no change from production)
SEO: 83 (no change from production)
PWA: -
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 10

Caution

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

⚠️ Outside diff range comments (3)
app/api/events/subscriptions/route.ts (1)

89-98: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Inconsistent user select shape for requestedBy.user between GET and PATCH.

The GET handler's requestedBy.user select (lines 91-98) returns only { id, name, email, image }, while the PATCH handler (line 175, 249) returns { id, name, email, image, role, phone }. This creates inconsistent response shapes for the same nested entity. The consultations route uses the full select consistently for both profiles.

Consider aligning with the PATCH shape for consistency:

Proposed fix
           requestedBy: {
             include: {
-              user: {
-                select: {
-                  id: true,
-                  name: true,
-                  email: true,
-                  image: true,
-                },
-              },
+              user: { select: { id: true, name: true, email: true, image: true, role: true, phone: true } },
             },
           },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/events/subscriptions/route.ts` around lines 89 - 98, The GET
handler's requestedBy.user select in route.ts returns a reduced shape
({id,name,email,image}) while the PATCH handlers return the fuller shape
({id,name,email,image,role,phone}), causing inconsistent response shapes; update
the GET handler's requestedBy.include.user.select to include role and phone to
match the PATCH response (ensure the same select shape is used for
requestedBy.user across the GET and PATCH handlers in this file, e.g., in the
GET function and the PATCH/update handlers) so clients receive a consistent
nested user object.
__tests__/payments/refund-operation.test.ts (1)

702-706: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Seed ownerOrgId on all wallet-funded test billing accounts.

Two wallet-funded fixtures still omit ownerOrgId; that creates unrealistic states for the new wallet-owner attribution path and can hide attribution regressions.

Suggested fixture tightening
@@
     state.billingAccounts.set("ba-mc", {
       id: "ba-mc",
       walletBalance: 50000,
       currency: "INR",
+      ownerOrgId: "org-1",
     });
@@
     state.billingAccounts.set("ba-neg", {
       id: "ba-neg",
       walletBalance: 50000,
       currency: "INR",
+      ownerOrgId: "org-1",
     });

Also applies to: 1043-1047

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@__tests__/payments/refund-operation.test.ts` around lines 702 - 706, The test
fixture for billing accounts is missing ownerOrgId for wallet-funded accounts;
update the calls to state.billingAccounts.set (e.g., the entry with id "ba-mc"
and the other occurrence around lines 1043-1047) to include an ownerOrgId field
(set to a realistic org id used in tests, e.g., "org-..." or the same org used
elsewhere in the test) so wallet-funded billingAccount fixtures always include
ownerOrgId for correct wallet-owner attribution.
app/dashboard/consultee/[consulteeId]/(features)/home/PendingPaymentsWidget.tsx (1)

67-69: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear stale error state after a successful refetch.

On Line 68, successful responses update pendingPayments but never clear error. After one transient failure, the widget can stay stuck in the error branch even when later polls succeed.

Suggested fix
         const data = await response.json();
+        setError(null);
         setPendingPayments(data.pendingPayments || []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/dashboard/consultee/`[consulteeId]/(features)/home/PendingPaymentsWidget.tsx
around lines 67 - 69, The fetch success path updates pendingPayments but doesn't
clear the error state, so after a transient fetch failure the component can
remain in the error branch; in the success block (where
setPendingPayments(data.pendingPayments || [] ) is called) also call the state
setter that clears the error (e.g., setError(null) or setFetchError(undefined))
to reset any previous error, ensuring the component can render the successful
state—locate the fetch logic and relevant state setters (setPendingPayments and
the error state setter) in PendingPaymentsWidget and add the clear-error call
immediately after updating pendingPayments.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@__tests__/payments/cancel-pending-checkout.test.ts`:
- Around line 281-410: The tests cover consultation, webinar, and class flows
but miss the subscription-parent branch of cancelPendingCheckout; add a new test
that seeds a payment with subscriptionId (e.g., paymentId "pay-s", userId
"user-1", paymentStatus "PENDING", subscriptionId set) and then calls
cancelPendingCheckout({ paymentId: "pay-s", userId: "user-1" }); assert that
transitionSubscriptionRequest was invoked with the subscription id (or
appropriate args), the payment's paymentStatus becomes "EXPIRED" (or expected
final status), cancelPaymentIntent behavior is handled (mock reject/resolve
variants), and no unrelated slots are removed; reference cancelPendingCheckout
and transitionSubscriptionRequest in the test to ensure the guarded
subscription-parent transition path is covered.

In `@jobs/payouts/process-payouts.ts`:
- Around line 123-127: When merging orgResult.errors into result in the block
that currently logs org errors (the orgResult.errors.forEach(...) branch), also
increment result.failed by the number of org errors so job signals remain
consistent; e.g. add result.failed += orgResult.errors.length (or increment per
error) alongside result.errors.push(...orgResult.errors) and result.success =
false in the same scope of the processPayouts handling code.

In `@jobs/waitlist/process-expired-notifications.ts`:
- Line 75: The top-level call main() is unhandled and may produce an unhandled
promise rejection; update the invocation to explicitly handle errors by chaining
a .catch(err => { processLogger?.error('process-expired-notifications failed',
err); process.exit(1); }) and (optionally) a .finally(() => process.exit(0)) or
ensure cleanup, referencing the existing main() function in this file; this
ensures any rejection from main() is logged and the process exits with an
appropriate non-zero code.

In `@lib/data/explore-programs.ts`:
- Around line 72-115: The cache key for unstable_cache in
getTrendingClassPlanIds (and similarly getTrendingWebinarPlanIds) omits the
limit argument causing stale results across different limits; fix by making the
cached call parameterized — either wrap the unstable_cache inside a non-cached
function so you call unstable_cache with a key that includes String(limit)
(e.g., ["trending-class-plan-ids", String(limit)]) or fetch a single large max
result inside the cache and then slice to the requested limit after the cached
call; update the unstable_cache invocation(s) accordingly to include the limit
in the key or use the fixed-large-fetch-and-slice approach so different limit
values return correct results.

In `@lib/payments/operations/refund.ts`:
- Around line 393-409: The WALLET ledger posting still uses
payment.organizationId only, causing mismatched attribution for org-funded B2C
refunds; update the WALLET ledger posting creation to use the same fallback as
the audit row by setting organizationId to payment.organizationId ??
credit.ownerOrgId (replace occurrences where ledger postings for type "WALLET"
are created — e.g., the Step 9 WALLET ledger posting and the similar block
around lines 760-773), ensuring ledger entries use credit.ownerOrgId when
payment.organizationId is null.

In `@lib/payments/payouts/org-payout-service.ts`:
- Around line 804-806: processOrgPayout can return "PROCESSING" both when this
worker successfully claimed the payout and when another worker already holds the
claim, so incrementing result.advanced on out.status === "PROCESSING"
overcounts; update processOrgPayout to include a deterministic claim indicator
(e.g., add a boolean property like claimed or a distinct status
"CLAIMED_BY_THIS_WORKER") and then change the caller (the code checking out from
processOrgPayout and touching result.advanced) to increment only when
out.claimed === true (or out.status === "CLAIMED_BY_THIS_WORKER"), leaving
no-op/other-worker paths uncounted.

In `@prisma/seedFiles/1a-create-users.ts`:
- Around line 630-638: The seed code updates denormalized profile IDs via
prisma.user.update but then still pushes the original pre-update user object
(see users.push(user)), causing callers of createUsers() to receive stale null
profile IDs; fix by using the updated user returned from prisma.user.update (or
re-fetching the user) and push that updated object into the users array so the
output reflects the DB changes and includes
consultantProfileId/consulteeProfileId/staffProfileId/adminProfileId.

In `@TESTING_PLAN.md`:
- Around line 224-225: Replace the informal term "ACKed" in the invariant text
with the clearer word "acknowledged" (e.g., change "webhook ACKed 2xx always" to
"webhook acknowledged 2xx always"); ensure any other occurrences of "ACKed" in
TESTING_PLAN.md use "acknowledged" for consistent, clearer documentation
language.

In
`@tests/typescript/race-conditions/scenarios/07-real-api-booking/test-cancel-pending-vs-webhook.ts`:
- Around line 247-251: The current test assertion only checks tentativeLeft ===
0 for the "webhook win" branch; update the check invocation (the call to check
with message "leg1/B: webhook win confirmed the booking (no tentative residue)")
to also assert the parent booking's status is not "CANCELLED" (e.g., include a
condition like parent?.status !== "CANCELLED" or an explicit status check) so
the invariant ensures no contradictory terminal parent state; keep the existing
tentativeLeft, confirmed and parent diagnostics in the object passed to check.
- Around line 324-330: The update is overwriting real DB state by hard-resetting
cancellationNotes/cancelledAt to null; instead restore the original values from
the saved snapshot (use the snapshot or the original consultation object) when
calling prisma.consultation.update so you set cancellationNotes:
consultationSnapshot.cancellationNotes and cancelledAt:
consultationSnapshot.cancelledAt (or consultation.cancellationNotes /
consultation.cancelledAt) rather than null, preserving pre-existing data in
shared dev fixtures.

---

Outside diff comments:
In `@__tests__/payments/refund-operation.test.ts`:
- Around line 702-706: The test fixture for billing accounts is missing
ownerOrgId for wallet-funded accounts; update the calls to
state.billingAccounts.set (e.g., the entry with id "ba-mc" and the other
occurrence around lines 1043-1047) to include an ownerOrgId field (set to a
realistic org id used in tests, e.g., "org-..." or the same org used elsewhere
in the test) so wallet-funded billingAccount fixtures always include ownerOrgId
for correct wallet-owner attribution.

In `@app/api/events/subscriptions/route.ts`:
- Around line 89-98: The GET handler's requestedBy.user select in route.ts
returns a reduced shape ({id,name,email,image}) while the PATCH handlers return
the fuller shape ({id,name,email,image,role,phone}), causing inconsistent
response shapes; update the GET handler's requestedBy.include.user.select to
include role and phone to match the PATCH response (ensure the same select shape
is used for requestedBy.user across the GET and PATCH handlers in this file,
e.g., in the GET function and the PATCH/update handlers) so clients receive a
consistent nested user object.

In
`@app/dashboard/consultee/`[consulteeId]/(features)/home/PendingPaymentsWidget.tsx:
- Around line 67-69: The fetch success path updates pendingPayments but doesn't
clear the error state, so after a transient fetch failure the component can
remain in the error branch; in the success block (where
setPendingPayments(data.pendingPayments || [] ) is called) also call the state
setter that clears the error (e.g., setError(null) or setFetchError(undefined))
to reset any previous error, ensuring the component can render the successful
state—locate the fetch logic and relevant state setters (setPendingPayments and
the error state setter) in PendingPaymentsWidget and add the clear-error call
immediately after updating pendingPayments.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 43a2afb7-2090-4002-b6c1-af36c4c231cd

📥 Commits

Reviewing files that changed from the base of the PR and between fcf52b6 and ec7620f.

📒 Files selected for processing (44)
  • .github/workflows/process-payouts.yml
  • .github/workflows/process-waitlist-expirations.yml
  • .github/workflows/send-waitlist-reminders.yml
  • .gitignore
  • TESTING_PLAN.md
  • __tests__/enterprise/process-payouts-false-failed.test.ts
  • __tests__/lib/connect-retry.test.ts
  • __tests__/payments/cancel-pending-checkout.test.ts
  • __tests__/payments/refund-operation.test.ts
  • app/api/checkout/pending/[paymentId]/route.ts
  • app/api/dashboard/consultant/[consultantId]/planner/route.ts
  • app/api/dashboard/consultant/[consultantId]/requests/route.ts
  • app/api/events/consultations/route.ts
  • app/api/events/subscriptions/route.ts
  • app/api/participants/class/[classId]/route.ts
  • app/api/participants/webinar/[webinarId]/route.ts
  • app/api/slots/availability/[consultantId]/route.ts
  • app/dashboard/consultant/[consultantId]/(features)/requests/page.tsx
  • app/dashboard/consultee/[consulteeId]/(features)/home/PendingPaymentsWidget.tsx
  • docs/booking/13-cron-jobs-and-background-tasks.md
  • docs/payments/payouts/06-configuration.md
  • docs/prisma/prisma-7-migration.md
  • hooks/useConsultantPrefetchDashboard.ts
  • jobs/payouts/process-payouts.ts
  • jobs/waitlist/process-expired-notifications.ts
  • jobs/waitlist/send-expiration-reminders.ts
  • lib/api/organizations/wallet.ts
  • lib/dashboard-queries.ts
  • lib/data/explore-programs.ts
  • lib/db/connect-retry.ts
  • lib/payments/operations/cancel-pending.ts
  • lib/payments/operations/refund.ts
  • lib/payments/payouts/index.ts
  • lib/payments/payouts/org-payout-service.ts
  • lib/payments/payouts/payout-service.ts
  • lib/rate-limit.ts
  • package.json
  • prisma/schema.prisma
  • prisma/seedFiles/1a-create-users.ts
  • scripts/payouts/process-payouts.ts
  • scripts/waitlist/process-expired-notifications.ts
  • scripts/waitlist/send-expiration-reminders.ts
  • tests/typescript/race-conditions/master-runner.ts
  • tests/typescript/race-conditions/scenarios/07-real-api-booking/test-cancel-pending-vs-webhook.ts
💤 Files with no reviewable changes (3)
  • scripts/payouts/process-payouts.ts
  • app/api/dashboard/consultant/[consultantId]/requests/route.ts
  • lib/dashboard-queries.ts

Comment thread __tests__/payments/cancel-pending-checkout.test.ts
Comment thread jobs/payouts/process-payouts.ts
Comment thread jobs/waitlist/process-expired-notifications.ts Outdated
Comment thread lib/data/explore-programs.ts
Comment thread lib/payments/operations/refund.ts
Comment thread lib/payments/payouts/org-payout-service.ts
Comment thread prisma/seedFiles/1a-create-users.ts
Comment thread TESTING_PLAN.md Outdated
teetangh and others added 3 commits June 11, 2026 18:11
…bution, send-result handling, cron retry boundary

Legit findings from the Gemini/CodeRabbit review pass, each verified
against current code before fixing (full triage table on the PR):

- cancel-pending: gatewayCancel now returned FROM the Serializable tx —
  the outer-scope mutation could leak a stale value when an aborted
  attempt retried into an early-exit path, firing a gateway cancel for an
  order this run did not expire
- refund step 9: the WALLET ledger posting now attributes to
  payment.organizationId ?? wallet-owner org (captured from walletCredit)
  — keying off the payment tag alone posted org-wallet-funded B2C refunds
  to a null-org WALLET account, guaranteeing WALLET_BALANCE_DRIFT at
  reconcile (the same attribution rule the #835 audit row already uses)
- waitlist cores: the connect-retry now wraps ONLY a SELECT 1 warm-up —
  retrying the whole body could replay non-idempotent email sends; and
  both cores now branch on the senders' { success:false } contract
  instead of counting every awaited call as delivered (a failed reminder
  no longer stamps reminderSentAt and silently drops forever)
- processOrgPayout: claimed flag surfaced through the public return so
  batch callers count real PENDING→PROCESSING claims, not another
  worker's status echo; jobs wrapper gains org_errors output so
  success=false is always explained (failed stays consultant-only)
- jobs/waitlist wrappers: main().catch parity with the payouts wrapper
- handle-stuck-payouts.yml + reconcile-payout-status.yml: RAZORPAY_SECRET
  mapping added — same env-name trap as #850's process-payouts fix (the
  payouts client never reads RAZORPAY_KEY_SECRET; the job-level gate
  passing alone left the client with an empty secret)
- chaos scenario: fixture restoration moved into finally (early throws
  left the shared fixture dirty), cancellation fields restore from
  snapshot instead of hard-nulling, webhook-win leg asserts the parent
  was not cancelled; subscription-parent unit coverage added
- connect-retry comment corrected (3 attempts = two sleeps); TESTING_PLAN
  env-regression row tightened; 'ACKed' → 'acknowledged'

Declined as wrong (justifications on the threads): nullable-paymentIntent
guard (schema: non-null @unique), null-targetOrgId guard (ownerOrgId is
required + walletCredit throws P2025 first), cancelPaymentIntent 'reason'
param claim (actual signature takes the gateway).

tsc clean; 1357/1357 jest (incl. updated processOrgPayout shape
assertions and 2 new subscription-parent cases).

Part of #854 / #857.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…isconnects, seed object sync

- trending rank caches the FULL ranked id list and callers slice to their
  limit — unstable_cache keys include fn args, so the limit parameter
  created one cache entry (and one full scan) per distinct limit. (The
  contradictory bot suggestions both land here: Gemini's per-limit-entry
  point was right; CodeRabbit's stale-cross-limit claim was wrong since
  args ARE keyed — moot either way with no arg.)
- participants DELETE: per-slot sequential updates → one prisma.$transaction
  batch (atomic; a mid-loop failure could partially remove a participant)
- seed: the in-memory user object now mirrors the denormalized profile-id
  column update — downstream seed files received it with stale nulls

tsc clean, 1357/1357 jest.

Part of #857.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
app/api/participants/class/[classId]/route.ts (1)

191-193: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

slotsAvailable hardcoded to 1 in both routes may under-notify waitlist.

Both class and webinar DELETE handlers pass slotsAvailable: 1 to handleSlotOpening, but userSlots.length could be > 1 if the removed participant occupied multiple slots. The shared root cause is that neither route accounts for multi-slot participants.

Per lib/waitlist/slot-handler.ts, handleSlotOpening notifies up to slotsAvailable queued users. If a participant held N slots, only 1 waitlist user is notified instead of N.

If the data model allows a single user to occupy multiple slots in the same appointment, both routes should pass userSlots.length instead of 1.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/participants/class/`[classId]/route.ts around lines 191 - 193, The
DELETE handlers currently call handleSlotOpening with slotsAvailable: 1 which
under-notifies when a removed participant occupied multiple slots; change both
places where participantRemoved is true to compute slotsAvailable from the
removed participant's slot count (use userSlots.length or the equivalent
variable that holds how many slots the participant held) and pass that value
into handleSlotOpening({ classId, slotsAvailable: userSlots.length }) (and the
analogous call in the webinar route) so handleSlotOpening can notify up to the
correct number of waitlist users.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@app/api/participants/class/`[classId]/route.ts:
- Around line 191-193: The DELETE handlers currently call handleSlotOpening with
slotsAvailable: 1 which under-notifies when a removed participant occupied
multiple slots; change both places where participantRemoved is true to compute
slotsAvailable from the removed participant's slot count (use userSlots.length
or the equivalent variable that holds how many slots the participant held) and
pass that value into handleSlotOpening({ classId, slotsAvailable:
userSlots.length }) (and the analogous call in the webinar route) so
handleSlotOpening can notify up to the correct number of waitlist users.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: adff8c5d-612a-4674-bace-cc1fd6c0d691

📥 Commits

Reviewing files that changed from the base of the PR and between ec7620f and c13e3d9.

📒 Files selected for processing (19)
  • .github/workflows/handle-stuck-payouts.yml
  • .github/workflows/reconcile-payout-status.yml
  • TESTING_PLAN.md
  • __tests__/enterprise/live-payout-submission.test.ts
  • __tests__/payments/cancel-pending-checkout.test.ts
  • app/api/participants/class/[classId]/route.ts
  • app/api/participants/webinar/[webinarId]/route.ts
  • jobs/payouts/process-payouts.ts
  • jobs/waitlist/process-expired-notifications.ts
  • jobs/waitlist/send-expiration-reminders.ts
  • lib/data/explore-programs.ts
  • lib/db/connect-retry.ts
  • lib/payments/operations/cancel-pending.ts
  • lib/payments/operations/refund.ts
  • lib/payments/payouts/org-payout-service.ts
  • prisma/seedFiles/1a-create-users.ts
  • scripts/waitlist/process-expired-notifications.ts
  • scripts/waitlist/send-expiration-reminders.ts
  • tests/typescript/race-conditions/scenarios/07-real-api-booking/test-cancel-pending-vs-webhook.ts

@teetangh teetangh merged commit dca950f into dev Jun 11, 2026
8 checks passed
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.

1 participant