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
298 changes: 177 additions & 121 deletions app/app/api/posts/[id]/select-winners/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,169 +68,225 @@ export const POST = async (
}
const body = parsed.data;

const post = await prisma.post.findUnique({
// ── Pre-transaction ownership & moderation checks (read-only) ───────
const preCheck = await prisma.post.findUnique({
where: { id },
include: {
entries: {
include: { burns: true },
orderBy: { createdAt: "asc" },
},
winners: true,
select: {
userId: true,
moderationStatus: true,
},
});

if (!post) return apiError("Post not found", 404);
if (post.userId !== user.id) return apiError("Forbidden", 403);
if (["suspended", "banned"].includes(post.moderationStatus)) {
if (!preCheck) return apiError("Post not found", 404);
if (preCheck.userId !== user.id) return apiError("Forbidden", 403);
if (["suspended", "banned"].includes(preCheck.moderationStatus)) {
return apiError("Cannot select winners for moderated content", 403);
}

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);
}
// ── Atomic transaction: re-read status, select winners, persist ─────
// Serializable isolation prevents two concurrent requests from both
// passing the "already completed" guard and over-selecting winners.
const result = await prisma.$transaction(
async (tx) => {
// Re-read the post with fresh status + winner count inside the transaction
const post = await tx.post.findUnique({
where: { id },
include: {
entries: {
include: { burns: true },
orderBy: { createdAt: "asc" },
},
winners: true,
},
});

// 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) throw new Error("Post not found");

const maxWinners = post.maxWinners ?? 1;
let selectedEntries: typeof eligibleEntries = [];
if (post.status === "completed") {
throw new Error("ALREADY_COMPLETED");
}
if (!["open", "active", "in_progress"].includes(post.status)) {
throw new Error(
`Cannot select winners for a post with status "${post.status}"`,
);
}
if (post.entries.length === 0) {
throw new Error("No entries to select from");
}

switch (body.method) {
case "random": {
const count = Math.min(
body.count ?? maxWinners,
eligibleEntries.length,
// Compute eligibility and remaining slots inside the transaction
const existingWinnerUserIds = new Set(post.winners.map((w) => w.userId));
const eligibleEntries = post.entries.filter(
(e) => !existingWinnerUserIds.has(e.userId),
);
selectedEntries = shuffle(eligibleEntries).slice(0, count);
break;
}

case "manual": {
const { entryIds } = body;
const maxWinners = post.maxWinners ?? 1;
const remainingSlots = Math.max(0, maxWinners - post.winners.length);

// 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,
);
if (remainingSlots === 0) {
throw new Error("ALREADY_COMPLETED");
}

// Deduplicate supplied IDs and cap at maxWinners
const uniqueIds = [...new Set(entryIds)].slice(0, maxWinners);
selectedEntries = eligibleEntries.filter((e) =>
uniqueIds.includes(e.id),
);
let selectedEntries: typeof eligibleEntries = [];

switch (body.method) {
case "random": {
const count = Math.min(
body.count ?? remainingSlots,
remainingSlots,
eligibleEntries.length,
);
selectedEntries = shuffle(eligibleEntries).slice(0, count);
break;
}

case "manual": {
const { entryIds } = body;

// 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 new Error(
`Entry IDs not found on this post: ${invalidIds.join(", ")}`,
);
}

// Deduplicate supplied IDs and cap at remainingSlots
const uniqueIds = [...new Set(entryIds)].slice(0, remainingSlots);
selectedEntries = eligibleEntries.filter((e) =>
uniqueIds.includes(e.id),
);

if (selectedEntries.length === 0) {
throw new Error(
"None of the provided entry IDs belong to eligible entries",
);
}
break;
}

case "merit_based": {
// Rank by burn count (descending), then entry age (ascending) as tiebreaker
const count = Math.min(
body.count ?? remainingSlots,
remainingSlots,
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;
}

case "firstcome": {
// Entries are already ordered by createdAt asc
const count = Math.min(
body.count ?? remainingSlots,
remainingSlots,
eligibleEntries.length,
);
selectedEntries = eligibleEntries.slice(0, count);
break;
}
}

if (selectedEntries.length === 0) {
return apiError(
"None of the provided entry IDs belong to eligible entries",
400,
throw new Error(
"No eligible entries found for the requested selection",
);
}
break;
}

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;
}
const entryIds = selectedEntries.map((e) => e.id);

case "firstcome": {
// Entries are already ordered by createdAt asc
const count = Math.min(
body.count ?? maxWinners,
eligibleEntries.length,
);
selectedEntries = eligibleEntries.slice(0, count);
break;
}
}
await tx.entry.updateMany({
where: { id: { in: entryIds } },
data: { isWinner: true },
});

if (selectedEntries.length === 0) {
return apiError(
"No eligible entries found for the requested selection",
400,
);
}
await tx.postWinner.createMany({
data: selectedEntries.map((e) => ({
postId: post.id,
userId: e.userId,
assignedBy: user.id,
})),
skipDuplicates: true,
});

// ── Persist in a single transaction ──────────────────────────────────
await prisma.$transaction(async (tx) => {
const entryIds = selectedEntries.map((e) => e.id);
await tx.post.update({
where: { id: post.id },
data: { status: "completed" },
});

await tx.entry.updateMany({
where: { id: { in: entryIds } },
data: { isWinner: true },
});
// 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}`,
});

await tx.postWinner.createMany({
data: selectedEntries.map((e) => ({
return {
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}`,
});
});
selectedEntries,
};
},
{
isolationLevel: "Serializable",
},
);

// Award badges to winners async (best-effort)
for (const entry of selectedEntries) {
for (const entry of result.selectedEntries) {
checkAndAwardBadges(entry.userId).catch(console.error);
}

return apiSuccess(
{
method: body.method,
postId: post.id,
postId: result.postId,
postStatus: "completed",
totalSelected: selectedEntries.length,
winners: selectedEntries.map((e) => ({
totalSelected: result.selectedEntries.length,
winners: result.selectedEntries.map((e) => ({
entryId: e.id,
userId: e.userId,
})),
},
"Winners selected successfully",
);
} catch (error) {
// Surface domain errors as 400 responses instead of 500
if (error instanceof Error) {
switch (error.message) {
case "Post not found":
return apiError("Post not found", 404);
case "ALREADY_COMPLETED":
return apiError("Winners already selected for this post", 400);
case "No entries to select from":
return apiError("No entries to select from", 400);
case "No eligible entries found for the requested selection":
return apiError(
"No eligible entries found for the requested selection",
400,
);
default:
if (error.message.startsWith("Cannot select winners")) {
return apiError(error.message, 400);
}
if (error.message.startsWith("Entry IDs not found")) {
return apiError(error.message, 400);
}
if (error.message.startsWith("None of the provided")) {
return apiError(error.message, 400);
}
break;
}
}
console.error("Select winners error:", error);
return apiError("Failed to select winners", 500);
}
Expand Down
Loading
Loading