From 3997fc987bc9f952c349f0e6f2f3677073997812 Mon Sep 17 00:00:00 2001 From: Jorel97 <83238249+Jorel97@users.noreply.github.com> Date: Fri, 29 May 2026 13:50:08 -0600 Subject: [PATCH 1/2] fix(affiliates): sync manual approval counts --- .../offers/[id]/applications/route.test.ts | 115 ++++++++++++++++++ .../offers/[id]/applications/route.ts | 30 +++++ ...djust_affiliate_offer_total_affiliates.sql | 17 +++ 3 files changed, 162 insertions(+) create mode 100644 supabase/migrations/20260529195000_adjust_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..18a6586b 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" }, }); @@ -65,6 +68,7 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { }; let updatePayload: Record | undefined; let notificationPayload: Record | undefined; + let applicationCall = 0; mockFrom.mockImplementation((table: string) => { if (table === "affiliate_offers") { @@ -82,6 +86,23 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { } if (table === "affiliate_applications") { + applicationCall++; + if (applicationCall === 1) { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { id: "app-1", status: "pending" }, + error: null, + }), + }), + }), + }), + }; + } + return { update: (payload: Record) => { updatePayload = payload; @@ -126,10 +147,104 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { expect(body.application).toEqual(updatedApplication); expect(updatePayload).toMatchObject({ status: "approved" }); expect(updatePayload?.approved_at).toEqual(expect.any(String)); + expect(mockRpc).toHaveBeenCalledWith("adjust_affiliate_offer_total_affiliates", { + p_offer_id: "offer-1", + p_delta: 1, + }); expect(notificationPayload).toMatchObject({ user_id: "affiliate-1", type: "affiliate_approved", data: { offer_id: "offer-1", application_id: "app-1" }, }); }); + + it("decrements the affiliate count when an approved application is rejected", async () => { + const updatedApplication = { + id: "app-1", + offer_id: "offer-1", + affiliate_id: "affiliate-1", + status: "rejected", + profiles: { username: "alice" }, + }; + let updatePayload: Record | undefined; + let applicationCall = 0; + + 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") { + applicationCall++; + if (applicationCall === 1) { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { id: "app-1", status: "approved" }, + error: null, + }), + }), + }), + }), + }; + } + + return { + update: (payload: Record) => { + updatePayload = payload; + return { + 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(updatePayload).toMatchObject({ status: "rejected", approved_at: null }); + expect(mockRpc).toHaveBeenCalledWith("adjust_affiliate_offer_total_affiliates", { + p_offer_id: "offer-1", + p_delta: -1, + }); + }); }); diff --git a/src/app/api/affiliates/offers/[id]/applications/route.ts b/src/app/api/affiliates/offers/[id]/applications/route.ts index da072f58..058b07e4 100644 --- a/src/app/api/affiliates/offers/[id]/applications/route.ts +++ b/src/app/api/affiliates/offers/[id]/applications/route.ts @@ -101,6 +101,17 @@ export async function PATCH( return NextResponse.json({ error: "Not found or not authorized" }, { status: 404 }); } + const { data: existingApplication, error: existingError } = await (admin as AnySupabase) + .from("affiliate_applications") + .select("id, status") + .eq("id", application_id) + .eq("offer_id", id) + .single(); + + if (existingError || !existingApplication) { + return NextResponse.json({ error: "Application not found" }, { status: 404 }); + } + const status = action === "approve" ? "approved" : "rejected"; const updateData: Record = { status, @@ -108,6 +119,8 @@ export async function PATCH( }; if (status === "approved") { updateData.approved_at = new Date().toISOString(); + } else { + updateData.approved_at = null; } const { data: application, error } = await (admin as AnySupabase) @@ -122,6 +135,23 @@ export async function PATCH( return NextResponse.json({ error: error.message }, { status: 400 }); } + const countDelta = + existingApplication.status !== "approved" && status === "approved" + ? 1 + : existingApplication.status === "approved" && status !== "approved" + ? -1 + : 0; + + if (countDelta !== 0) { + const { error: countError } = await (admin as AnySupabase).rpc( + "adjust_affiliate_offer_total_affiliates", + { p_offer_id: id, p_delta: countDelta } + ); + if (countError) { + return NextResponse.json({ error: countError.message }, { status: 400 }); + } + } + // Notify affiliate const notificationType = status === "approved" ? "affiliate_approved" : "affiliate_rejected"; await (admin as AnySupabase) diff --git a/supabase/migrations/20260529195000_adjust_affiliate_offer_total_affiliates.sql b/supabase/migrations/20260529195000_adjust_affiliate_offer_total_affiliates.sql new file mode 100644 index 00000000..26b019ca --- /dev/null +++ b/supabase/migrations/20260529195000_adjust_affiliate_offer_total_affiliates.sql @@ -0,0 +1,17 @@ +CREATE OR REPLACE FUNCTION public.adjust_affiliate_offer_total_affiliates( + p_offer_id UUID, + p_delta INTEGER +) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + UPDATE public.affiliate_offers + SET + total_affiliates = GREATEST(0, total_affiliates + p_delta), + updated_at = NOW() + WHERE id = p_offer_id; +END; +$$; From 1118d5f433e1a74056c2ae3a88cff9ab27a05987 Mon Sep 17 00:00:00 2001 From: Jorel97 <83238249+Jorel97@users.noreply.github.com> Date: Fri, 29 May 2026 13:56:13 -0600 Subject: [PATCH 2/2] fix(affiliates): moderate counts atomically --- .../offers/[id]/applications/route.test.ts | 129 +++++------------- .../offers/[id]/applications/route.ts | 77 +++-------- ...djust_affiliate_offer_total_affiliates.sql | 71 +++++++++- 3 files changed, 120 insertions(+), 157 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 18a6586b..842b45aa 100644 --- a/src/app/api/affiliates/offers/[id]/applications/route.test.ts +++ b/src/app/api/affiliates/offers/[id]/applications/route.test.ts @@ -68,58 +68,26 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { }; let updatePayload: Record | undefined; let notificationPayload: Record | undefined; - let applicationCall = 0; + + mockRpc.mockResolvedValue({ + data: updatedApplication, + error: null, + }); mockFrom.mockImplementation((table: string) => { - if (table === "affiliate_offers") { + if (table === "affiliate_applications") { return { select: () => ({ eq: () => ({ - single: () => - Promise.resolve({ - data: { id: "offer-1", seller_id: "seller-1" }, - error: null, - }), - }), - }), - }; - } - - if (table === "affiliate_applications") { - applicationCall++; - if (applicationCall === 1) { - return { - select: () => ({ eq: () => ({ - eq: () => ({ - single: () => - Promise.resolve({ - data: { id: "app-1", status: "pending" }, - error: null, - }), - }), - }), - }), - }; - } - - return { - update: (payload: Record) => { - updatePayload = payload; - return { - eq: () => ({ - eq: () => ({ - select: () => ({ - single: () => - Promise.resolve({ - data: updatedApplication, - error: null, - }), + single: () => + Promise.resolve({ + data: updatedApplication, + error: null, }), - }), }), - }; - }, + }), + }), }; } @@ -145,11 +113,12 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { expect(res.status).toBe(200); expect(body.application).toEqual(updatedApplication); - expect(updatePayload).toMatchObject({ status: "approved" }); - expect(updatePayload?.approved_at).toEqual(expect.any(String)); - expect(mockRpc).toHaveBeenCalledWith("adjust_affiliate_offer_total_affiliates", { + expect(updatePayload).toBeUndefined(); + expect(mockRpc).toHaveBeenCalledWith("moderate_affiliate_application", { p_offer_id: "offer-1", - p_delta: 1, + p_application_id: "app-1", + p_seller_id: "seller-1", + p_status: "approved", }); expect(notificationPayload).toMatchObject({ user_id: "affiliate-1", @@ -167,58 +136,26 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { profiles: { username: "alice" }, }; let updatePayload: Record | undefined; - let applicationCall = 0; + + mockRpc.mockResolvedValue({ + data: updatedApplication, + error: null, + }); mockFrom.mockImplementation((table: string) => { - if (table === "affiliate_offers") { + if (table === "affiliate_applications") { return { select: () => ({ eq: () => ({ - single: () => - Promise.resolve({ - data: { id: "offer-1", seller_id: "seller-1" }, - error: null, - }), - }), - }), - }; - } - - if (table === "affiliate_applications") { - applicationCall++; - if (applicationCall === 1) { - return { - select: () => ({ eq: () => ({ - eq: () => ({ - single: () => - Promise.resolve({ - data: { id: "app-1", status: "approved" }, - error: null, - }), - }), - }), - }), - }; - } - - return { - update: (payload: Record) => { - updatePayload = payload; - return { - eq: () => ({ - eq: () => ({ - select: () => ({ - single: () => - Promise.resolve({ - data: updatedApplication, - error: null, - }), + single: () => + Promise.resolve({ + data: updatedApplication, + error: null, }), - }), }), - }; - }, + }), + }), }; } @@ -241,10 +178,12 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { expect(res.status).toBe(200); expect(body.application).toEqual(updatedApplication); - expect(updatePayload).toMatchObject({ status: "rejected", approved_at: null }); - expect(mockRpc).toHaveBeenCalledWith("adjust_affiliate_offer_total_affiliates", { + expect(updatePayload).toBeUndefined(); + expect(mockRpc).toHaveBeenCalledWith("moderate_affiliate_application", { p_offer_id: "offer-1", - p_delta: -1, + p_application_id: "app-1", + p_seller_id: "seller-1", + p_status: "rejected", }); }); }); diff --git a/src/app/api/affiliates/offers/[id]/applications/route.ts b/src/app/api/affiliates/offers/[id]/applications/route.ts index 058b07e4..66e58fc1 100644 --- a/src/app/api/affiliates/offers/[id]/applications/route.ts +++ b/src/app/api/affiliates/offers/[id]/applications/route.ts @@ -88,76 +88,41 @@ export async function PATCH( return NextResponse.json({ error: "application_id and action (approve|reject) required" }, { status: 400 }); } - const admin = createServiceClient(); - - // Verify seller ownership - const { data: offer } = await (admin as AnySupabase) - .from("affiliate_offers") - .select("id, seller_id") - .eq("id", id) - .single(); - - if (!offer || offer.seller_id !== auth.user.id) { - return NextResponse.json({ error: "Not found or not authorized" }, { status: 404 }); - } - - const { data: existingApplication, error: existingError } = 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 (existingError || !existingApplication) { - return NextResponse.json({ error: "Application not found" }, { status: 404 }); - } + const admin = createServiceClient(); + const { data: moderatedApplication, error } = await (admin as AnySupabase) + .rpc("moderate_affiliate_application", { + p_offer_id: id, + p_application_id: application_id, + p_seller_id: auth.user.id, + p_status: status, + }); - const status = action === "approve" ? "approved" : "rejected"; - const updateData: Record = { - status, - updated_at: new Date().toISOString(), - }; - if (status === "approved") { - updateData.approved_at = new Date().toISOString(); - } else { - updateData.approved_at = null; + if (error) { + const statusCode = + error.message === "Not found or not authorized" || + error.message === "Application not found" + ? 404 + : 400; + return NextResponse.json({ error: error.message }, { status: statusCode }); } - const { data: application, error } = await (admin as AnySupabase) + const { data: application } = 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 }); - } - - const countDelta = - existingApplication.status !== "approved" && status === "approved" - ? 1 - : existingApplication.status === "approved" && status !== "approved" - ? -1 - : 0; - - if (countDelta !== 0) { - const { error: countError } = await (admin as AnySupabase).rpc( - "adjust_affiliate_offer_total_affiliates", - { p_offer_id: id, p_delta: countDelta } - ); - if (countError) { - return NextResponse.json({ error: countError.message }, { status: 400 }); - } - } + const responseApplication = application || moderatedApplication; // Notify affiliate const notificationType = status === "approved" ? "affiliate_approved" : "affiliate_rejected"; await (admin as AnySupabase) .from("notifications") .insert({ - user_id: application.affiliate_id, + user_id: responseApplication.affiliate_id, type: notificationType, title: status === "approved" ? "Affiliate application approved! 🎉" : "Affiliate application declined", body: status === "approved" @@ -166,7 +131,7 @@ export async function PATCH( data: { offer_id: id, application_id }, }); - return NextResponse.json({ application }); + return NextResponse.json({ application: responseApplication }); } catch { return NextResponse.json({ error: "An unexpected error occurred" }, { status: 500 }); } diff --git a/supabase/migrations/20260529195000_adjust_affiliate_offer_total_affiliates.sql b/supabase/migrations/20260529195000_adjust_affiliate_offer_total_affiliates.sql index 26b019ca..833389d7 100644 --- a/supabase/migrations/20260529195000_adjust_affiliate_offer_total_affiliates.sql +++ b/supabase/migrations/20260529195000_adjust_affiliate_offer_total_affiliates.sql @@ -1,17 +1,76 @@ -CREATE OR REPLACE FUNCTION public.adjust_affiliate_offer_total_affiliates( +CREATE OR REPLACE FUNCTION public.moderate_affiliate_application( p_offer_id UUID, - p_delta INTEGER + p_application_id UUID, + p_seller_id UUID, + p_status TEXT ) -RETURNS VOID +RETURNS public.affiliate_applications LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_offer public.affiliate_offers%ROWTYPE; + v_existing public.affiliate_applications%ROWTYPE; + v_updated public.affiliate_applications%ROWTYPE; + v_delta INTEGER := 0; BEGIN - UPDATE public.affiliate_offers + IF p_status NOT IN ('approved', 'rejected') THEN + RAISE EXCEPTION 'Invalid affiliate application status'; + END IF; + + SELECT * + INTO v_offer + FROM public.affiliate_offers + WHERE id = p_offer_id + FOR UPDATE; + + IF v_offer.id IS NULL OR v_offer.seller_id <> p_seller_id THEN + RAISE EXCEPTION 'Not found or not authorized'; + END IF; + + SELECT * + INTO v_existing + FROM public.affiliate_applications + WHERE id = p_application_id + AND offer_id = p_offer_id + FOR UPDATE; + + IF v_existing.id IS NULL THEN + RAISE EXCEPTION 'Application not found'; + END IF; + + IF v_existing.status <> 'approved' AND p_status = 'approved' THEN + v_delta := 1; + ELSIF v_existing.status = 'approved' AND p_status <> 'approved' THEN + v_delta := -1; + END IF; + + UPDATE public.affiliate_applications SET - total_affiliates = GREATEST(0, total_affiliates + p_delta), + status = p_status::public.affiliate_application_status, + approved_at = CASE + WHEN p_status = 'approved' THEN COALESCE(approved_at, NOW()) + ELSE NULL + END, updated_at = NOW() - WHERE id = p_offer_id; + WHERE id = p_application_id + AND offer_id = p_offer_id + RETURNING * INTO v_updated; + + IF v_delta <> 0 THEN + UPDATE public.affiliate_offers + SET + total_affiliates = GREATEST(0, total_affiliates + v_delta), + updated_at = NOW() + WHERE id = p_offer_id; + END IF; + + RETURN v_updated; END; $$; + +REVOKE ALL ON FUNCTION public.moderate_affiliate_application(UUID, UUID, UUID, TEXT) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.moderate_affiliate_application(UUID, UUID, UUID, TEXT) FROM anon; +REVOKE ALL ON FUNCTION public.moderate_affiliate_application(UUID, UUID, UUID, TEXT) FROM authenticated; +GRANT EXECUTE ON FUNCTION public.moderate_affiliate_application(UUID, UUID, UUID, TEXT) TO service_role;