From 33de2054a10ef8720601d3af5d7e9b99d8e3a6d2 Mon Sep 17 00:00:00 2001 From: ifanatics-media Date: Wed, 27 May 2026 16:01:32 -0700 Subject: [PATCH 1/6] Fix affiliate approval counts --- .../offers/[id]/applications/route.test.ts | 167 ++++++++++++++++++ .../offers/[id]/applications/route.ts | 22 +++ ...ement_affiliate_offer_total_affiliates.sql | 16 ++ 3 files changed, 205 insertions(+) create mode 100644 supabase/migrations/20260527160000_decrement_affiliate_offer_total_affiliates.sql diff --git a/src/app/api/affiliates/offers/[id]/applications/route.test.ts b/src/app/api/affiliates/offers/[id]/applications/route.test.ts index d9b04aad..dde0e641 100644 --- a/src/app/api/affiliates/offers/[id]/applications/route.test.ts +++ b/src/app/api/affiliates/offers/[id]/applications/route.test.ts @@ -8,9 +8,11 @@ vi.mock("@/lib/auth/get-user", () => ({ })); const mockFrom = vi.fn(); +const mockRpc = vi.fn(); vi.mock("@/lib/supabase/service", () => ({ createServiceClient: () => ({ from: (...args: unknown[]) => mockFrom(...args), + rpc: (...args: unknown[]) => mockRpc(...args), }), })); @@ -32,6 +34,7 @@ function makePatchRequest(body: BodyInit, contentType = "application/json") { describe("PATCH /api/affiliates/offers/[id]/applications", () => { beforeEach(() => { vi.clearAllMocks(); + mockRpc.mockResolvedValue({ data: null, error: null }); mockGetAuthContext.mockResolvedValue({ user: { id: "seller-1", authMethod: "session" }, }); @@ -83,6 +86,17 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { if (table === "affiliate_applications") { return { + select: () => ({ + eq: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { id: "app-1", status: "pending" }, + error: null, + }), + }), + }), + }), update: (payload: Record) => { updatePayload = payload; return { @@ -131,5 +145,158 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { type: "affiliate_approved", data: { offer_id: "offer-1", application_id: "app-1" }, }); + expect(mockRpc).toHaveBeenCalledWith("increment_affiliate_offer_total_affiliates", { + p_offer_id: "offer-1", + }); + }); + + it("decrements the affiliate count when rejecting a previously approved application", async () => { + const updatedApplication = { + id: "app-1", + offer_id: "offer-1", + affiliate_id: "affiliate-1", + status: "rejected", + profiles: { username: "alice" }, + }; + + mockFrom.mockImplementation((table: string) => { + if (table === "affiliate_offers") { + return { + select: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { id: "offer-1", seller_id: "seller-1" }, + error: null, + }), + }), + }), + }; + } + + if (table === "affiliate_applications") { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { id: "app-1", status: "approved" }, + error: null, + }), + }), + }), + }), + update: () => ({ + eq: () => ({ + eq: () => ({ + select: () => ({ + single: () => + Promise.resolve({ + data: updatedApplication, + error: null, + }), + }), + }), + }), + }), + }; + } + + if (table === "notifications") { + return { + insert: () => Promise.resolve({ data: null, error: null }), + }; + } + + throw new Error(`Unexpected table: ${table}`); + }); + + const res = await PATCH( + makePatchRequest( + JSON.stringify({ application_id: "app-1", action: "reject" }) + ), + makeParams("offer-1") + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.application).toEqual(updatedApplication); + expect(mockRpc).toHaveBeenCalledWith("decrement_affiliate_offer_total_affiliates", { + p_offer_id: "offer-1", + }); + }); + + it("does not double count when approving an already approved application", async () => { + const updatedApplication = { + id: "app-1", + offer_id: "offer-1", + affiliate_id: "affiliate-1", + status: "approved", + profiles: { username: "alice" }, + }; + + mockFrom.mockImplementation((table: string) => { + if (table === "affiliate_offers") { + return { + select: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { id: "offer-1", seller_id: "seller-1" }, + error: null, + }), + }), + }), + }; + } + + if (table === "affiliate_applications") { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { id: "app-1", status: "approved" }, + error: null, + }), + }), + }), + }), + update: () => ({ + eq: () => ({ + eq: () => ({ + select: () => ({ + single: () => + Promise.resolve({ + data: updatedApplication, + error: null, + }), + }), + }), + }), + }), + }; + } + + if (table === "notifications") { + return { + insert: () => Promise.resolve({ data: null, error: null }), + }; + } + + throw new Error(`Unexpected table: ${table}`); + }); + + const res = await PATCH( + makePatchRequest( + JSON.stringify({ application_id: "app-1", action: "approve" }) + ), + makeParams("offer-1") + ); + + expect(res.status).toBe(200); + expect(mockRpc).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/affiliates/offers/[id]/applications/route.ts b/src/app/api/affiliates/offers/[id]/applications/route.ts index da072f58..0f358b16 100644 --- a/src/app/api/affiliates/offers/[id]/applications/route.ts +++ b/src/app/api/affiliates/offers/[id]/applications/route.ts @@ -101,7 +101,19 @@ export async function PATCH( return NextResponse.json({ error: "Not found or not authorized" }, { status: 404 }); } + const { data: existingApplication, error: existingApplicationError } = await (admin as AnySupabase) + .from("affiliate_applications") + .select("id, status") + .eq("id", application_id) + .eq("offer_id", id) + .single(); + + if (existingApplicationError || !existingApplication) { + return NextResponse.json({ error: "Application not found" }, { status: 404 }); + } + const status = action === "approve" ? "approved" : "rejected"; + const wasApproved = existingApplication.status === "approved"; const updateData: Record = { status, updated_at: new Date().toISOString(), @@ -122,6 +134,16 @@ export async function PATCH( return NextResponse.json({ error: error.message }, { status: 400 }); } + if (status === "approved" && !wasApproved) { + await (admin as AnySupabase).rpc("increment_affiliate_offer_total_affiliates", { + p_offer_id: id, + }); + } else if (status === "rejected" && wasApproved) { + await (admin as AnySupabase).rpc("decrement_affiliate_offer_total_affiliates", { + p_offer_id: id, + }); + } + // Notify affiliate const notificationType = status === "approved" ? "affiliate_approved" : "affiliate_rejected"; await (admin as AnySupabase) diff --git a/supabase/migrations/20260527160000_decrement_affiliate_offer_total_affiliates.sql b/supabase/migrations/20260527160000_decrement_affiliate_offer_total_affiliates.sql new file mode 100644 index 00000000..d7bca869 --- /dev/null +++ b/supabase/migrations/20260527160000_decrement_affiliate_offer_total_affiliates.sql @@ -0,0 +1,16 @@ +CREATE OR REPLACE FUNCTION public.decrement_affiliate_offer_total_affiliates( + p_offer_id UUID +) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + UPDATE public.affiliate_offers + SET + total_affiliates = GREATEST(total_affiliates - 1, 0), + updated_at = NOW() + WHERE id = p_offer_id; +END; +$$; From c4552a957eae3ebe47843dd1e173bdf20d5d85b7 Mon Sep 17 00:00:00 2001 From: ifanatics-media Date: Wed, 27 May 2026 16:08:52 -0700 Subject: [PATCH 2/6] Handle affiliate count update failures --- .../offers/[id]/applications/route.test.ts | 77 +++++++++++++++++++ .../offers/[id]/applications/route.ts | 10 ++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/app/api/affiliates/offers/[id]/applications/route.test.ts b/src/app/api/affiliates/offers/[id]/applications/route.test.ts index dde0e641..78457f7e 100644 --- a/src/app/api/affiliates/offers/[id]/applications/route.test.ts +++ b/src/app/api/affiliates/offers/[id]/applications/route.test.ts @@ -299,4 +299,81 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { expect(res.status).toBe(200); expect(mockRpc).not.toHaveBeenCalled(); }); + + it("returns an error when the affiliate count update fails", async () => { + mockRpc.mockResolvedValueOnce({ + data: null, + error: { message: "missing increment function" }, + }); + + mockFrom.mockImplementation((table: string) => { + if (table === "affiliate_offers") { + return { + select: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { id: "offer-1", seller_id: "seller-1" }, + error: null, + }), + }), + }), + }; + } + + if (table === "affiliate_applications") { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { id: "app-1", status: "pending" }, + error: null, + }), + }), + }), + }), + update: () => ({ + eq: () => ({ + eq: () => ({ + select: () => ({ + single: () => + Promise.resolve({ + data: { + id: "app-1", + offer_id: "offer-1", + affiliate_id: "affiliate-1", + status: "approved", + profiles: { username: "alice" }, + }, + error: null, + }), + }), + }), + }), + }), + }; + } + + if (table === "notifications") { + return { + insert: () => Promise.resolve({ data: null, error: null }), + }; + } + + throw new Error(`Unexpected table: ${table}`); + }); + + const res = await PATCH( + makePatchRequest( + JSON.stringify({ application_id: "app-1", action: "approve" }) + ), + makeParams("offer-1") + ); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toBe("missing increment function"); + }); }); diff --git a/src/app/api/affiliates/offers/[id]/applications/route.ts b/src/app/api/affiliates/offers/[id]/applications/route.ts index 0f358b16..69fbda26 100644 --- a/src/app/api/affiliates/offers/[id]/applications/route.ts +++ b/src/app/api/affiliates/offers/[id]/applications/route.ts @@ -135,13 +135,19 @@ export async function PATCH( } if (status === "approved" && !wasApproved) { - await (admin as AnySupabase).rpc("increment_affiliate_offer_total_affiliates", { + const { error: countError } = await (admin as AnySupabase).rpc("increment_affiliate_offer_total_affiliates", { p_offer_id: id, }); + if (countError) { + return NextResponse.json({ error: countError.message }, { status: 400 }); + } } else if (status === "rejected" && wasApproved) { - await (admin as AnySupabase).rpc("decrement_affiliate_offer_total_affiliates", { + const { error: countError } = await (admin as AnySupabase).rpc("decrement_affiliate_offer_total_affiliates", { p_offer_id: id, }); + if (countError) { + return NextResponse.json({ error: countError.message }, { status: 400 }); + } } // Notify affiliate From a6414b09e8a7350e4e0898dfc1a47ba983c82078 Mon Sep 17 00:00:00 2001 From: ifanatics-media Date: Wed, 27 May 2026 16:13:44 -0700 Subject: [PATCH 3/6] Make affiliate status count updates atomic --- .../offers/[id]/applications/route.test.ts | 304 ++++-------------- .../offers/[id]/applications/route.ts | 51 +-- ...00_update_affiliate_application_status.sql | 47 +++ 3 files changed, 121 insertions(+), 281 deletions(-) create mode 100644 supabase/migrations/20260527161500_update_affiliate_application_status.sql diff --git a/src/app/api/affiliates/offers/[id]/applications/route.test.ts b/src/app/api/affiliates/offers/[id]/applications/route.test.ts index 78457f7e..86485be6 100644 --- a/src/app/api/affiliates/offers/[id]/applications/route.test.ts +++ b/src/app/api/affiliates/offers/[id]/applications/route.test.ts @@ -31,6 +31,42 @@ function makePatchRequest(body: BodyInit, contentType = "application/json") { ); } +function mockOwnedOffer() { + return { + select: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { id: "offer-1", seller_id: "seller-1" }, + error: null, + }), + }), + }), + }; +} + +function mockUpdatedApplication(status: "approved" | "rejected") { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { + id: "app-1", + offer_id: "offer-1", + affiliate_id: "affiliate-1", + status, + profiles: { username: "alice" }, + }, + error: null, + }), + }), + }), + }), + }; +} + describe("PATCH /api/affiliates/offers/[id]/applications", () => { beforeEach(() => { vi.clearAllMocks(); @@ -47,6 +83,7 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { expect(res.status).toBe(400); expect(body.error).toBe("Invalid request body"); expect(mockFrom).not.toHaveBeenCalled(); + expect(mockRpc).not.toHaveBeenCalled(); }); it("returns 400 for non-object JSON bodies", async () => { @@ -56,66 +93,15 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { expect(res.status).toBe(400); expect(body.error).toBe("Invalid request body"); expect(mockFrom).not.toHaveBeenCalled(); + expect(mockRpc).not.toHaveBeenCalled(); }); - it("approves an application and sends an approval notification", async () => { - const updatedApplication = { - id: "app-1", - offer_id: "offer-1", - affiliate_id: "affiliate-1", - status: "approved", - profiles: { username: "alice" }, - }; - let updatePayload: Record | undefined; + it("approves an application through the atomic status RPC and sends a notification", async () => { let notificationPayload: Record | undefined; mockFrom.mockImplementation((table: string) => { - if (table === "affiliate_offers") { - return { - select: () => ({ - eq: () => ({ - single: () => - Promise.resolve({ - data: { id: "offer-1", seller_id: "seller-1" }, - error: null, - }), - }), - }), - }; - } - - if (table === "affiliate_applications") { - return { - select: () => ({ - eq: () => ({ - eq: () => ({ - single: () => - Promise.resolve({ - data: { id: "app-1", status: "pending" }, - error: null, - }), - }), - }), - }), - update: (payload: Record) => { - updatePayload = payload; - return { - eq: () => ({ - eq: () => ({ - select: () => ({ - single: () => - Promise.resolve({ - data: updatedApplication, - error: null, - }), - }), - }), - }), - }; - }, - }; - } - + if (table === "affiliate_offers") return mockOwnedOffer(); + if (table === "affiliate_applications") return mockUpdatedApplication("approved"); if (table === "notifications") { return { insert: (payload: Record) => { @@ -124,7 +110,6 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { }, }; } - throw new Error(`Unexpected table: ${table}`); }); @@ -137,78 +122,28 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { const body = await res.json(); expect(res.status).toBe(200); - expect(body.application).toEqual(updatedApplication); - expect(updatePayload).toMatchObject({ status: "approved" }); - expect(updatePayload?.approved_at).toEqual(expect.any(String)); + expect(body.application).toMatchObject({ id: "app-1", status: "approved" }); + expect(mockRpc).toHaveBeenCalledWith("update_affiliate_application_status", { + p_application_id: "app-1", + p_offer_id: "offer-1", + p_status: "approved", + }); expect(notificationPayload).toMatchObject({ user_id: "affiliate-1", type: "affiliate_approved", data: { offer_id: "offer-1", application_id: "app-1" }, }); - expect(mockRpc).toHaveBeenCalledWith("increment_affiliate_offer_total_affiliates", { - p_offer_id: "offer-1", - }); }); - it("decrements the affiliate count when rejecting a previously approved application", async () => { - const updatedApplication = { - id: "app-1", - offer_id: "offer-1", - affiliate_id: "affiliate-1", - status: "rejected", - profiles: { username: "alice" }, - }; - + it("rejects an application through the atomic status RPC", async () => { mockFrom.mockImplementation((table: string) => { - if (table === "affiliate_offers") { - return { - select: () => ({ - eq: () => ({ - single: () => - Promise.resolve({ - data: { id: "offer-1", seller_id: "seller-1" }, - error: null, - }), - }), - }), - }; - } - - if (table === "affiliate_applications") { - return { - select: () => ({ - eq: () => ({ - eq: () => ({ - single: () => - Promise.resolve({ - data: { id: "app-1", status: "approved" }, - error: null, - }), - }), - }), - }), - update: () => ({ - eq: () => ({ - eq: () => ({ - select: () => ({ - single: () => - Promise.resolve({ - data: updatedApplication, - error: null, - }), - }), - }), - }), - }), - }; - } - + if (table === "affiliate_offers") return mockOwnedOffer(); + if (table === "affiliate_applications") return mockUpdatedApplication("rejected"); if (table === "notifications") { return { insert: () => Promise.resolve({ data: null, error: null }), }; } - throw new Error(`Unexpected table: ${table}`); }); @@ -221,147 +156,27 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { const body = await res.json(); expect(res.status).toBe(200); - expect(body.application).toEqual(updatedApplication); - expect(mockRpc).toHaveBeenCalledWith("decrement_affiliate_offer_total_affiliates", { + expect(body.application).toMatchObject({ id: "app-1", status: "rejected" }); + expect(mockRpc).toHaveBeenCalledWith("update_affiliate_application_status", { + p_application_id: "app-1", p_offer_id: "offer-1", + p_status: "rejected", }); }); - it("does not double count when approving an already approved application", async () => { - const updatedApplication = { - id: "app-1", - offer_id: "offer-1", - affiliate_id: "affiliate-1", - status: "approved", - profiles: { username: "alice" }, - }; - - mockFrom.mockImplementation((table: string) => { - if (table === "affiliate_offers") { - return { - select: () => ({ - eq: () => ({ - single: () => - Promise.resolve({ - data: { id: "offer-1", seller_id: "seller-1" }, - error: null, - }), - }), - }), - }; - } - - if (table === "affiliate_applications") { - return { - select: () => ({ - eq: () => ({ - eq: () => ({ - single: () => - Promise.resolve({ - data: { id: "app-1", status: "approved" }, - error: null, - }), - }), - }), - }), - update: () => ({ - eq: () => ({ - eq: () => ({ - select: () => ({ - single: () => - Promise.resolve({ - data: updatedApplication, - error: null, - }), - }), - }), - }), - }), - }; - } - - if (table === "notifications") { - return { - insert: () => Promise.resolve({ data: null, error: null }), - }; - } - - throw new Error(`Unexpected table: ${table}`); - }); - - const res = await PATCH( - makePatchRequest( - JSON.stringify({ application_id: "app-1", action: "approve" }) - ), - makeParams("offer-1") - ); - - expect(res.status).toBe(200); - expect(mockRpc).not.toHaveBeenCalled(); - }); - - it("returns an error when the affiliate count update fails", async () => { + it("returns an error and does not notify when the atomic status RPC fails", async () => { mockRpc.mockResolvedValueOnce({ data: null, - error: { message: "missing increment function" }, + error: { message: "Application not found" }, }); mockFrom.mockImplementation((table: string) => { - if (table === "affiliate_offers") { - return { - select: () => ({ - eq: () => ({ - single: () => - Promise.resolve({ - data: { id: "offer-1", seller_id: "seller-1" }, - error: null, - }), - }), - }), - }; - } - - if (table === "affiliate_applications") { - return { - select: () => ({ - eq: () => ({ - eq: () => ({ - single: () => - Promise.resolve({ - data: { id: "app-1", status: "pending" }, - error: null, - }), - }), - }), - }), - update: () => ({ - eq: () => ({ - eq: () => ({ - select: () => ({ - single: () => - Promise.resolve({ - data: { - id: "app-1", - offer_id: "offer-1", - affiliate_id: "affiliate-1", - status: "approved", - profiles: { username: "alice" }, - }, - error: null, - }), - }), - }), - }), - }), - }; - } - + if (table === "affiliate_offers") return mockOwnedOffer(); if (table === "notifications") { return { - insert: () => Promise.resolve({ data: null, error: null }), + insert: vi.fn(), }; } - throw new Error(`Unexpected table: ${table}`); }); @@ -374,6 +189,7 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { const body = await res.json(); expect(res.status).toBe(400); - expect(body.error).toBe("missing increment function"); + expect(body.error).toBe("Application not found"); + expect(mockFrom).not.toHaveBeenCalledWith("notifications"); }); }); diff --git a/src/app/api/affiliates/offers/[id]/applications/route.ts b/src/app/api/affiliates/offers/[id]/applications/route.ts index 69fbda26..a18a79e5 100644 --- a/src/app/api/affiliates/offers/[id]/applications/route.ts +++ b/src/app/api/affiliates/offers/[id]/applications/route.ts @@ -101,53 +101,30 @@ export async function PATCH( return NextResponse.json({ error: "Not found or not authorized" }, { status: 404 }); } - const { data: existingApplication, error: existingApplicationError } = await (admin as AnySupabase) - .from("affiliate_applications") - .select("id, status") - .eq("id", application_id) - .eq("offer_id", id) - .single(); + const status = action === "approve" ? "approved" : "rejected"; - if (existingApplicationError || !existingApplication) { - return NextResponse.json({ error: "Application not found" }, { status: 404 }); - } + const { error: statusError } = await (admin as AnySupabase).rpc( + "update_affiliate_application_status", + { + p_application_id: application_id, + p_offer_id: id, + p_status: status, + } + ); - const status = action === "approve" ? "approved" : "rejected"; - const wasApproved = existingApplication.status === "approved"; - const updateData: Record = { - status, - updated_at: new Date().toISOString(), - }; - if (status === "approved") { - updateData.approved_at = new Date().toISOString(); + if (statusError) { + return NextResponse.json({ error: statusError.message }, { status: 400 }); } const { data: application, error } = await (admin as AnySupabase) .from("affiliate_applications") - .update(updateData) + .select(`*, profiles!affiliate_applications_affiliate_id_fkey(username)`) .eq("id", application_id) .eq("offer_id", id) - .select(`*, profiles!affiliate_applications_affiliate_id_fkey(username)`) .single(); - if (error) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - - if (status === "approved" && !wasApproved) { - const { error: countError } = await (admin as AnySupabase).rpc("increment_affiliate_offer_total_affiliates", { - p_offer_id: id, - }); - if (countError) { - return NextResponse.json({ error: countError.message }, { status: 400 }); - } - } else if (status === "rejected" && wasApproved) { - const { error: countError } = await (admin as AnySupabase).rpc("decrement_affiliate_offer_total_affiliates", { - p_offer_id: id, - }); - if (countError) { - return NextResponse.json({ error: countError.message }, { status: 400 }); - } + if (error || !application) { + return NextResponse.json({ error: error?.message || "Application not found" }, { status: 404 }); } // Notify affiliate diff --git a/supabase/migrations/20260527161500_update_affiliate_application_status.sql b/supabase/migrations/20260527161500_update_affiliate_application_status.sql new file mode 100644 index 00000000..98c1fe64 --- /dev/null +++ b/supabase/migrations/20260527161500_update_affiliate_application_status.sql @@ -0,0 +1,47 @@ +CREATE OR REPLACE FUNCTION public.update_affiliate_application_status( + p_application_id UUID, + p_offer_id UUID, + p_status affiliate_application_status +) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + previous_status affiliate_application_status; +BEGIN + SELECT status + INTO previous_status + FROM public.affiliate_applications + WHERE id = p_application_id + AND offer_id = p_offer_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Application not found'; + END IF; + + UPDATE public.affiliate_applications + SET + status = p_status, + approved_at = CASE WHEN p_status = 'approved' THEN NOW() ELSE approved_at END, + updated_at = NOW() + WHERE id = p_application_id + AND offer_id = p_offer_id; + + IF p_status = 'approved' AND previous_status <> 'approved' THEN + UPDATE public.affiliate_offers + SET + total_affiliates = total_affiliates + 1, + updated_at = NOW() + WHERE id = p_offer_id; + ELSIF p_status = 'rejected' AND previous_status = 'approved' THEN + UPDATE public.affiliate_offers + SET + total_affiliates = GREATEST(total_affiliates - 1, 0), + updated_at = NOW() + WHERE id = p_offer_id; + END IF; +END; +$$; From 5901afafaacc8e993e8ea9a2160916517c5b54ec Mon Sep 17 00:00:00 2001 From: ifanatics-media Date: Wed, 27 May 2026 16:17:37 -0700 Subject: [PATCH 4/6] Remove orphaned affiliate decrement RPC --- ...ecrement_affiliate_offer_total_affiliates.sql | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 supabase/migrations/20260527160000_decrement_affiliate_offer_total_affiliates.sql diff --git a/supabase/migrations/20260527160000_decrement_affiliate_offer_total_affiliates.sql b/supabase/migrations/20260527160000_decrement_affiliate_offer_total_affiliates.sql deleted file mode 100644 index d7bca869..00000000 --- a/supabase/migrations/20260527160000_decrement_affiliate_offer_total_affiliates.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE OR REPLACE FUNCTION public.decrement_affiliate_offer_total_affiliates( - p_offer_id UUID -) -RETURNS VOID -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -BEGIN - UPDATE public.affiliate_offers - SET - total_affiliates = GREATEST(total_affiliates - 1, 0), - updated_at = NOW() - WHERE id = p_offer_id; -END; -$$; From 9a6cba395da930d21d0caa98c282c5f53cde50ac Mon Sep 17 00:00:00 2001 From: ifanatics-media Date: Wed, 27 May 2026 16:24:41 -0700 Subject: [PATCH 5/6] Restrict affiliate status RPC execution --- ...527161500_update_affiliate_application_status.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/supabase/migrations/20260527161500_update_affiliate_application_status.sql b/supabase/migrations/20260527161500_update_affiliate_application_status.sql index 98c1fe64..2230adbe 100644 --- a/supabase/migrations/20260527161500_update_affiliate_application_status.sql +++ b/supabase/migrations/20260527161500_update_affiliate_application_status.sql @@ -45,3 +45,15 @@ BEGIN END IF; END; $$; + +REVOKE ALL ON FUNCTION public.update_affiliate_application_status( + UUID, + UUID, + affiliate_application_status +) FROM PUBLIC; + +GRANT EXECUTE ON FUNCTION public.update_affiliate_application_status( + UUID, + UUID, + affiliate_application_status +) TO service_role; From a7dd6655f7e0ee8a851d0d8b009d4f5a25c492ef Mon Sep 17 00:00:00 2001 From: ifanatics-media Date: Wed, 27 May 2026 16:29:51 -0700 Subject: [PATCH 6/6] Assert affiliate rejection notification --- .../offers/[id]/applications/route.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/app/api/affiliates/offers/[id]/applications/route.test.ts b/src/app/api/affiliates/offers/[id]/applications/route.test.ts index 86485be6..3237dfb0 100644 --- a/src/app/api/affiliates/offers/[id]/applications/route.test.ts +++ b/src/app/api/affiliates/offers/[id]/applications/route.test.ts @@ -135,13 +135,18 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { }); }); - it("rejects an application through the atomic status RPC", async () => { + it("rejects an application through the atomic status RPC and sends a notification", async () => { + let notificationPayload: Record | undefined; + mockFrom.mockImplementation((table: string) => { if (table === "affiliate_offers") return mockOwnedOffer(); if (table === "affiliate_applications") return mockUpdatedApplication("rejected"); if (table === "notifications") { return { - insert: () => Promise.resolve({ data: null, error: null }), + insert: (payload: Record) => { + notificationPayload = payload; + return Promise.resolve({ data: null, error: null }); + }, }; } throw new Error(`Unexpected table: ${table}`); @@ -162,6 +167,13 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { p_offer_id: "offer-1", p_status: "rejected", }); + expect(notificationPayload).toMatchObject({ + user_id: "affiliate-1", + type: "affiliate_rejected", + title: "Affiliate application declined", + body: "Your affiliate application was not approved.", + data: { offer_id: "offer-1", application_id: "app-1" }, + }); }); it("returns an error and does not notify when the atomic status RPC fails", async () => {