Skip to content
Open
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
338 changes: 209 additions & 129 deletions app/app/api/posts/[id]/select-winners/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -48,6 +49,12 @@ function shuffle<T>(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 (
Expand All @@ -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) {
Expand All @@ -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) => ({
Expand Down
Loading
Loading