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..842b45aa 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" }, }); @@ -66,39 +69,25 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { let updatePayload: Record | undefined; let notificationPayload: Record | undefined; + 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") { - return { - update: (payload: Record) => { - updatePayload = payload; - return { eq: () => ({ - eq: () => ({ - select: () => ({ - single: () => - Promise.resolve({ - data: updatedApplication, - error: null, - }), + single: () => + Promise.resolve({ + data: updatedApplication, + error: null, }), - }), }), - }; - }, + }), + }), }; } @@ -124,12 +113,77 @@ 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(updatePayload).toBeUndefined(); + expect(mockRpc).toHaveBeenCalledWith("moderate_affiliate_application", { + p_offer_id: "offer-1", + p_application_id: "app-1", + p_seller_id: "seller-1", + p_status: "approved", + }); 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; + + mockRpc.mockResolvedValue({ + data: updatedApplication, + error: null, + }); + + mockFrom.mockImplementation((table: string) => { + if (table === "affiliate_applications") { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + 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).toBeUndefined(); + expect(mockRpc).toHaveBeenCalledWith("moderate_affiliate_application", { + p_offer_id: "offer-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 da072f58..66e58fc1 100644 --- a/src/app/api/affiliates/offers/[id]/applications/route.ts +++ b/src/app/api/affiliates/offers/[id]/applications/route.ts @@ -88,46 +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(); + const status = action === "approve" ? "approved" : "rejected"; - if (!offer || offer.seller_id !== auth.user.id) { - return NextResponse.json({ error: "Not found or not authorized" }, { 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(); + 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 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" @@ -136,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 new file mode 100644 index 00000000..833389d7 --- /dev/null +++ b/supabase/migrations/20260529195000_adjust_affiliate_offer_total_affiliates.sql @@ -0,0 +1,76 @@ +CREATE OR REPLACE FUNCTION public.moderate_affiliate_application( + p_offer_id UUID, + p_application_id UUID, + p_seller_id UUID, + p_status TEXT +) +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 + 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 + 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_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;