Skip to content

[codex] Fix affiliate approval counts#276

Open
ifanatics-media wants to merge 6 commits into
profullstack:masterfrom
ifanatics-media:codex/fix-affiliate-approval-count
Open

[codex] Fix affiliate approval counts#276
ifanatics-media wants to merge 6 commits into
profullstack:masterfrom
ifanatics-media:codex/fix-affiliate-approval-count

Conversation

@ifanatics-media
Copy link
Copy Markdown

@ifanatics-media ifanatics-media commented May 27, 2026

Summary

  • replace affiliate application status changes with an atomic Postgres RPC so manual approvals/rejections keep affiliate_offers.total_affiliates in sync
  • return route errors when the RPC fails instead of partially succeeding
  • restrict the SECURITY DEFINER RPC to service_role so callers cannot bypass the route-level seller ownership check
  • cover approve, reject, invalid body, and RPC failure paths in the route tests

Verification

  • ./node_modules/.bin/vitest.cmd run src/app/api/affiliates/offers/[id]/applications/route.test.ts
  • ./node_modules/.bin/tsc.cmd --noEmit
  • git diff --check -- src/app/api/affiliates/offers/[id]/applications/route.ts src/app/api/affiliates/offers/[id]/applications/route.test.ts supabase/migrations/20260527161500_update_affiliate_application_status.sql

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 27, 2026

Greptile Summary

This PR replaces a non-atomic status-update + counter-adjustment pair with a single Postgres RPC (update_affiliate_application_status) that updates affiliate_applications and adjusts affiliate_offers.total_affiliates in one transaction, addressing the counter drift and partial-commit risks identified in the previous review.

  • Atomic RPC with FOR UPDATE lockingSELECT … FOR UPDATE on the application row prevents double-counting under concurrent approve/reject calls; counter logic is idempotent (previous_status <> 'approved' guards), so retries are safe.
  • Privilege restrictionREVOKE ALL … FROM PUBLIC followed by GRANT EXECUTE … TO service_role ensures callers cannot bypass the route's seller-ownership check by calling the SECURITY DEFINER function directly from the client SDK.
  • Error surfacing — The route now destructures { error: statusError } from the RPC call and returns 400 immediately on failure, so no commit can occur without the caller knowing.

Confidence Score: 5/5

Safe to merge — the atomic RPC correctly handles concurrent approvals and the privilege restriction prevents client-side bypass.

The two main issues from the previous review (silent RPC errors and unguarded SECURITY DEFINER exposure) are both resolved. The SQL uses FOR UPDATE locking and idempotent counter guards so concurrent calls cannot double-count. The remaining open item — the VOID return type requiring a post-commit SELECT — is a reliability nuance on an already-committed write, not a correctness defect on the happy path.

The migration SQL is the most load-bearing file; worth a quick read to confirm the REVOKE/GRANT block was applied to the correct function signature in your target environment.

Important Files Changed

Filename Overview
src/app/api/affiliates/offers/[id]/applications/route.ts Replaces direct .update() with an atomic RPC call; errors from the RPC are now properly surfaced and the partial-commit risk from the previous review is eliminated. Post-RPC SELECT is the only remaining read outside the atomic boundary.
supabase/migrations/20260527161500_update_affiliate_application_status.sql Adds an atomic SECURITY DEFINER RPC with FOR UPDATE locking, idempotent counter logic, and correct REVOKE/GRANT privilege scoping to service_role. Returns VOID, requiring a separate SELECT in the route.
src/app/api/affiliates/offers/[id]/applications/route.test.ts Adds approve, reject, invalid-body, and RPC-failure test cases with correct mock chains; RPC failure test correctly asserts the notifications table is never reached.

Sequence Diagram

sequenceDiagram
  participant Client
  participant Route as PATCH /api/…/applications
  participant Supabase as Supabase (service_role)
  participant DB as Postgres

  Client->>Route: "PATCH {application_id, action}"
  Route->>Supabase: from("affiliate_offers").select().eq().single()
  Supabase->>DB: SELECT id, seller_id FROM affiliate_offers
  DB-->>Route: offer (ownership check)
  alt "seller_id !== auth.user.id"
    Route-->>Client: 404 Not found or not authorized
  end
  Route->>Supabase: "rpc("update_affiliate_application_status", {p_application_id, p_offer_id, p_status})"
  Supabase->>DB: BEGIN — SELECT … FOR UPDATE on affiliate_applications
  DB->>DB: UPDATE affiliate_applications SET status, approved_at, updated_at
  DB->>DB: UPDATE affiliate_offers SET total_affiliates ± 1 (if transition warrants)
  DB-->>Supabase: COMMIT
  alt RPC error
    Route-->>Client: "400 {error: statusError.message}"
  end
  Route->>Supabase: from("affiliate_applications").select().eq().eq().single()
  Supabase->>DB: "SELECT * FROM affiliate_applications WHERE id AND offer_id"
  DB-->>Route: updated application row
  Route->>Supabase: "from("notifications").insert({...}) [fire-and-forget]"
  Route-->>Client: "200 {application}"
Loading

Reviews (6): Last reviewed commit: "Assert affiliate rejection notification" | Re-trigger Greptile

Comment thread src/app/api/affiliates/offers/[id]/applications/route.ts Outdated
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