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..3237dfb0 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), }), })); @@ -29,9 +31,46 @@ 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(); + mockRpc.mockResolvedValue({ data: null, error: null }); mockGetAuthContext.mockResolvedValue({ user: { id: "seller-1", authMethod: "session" }, }); @@ -44,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 () => { @@ -53,55 +93,54 @@ 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") { + if (table === "affiliate_offers") return mockOwnedOffer(); + if (table === "affiliate_applications") return mockUpdatedApplication("approved"); + if (table === "notifications") { return { - update: (payload: Record) => { - updatePayload = payload; - return { - eq: () => ({ - eq: () => ({ - select: () => ({ - single: () => - Promise.resolve({ - data: updatedApplication, - error: null, - }), - }), - }), - }), - }; + insert: (payload: Record) => { + notificationPayload = payload; + return 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(200); + 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" }, + }); + }); + + 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: (payload: Record) => { @@ -110,26 +149,59 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => { }, }; } - throw new Error(`Unexpected table: ${table}`); }); const res = await PATCH( makePatchRequest( - JSON.stringify({ application_id: "app-1", action: "approve" }) + 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: "approved" }); - expect(updatePayload?.approved_at).toEqual(expect.any(String)); + 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", + }); expect(notificationPayload).toMatchObject({ user_id: "affiliate-1", - type: "affiliate_approved", + 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 () => { + mockRpc.mockResolvedValueOnce({ + data: null, + error: { message: "Application not found" }, + }); + + mockFrom.mockImplementation((table: string) => { + if (table === "affiliate_offers") return mockOwnedOffer(); + if (table === "notifications") { + return { + insert: vi.fn(), + }; + } + 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("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 da072f58..a18a79e5 100644 --- a/src/app/api/affiliates/offers/[id]/applications/route.ts +++ b/src/app/api/affiliates/offers/[id]/applications/route.ts @@ -102,24 +102,29 @@ export async function PATCH( } const status = action === "approve" ? "approved" : "rejected"; - const updateData: Record = { - status, - updated_at: new Date().toISOString(), - }; - if (status === "approved") { - updateData.approved_at = new Date().toISOString(); + + const { error: statusError } = await (admin as AnySupabase).rpc( + "update_affiliate_application_status", + { + p_application_id: application_id, + p_offer_id: id, + p_status: status, + } + ); + + 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 (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..2230adbe --- /dev/null +++ b/supabase/migrations/20260527161500_update_affiliate_application_status.sql @@ -0,0 +1,59 @@ +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; +$$; + +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;