99 lines
3.6 KiB
TypeScript
99 lines
3.6 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { GiveawayStore } from '@/lib/giveaway-store';
|
|
import { generateCryptoSecureSeed } from '@/core/randomizer/hasher';
|
|
import { executeDeterministicDrawV1 } from '@/core/randomizer/deterministic';
|
|
import { executeDrawSchema } from '@/core/validation/giveaway-schemas';
|
|
import {
|
|
handleApiError,
|
|
ConflictError,
|
|
ValidationError,
|
|
DrawAlreadyCompletedError
|
|
} from '@/core/errors/http-errors';
|
|
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
|
|
import { resolveClientIp } from '@/lib/client-ip';
|
|
import { requireGiveawayOwner } from '@/lib/auth/auth-guard';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
export async function POST(
|
|
req: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> | { id: string } }
|
|
) {
|
|
try {
|
|
const { id } = await params;
|
|
|
|
// 1. Enforce giveaway ownership authorization (extracts trusted sessionUser)
|
|
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);
|
|
|
|
// 2. User-scoped rate limiter: isolates organizer quota
|
|
expensiveApiRateLimiter.assertAllowed(`draw-execute:${sessionUser.id}:${id}`);
|
|
|
|
// 1. Strict Terminal State Guard: If already DRAWN or PUBLISHED, return 409 DRAW_ALREADY_COMPLETED
|
|
if (giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED') {
|
|
throw new DrawAlreadyCompletedError(
|
|
`Giveaway "${id}" has already been drawn and finalized. Repeat draws are not permitted.`,
|
|
{ giveawayId: id, status: giveaway.status, drawnAt: giveaway.drawnAt }
|
|
);
|
|
}
|
|
|
|
if (giveaway.status !== 'SNAPSHOT_LOCKED') {
|
|
throw new ConflictError(
|
|
`Cannot execute draw: giveaway status is "${giveaway.status}", but draw requires "SNAPSHOT_LOCKED"`
|
|
);
|
|
}
|
|
|
|
// 2. Strict Snapshot requirement
|
|
const snapshot = giveaway.latestSnapshot;
|
|
if (!snapshot) {
|
|
throw new ConflictError(
|
|
'Cannot execute draw: no locked participant snapshot exists. Lock a snapshot before drawing.'
|
|
);
|
|
}
|
|
|
|
const rawBody = await req.json().catch(() => ({}));
|
|
const validated = executeDrawSchema.parse(rawBody);
|
|
|
|
const winnersCount = validated.winnersCount;
|
|
const reserveWinnersCount = validated.reserveWinnersCount;
|
|
const totalRequired = winnersCount + reserveWinnersCount;
|
|
|
|
// 3. Strict Winner Count Contract: Never under-deliver winners
|
|
if (totalRequired > snapshot.participantCount) {
|
|
throw new ValidationError(
|
|
`Requested ${winnersCount} winners and ${reserveWinnersCount} reserve winners (${totalRequired} total) exceeds eligible participants count (${snapshot.participantCount})`,
|
|
{ winnersCount, reserveWinnersCount, eligibleCount: snapshot.participantCount }
|
|
);
|
|
}
|
|
|
|
// 4. Strict Seed Pre-Commit Guard: Read seed strictly from locked database state
|
|
if (!giveaway.seed) {
|
|
throw new ConflictError(
|
|
'Cannot execute draw: no pre-committed seed is locked for this giveaway. Lock a snapshot before drawing.'
|
|
);
|
|
}
|
|
|
|
const seed = giveaway.seed;
|
|
|
|
// 5. Execute Provably Fair Fisher-Yates Draw V1
|
|
const drawResult = executeDeterministicDrawV1({
|
|
giveawayId: id,
|
|
snapshot,
|
|
totalLoadedCount: giveaway.participants.length,
|
|
winnersCount,
|
|
reserveWinnersCount,
|
|
seed,
|
|
filterRules: giveaway.filterRules,
|
|
});
|
|
|
|
// 6. Save DrawResult & AuditRecord in database with atomic status transition
|
|
const updatedGiveaway = await GiveawayStore.saveDrawResult(id, snapshot.id, drawResult);
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
giveaway: updatedGiveaway,
|
|
drawResult,
|
|
});
|
|
} catch (error: any) {
|
|
return handleApiError(error);
|
|
}
|
|
}
|