diff --git a/app/app/api/posts/[id]/select-winners/route.ts b/app/app/api/posts/[id]/select-winners/route.ts index 68f39cc..e7cd3ac 100644 --- a/app/app/api/posts/[id]/select-winners/route.ts +++ b/app/app/api/posts/[id]/select-winners/route.ts @@ -1,4 +1,5 @@ import { apiError, apiSuccess } from "@/lib/api-response"; +import { Prisma } from "@prisma/client"; import { NextRequest } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; @@ -48,6 +49,12 @@ function shuffle(arr: T[]): T[] { return a; } +// Tagged on errors thrown inside the selection transaction so the catch block +// can turn them back into the matching HTTP response after the rollback. +function txError(message: string, status: number) { + return Object.assign(new Error(message), { httpStatus: status }); +} + // ── Route handler ───────────────────────────────────────────────────────────── export const POST = async ( @@ -68,149 +75,222 @@ export const POST = async ( } const body = parsed.data; - const post = await prisma.post.findUnique({ - where: { id }, - include: { - entries: { - include: { burns: true }, - orderBy: { createdAt: "asc" }, - }, - winners: true, - }, - }); + let postId = ""; + let selectedEntries: Array<{ id: string; userId: string }> = []; - if (!post) return apiError("Post not found", 404); - if (post.userId !== user.id) return apiError("Forbidden", 403); - if (["suspended", "banned"].includes(post.moderationStatus)) { - return apiError("Cannot select winners for moderated content", 403); - } + try { + // ── Read, validate, select and persist in one transaction ────────── + // Eligibility, the "already completed" guard and the winner writes must + // share a transaction: otherwise two concurrent requests both pass the + // guard on stale data and over-select. + const txResult = await prisma.$transaction( + async (tx) => { + const post = await tx.post.findUnique({ + where: { id }, + include: { + entries: { + include: { burns: true }, + orderBy: { createdAt: "asc" }, + }, + winners: true, + }, + }); - if (post.status === "completed") { - return apiError("Winners already selected for this post", 400); - } - if (!["open", "active", "in_progress"].includes(post.status)) { - return apiError( - `Cannot select winners for a post with status "${post.status}"`, - 400, - ); - } - if (post.entries.length === 0) { - return apiError("No entries to select from", 400); - } + if (!post) throw txError("Post not found", 404); + if (post.userId !== user.id) throw txError("Forbidden", 403); + if (["suspended", "banned"].includes(post.moderationStatus)) { + throw txError("Cannot select winners for moderated content", 403); + } - // Exclude users who are already winners (prevents duplicates across calls) - const existingWinnerUserIds = new Set(post.winners.map((w) => w.userId)); - const eligibleEntries = post.entries.filter( - (e) => !existingWinnerUserIds.has(e.userId), - ); + if (post.status === "completed") { + throw txError("Winners already selected for this post", 400); + } + if (!["open", "active", "in_progress"].includes(post.status)) { + throw txError( + `Cannot select winners for a post with status "${post.status}"`, + 400, + ); + } + if (post.entries.length === 0) { + throw txError("No entries to select from", 400); + } - const maxWinners = post.maxWinners ?? 1; - let selectedEntries: typeof eligibleEntries = []; + // Atomically claim the post for this selection. A concurrent + // request that already flipped the status affects 0 rows here, so + // exactly one selection can win the race. + const claimed = await tx.post.updateMany({ + where: { + id: post.id, + status: { in: ["open", "active", "in_progress"] }, + }, + data: { status: "completed" }, + }); + if (claimed.count === 0) { + throw txError("Winners already selected for this post", 400); + } - switch (body.method) { - case "random": { - const count = Math.min( - body.count ?? maxWinners, - eligibleEntries.length, - ); - selectedEntries = shuffle(eligibleEntries).slice(0, count); - break; - } + // Exclude users who are already winners (prevents duplicates across calls) + const existingWinnerUserIds = new Set( + post.winners.map((w) => w.userId), + ); + const eligibleEntries = post.entries.filter( + (e) => !existingWinnerUserIds.has(e.userId), + ); - case "manual": { - const { entryIds } = body; + const maxWinners = post.maxWinners ?? 1; + // Remaining slots are computed inside the transaction so concurrent + // selections cannot collectively assign more than maxWinners. + const remainingSlots = Math.max(0, maxWinners - post.winners.length); + if (remainingSlots === 0) { + throw txError("Winners already selected for this post", 400); + } - // All supplied IDs must belong to this post - const validEntryIds = new Set(post.entries.map((e) => e.id)); - const invalidIds = entryIds.filter((eid) => !validEntryIds.has(eid)); - if (invalidIds.length > 0) { - return apiError( - `Entry IDs not found on this post: ${invalidIds.join(", ")}`, - 400, - ); - } + let selected: typeof eligibleEntries = []; - // Deduplicate supplied IDs and cap at maxWinners - const uniqueIds = [...new Set(entryIds)].slice(0, maxWinners); - selectedEntries = eligibleEntries.filter((e) => - uniqueIds.includes(e.id), - ); + switch (body.method) { + case "random": { + const count = Math.min( + body.count ?? maxWinners, + remainingSlots, + eligibleEntries.length, + ); + selected = shuffle(eligibleEntries).slice(0, count); + break; + } - if (selectedEntries.length === 0) { - return apiError( - "None of the provided entry IDs belong to eligible entries", - 400, - ); - } - break; - } + case "manual": { + const { entryIds } = body; - case "merit_based": { - // Rank by burn count (descending), then entry age (ascending) as tiebreaker - const count = Math.min( - body.count ?? maxWinners, - eligibleEntries.length, - ); - selectedEntries = [...eligibleEntries] - .sort((a, b) => { - const burnDiff = b.burns.length - a.burns.length; - if (burnDiff !== 0) return burnDiff; - return a.createdAt.getTime() - b.createdAt.getTime(); - }) - .slice(0, count); - break; - } + // All supplied IDs must belong to this post + const validEntryIds = new Set(post.entries.map((e) => e.id)); + const invalidIds = entryIds.filter( + (eid) => !validEntryIds.has(eid), + ); + if (invalidIds.length > 0) { + throw txError( + `Entry IDs not found on this post: ${invalidIds.join(", ")}`, + 400, + ); + } - case "firstcome": { - // Entries are already ordered by createdAt asc - const count = Math.min( - body.count ?? maxWinners, - eligibleEntries.length, - ); - selectedEntries = eligibleEntries.slice(0, count); - break; - } - } + // Deduplicate supplied IDs and cap at the remaining slots + const uniqueIds = [...new Set(entryIds)].slice( + 0, + remainingSlots, + ); + selected = eligibleEntries.filter((e) => + uniqueIds.includes(e.id), + ); - if (selectedEntries.length === 0) { - return apiError( - "No eligible entries found for the requested selection", - 400, - ); - } + if (selected.length === 0) { + throw txError( + "None of the provided entry IDs belong to eligible entries", + 400, + ); + } + break; + } - // ── Persist in a single transaction ────────────────────────────────── - await prisma.$transaction(async (tx) => { - const entryIds = selectedEntries.map((e) => e.id); + case "merit_based": { + // Rank by burn count (descending), then entry age (ascending) as tiebreaker + const count = Math.min( + body.count ?? maxWinners, + remainingSlots, + eligibleEntries.length, + ); + selected = [...eligibleEntries] + .sort((a, b) => { + const burnDiff = b.burns.length - a.burns.length; + if (burnDiff !== 0) return burnDiff; + return a.createdAt.getTime() - b.createdAt.getTime(); + }) + .slice(0, count); + break; + } - await tx.entry.updateMany({ - where: { id: { in: entryIds } }, - data: { isWinner: true }, - }); + case "firstcome": { + // Entries are already ordered by createdAt asc + const count = Math.min( + body.count ?? maxWinners, + remainingSlots, + eligibleEntries.length, + ); + selected = eligibleEntries.slice(0, count); + break; + } + } - await tx.postWinner.createMany({ - data: selectedEntries.map((e) => ({ - postId: post.id, - userId: e.userId, - assignedBy: user.id, - })), - skipDuplicates: true, - }); - - await tx.post.update({ - where: { id: post.id }, - data: { status: "completed" }, - }); - - // Notify each winner using delivery layer - const fanOut = fanOutNotificationsInTransaction(tx); - await fanOut({ - userIds: selectedEntries.map((e) => e.userId), - type: "giveaway_win", - message: `Congratulations! You won the giveaway "${post.title}".`, - link: `/posts/${post.id}`, - }); - }); + if (selected.length === 0) { + throw txError( + "No eligible entries found for the requested selection", + 400, + ); + } + + // Re-check committed winner rows so a concurrent selection can never + // push the total past maxWinners. + const winnerCount = await tx.postWinner.count({ + where: { postId: post.id }, + }); + if (winnerCount + selected.length > maxWinners) { + throw txError("Winners already selected for this post", 400); + } + + const entryIds = selected.map((e) => e.id); + + await tx.entry.updateMany({ + where: { id: { in: entryIds } }, + data: { isWinner: true }, + }); + + await tx.postWinner.createMany({ + data: selected.map((e) => ({ + postId: post.id, + userId: e.userId, + assignedBy: user.id, + })), + skipDuplicates: true, + }); + + // Notify each winner using delivery layer + const fanOut = fanOutNotificationsInTransaction(tx); + await fanOut({ + userIds: selected.map((e) => e.userId), + type: "giveaway_win", + message: `Congratulations! You won the giveaway "${post.title}".`, + link: `/posts/${post.id}`, + }); + + return { + postId: post.id, + selectedEntries: selected, + }; + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + maxWait: 5000, + timeout: 10000, + }, + ); + + postId = txResult.postId; + selectedEntries = txResult.selectedEntries; + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2034" + ) { + return apiError( + "Winners are being selected concurrently, please try again", + 409, + ); + } + const httpStatus = (error as { httpStatus?: number }).httpStatus; + if (typeof httpStatus === "number" && error instanceof Error) { + return apiError(error.message, httpStatus); + } + throw error; + } // Award badges to winners async (best-effort) for (const entry of selectedEntries) { @@ -220,7 +300,7 @@ export const POST = async ( return apiSuccess( { method: body.method, - postId: post.id, + postId, postStatus: "completed", totalSelected: selectedEntries.length, winners: selectedEntries.map((e) => ({ diff --git a/app/tests/api/select-winners.test.ts b/app/tests/api/select-winners.test.ts new file mode 100644 index 0000000..3c9e408 --- /dev/null +++ b/app/tests/api/select-winners.test.ts @@ -0,0 +1,214 @@ +import { POST as SelectWinners } from "@/app/api/posts/[id]/select-winners/route"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createMockRequest, parseResponse } from "../helpers/api"; + +import { prisma } from "@/lib/prisma"; + +vi.mock("@/lib/badges", () => ({ + checkAndAwardBadges: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/notifications", () => ({ + fanOutNotificationsInTransaction: vi.fn(() => + vi.fn().mockResolvedValue(undefined), + ), +})); + +const POST_ID = "post_1"; +const OWNER_ID = "owner_1"; + +type FakeEntry = { + id: string; + userId: string; + createdAt: Date; + burns: unknown[]; +}; + +type FakeState = { + status: string; + maxWinners: number; + entries: FakeEntry[]; + winners: Array<{ userId: string }>; +}; + +function makeEntry(id: string, userId: string, createdAt: Date): FakeEntry { + return { id, userId, createdAt, burns: [] }; +} + +// Lets both concurrent requests read the post *before* either one commits, +// reproducing the stale read that the TOCTOU race depends on. +function makeReadGate(arrivals: number) { + let count = 0; + let release: (() => void) | null = null; + const gate = new Promise((resolve) => { + release = resolve; + }); + return async () => { + count += 1; + if (count >= arrivals) release?.(); + await gate; + }; +} + +function installFakePrisma( + state: FakeState, + readGate: () => Promise = async () => {}, +) { + const baseTx = { + post: { + findUnique: async () => { + await readGate(); + return { + id: POST_ID, + userId: OWNER_ID, + title: "Test Giveaway", + status: state.status, + moderationStatus: "none", + maxWinners: state.maxWinners, + entries: state.entries.map((e) => ({ ...e })), + winners: state.winners.map((w) => ({ ...w })), + }; + }, + }, + entry: { + updateMany: async () => ({ count: 0 }), + }, + }; + + prisma.$transaction = vi.fn(async (callback: any) => { + // Roll back only what THIS transaction wrote, so a losing concurrent + // transaction cannot undo the winner's committed claim. + let claimedHere = false; + const addedWinners: string[] = []; + + const tx = { + ...baseTx, + post: { + ...baseTx.post, + // Compare-and-set on the status, exactly like the conditional + // updateMany in the route: only the first concurrent claim wins. + updateMany: async ({ data }: { data: { status: string } }) => { + if (state.status === "completed") return { count: 0 }; + state.status = data.status; + claimedHere = true; + return { count: 1 }; + }, + }, + postWinner: { + count: async () => state.winners.length, + createMany: async ({ data }: { data: Array<{ userId: string }> }) => { + let created = 0; + for (const row of data) { + if (!state.winners.some((w) => w.userId === row.userId)) { + state.winners.push({ userId: row.userId }); + addedWinners.push(row.userId); + created += 1; + } + } + return { count: created }; + }, + }, + }; + + try { + return await callback(tx); + } catch (error) { + if (claimedHere) state.status = "open"; + state.winners = state.winners.filter( + (w) => !addedWinners.includes(w.userId), + ); + throw error; + } + }) as any; + + return state; +} + +function selectRequest() { + return createMockRequest( + `http://localhost:3000/api/posts/${POST_ID}/select-winners`, + { method: "POST", body: { method: "random" } }, + ); +} + +describe("POST /api/posts/[id]/select-winners concurrency", () => { + beforeEach(async () => { + vi.clearAllMocks(); + vi.spyOn(await import("@/lib/auth"), "getCurrentUser").mockResolvedValue({ + id: OWNER_ID, + name: "Owner", + } as any); + }); + + it("lets exactly one of two simultaneous selections win and never exceeds maxWinners", async () => { + const state = installFakePrisma( + { + status: "open", + maxWinners: 1, + entries: [ + makeEntry("entry_1", "user_1", new Date(2026, 0, 1)), + makeEntry("entry_2", "user_2", new Date(2026, 0, 2)), + ], + winners: [], + }, + makeReadGate(2), + ); + + const [first, second] = await Promise.all([ + SelectWinners(selectRequest(), { + params: Promise.resolve({ id: POST_ID }), + }), + SelectWinners(selectRequest(), { + params: Promise.resolve({ id: POST_ID }), + }), + ]); + + const [firstRes, secondRes] = await Promise.all([ + parseResponse(first), + parseResponse(second), + ]); + + expect([firstRes.status, secondRes.status].sort()).toEqual([200, 400]); + expect(state.winners).toHaveLength(1); + expect(state.status).toBe("completed"); + }); + + it("caps a single selection at the remaining slots", async () => { + const state = installFakePrisma({ + status: "open", + maxWinners: 3, + entries: [ + makeEntry("entry_1", "user_1", new Date(2026, 0, 1)), + makeEntry("entry_2", "user_2", new Date(2026, 0, 2)), + makeEntry("entry_3", "user_3", new Date(2026, 0, 3)), + ], + winners: [{ userId: "already_won" }], + }); + + const response = await SelectWinners(selectRequest(), { + params: Promise.resolve({ id: POST_ID }), + }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.data.totalSelected).toBe(2); + expect(state.winners).toHaveLength(3); + }); + + it("rejects a selection on a post that is already completed", async () => { + installFakePrisma({ + status: "completed", + maxWinners: 1, + entries: [makeEntry("entry_1", "user_1", new Date(2026, 0, 1))], + winners: [{ userId: "user_1" }], + }); + + const response = await SelectWinners(selectRequest(), { + params: Promise.resolve({ id: POST_ID }), + }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(400); + expect(data.error).toBe("Winners already selected for this post"); + }); +});