randomayzer/src/app/api/giveaways/[id]/draw/route.ts

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);
}
}