Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 82 additions & 28 deletions src/app/api/affiliates/offers/[id]/applications/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}),
}));

Expand All @@ -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" },
});
Expand Down Expand Up @@ -66,39 +69,25 @@ describe("PATCH /api/affiliates/offers/[id]/applications", () => {
let updatePayload: Record<string, unknown> | undefined;
let notificationPayload: Record<string, unknown> | 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<string, unknown>) => {
updatePayload = payload;
return {
eq: () => ({
eq: () => ({
select: () => ({
single: () =>
Promise.resolve({
data: updatedApplication,
error: null,
}),
single: () =>
Promise.resolve({
data: updatedApplication,
error: null,
}),
}),
}),
};
},
}),
}),
};
}

Expand All @@ -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<string, unknown> | 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",
});
});
});
47 changes: 21 additions & 26 deletions src/app/api/affiliates/offers/[id]/applications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {
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"
Expand All @@ -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 });
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
$$;
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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;
Loading