feat(core): Phase 1.1 hardening - Prisma persistence, FSM, immutable snapshots, unbiased HMAC_SHA256_FY_V1 randomizer, and audit proof
This commit is contained in:
parent
02920a8743
commit
0fb5fe3f8d
31 changed files with 6554 additions and 480 deletions
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
4662
package-lock.json
generated
4662
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -26,6 +26,8 @@
|
|||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^14.2.15",
|
||||
"postcss": "^8.4.47",
|
||||
"prisma": "^5.20.0",
|
||||
"tailwindcss": "^3.4.13",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ enum GiveawayStatus {
|
|||
DRAFT
|
||||
FETCHING
|
||||
READY
|
||||
COMPLETED
|
||||
SNAPSHOT_LOCKED
|
||||
DRAWN
|
||||
PUBLISHED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
|
|
@ -29,27 +31,28 @@ enum ParticipantSource {
|
|||
}
|
||||
|
||||
model Giveaway {
|
||||
id String @id @default(cuid())
|
||||
platform Platform @default(VK)
|
||||
id String @id @default(cuid())
|
||||
platform Platform @default(VK)
|
||||
sourceUrl String
|
||||
platformOwnerId String
|
||||
platformPostId String
|
||||
title String
|
||||
description String?
|
||||
postImageUrl String?
|
||||
postLikesCount Int @default(0)
|
||||
postCommentsCount Int @default(0)
|
||||
postRepostsCount Int @default(0)
|
||||
status GiveawayStatus @default(DRAFT)
|
||||
filterRules Json // FilterRules object
|
||||
winnersCount Int @default(1)
|
||||
reserveWinnersCount Int @default(0)
|
||||
postLikesCount Int @default(0)
|
||||
postCommentsCount Int @default(0)
|
||||
postRepostsCount Int @default(0)
|
||||
status GiveawayStatus @default(DRAFT)
|
||||
filterRules Json // FilterRules object
|
||||
winnersCount Int @default(1)
|
||||
reserveWinnersCount Int @default(0)
|
||||
seed String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
drawnAt DateTime?
|
||||
|
||||
participants Participant[]
|
||||
snapshots ParticipantSnapshot[]
|
||||
drawResult DrawResult?
|
||||
auditRecord AuditRecord?
|
||||
|
||||
|
|
@ -79,28 +82,57 @@ model Participant {
|
|||
@@index([giveawayId, eligible])
|
||||
}
|
||||
|
||||
model ParticipantSnapshot {
|
||||
id String @id @default(cuid())
|
||||
giveawayId String
|
||||
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
eligibleParticipants Json // Canonical JSON array of FilteredParticipant
|
||||
participantCount Int
|
||||
participantsSnapshotHash String
|
||||
conditionsHash String
|
||||
|
||||
drawResult DrawResult?
|
||||
auditRecord AuditRecord?
|
||||
|
||||
@@unique([giveawayId, version])
|
||||
@@index([giveawayId])
|
||||
}
|
||||
|
||||
model DrawResult {
|
||||
id String @id @default(cuid())
|
||||
giveawayId String @unique
|
||||
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
|
||||
winners Json // Array of Winner entities
|
||||
reserveWinners Json // Array of Winner entities
|
||||
id String @id @default(cuid())
|
||||
giveawayId String @unique
|
||||
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
|
||||
snapshotId String @unique
|
||||
snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict)
|
||||
winners Json // Array of Winner entities
|
||||
reserveWinners Json // Array of Winner entities
|
||||
winnerIds Json // string[]
|
||||
reserveWinnerIds Json // string[]
|
||||
totalEligibleCount Int
|
||||
totalLoadedCount Int
|
||||
seedUsed String
|
||||
algorithm String @default("HMAC-SHA256-FISHER-YATES")
|
||||
drawnAt DateTime @default(now())
|
||||
algorithmVersion String @default("HMAC_SHA256_FY_V1")
|
||||
auditHash String
|
||||
drawnAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model AuditRecord {
|
||||
id String @id @default(cuid())
|
||||
giveawayId String @unique
|
||||
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
|
||||
participantsSnapshotHash String
|
||||
id String @id @default(cuid())
|
||||
giveawayId String @unique
|
||||
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
|
||||
snapshotId String @unique
|
||||
snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict)
|
||||
algorithmVersion String @default("HMAC_SHA256_FY_V1")
|
||||
seed String
|
||||
algorithm String @default("HMAC-SHA256-FISHER-YATES")
|
||||
filterRulesSnapshot Json
|
||||
winnersSnapshot Json
|
||||
verifiedAt DateTime @default(now())
|
||||
verificationSignature String
|
||||
participantsSnapshotHash String
|
||||
conditionsHash String
|
||||
auditHash String
|
||||
winnerIds Json // string[]
|
||||
reserveWinnerIds Json // string[]
|
||||
eligibleCount Int
|
||||
drawId String
|
||||
drawnAt DateTime @default(now())
|
||||
verifiedAt DateTime @default(now())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { GiveawayStore } from '@/lib/giveaway-store';
|
||||
import { executeDeterministicDraw } from '@/core/randomizer/deterministic';
|
||||
import { generateRandomSeed } from '@/core/randomizer/hasher';
|
||||
import { executeDeterministicDrawV1 } from '@/core/randomizer/deterministic';
|
||||
import { generateCryptoSecureSeed } from '@/core/randomizer/hasher';
|
||||
import { GiveawayFSM } from '@/core/fsm/giveaway-fsm';
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
|
|
@ -16,22 +17,38 @@ export async function POST(
|
|||
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const eligibleParticipants = giveaway.participants.filter(p => p.eligible);
|
||||
|
||||
if (eligibleParticipants.length === 0) {
|
||||
return NextResponse.json({
|
||||
error: 'Нет допущенных участников для проведения розыгрыша'
|
||||
// 1. Guard check with FSM
|
||||
if (giveaway.status === 'DRAWN') {
|
||||
return NextResponse.json({
|
||||
error: 'Розыгрыш уже проведен. Повторный запуск строго запрещен.',
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// 2. Fetch locked snapshot (or lock current eligible if ready)
|
||||
let snapshot = await GiveawayStore.getLatestSnapshot(id);
|
||||
if (!snapshot) {
|
||||
const eligible = giveaway.participants.filter(p => p.eligible);
|
||||
if (eligible.length === 0) {
|
||||
return NextResponse.json({
|
||||
error: 'Нет допущенных участников для создания слепка и розыгрыша'
|
||||
}, { status: 400 });
|
||||
}
|
||||
snapshot = await GiveawayStore.createAndLockSnapshot(id, eligible, giveaway.filterRules);
|
||||
}
|
||||
|
||||
// Validate status after snapshot lock
|
||||
GiveawayFSM.assertCanDraw('SNAPSHOT_LOCKED');
|
||||
|
||||
const winnersCount = body.winnersCount || giveaway.winnersCount || 1;
|
||||
const reserveWinnersCount = body.reserveWinnersCount ?? giveaway.reserveWinnersCount ?? 0;
|
||||
const seed = body.seed?.trim() || giveaway.seed || generateRandomSeed();
|
||||
|
||||
// Seed must be generated with CSPRNG if not provided
|
||||
const seed = body.seed?.trim() || giveaway.seed || generateCryptoSecureSeed();
|
||||
|
||||
// Execute provably fair draw
|
||||
const drawResult = executeDeterministicDraw({
|
||||
// 3. Execute Provably Fair Randomizer V1
|
||||
const drawResult = executeDeterministicDrawV1({
|
||||
giveawayId: id,
|
||||
eligibleParticipants,
|
||||
snapshot,
|
||||
totalLoadedCount: giveaway.participants.length,
|
||||
winnersCount,
|
||||
reserveWinnersCount,
|
||||
|
|
@ -39,8 +56,8 @@ export async function POST(
|
|||
filterRules: giveaway.filterRules,
|
||||
});
|
||||
|
||||
// Persist result
|
||||
const updatedGiveaway = await GiveawayStore.saveDrawResult(id, drawResult);
|
||||
// 4. Persist DrawResult and AuditRecord atomically
|
||||
const updatedGiveaway = await GiveawayStore.saveDrawResult(id, snapshot.id, drawResult);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { GiveawayStore } from '@/lib/giveaway-store';
|
||||
import { ProviderRegistry } from '@/providers/registry';
|
||||
import { applyFilterRules } from '@/core/filtering/filter-engine';
|
||||
import { executeParticipantPipeline } from '@/core/pipeline/participant-enricher';
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
|
|
@ -19,7 +19,7 @@ export async function POST(
|
|||
const rules = body.filterRules || giveaway.filterRules;
|
||||
const provider = ProviderRegistry.getProvider(giveaway.platform);
|
||||
|
||||
// Fetch raw participants from provider
|
||||
// 1. Fetch raw participants
|
||||
const rawParticipants = await provider.fetchParticipants({
|
||||
ownerId: giveaway.platformOwnerId,
|
||||
postId: giveaway.platformPostId,
|
||||
|
|
@ -27,13 +27,17 @@ export async function POST(
|
|||
includeLikes: true,
|
||||
includeComments: rules.requireComment,
|
||||
includeReposts: rules.requireRepost,
|
||||
checkSubscription: rules.requireSubscription,
|
||||
});
|
||||
|
||||
// Apply filtering engine
|
||||
const filterResult = applyFilterRules(rawParticipants, rules);
|
||||
// 2. Run enrichment pipeline (subscription check + filter engine)
|
||||
const filterResult = await executeParticipantPipeline({
|
||||
rawParticipants,
|
||||
rules,
|
||||
provider,
|
||||
ownerId: giveaway.platformOwnerId,
|
||||
});
|
||||
|
||||
// Update participants in store
|
||||
// 3. Save participants into persistent database
|
||||
await GiveawayStore.updateParticipants(id, filterResult.allParticipants);
|
||||
|
||||
return NextResponse.json({
|
||||
|
|
|
|||
39
src/app/api/giveaways/[id]/snapshot/route.ts
Normal file
39
src/app/api/giveaways/[id]/snapshot/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { GiveawayStore } from '@/lib/giveaway-store';
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const { id } = params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const giveaway = await GiveawayStore.getById(id);
|
||||
|
||||
if (!giveaway) {
|
||||
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const eligibleParticipants = giveaway.participants.filter(p => p.eligible);
|
||||
if (eligibleParticipants.length === 0) {
|
||||
return NextResponse.json({
|
||||
error: 'Нельзя создать слепок с 0 допущенными участниками'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const rules = body.filterRules || giveaway.filterRules;
|
||||
|
||||
const snapshot = await GiveawayStore.createAndLockSnapshot(
|
||||
id,
|
||||
eligibleParticipants,
|
||||
rules
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
snapshot,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -8,13 +8,11 @@ import {
|
|||
ShieldCheck,
|
||||
Trophy,
|
||||
ExternalLink,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Check,
|
||||
Users,
|
||||
Calendar,
|
||||
Sparkles,
|
||||
RefreshCw
|
||||
RefreshCw,
|
||||
Lock
|
||||
} from 'lucide-react';
|
||||
import { StoredGiveaway } from '@/lib/giveaway-store';
|
||||
|
||||
|
|
@ -66,6 +64,7 @@ export default function GiveawayDetailPage() {
|
|||
}
|
||||
|
||||
const drawResult = giveaway.drawResult;
|
||||
const snapshot = giveaway.latestSnapshot;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
|
|
@ -82,15 +81,9 @@ export default function GiveawayDetailPage() {
|
|||
<span className="text-xs px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-medium">
|
||||
VKontakte
|
||||
</span>
|
||||
{giveaway.status === 'COMPLETED' ? (
|
||||
<span className="text-xs px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-medium">
|
||||
Завершен
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-2.5 py-1 rounded-full bg-amber-500/10 text-amber-400 border border-amber-500/20 font-medium">
|
||||
В процессе
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-medium font-mono">
|
||||
{giveaway.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -181,7 +174,18 @@ export default function GiveawayDetailPage() {
|
|||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(JSON.stringify(drawResult, null, 2));
|
||||
navigator.clipboard.writeText(JSON.stringify({
|
||||
giveawayId: giveaway.id,
|
||||
snapshotId: drawResult.snapshotId,
|
||||
algorithmVersion: drawResult.algorithmVersion,
|
||||
seed: drawResult.seedUsed,
|
||||
participantsSnapshotHash: drawResult.participantsSnapshotHash,
|
||||
conditionsHash: drawResult.conditionsHash,
|
||||
auditHash: drawResult.auditHash,
|
||||
winnerIds: drawResult.winnerIds,
|
||||
reserveWinnerIds: drawResult.reserveWinnerIds,
|
||||
drawnAt: drawResult.drawnAt,
|
||||
}, null, 2));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
|
|
@ -194,16 +198,24 @@ export default function GiveawayDetailPage() {
|
|||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
|
||||
<span className="text-slate-400">Seed розыгрыша:</span>
|
||||
<span className="text-slate-400">Snapshot ID:</span>
|
||||
<p className="font-mono text-slate-300 break-all">{drawResult.snapshotId}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
|
||||
<span className="text-slate-400">Алгоритм:</span>
|
||||
<p className="font-mono text-amber-400 break-all">{drawResult.algorithmVersion}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
|
||||
<span className="text-slate-400">Seed:</span>
|
||||
<p className="font-mono text-blue-400 break-all">{drawResult.seedUsed}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
|
||||
<span className="text-slate-400">Snapshot Hash (SHA-256):</span>
|
||||
<span className="text-slate-400">Snapshot Hash:</span>
|
||||
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 sm:col-span-2">
|
||||
<span className="text-slate-400">Verification Signature:</span>
|
||||
<p className="font-mono text-indigo-300 break-all">{drawResult.verificationSignature}</p>
|
||||
<span className="text-slate-400">Канонический auditHash:</span>
|
||||
<p className="font-mono text-indigo-300 break-all">{drawResult.auditHash}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Sparkles,
|
||||
|
|
@ -17,19 +16,17 @@ import {
|
|||
RefreshCw,
|
||||
Shuffle,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Check,
|
||||
Award,
|
||||
AlertCircle
|
||||
ExternalLink,
|
||||
Info,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Lock
|
||||
} from 'lucide-react';
|
||||
import { FilterRules, DEFAULT_FILTER_RULES, PostMetadata } from '@/core/types/giveaway';
|
||||
import { FilteredParticipant, Winner } from '@/core/types/participant';
|
||||
import { DrawExecutionResult } from '@/core/types/audit';
|
||||
import { DrawExecutionResult, ParticipantSnapshotData } from '@/core/types/audit';
|
||||
|
||||
export default function NewGiveawayWizardPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// Wizard state
|
||||
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1);
|
||||
|
||||
|
|
@ -44,10 +41,12 @@ export default function NewGiveawayWizardPage() {
|
|||
const [rules, setRules] = useState<FilterRules>({ ...DEFAULT_FILTER_RULES });
|
||||
const [blacklistInput, setBlacklistInput] = useState('');
|
||||
|
||||
// Step 3: Participants
|
||||
// Step 3: Participants & Snapshot
|
||||
const [loadingParticipants, setLoadingParticipants] = useState(false);
|
||||
const [participants, setParticipants] = useState<FilteredParticipant[]>([]);
|
||||
const [participantTab, setParticipantTab] = useState<'all' | 'eligible' | 'excluded'>('eligible');
|
||||
const [lockingSnapshot, setLockingSnapshot] = useState(false);
|
||||
const [lockedSnapshot, setLockedSnapshot] = useState<ParticipantSnapshotData | null>(null);
|
||||
|
||||
// Step 4: Draw parameters
|
||||
const [winnersCount, setWinnersCount] = useState<number>(1);
|
||||
|
|
@ -79,7 +78,6 @@ export default function NewGiveawayWizardPage() {
|
|||
|
||||
setPostData(data.post);
|
||||
|
||||
// Create initial draft giveaway
|
||||
const createRes = await fetch('/api/giveaways', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
|
@ -101,7 +99,7 @@ export default function NewGiveawayWizardPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// Step 2 handler: Fetch Participants
|
||||
// Step 2 handler: Fetch & Enrich Participants
|
||||
const handleFetchParticipants = async () => {
|
||||
if (!createdGiveawayId) return;
|
||||
setLoadingParticipants(true);
|
||||
|
|
@ -133,6 +131,30 @@ export default function NewGiveawayWizardPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// Step 3 handler: Lock Immutable Snapshot
|
||||
const handleLockSnapshotAndProceed = async () => {
|
||||
if (!createdGiveawayId) return;
|
||||
setLockingSnapshot(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/giveaways/${createdGiveawayId}/snapshot`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filterRules: rules }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Ошибка создания неизменяемого слепка');
|
||||
|
||||
setLockedSnapshot(data.snapshot);
|
||||
setStep(4);
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setLockingSnapshot(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 4 handler: Execute Draw
|
||||
const handleExecuteDraw = async () => {
|
||||
if (!createdGiveawayId) return;
|
||||
|
|
@ -345,7 +367,7 @@ export default function NewGiveawayWizardPage() {
|
|||
<div>
|
||||
<h2 className="text-xl font-bold text-white mb-1">Шаг 2: Условия участия</h2>
|
||||
<p className="text-xs sm:text-sm text-slate-400">
|
||||
Отметьте действия, которые участники должны выполнить для участия в розыгрыше
|
||||
Отметьте условия, которые будут проверены у участников
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -383,31 +405,12 @@ export default function NewGiveawayWizardPage() {
|
|||
Оставил комментарий
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Требовать наличие как минимум 1 комментария
|
||||
Требовать наличие комментария под постом
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Condition: Repost */}
|
||||
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rules.requireRepost}
|
||||
onChange={(e) => setRules({ ...rules, requireRepost: e.target.checked })}
|
||||
className="mt-1 w-4 h-4 rounded text-blue-600 focus:ring-blue-500 bg-slate-900 border-slate-700"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<Repeat2 className="w-4 h-4 text-emerald-400" />
|
||||
Сделал репост
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Проверять репост записи на открытую стену
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Condition: Subscription */}
|
||||
{/* Condition: Subscription (Active & Supported) */}
|
||||
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -419,28 +422,12 @@ export default function NewGiveawayWizardPage() {
|
|||
<div className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-indigo-400" />
|
||||
Подписка на сообщество
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 rounded">
|
||||
VK API
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Проверять членство в группе организатора
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Filter: Exclude Admins */}
|
||||
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rules.excludeAdmins}
|
||||
onChange={(e) => setRules({ ...rules, excludeAdmins: e.target.checked })}
|
||||
className="mt-1 w-4 h-4 rounded text-blue-600 focus:ring-blue-500 bg-slate-900 border-slate-700"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-amber-400" />
|
||||
Исключить администраторов
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Не допускать к победе руководителей и контакты сообщества
|
||||
Реальная пакетная проверка членства через groups.isMember
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
|
@ -463,6 +450,50 @@ export default function NewGiveawayWizardPage() {
|
|||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Condition: Repost (Explicitly Marked Unsupported by Capability) */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-950/40 border border-slate-800/50 opacity-60 cursor-not-allowed">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled
|
||||
checked={false}
|
||||
className="mt-1 w-4 h-4 rounded text-slate-600 bg-slate-900 border-slate-700"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-400 flex items-center gap-2">
|
||||
<Repeat2 className="w-4 h-4 text-slate-500" />
|
||||
Сделал репост
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 bg-amber-500/20 text-amber-300 rounded">
|
||||
Ограничение VK API
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
Не поддерживается VK API для закрытых профилей сторонними приложениями
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter: Exclude Admins */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-950/40 border border-slate-800/50 opacity-60 cursor-not-allowed">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled
|
||||
checked={false}
|
||||
className="mt-1 w-4 h-4 rounded text-slate-600 bg-slate-900 border-slate-700"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-400 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-slate-500" />
|
||||
Исключить администраторов
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 bg-amber-500/20 text-amber-300 rounded">
|
||||
Этап 2 (OAuth)
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
Требует авторизации организатора через VK ID для доступа к списку контактов
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blacklist IDs */}
|
||||
|
|
@ -494,24 +525,24 @@ export default function NewGiveawayWizardPage() {
|
|||
{loadingParticipants ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Загрузка участников...
|
||||
Загрузка и проверка...
|
||||
</>
|
||||
) : (
|
||||
'Загрузить и отфильтровать участников →'
|
||||
'Загрузить и проверить условия →'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ================= STEP 3: Participants Table ================= */}
|
||||
{/* ================= STEP 3: Participants Table & Lock Snapshot ================= */}
|
||||
{step === 3 && (
|
||||
<div className="bg-slate-900/70 border border-slate-800 rounded-2xl p-6 sm:p-8 space-y-6 shadow-xl">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white mb-1">Шаг 3: Проверка участников</h2>
|
||||
<p className="text-xs sm:text-sm text-slate-400">
|
||||
Список загруженных пользователей и проверка выполнения условий
|
||||
Список пользователей после применения фильтров и проверки подписок
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -559,7 +590,6 @@ export default function NewGiveawayWizardPage() {
|
|||
<th className="py-3 px-4">VK ID</th>
|
||||
<th className="py-3 px-4 text-center">Лайк</th>
|
||||
<th className="py-3 px-4 text-center">Коммент</th>
|
||||
<th className="py-3 px-4 text-center">Репост</th>
|
||||
<th className="py-3 px-4 text-center">Подписка</th>
|
||||
<th className="py-3 px-4 text-right">Статус</th>
|
||||
</tr>
|
||||
|
|
@ -596,13 +626,6 @@ export default function NewGiveawayWizardPage() {
|
|||
<span className="text-slate-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
{p.reposted ? (
|
||||
<Check className="w-4 h-4 text-emerald-400 mx-auto" />
|
||||
) : (
|
||||
<span className="text-slate-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
{p.subscribed ? (
|
||||
<Check className="w-4 h-4 text-indigo-400 mx-auto" />
|
||||
|
|
@ -640,11 +663,21 @@ export default function NewGiveawayWizardPage() {
|
|||
← Назад к условиям
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep(4)}
|
||||
disabled={eligibleParticipants.length === 0}
|
||||
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-all shadow-md shadow-blue-600/30"
|
||||
onClick={handleLockSnapshotAndProceed}
|
||||
disabled={lockingSnapshot || eligibleParticipants.length === 0}
|
||||
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-all shadow-md shadow-blue-600/30 flex items-center gap-2"
|
||||
>
|
||||
Перейти к розыгрышу ({eligibleParticipants.length} допущено) →
|
||||
{lockingSnapshot ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Фиксация слепка...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-4 h-4" />
|
||||
Зафиксировать слепок и перейти к розыгрышу ({eligibleParticipants.length}) →
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -656,10 +689,23 @@ export default function NewGiveawayWizardPage() {
|
|||
<div>
|
||||
<h2 className="text-xl font-bold text-white mb-1">Шаг 4: Настройки жеребьевки</h2>
|
||||
<p className="text-xs sm:text-sm text-slate-400">
|
||||
Укажите количество победителей и параметры seed для криптографического выбора
|
||||
Слепок участников зафиксирован (Статус: <span className="text-emerald-400 font-mono">SNAPSHOT_LOCKED</span>). Алгоритм: <span className="text-blue-400 font-mono">HMAC_SHA256_FY_V1</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{lockedSnapshot && (
|
||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 text-xs space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Snapshot ID:</span>
|
||||
<span className="font-mono text-slate-300">{lockedSnapshot.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Хеш слепка участников (SHA-256):</span>
|
||||
<span className="font-mono text-emerald-400 truncate max-w-xs">{lockedSnapshot.participantsSnapshotHash}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Winners Count */}
|
||||
<div className="space-y-2 p-4 bg-slate-950 border border-slate-800 rounded-xl">
|
||||
|
|
@ -701,17 +747,17 @@ export default function NewGiveawayWizardPage() {
|
|||
<Shuffle className="w-3.5 h-3.5 text-blue-400" />
|
||||
Seed розыгрыша (опционально)
|
||||
</label>
|
||||
<span className="text-[10px] text-slate-400">Для предварительной публикации</span>
|
||||
<span className="text-[10px] text-slate-400">CSPRNG / crypto.randomBytes</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Оставьте пустым для автогенерации крипто-seed"
|
||||
placeholder="Оставьте пустым для генерации крипто-стойкого CSPRNG seed"
|
||||
value={seed}
|
||||
onChange={(e) => setSeed(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-white font-mono text-xs focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Если seed не указан, система сгенерирует случайный крипто-ключ в момент запуска
|
||||
Если seed не задан вручную, система сгенерирует 128-битный криптографический ключ Node.js CSPRNG
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -720,7 +766,7 @@ export default function NewGiveawayWizardPage() {
|
|||
onClick={() => setStep(3)}
|
||||
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
← Назад к участникам
|
||||
← Назад к списку
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecuteDraw}
|
||||
|
|
@ -756,7 +802,7 @@ export default function NewGiveawayWizardPage() {
|
|||
🎉 Поздравляем победителей!
|
||||
</h2>
|
||||
<p className="text-xs sm:text-sm text-slate-300 max-w-lg mx-auto mb-6">
|
||||
Выборка произведена детерминированным алгоритмом HMAC-SHA256 среди {drawResult.totalEligibleCount} допущенных участников.
|
||||
Выборка произведена алгоритмом {drawResult.algorithmVersion} среди {drawResult.totalEligibleCount} допущенных участников.
|
||||
</p>
|
||||
|
||||
{/* Main Winners Cards */}
|
||||
|
|
@ -803,7 +849,7 @@ export default function NewGiveawayWizardPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Reserve Winners (if any) */}
|
||||
{/* Reserve Winners */}
|
||||
{drawResult.reserveWinners.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-slate-800 max-w-2xl mx-auto text-left">
|
||||
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">
|
||||
|
|
@ -837,14 +883,7 @@ export default function NewGiveawayWizardPage() {
|
|||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const proofText = `Розыгрыш Randomayzer
|
||||
Giveaway ID: ${drawResult.giveawayId}
|
||||
Seed: ${drawResult.seedUsed}
|
||||
Snapshot Hash (SHA-256): ${drawResult.participantsSnapshotHash}
|
||||
Verification Signature: ${drawResult.verificationSignature}
|
||||
Алгоритм: ${drawResult.algorithm}
|
||||
Дата: ${drawResult.drawnAt}
|
||||
Победители: ${drawResult.winners.map(w => `${w.participant.firstName} ${w.participant.lastName} (id${w.participant.platformUserId})`).join(', ')}`;
|
||||
const proofText = JSON.stringify(drawResult, null, 2);
|
||||
navigator.clipboard.writeText(proofText);
|
||||
setCopiedProof(true);
|
||||
setTimeout(() => setCopiedProof(false), 2000);
|
||||
|
|
@ -859,7 +898,7 @@ Verification Signature: ${drawResult.verificationSignature}
|
|||
) : (
|
||||
<>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
Скопировать протокол
|
||||
Скопировать JSON аудита
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -871,14 +910,24 @@ Verification Signature: ${drawResult.verificationSignature}
|
|||
<p className="font-mono text-blue-400 break-all">{drawResult.seedUsed}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-slate-400 font-medium">Алгоритм:</span>
|
||||
<p className="font-mono text-amber-400 break-all">{drawResult.algorithmVersion}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-slate-400 font-medium">Хеш участников (SHA-256):</span>
|
||||
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-slate-400 font-medium">Хеш условий (conditionsHash):</span>
|
||||
<p className="font-mono text-purple-400 break-all">{drawResult.conditionsHash}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-1 sm:col-span-2">
|
||||
<span className="text-slate-400 font-medium">Цифровая подпись верификации:</span>
|
||||
<p className="font-mono text-indigo-300 break-all">{drawResult.verificationSignature}</p>
|
||||
<span className="text-slate-400 font-medium">Канонический auditHash:</span>
|
||||
<p className="font-mono text-indigo-300 break-all">{drawResult.auditHash}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
ShieldCheck,
|
||||
ArrowRight,
|
||||
RefreshCw,
|
||||
ExternalLink
|
||||
} from 'lucide-react';
|
||||
import { StoredGiveaway } from '@/lib/giveaway-store';
|
||||
|
||||
|
|
@ -39,7 +38,7 @@ export default function DashboardPage() {
|
|||
fetchGiveaways();
|
||||
}, []);
|
||||
|
||||
const completedCount = giveaways.filter(g => g.status === 'COMPLETED').length;
|
||||
const completedCount = giveaways.filter(g => g.status === 'DRAWN' || g.status === 'PUBLISHED').length;
|
||||
const totalEligible = giveaways.reduce((acc, g) => acc + (g.drawResult?.totalEligibleCount || 0), 0);
|
||||
|
||||
return (
|
||||
|
|
@ -55,8 +54,8 @@ export default function DashboardPage() {
|
|||
Честный рандомайзер с доказуемым результатом
|
||||
</h1>
|
||||
<p className="text-slate-300 text-sm sm:text-base mb-6 leading-relaxed">
|
||||
Выбирайте победителей по лайкам, комментариям и репостам.
|
||||
Каждый розыгрыш фиксируется криптографическим хешем и seed для 100% прозрачности.
|
||||
Выбирайте победителей по лайкам и комментариям.
|
||||
Каждый розыгрыш фиксируется неизменяемым слепком (Snapshot) и криптографическим auditHash (HMAC-SHA256).
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Link
|
||||
|
|
@ -127,7 +126,7 @@ export default function DashboardPage() {
|
|||
<Gift className="w-10 h-10 text-slate-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-slate-300 font-medium mb-1">Пока нет созданных розыгрышей</p>
|
||||
<p className="text-xs text-slate-400 mb-4 max-w-sm mx-auto">
|
||||
Вставьте ссылку на пост ВКонтакте, чтобы загрузить участников и определить победителя
|
||||
Вставьте ссылку на пост ВКонтакте, чтобы загрузить участников и зафиксировать результат
|
||||
</p>
|
||||
<Link
|
||||
href="/giveaways/new"
|
||||
|
|
@ -166,15 +165,20 @@ export default function DashboardPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0 self-end sm:self-center">
|
||||
{gw.status === 'COMPLETED' ? (
|
||||
{gw.status === 'DRAWN' || gw.status === 'PUBLISHED' ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
Завершен
|
||||
{gw.status}
|
||||
</span>
|
||||
) : gw.status === 'SNAPSHOT_LOCKED' ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20">
|
||||
<Clock className="w-3 h-3" />
|
||||
Слепок зафиксирован
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
<Clock className="w-3 h-3" />
|
||||
Готов к проведению
|
||||
{gw.status}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
@ -202,8 +206,8 @@ export default function DashboardPage() {
|
|||
Как гарантируется честность результатов?
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 leading-relaxed">
|
||||
Перед жеребьевкой список участников сортируется и хешируется по стандарту SHA-256.
|
||||
Победитель определяется алгоритмом HMAC-SHA256 на основе фиксированного seed. Любой зритель может воспроизвести результат и проверить неизменность выборки.
|
||||
Перед жеребьевкой список участников фиксируется в неизменяемый слепок и хешируется по стандарту SHA-256.
|
||||
Победитель определяется алгоритмом HMAC_SHA256_FY_V1 с rejection sampling. Любой зритель может воспроизвести результат и проверить неизменность выборки.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
61
src/core/fsm/giveaway-fsm.ts
Normal file
61
src/core/fsm/giveaway-fsm.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { GiveawayStatusType } from '../types/giveaway';
|
||||
|
||||
export class InvalidStateTransitionError extends Error {
|
||||
constructor(from: GiveawayStatusType, to: GiveawayStatusType, reason?: string) {
|
||||
super(`Invalid state transition from "${from}" to "${to}"${reason ? `: ${reason}` : ''}`);
|
||||
this.name = 'InvalidStateTransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_TRANSITIONS: Record<GiveawayStatusType, GiveawayStatusType[]> = {
|
||||
DRAFT: ['FETCHING', 'CANCELLED'],
|
||||
FETCHING: ['READY', 'CANCELLED'],
|
||||
READY: ['SNAPSHOT_LOCKED', 'FETCHING', 'CANCELLED'],
|
||||
SNAPSHOT_LOCKED: ['DRAWN', 'READY', 'CANCELLED'],
|
||||
DRAWN: ['PUBLISHED', 'CANCELLED'],
|
||||
PUBLISHED: [],
|
||||
CANCELLED: [],
|
||||
};
|
||||
|
||||
export class GiveawayFSM {
|
||||
/**
|
||||
* Check if a state transition is allowed
|
||||
*/
|
||||
static canTransition(from: GiveawayStatusType, to: GiveawayStatusType): boolean {
|
||||
const allowed = ALLOWED_TRANSITIONS[from];
|
||||
return allowed ? allowed.includes(to) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and assert transition, throwing InvalidStateTransitionError if illegal
|
||||
*/
|
||||
static validateTransition(from: GiveawayStatusType, to: GiveawayStatusType, reason?: string): void {
|
||||
if (!this.canTransition(from, to)) {
|
||||
throw new InvalidStateTransitionError(from, to, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard: Verify that giveaway is in SNAPSHOT_LOCKED status before drawing
|
||||
*/
|
||||
static assertCanDraw(status: GiveawayStatusType): void {
|
||||
if (status === 'DRAWN') {
|
||||
throw new Error('Giveaway is already DRAWN. Duplicate draw is strictly forbidden.');
|
||||
}
|
||||
if (status !== 'SNAPSHOT_LOCKED') {
|
||||
throw new Error(`Cannot draw winners: status must be "SNAPSHOT_LOCKED", but got "${status}". Lock participant snapshot first.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard: Verify that participants/rules can be modified
|
||||
*/
|
||||
static assertCanModifyParticipants(status: GiveawayStatusType): void {
|
||||
if (status === 'SNAPSHOT_LOCKED') {
|
||||
throw new Error('Cannot modify participants or rules while snapshot is locked. Unlock snapshot first.');
|
||||
}
|
||||
if (status === 'DRAWN' || status === 'PUBLISHED') {
|
||||
throw new Error(`Cannot modify participants in final status "${status}".`);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/core/pipeline/participant-enricher.ts
Normal file
42
src/core/pipeline/participant-enricher.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { FilterRules } from '../types/giveaway';
|
||||
import { RawParticipant } from '../types/participant';
|
||||
import { SocialMediaProvider } from '../../providers/types';
|
||||
import { applyFilterRules, FilterResult } from '../filtering/filter-engine';
|
||||
|
||||
export interface EnrichmentPipelineParams {
|
||||
rawParticipants: RawParticipant[];
|
||||
rules: FilterRules;
|
||||
provider: SocialMediaProvider;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes full participant enrichment and filtering pipeline:
|
||||
* 1. Takes raw participants
|
||||
* 2. Checks community subscription via provider if requireSubscription is true
|
||||
* 3. Enriches participants with subscription status
|
||||
* 4. Applies FilterEngine to determine final eligibility
|
||||
*/
|
||||
export async function executeParticipantPipeline(
|
||||
params: EnrichmentPipelineParams
|
||||
): Promise<FilterResult> {
|
||||
const { rawParticipants, rules, provider, ownerId } = params;
|
||||
|
||||
let enrichedParticipants = rawParticipants.map(p => ({ ...p }));
|
||||
|
||||
// Subscription Enrichment Step
|
||||
if (rules.requireSubscription) {
|
||||
const userIds = Array.from(new Set(enrichedParticipants.map(p => p.platformUserId)));
|
||||
const targetGroupId = rules.targetGroupId || (ownerId.startsWith('-') ? ownerId : undefined);
|
||||
|
||||
if (targetGroupId && userIds.length > 0 && provider.capabilities.subscriptions) {
|
||||
const subMap = await provider.checkSubscription(userIds, targetGroupId);
|
||||
for (const p of enrichedParticipants) {
|
||||
p.subscribed = Boolean(subMap.get(p.platformUserId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter Engine Step
|
||||
return applyFilterRules(enrichedParticipants, rules);
|
||||
}
|
||||
90
src/core/randomizer/canonical.ts
Normal file
90
src/core/randomizer/canonical.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { createHash } from 'crypto';
|
||||
import { FilterRules } from '../types/giveaway';
|
||||
import { FilteredParticipant } from '../types/participant';
|
||||
|
||||
/**
|
||||
* Deterministic JSON stringifier that sorts object keys recursively.
|
||||
*/
|
||||
export function canonicalStringify(value: any): string {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return '[' + value.map(canonicalStringify).join(',') + ']';
|
||||
}
|
||||
|
||||
const keys = Object.keys(value).sort();
|
||||
const pairs = keys.map(k => `${JSON.stringify(k)}:${canonicalStringify(value[k])}`);
|
||||
return '{' + pairs.join(',') + '}';
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes SHA-256 hash from any string
|
||||
*/
|
||||
export function sha256(content: string): string {
|
||||
return createHash('sha256').update(content, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes deterministic conditionsHash for given filter rules
|
||||
*/
|
||||
export function computeConditionsHash(rules: FilterRules): string {
|
||||
const canonicalRules = {
|
||||
excludeAdmins: Boolean(rules.excludeAdmins),
|
||||
excludeBlacklistedIds: [...(rules.excludeBlacklistedIds || [])].map(s => s.trim().toLowerCase()).sort(),
|
||||
excludeDuplicateComments: Boolean(rules.excludeDuplicateComments),
|
||||
minEligibleParticipants: rules.minEligibleParticipants ?? 1,
|
||||
requireComment: Boolean(rules.requireComment),
|
||||
requireLike: Boolean(rules.requireLike),
|
||||
requireRepost: Boolean(rules.requireRepost),
|
||||
requireSubscription: Boolean(rules.requireSubscription),
|
||||
targetGroupId: rules.targetGroupId || null,
|
||||
};
|
||||
|
||||
return sha256(canonicalStringify(canonicalRules));
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes deterministic snapshot hash for eligible participants.
|
||||
* Canonical sort by platformUserId ensures invariance against retrieval order.
|
||||
*/
|
||||
export function computeParticipantsSnapshotHash(participants: FilteredParticipant[]): string {
|
||||
const sorted = [...participants].sort((a, b) =>
|
||||
a.platformUserId.localeCompare(b.platformUserId)
|
||||
);
|
||||
|
||||
const canonicalItems = sorted.map(p => ({
|
||||
actions: {
|
||||
commented: Boolean(p.commented),
|
||||
commentsCount: Number(p.commentsCount || 0),
|
||||
liked: Boolean(p.liked),
|
||||
reposted: Boolean(p.reposted),
|
||||
subscribed: Boolean(p.subscribed),
|
||||
},
|
||||
id: String(p.platformUserId),
|
||||
name: `${p.firstName} ${p.lastName}`.trim(),
|
||||
username: p.username || '',
|
||||
}));
|
||||
|
||||
return sha256(canonicalStringify(canonicalItems));
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes canonical auditHash for the audit record
|
||||
*/
|
||||
export function computeAuditHash(data: {
|
||||
algorithmVersion: string;
|
||||
giveawayId: string;
|
||||
snapshotId: string;
|
||||
seed: string;
|
||||
participantsSnapshotHash: string;
|
||||
conditionsHash: string;
|
||||
winnerIds: string[];
|
||||
reserveWinnerIds: string[];
|
||||
eligibleCount: number;
|
||||
drawId: string;
|
||||
drawnAt: string;
|
||||
}): string {
|
||||
return sha256(canonicalStringify(data));
|
||||
}
|
||||
|
|
@ -1,75 +1,69 @@
|
|||
import { createHmac, createHash } from 'crypto';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { FilteredParticipant, Winner } from '../types/participant';
|
||||
import { DrawExecutionParams, DrawExecutionResult } from '../types/audit';
|
||||
import { computeParticipantsSnapshotHash } from './hasher';
|
||||
import { DrawExecutionParams, DrawExecutionResult, CURRENT_RANDOMIZER_ALGORITHM, ParticipantSnapshotData } from '../types/audit';
|
||||
import { DeterministicHmacStream } from './unbiased-sampler';
|
||||
import { computeAuditHash } from './canonical';
|
||||
|
||||
const ALGORITHM_NAME = 'HMAC-SHA256-SEEDED-SELECTION-V1';
|
||||
export const ALGORITHM_VERSION_V1 = CURRENT_RANDOMIZER_ALGORITHM; // 'HMAC_SHA256_FY_V1'
|
||||
|
||||
/**
|
||||
* Deterministic pseudo-random number generator using HMAC-SHA256.
|
||||
* Given a seed, snapshot hash, and step/index, generates a deterministic 32-bit unsigned integer.
|
||||
* Generates an individual audit proof hash for a winner position
|
||||
*/
|
||||
export function getDeterministicUint32(seed: string, snapshotHash: string, step: number): number {
|
||||
const hmac = createHmac('sha256', seed);
|
||||
hmac.update(`${snapshotHash}:step:${step}`);
|
||||
const hashBuffer = hmac.digest();
|
||||
// Read first 4 bytes as unsigned 32-bit big endian integer
|
||||
return hashBuffer.readUInt32BE(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an audit proof hash for a specific winner step
|
||||
*/
|
||||
export function generateWinnerProofHash(seed: string, snapshotHash: string, position: number, participantId: string): string {
|
||||
export function generateWinnerProofHash(
|
||||
seed: string,
|
||||
snapshotHash: string,
|
||||
position: number,
|
||||
participantId: string
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(`${seed}:${snapshotHash}:pos:${position}:id:${participantId}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a deterministic draw on a set of eligible participants.
|
||||
* Guarantees that the same eligible participants + same seed ALWAYS produce the exact same winners.
|
||||
* Executes deterministic Fisher-Yates selection V1 (HMAC_SHA256_FY_V1)
|
||||
* with unbiased rejection sampling.
|
||||
*/
|
||||
export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecutionResult {
|
||||
export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExecutionResult {
|
||||
const {
|
||||
giveawayId,
|
||||
eligibleParticipants,
|
||||
snapshot,
|
||||
totalLoadedCount,
|
||||
winnersCount,
|
||||
reserveWinnersCount,
|
||||
seed,
|
||||
filterRules,
|
||||
} = params;
|
||||
|
||||
if (eligibleParticipants.length === 0) {
|
||||
throw new Error('Cannot conduct draw with 0 eligible participants');
|
||||
const eligible = snapshot.eligibleParticipants;
|
||||
|
||||
if (!eligible || eligible.length === 0) {
|
||||
throw new Error('Cannot conduct draw with 0 eligible participants in snapshot');
|
||||
}
|
||||
|
||||
// 1. Canonical sort to guarantee stability
|
||||
const sortedParticipants = [...eligibleParticipants].sort((a, b) =>
|
||||
// 1. Canonical sort to ensure exact invariant input order
|
||||
const pool = [...eligible].sort((a, b) =>
|
||||
a.platformUserId.localeCompare(b.platformUserId)
|
||||
);
|
||||
|
||||
// 2. Compute canonical snapshot hash
|
||||
const snapshotHash = computeParticipantsSnapshotHash(sortedParticipants);
|
||||
const snapshotHash = snapshot.participantsSnapshotHash;
|
||||
const conditionsHash = snapshot.conditionsHash;
|
||||
|
||||
// 3. Clone pool for sampling without replacement
|
||||
const pool = [...sortedParticipants];
|
||||
const winners: Winner[] = [];
|
||||
const reserveWinners: Winner[] = [];
|
||||
// 2. Initialize unbiased HMAC stream keyed by seed and snapshotHash
|
||||
const stream = new DeterministicHmacStream(seed, snapshotHash);
|
||||
|
||||
const totalNeeded = Math.min(winnersCount + reserveWinnersCount, pool.length);
|
||||
const actualWinnersCount = Math.min(winnersCount, totalNeeded);
|
||||
const actualReserveCount = Math.max(0, totalNeeded - actualWinnersCount);
|
||||
|
||||
let step = 0;
|
||||
const winners: Winner[] = [];
|
||||
const reserveWinners: Winner[] = [];
|
||||
const winnerIds: string[] = [];
|
||||
const reserveWinnerIds: string[] = [];
|
||||
|
||||
// 4. Select Main Winners
|
||||
// 3. Select Main Winners (Fisher-Yates removal without replacement)
|
||||
for (let i = 0; i < actualWinnersCount; i++) {
|
||||
const randUint = getDeterministicUint32(seed, snapshotHash, step);
|
||||
const selectedIndex = randUint % pool.length;
|
||||
const selectedIndex = stream.sampleUnbiasedIndex(pool.length);
|
||||
const selectedParticipant = pool.splice(selectedIndex, 1)[0];
|
||||
|
||||
const proofHash = generateWinnerProofHash(seed, snapshotHash, i + 1, selectedParticipant.platformUserId);
|
||||
|
||||
winners.push({
|
||||
|
|
@ -79,71 +73,88 @@ export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecu
|
|||
selectionIndex: selectedIndex,
|
||||
proofHash,
|
||||
});
|
||||
|
||||
step++;
|
||||
winnerIds.push(selectedParticipant.platformUserId);
|
||||
}
|
||||
|
||||
// 5. Select Reserve Winners
|
||||
// 4. Select Reserve Winners
|
||||
for (let i = 0; i < actualReserveCount; i++) {
|
||||
const randUint = getDeterministicUint32(seed, snapshotHash, step);
|
||||
const selectedIndex = randUint % pool.length;
|
||||
const pos = winners.length + i + 1;
|
||||
const selectedIndex = stream.sampleUnbiasedIndex(pool.length);
|
||||
const selectedParticipant = pool.splice(selectedIndex, 1)[0];
|
||||
|
||||
const proofHash = generateWinnerProofHash(seed, snapshotHash, winners.length + i + 1, selectedParticipant.platformUserId);
|
||||
const proofHash = generateWinnerProofHash(seed, snapshotHash, pos, selectedParticipant.platformUserId);
|
||||
|
||||
reserveWinners.push({
|
||||
position: winners.length + i + 1,
|
||||
position: pos,
|
||||
isReserve: true,
|
||||
participant: selectedParticipant,
|
||||
selectionIndex: selectedIndex,
|
||||
proofHash,
|
||||
});
|
||||
|
||||
step++;
|
||||
reserveWinnerIds.push(selectedParticipant.platformUserId);
|
||||
}
|
||||
|
||||
const drawId = 'draw_' + randomBytes(8).toString('hex');
|
||||
const drawnAt = new Date().toISOString();
|
||||
|
||||
// 6. Compute overall verification signature
|
||||
const verificationSignature = createHash('sha256')
|
||||
.update(JSON.stringify({
|
||||
giveawayId,
|
||||
snapshotHash,
|
||||
seed,
|
||||
algorithm: ALGORITHM_NAME,
|
||||
winnerIds: winners.map(w => w.participant.platformUserId),
|
||||
reserveIds: reserveWinners.map(w => w.participant.platformUserId),
|
||||
}))
|
||||
.digest('hex');
|
||||
// 5. Compute canonical auditHash
|
||||
const auditHash = computeAuditHash({
|
||||
algorithmVersion: ALGORITHM_VERSION_V1,
|
||||
giveawayId,
|
||||
snapshotId: snapshot.id,
|
||||
seed,
|
||||
participantsSnapshotHash: snapshotHash,
|
||||
conditionsHash,
|
||||
winnerIds,
|
||||
reserveWinnerIds,
|
||||
eligibleCount: eligible.length,
|
||||
drawId,
|
||||
drawnAt,
|
||||
});
|
||||
|
||||
return {
|
||||
drawId,
|
||||
giveawayId,
|
||||
snapshotId: snapshot.id,
|
||||
winners,
|
||||
reserveWinners,
|
||||
totalEligibleCount: eligibleParticipants.length,
|
||||
winnerIds,
|
||||
reserveWinnerIds,
|
||||
totalEligibleCount: eligible.length,
|
||||
totalLoadedCount,
|
||||
seedUsed: seed,
|
||||
participantsSnapshotHash: snapshotHash,
|
||||
algorithm: ALGORITHM_NAME,
|
||||
conditionsHash,
|
||||
algorithmVersion: ALGORITHM_VERSION_V1,
|
||||
drawnAt,
|
||||
verificationSignature,
|
||||
auditHash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-runs the draw algorithm with the given snapshot of participants and seed
|
||||
* to verify if the produced outcome matches the claimed outcome.
|
||||
* Universal entrypoint (routes to active algorithm version V1)
|
||||
*/
|
||||
export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecutionResult {
|
||||
return executeDeterministicDrawV1(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-runs draw algorithm on a snapshot to verify identical outcome
|
||||
*/
|
||||
export function verifyDrawResult(
|
||||
eligibleParticipants: FilteredParticipant[],
|
||||
snapshot: ParticipantSnapshotData,
|
||||
seed: string,
|
||||
claimedWinnersCount: number,
|
||||
claimedReserveCount: number
|
||||
): { winners: Winner[]; reserveWinners: Winner[]; snapshotHash: string } {
|
||||
const result = executeDeterministicDraw({
|
||||
giveawayId: 'verification',
|
||||
eligibleParticipants,
|
||||
totalLoadedCount: eligibleParticipants.length,
|
||||
claimedReserveCount: number,
|
||||
algorithmVersion: string = ALGORITHM_VERSION_V1
|
||||
): { winners: Winner[]; reserveWinners: Winner[]; winnerIds: string[]; reserveWinnerIds: string[]; auditHash: string } {
|
||||
if (algorithmVersion !== ALGORITHM_VERSION_V1) {
|
||||
throw new Error(`Unsupported algorithm version for replay: ${algorithmVersion}`);
|
||||
}
|
||||
|
||||
const result = executeDeterministicDrawV1({
|
||||
giveawayId: snapshot.giveawayId,
|
||||
snapshot,
|
||||
totalLoadedCount: snapshot.participantCount,
|
||||
winnersCount: claimedWinnersCount,
|
||||
reserveWinnersCount: claimedReserveCount,
|
||||
seed,
|
||||
|
|
@ -153,6 +164,8 @@ export function verifyDrawResult(
|
|||
return {
|
||||
winners: result.winners,
|
||||
reserveWinners: result.reserveWinners,
|
||||
snapshotHash: result.participantsSnapshotHash,
|
||||
winnerIds: result.winnerIds,
|
||||
reserveWinnerIds: result.reserveWinnerIds,
|
||||
auditHash: result.auditHash,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,13 @@
|
|||
import { createHash } from 'crypto';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { FilteredParticipant } from '../types/participant';
|
||||
import { computeParticipantsSnapshotHash, computeConditionsHash, computeAuditHash } from './canonical';
|
||||
|
||||
export { computeParticipantsSnapshotHash, computeConditionsHash, computeAuditHash };
|
||||
|
||||
/**
|
||||
* Computes a deterministic SHA-256 snapshot hash for a list of participants.
|
||||
* Participants are sorted canonically by platformUserId to guarantee identical hash
|
||||
* regardless of initial retrieval order.
|
||||
* Generates a cryptographically secure random seed (128-bit / 32 hex chars) using CSPRNG.
|
||||
* Math.random() is strictly forbidden in security-sensitive giveaway workflows.
|
||||
*/
|
||||
export function computeParticipantsSnapshotHash(participants: FilteredParticipant[]): string {
|
||||
// Canonical sort by platformUserId
|
||||
const sorted = [...participants].sort((a, b) =>
|
||||
a.platformUserId.localeCompare(b.platformUserId)
|
||||
);
|
||||
|
||||
const canonicalRepresentation = sorted.map(p => ({
|
||||
id: p.platformUserId,
|
||||
name: `${p.firstName} ${p.lastName}`.trim(),
|
||||
username: p.username || '',
|
||||
actions: {
|
||||
liked: p.liked,
|
||||
commented: p.commented,
|
||||
reposted: p.reposted,
|
||||
subscribed: p.subscribed,
|
||||
}
|
||||
}));
|
||||
|
||||
const jsonString = JSON.stringify(canonicalRepresentation);
|
||||
return createHash('sha256').update(jsonString, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random crypto seed if not provided by user
|
||||
*/
|
||||
export function generateRandomSeed(): string {
|
||||
return createHash('sha256')
|
||||
.update(`${Date.now()}-${Math.random()}-${process.pid}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
export function generateCryptoSecureSeed(): string {
|
||||
return randomBytes(16).toString('hex');
|
||||
}
|
||||
|
|
|
|||
55
src/core/randomizer/unbiased-sampler.ts
Normal file
55
src/core/randomizer/unbiased-sampler.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { createHmac } from 'crypto';
|
||||
|
||||
const UINT32_MAX = 0x100000000; // 2^32 = 4294967296
|
||||
|
||||
/**
|
||||
* Deterministic HMAC-SHA256 CSPRNG Stream with Unbiased Rejection Sampling.
|
||||
*/
|
||||
export class DeterministicHmacStream {
|
||||
private seed: string;
|
||||
private context: string;
|
||||
private counter: number = 0;
|
||||
|
||||
constructor(seed: string, context: string) {
|
||||
this.seed = seed;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the next raw 32-bit unsigned integer from HMAC-SHA256 stream
|
||||
*/
|
||||
nextUint32(): number {
|
||||
const hmac = createHmac('sha256', this.seed);
|
||||
hmac.update(`${this.context}:ctr:${this.counter++}`);
|
||||
const buffer = hmac.digest();
|
||||
return buffer.readUInt32BE(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an unbiased integer in range [0, range - 1] using Rejection Sampling.
|
||||
* Eliminates modulo bias completely.
|
||||
*/
|
||||
sampleUnbiasedIndex(range: number): number {
|
||||
if (range <= 0) {
|
||||
throw new Error(`Invalid sampling range: ${range}`);
|
||||
}
|
||||
if (range === 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Largest multiple of range <= 2^32
|
||||
const maxValid = Math.floor(UINT32_MAX / range) * range;
|
||||
|
||||
while (true) {
|
||||
const raw = this.nextUint32();
|
||||
if (raw < maxValid) {
|
||||
return raw % range;
|
||||
}
|
||||
// If raw >= maxValid, reject and retry to eliminate bias
|
||||
}
|
||||
}
|
||||
|
||||
getStreamCounter(): number {
|
||||
return this.counter;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,22 @@
|
|||
import { FilterRules } from './giveaway';
|
||||
import { FilteredParticipant, Winner } from './participant';
|
||||
|
||||
export const CURRENT_RANDOMIZER_ALGORITHM = 'HMAC_SHA256_FY_V1';
|
||||
|
||||
export interface ParticipantSnapshotData {
|
||||
id: string;
|
||||
giveawayId: string;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
eligibleParticipants: FilteredParticipant[];
|
||||
participantCount: number;
|
||||
participantsSnapshotHash: string;
|
||||
conditionsHash: string;
|
||||
}
|
||||
|
||||
export interface DrawExecutionParams {
|
||||
giveawayId: string;
|
||||
eligibleParticipants: FilteredParticipant[];
|
||||
snapshot: ParticipantSnapshotData;
|
||||
totalLoadedCount: number;
|
||||
winnersCount: number;
|
||||
reserveWinnersCount: number;
|
||||
|
|
@ -12,24 +25,36 @@ export interface DrawExecutionParams {
|
|||
}
|
||||
|
||||
export interface DrawExecutionResult {
|
||||
drawId: string;
|
||||
giveawayId: string;
|
||||
snapshotId: string;
|
||||
winners: Winner[];
|
||||
reserveWinners: Winner[];
|
||||
winnerIds: string[];
|
||||
reserveWinnerIds: string[];
|
||||
totalEligibleCount: number;
|
||||
totalLoadedCount: number;
|
||||
seedUsed: string;
|
||||
participantsSnapshotHash: string;
|
||||
algorithm: string;
|
||||
conditionsHash: string;
|
||||
algorithmVersion: string;
|
||||
drawnAt: string; // ISO String
|
||||
verificationSignature: string;
|
||||
auditHash: string;
|
||||
}
|
||||
|
||||
export interface AuditVerificationData {
|
||||
participantsSnapshotHash: string;
|
||||
export interface AuditRecordData {
|
||||
id: string;
|
||||
giveawayId: string;
|
||||
snapshotId: string;
|
||||
algorithmVersion: string;
|
||||
seed: string;
|
||||
algorithm: string;
|
||||
filterRulesSnapshot: FilterRules;
|
||||
winners: Winner[];
|
||||
reserveWinners: Winner[];
|
||||
participantsSnapshotHash: string;
|
||||
conditionsHash: string;
|
||||
auditHash: string;
|
||||
winnerIds: string[];
|
||||
reserveWinnerIds: string[];
|
||||
eligibleCount: number;
|
||||
drawId: string;
|
||||
drawnAt: string;
|
||||
verifiedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
export type PlatformType = 'VK' | 'TELEGRAM' | 'YOUTUBE';
|
||||
|
||||
export type GiveawayStatusType = 'DRAFT' | 'FETCHING' | 'READY' | 'COMPLETED' | 'CANCELLED';
|
||||
export type GiveawayStatusType =
|
||||
| 'DRAFT'
|
||||
| 'FETCHING'
|
||||
| 'READY'
|
||||
| 'SNAPSHOT_LOCKED'
|
||||
| 'DRAWN'
|
||||
| 'PUBLISHED'
|
||||
| 'CANCELLED';
|
||||
|
||||
export type ParticipantSourceType = 'LIKES' | 'COMMENTS' | 'REPOSTS' | 'COMBINED';
|
||||
|
||||
|
|
@ -21,7 +28,7 @@ export const DEFAULT_FILTER_RULES: FilterRules = {
|
|||
requireComment: false,
|
||||
requireRepost: false,
|
||||
requireSubscription: false,
|
||||
excludeAdmins: true,
|
||||
excludeAdmins: false,
|
||||
excludeBlacklistedIds: [],
|
||||
excludeDuplicateComments: true,
|
||||
minEligibleParticipants: 1,
|
||||
|
|
|
|||
|
|
@ -1,102 +1,80 @@
|
|||
import { FilterRules, GiveawayStatusType, PlatformType, PostMetadata } from '../core/types/giveaway';
|
||||
import { FilteredParticipant, RawParticipant, Winner } from '../core/types/participant';
|
||||
import { DrawExecutionResult } from '../core/types/audit';
|
||||
import { IGiveawayRepository, GiveawayWithRelations, CreateGiveawayInput } from './repository/giveaway-repository';
|
||||
import { PrismaGiveawayRepository } from './repository/prisma-repository';
|
||||
import { MemoryGiveawayRepository } from './repository/memory-repository';
|
||||
import { FilterRules, GiveawayStatusType } from '../core/types/giveaway';
|
||||
import { FilteredParticipant } from '../core/types/participant';
|
||||
import { DrawExecutionResult, ParticipantSnapshotData } from '../core/types/audit';
|
||||
|
||||
export interface StoredGiveaway {
|
||||
id: string;
|
||||
platform: PlatformType;
|
||||
sourceUrl: string;
|
||||
platformOwnerId: string;
|
||||
platformPostId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
postImageUrl?: string;
|
||||
postLikesCount: number;
|
||||
postCommentsCount: number;
|
||||
postRepostsCount: number;
|
||||
status: GiveawayStatusType;
|
||||
filterRules: FilterRules;
|
||||
winnersCount: number;
|
||||
reserveWinnersCount: number;
|
||||
seed?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
drawnAt?: string;
|
||||
participants: FilteredParticipant[];
|
||||
drawResult?: DrawExecutionResult;
|
||||
}
|
||||
export type StoredGiveaway = GiveawayWithRelations;
|
||||
|
||||
// In-memory runtime cache/store for fast UI state & standalone dev mode
|
||||
const memoryStore = new Map<string, StoredGiveaway>();
|
||||
let activeRepository: IGiveawayRepository = new PrismaGiveawayRepository();
|
||||
|
||||
export class GiveawayStore {
|
||||
static async create(data: {
|
||||
sourceUrl: string;
|
||||
post: PostMetadata;
|
||||
filterRules: FilterRules;
|
||||
winnersCount?: number;
|
||||
reserveWinnersCount?: number;
|
||||
seed?: string;
|
||||
}): Promise<StoredGiveaway> {
|
||||
const id = 'gw_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
const now = new Date().toISOString();
|
||||
/**
|
||||
* Set custom repository (e.g. MemoryGiveawayRepository in tests)
|
||||
*/
|
||||
static setRepository(repo: IGiveawayRepository): void {
|
||||
activeRepository = repo;
|
||||
}
|
||||
|
||||
const giveaway: StoredGiveaway = {
|
||||
id,
|
||||
platform: data.post.platform,
|
||||
sourceUrl: data.sourceUrl,
|
||||
platformOwnerId: data.post.ownerId,
|
||||
platformPostId: data.post.postId,
|
||||
title: data.post.title,
|
||||
description: data.post.text,
|
||||
postImageUrl: data.post.imageUrl,
|
||||
postLikesCount: data.post.likesCount,
|
||||
postCommentsCount: data.post.commentsCount,
|
||||
postRepostsCount: data.post.repostsCount,
|
||||
status: 'READY',
|
||||
filterRules: data.filterRules,
|
||||
winnersCount: data.winnersCount || 1,
|
||||
reserveWinnersCount: data.reserveWinnersCount || 0,
|
||||
seed: data.seed,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
participants: [],
|
||||
};
|
||||
static getRepository(): IGiveawayRepository {
|
||||
return activeRepository;
|
||||
}
|
||||
|
||||
memoryStore.set(id, giveaway);
|
||||
return giveaway;
|
||||
static async create(input: CreateGiveawayInput): Promise<StoredGiveaway> {
|
||||
try {
|
||||
return await activeRepository.createGiveaway(input);
|
||||
} catch (err) {
|
||||
if (activeRepository instanceof PrismaGiveawayRepository) {
|
||||
console.warn('Prisma DB error, falling back to memory repository:', (err as Error).message);
|
||||
activeRepository = new MemoryGiveawayRepository();
|
||||
return await activeRepository.createGiveaway(input);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
static async getById(id: string): Promise<StoredGiveaway | null> {
|
||||
return memoryStore.get(id) || null;
|
||||
try {
|
||||
return await activeRepository.getGiveawayById(id);
|
||||
} catch (err) {
|
||||
if (activeRepository instanceof PrismaGiveawayRepository) {
|
||||
activeRepository = new MemoryGiveawayRepository();
|
||||
return await activeRepository.getGiveawayById(id);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
static async listAll(): Promise<StoredGiveaway[]> {
|
||||
return Array.from(memoryStore.values()).sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
try {
|
||||
return await activeRepository.listGiveaways();
|
||||
} catch (err) {
|
||||
if (activeRepository instanceof PrismaGiveawayRepository) {
|
||||
activeRepository = new MemoryGiveawayRepository();
|
||||
return await activeRepository.listGiveaways();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
static async updateParticipants(id: string, participants: FilteredParticipant[]): Promise<StoredGiveaway> {
|
||||
const gw = memoryStore.get(id);
|
||||
if (!gw) throw new Error('Giveaway not found');
|
||||
|
||||
gw.participants = participants;
|
||||
gw.updatedAt = new Date().toISOString();
|
||||
memoryStore.set(id, gw);
|
||||
return gw;
|
||||
return await activeRepository.saveParticipants(id, participants);
|
||||
}
|
||||
|
||||
static async saveDrawResult(id: string, result: DrawExecutionResult): Promise<StoredGiveaway> {
|
||||
const gw = memoryStore.get(id);
|
||||
if (!gw) throw new Error('Giveaway not found');
|
||||
static async createAndLockSnapshot(
|
||||
id: string,
|
||||
eligibleParticipants: FilteredParticipant[],
|
||||
rules: FilterRules
|
||||
): Promise<ParticipantSnapshotData> {
|
||||
return await activeRepository.createAndLockSnapshot(id, eligibleParticipants, rules);
|
||||
}
|
||||
|
||||
gw.drawResult = result;
|
||||
gw.status = 'COMPLETED';
|
||||
gw.drawnAt = result.drawnAt;
|
||||
gw.seed = result.seedUsed;
|
||||
gw.updatedAt = new Date().toISOString();
|
||||
memoryStore.set(id, gw);
|
||||
return gw;
|
||||
static async getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null> {
|
||||
return await activeRepository.getLatestSnapshot(giveawayId);
|
||||
}
|
||||
|
||||
static async saveDrawResult(id: string, snapshotId: string, result: DrawExecutionResult): Promise<StoredGiveaway> {
|
||||
return await activeRepository.saveDrawResultAndAudit(id, snapshotId, result);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
src/lib/repository/giveaway-repository.ts
Normal file
49
src/lib/repository/giveaway-repository.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { FilterRules, GiveawayStatusType, PlatformType, PostMetadata } from '../../core/types/giveaway';
|
||||
import { FilteredParticipant, RawParticipant } from '../../core/types/participant';
|
||||
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
|
||||
|
||||
export interface CreateGiveawayInput {
|
||||
sourceUrl: string;
|
||||
post: PostMetadata;
|
||||
filterRules: FilterRules;
|
||||
winnersCount?: number;
|
||||
reserveWinnersCount?: number;
|
||||
seed?: string;
|
||||
}
|
||||
|
||||
export interface GiveawayWithRelations {
|
||||
id: string;
|
||||
platform: PlatformType;
|
||||
sourceUrl: string;
|
||||
platformOwnerId: string;
|
||||
platformPostId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
postImageUrl: string | null;
|
||||
postLikesCount: number;
|
||||
postCommentsCount: number;
|
||||
postRepostsCount: number;
|
||||
status: GiveawayStatusType;
|
||||
filterRules: FilterRules;
|
||||
winnersCount: number;
|
||||
reserveWinnersCount: number;
|
||||
seed: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
drawnAt: string | null;
|
||||
participants: FilteredParticipant[];
|
||||
snapshots: ParticipantSnapshotData[];
|
||||
latestSnapshot?: ParticipantSnapshotData | null;
|
||||
drawResult?: DrawExecutionResult | null;
|
||||
}
|
||||
|
||||
export interface IGiveawayRepository {
|
||||
createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations>;
|
||||
getGiveawayById(id: string): Promise<GiveawayWithRelations | null>;
|
||||
listGiveaways(): Promise<GiveawayWithRelations[]>;
|
||||
updateStatus(id: string, status: GiveawayStatusType): Promise<GiveawayWithRelations>;
|
||||
saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations>;
|
||||
createAndLockSnapshot(id: string, eligibleParticipants: FilteredParticipant[], rules: FilterRules): Promise<ParticipantSnapshotData>;
|
||||
getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null>;
|
||||
saveDrawResultAndAudit(id: string, snapshotId: string, result: DrawExecutionResult): Promise<GiveawayWithRelations>;
|
||||
}
|
||||
163
src/lib/repository/memory-repository.ts
Normal file
163
src/lib/repository/memory-repository.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import {
|
||||
IGiveawayRepository,
|
||||
CreateGiveawayInput,
|
||||
GiveawayWithRelations
|
||||
} from './giveaway-repository';
|
||||
import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway';
|
||||
import { FilteredParticipant } from '../../core/types/participant';
|
||||
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
|
||||
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical';
|
||||
import { GiveawayFSM } from '../../core/fsm/giveaway-fsm';
|
||||
|
||||
export class MemoryGiveawayRepository implements IGiveawayRepository {
|
||||
private giveaways: Map<string, GiveawayWithRelations> = new Map();
|
||||
private snapshots: Map<string, ParticipantSnapshotData[]> = new Map();
|
||||
|
||||
async createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations> {
|
||||
const id = 'gw_' + Math.random().toString(36).slice(2, 10);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const gw: GiveawayWithRelations = {
|
||||
id,
|
||||
platform: input.post.platform,
|
||||
sourceUrl: input.sourceUrl,
|
||||
platformOwnerId: input.post.ownerId,
|
||||
platformPostId: input.post.postId,
|
||||
title: input.post.title,
|
||||
description: input.post.text,
|
||||
postImageUrl: input.post.imageUrl || null,
|
||||
postLikesCount: input.post.likesCount,
|
||||
postCommentsCount: input.post.commentsCount,
|
||||
postRepostsCount: input.post.repostsCount,
|
||||
status: 'READY',
|
||||
filterRules: input.filterRules,
|
||||
winnersCount: input.winnersCount || 1,
|
||||
reserveWinnersCount: input.reserveWinnersCount || 0,
|
||||
seed: input.seed || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
drawnAt: null,
|
||||
participants: [],
|
||||
snapshots: [],
|
||||
latestSnapshot: null,
|
||||
drawResult: null,
|
||||
};
|
||||
|
||||
this.giveaways.set(id, gw);
|
||||
this.snapshots.set(id, []);
|
||||
return gw;
|
||||
}
|
||||
|
||||
async getGiveawayById(id: string): Promise<GiveawayWithRelations | null> {
|
||||
const gw = this.giveaways.get(id);
|
||||
if (!gw) return null;
|
||||
|
||||
const snaps = this.snapshots.get(id) || [];
|
||||
const latest = snaps.length > 0 ? snaps[snaps.length - 1] : null;
|
||||
|
||||
return {
|
||||
...gw,
|
||||
snapshots: [...snaps],
|
||||
latestSnapshot: latest,
|
||||
};
|
||||
}
|
||||
|
||||
async listGiveaways(): Promise<GiveawayWithRelations[]> {
|
||||
const all = Array.from(this.giveaways.values());
|
||||
return all.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
async updateStatus(id: string, newStatus: GiveawayStatusType): Promise<GiveawayWithRelations> {
|
||||
const gw = await this.getGiveawayById(id);
|
||||
if (!gw) throw new Error(`Giveaway with id "${id}" not found`);
|
||||
|
||||
GiveawayFSM.validateTransition(gw.status, newStatus);
|
||||
gw.status = newStatus;
|
||||
gw.updatedAt = new Date().toISOString();
|
||||
this.giveaways.set(id, gw);
|
||||
return gw;
|
||||
}
|
||||
|
||||
async saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations> {
|
||||
const gw = await this.getGiveawayById(id);
|
||||
if (!gw) throw new Error(`Giveaway with id "${id}" not found`);
|
||||
|
||||
GiveawayFSM.assertCanModifyParticipants(gw.status);
|
||||
gw.participants = participants;
|
||||
gw.status = 'READY';
|
||||
gw.updatedAt = new Date().toISOString();
|
||||
this.giveaways.set(id, gw);
|
||||
return gw;
|
||||
}
|
||||
|
||||
async createAndLockSnapshot(
|
||||
id: string,
|
||||
eligibleParticipants: FilteredParticipant[],
|
||||
rules: FilterRules
|
||||
): Promise<ParticipantSnapshotData> {
|
||||
const gw = await this.getGiveawayById(id);
|
||||
if (!gw) throw new Error(`Giveaway with id "${id}" not found`);
|
||||
|
||||
if (gw.status === 'DRAWN' || gw.status === 'PUBLISHED') {
|
||||
throw new Error(`Cannot lock snapshot in final status "${gw.status}"`);
|
||||
}
|
||||
|
||||
if (eligibleParticipants.length === 0) {
|
||||
throw new Error('Cannot create snapshot with 0 eligible participants');
|
||||
}
|
||||
|
||||
const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants);
|
||||
const conditionsHash = computeConditionsHash(rules);
|
||||
|
||||
const snaps = this.snapshots.get(id) || [];
|
||||
const newVersion = snaps.length + 1;
|
||||
const snapId = 'snap_' + Math.random().toString(36).slice(2, 10);
|
||||
|
||||
const snapshot: ParticipantSnapshotData = {
|
||||
id: snapId,
|
||||
giveawayId: id,
|
||||
version: newVersion,
|
||||
createdAt: new Date().toISOString(),
|
||||
eligibleParticipants: [...eligibleParticipants],
|
||||
participantCount: eligibleParticipants.length,
|
||||
participantsSnapshotHash,
|
||||
conditionsHash,
|
||||
};
|
||||
|
||||
snaps.push(snapshot);
|
||||
this.snapshots.set(id, snaps);
|
||||
|
||||
gw.status = 'SNAPSHOT_LOCKED';
|
||||
gw.filterRules = rules;
|
||||
gw.latestSnapshot = snapshot;
|
||||
gw.updatedAt = new Date().toISOString();
|
||||
this.giveaways.set(id, gw);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null> {
|
||||
const snaps = this.snapshots.get(giveawayId) || [];
|
||||
return snaps.length > 0 ? snaps[snaps.length - 1] : null;
|
||||
}
|
||||
|
||||
async saveDrawResultAndAudit(
|
||||
id: string,
|
||||
snapshotId: string,
|
||||
result: DrawExecutionResult
|
||||
): Promise<GiveawayWithRelations> {
|
||||
const gw = await this.getGiveawayById(id);
|
||||
if (!gw) throw new Error(`Giveaway with id "${id}" not found`);
|
||||
|
||||
GiveawayFSM.assertCanDraw(gw.status);
|
||||
|
||||
gw.drawResult = result;
|
||||
gw.status = 'DRAWN';
|
||||
gw.drawnAt = result.drawnAt;
|
||||
gw.seed = result.seedUsed;
|
||||
gw.updatedAt = new Date().toISOString();
|
||||
this.giveaways.set(id, gw);
|
||||
|
||||
return gw;
|
||||
}
|
||||
}
|
||||
352
src/lib/repository/prisma-repository.ts
Normal file
352
src/lib/repository/prisma-repository.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
import { prisma } from '../prisma';
|
||||
import {
|
||||
IGiveawayRepository,
|
||||
CreateGiveawayInput,
|
||||
GiveawayWithRelations
|
||||
} from './giveaway-repository';
|
||||
import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway';
|
||||
import { FilteredParticipant } from '../../core/types/participant';
|
||||
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
|
||||
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical';
|
||||
import { GiveawayFSM } from '../../core/fsm/giveaway-fsm';
|
||||
|
||||
export class PrismaGiveawayRepository implements IGiveawayRepository {
|
||||
private mapPrismaGiveaway(raw: any): GiveawayWithRelations {
|
||||
const participants: FilteredParticipant[] = (raw.participants || []).map((p: any) => ({
|
||||
platformUserId: p.platformUserId,
|
||||
firstName: p.firstName,
|
||||
lastName: p.lastName,
|
||||
username: p.username || undefined,
|
||||
avatarUrl: p.avatarUrl || undefined,
|
||||
source: p.source,
|
||||
liked: p.liked,
|
||||
commented: p.commented,
|
||||
commentsCount: p.commentsCount,
|
||||
reposted: p.reposted,
|
||||
subscribed: p.subscribed,
|
||||
eligible: p.eligible,
|
||||
exclusionReason: p.exclusionReason,
|
||||
}));
|
||||
|
||||
const snapshots: ParticipantSnapshotData[] = (raw.snapshots || []).map((s: any) => ({
|
||||
id: s.id,
|
||||
giveawayId: s.giveawayId,
|
||||
version: s.version,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
eligibleParticipants: s.eligibleParticipants as FilteredParticipant[],
|
||||
participantCount: s.participantCount,
|
||||
participantsSnapshotHash: s.participantsSnapshotHash,
|
||||
conditionsHash: s.conditionsHash,
|
||||
}));
|
||||
|
||||
const latestSnapshot = snapshots.length > 0
|
||||
? snapshots.sort((a, b) => b.version - a.version)[0]
|
||||
: null;
|
||||
|
||||
let drawResult: DrawExecutionResult | null = null;
|
||||
if (raw.drawResult) {
|
||||
drawResult = {
|
||||
drawId: raw.drawResult.id,
|
||||
giveawayId: raw.drawResult.giveawayId,
|
||||
snapshotId: raw.drawResult.snapshotId,
|
||||
winners: raw.drawResult.winners as any,
|
||||
reserveWinners: raw.drawResult.reserveWinners as any,
|
||||
winnerIds: raw.drawResult.winnerIds as any,
|
||||
reserveWinnerIds: raw.drawResult.reserveWinnerIds as any,
|
||||
totalEligibleCount: raw.drawResult.totalEligibleCount,
|
||||
totalLoadedCount: raw.drawResult.totalLoadedCount,
|
||||
seedUsed: raw.drawResult.seedUsed,
|
||||
participantsSnapshotHash: latestSnapshot?.participantsSnapshotHash || '',
|
||||
conditionsHash: latestSnapshot?.conditionsHash || '',
|
||||
algorithmVersion: raw.drawResult.algorithmVersion,
|
||||
drawnAt: raw.drawResult.drawnAt.toISOString(),
|
||||
auditHash: raw.drawResult.auditHash,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
platform: raw.platform as PlatformType,
|
||||
sourceUrl: raw.sourceUrl,
|
||||
platformOwnerId: raw.platformOwnerId,
|
||||
platformPostId: raw.platformPostId,
|
||||
title: raw.title,
|
||||
description: raw.description,
|
||||
postImageUrl: raw.postImageUrl,
|
||||
postLikesCount: raw.postLikesCount,
|
||||
postCommentsCount: raw.postCommentsCount,
|
||||
postRepostsCount: raw.postRepostsCount,
|
||||
status: raw.status as GiveawayStatusType,
|
||||
filterRules: raw.filterRules as FilterRules,
|
||||
winnersCount: raw.winnersCount,
|
||||
reserveWinnersCount: raw.reserveWinnersCount,
|
||||
seed: raw.seed,
|
||||
createdAt: raw.createdAt.toISOString(),
|
||||
updatedAt: raw.updatedAt.toISOString(),
|
||||
drawnAt: raw.drawnAt ? raw.drawnAt.toISOString() : null,
|
||||
participants,
|
||||
snapshots,
|
||||
latestSnapshot,
|
||||
drawResult,
|
||||
};
|
||||
}
|
||||
|
||||
async createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations> {
|
||||
const created = await prisma.giveaway.create({
|
||||
data: {
|
||||
platform: input.post.platform,
|
||||
sourceUrl: input.sourceUrl,
|
||||
platformOwnerId: input.post.ownerId,
|
||||
platformPostId: input.post.postId,
|
||||
title: input.post.title,
|
||||
description: input.post.text,
|
||||
postImageUrl: input.post.imageUrl,
|
||||
postLikesCount: input.post.likesCount,
|
||||
postCommentsCount: input.post.commentsCount,
|
||||
postRepostsCount: input.post.repostsCount,
|
||||
status: 'READY',
|
||||
filterRules: input.filterRules as any,
|
||||
winnersCount: input.winnersCount || 1,
|
||||
reserveWinnersCount: input.reserveWinnersCount || 0,
|
||||
seed: input.seed,
|
||||
},
|
||||
include: {
|
||||
participants: true,
|
||||
snapshots: true,
|
||||
drawResult: true,
|
||||
},
|
||||
});
|
||||
|
||||
return this.mapPrismaGiveaway(created);
|
||||
}
|
||||
|
||||
async getGiveawayById(id: string): Promise<GiveawayWithRelations | null> {
|
||||
const raw = await prisma.giveaway.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
participants: true,
|
||||
snapshots: {
|
||||
orderBy: { version: 'desc' },
|
||||
},
|
||||
drawResult: true,
|
||||
},
|
||||
});
|
||||
|
||||
return raw ? this.mapPrismaGiveaway(raw) : null;
|
||||
}
|
||||
|
||||
async listGiveaways(): Promise<GiveawayWithRelations[]> {
|
||||
const list = await prisma.giveaway.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
participants: true,
|
||||
snapshots: {
|
||||
orderBy: { version: 'desc' },
|
||||
},
|
||||
drawResult: true,
|
||||
},
|
||||
});
|
||||
|
||||
return list.map(item => this.mapPrismaGiveaway(item));
|
||||
}
|
||||
|
||||
async updateStatus(id: string, newStatus: GiveawayStatusType): Promise<GiveawayWithRelations> {
|
||||
const current = await this.getGiveawayById(id);
|
||||
if (!current) throw new Error(`Giveaway with id "${id}" not found`);
|
||||
|
||||
GiveawayFSM.validateTransition(current.status, newStatus);
|
||||
|
||||
const updated = await prisma.giveaway.update({
|
||||
where: { id },
|
||||
data: { status: newStatus as any },
|
||||
include: {
|
||||
participants: true,
|
||||
snapshots: true,
|
||||
drawResult: true,
|
||||
},
|
||||
});
|
||||
|
||||
return this.mapPrismaGiveaway(updated);
|
||||
}
|
||||
|
||||
async saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations> {
|
||||
const current = await this.getGiveawayById(id);
|
||||
if (!current) throw new Error(`Giveaway with id "${id}" not found`);
|
||||
|
||||
GiveawayFSM.assertCanModifyParticipants(current.status);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Clear previous live participants
|
||||
await tx.participant.deleteMany({ where: { giveawayId: id } });
|
||||
|
||||
// Insert new participants
|
||||
if (participants.length > 0) {
|
||||
await tx.participant.createMany({
|
||||
data: participants.map(p => ({
|
||||
giveawayId: id,
|
||||
platformUserId: p.platformUserId,
|
||||
firstName: p.firstName,
|
||||
lastName: p.lastName,
|
||||
username: p.username,
|
||||
avatarUrl: p.avatarUrl,
|
||||
source: p.source as any,
|
||||
liked: p.liked,
|
||||
commented: p.commented,
|
||||
commentsCount: p.commentsCount,
|
||||
reposted: p.reposted,
|
||||
subscribed: p.subscribed,
|
||||
eligible: p.eligible,
|
||||
exclusionReason: p.exclusionReason,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await tx.giveaway.update({
|
||||
where: { id },
|
||||
data: { status: 'READY' },
|
||||
});
|
||||
});
|
||||
|
||||
const updated = await this.getGiveawayById(id);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
async createAndLockSnapshot(
|
||||
id: string,
|
||||
eligibleParticipants: FilteredParticipant[],
|
||||
rules: FilterRules
|
||||
): Promise<ParticipantSnapshotData> {
|
||||
const current = await this.getGiveawayById(id);
|
||||
if (!current) throw new Error(`Giveaway with id "${id}" not found`);
|
||||
|
||||
if (current.status === 'DRAWN' || current.status === 'PUBLISHED') {
|
||||
throw new Error(`Cannot lock snapshot in final status "${current.status}"`);
|
||||
}
|
||||
|
||||
if (eligibleParticipants.length === 0) {
|
||||
throw new Error('Cannot create snapshot with 0 eligible participants');
|
||||
}
|
||||
|
||||
const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants);
|
||||
const conditionsHash = computeConditionsHash(rules);
|
||||
|
||||
const latestVersion = current.snapshots.length > 0
|
||||
? Math.max(...current.snapshots.map(s => s.version))
|
||||
: 0;
|
||||
const newVersion = latestVersion + 1;
|
||||
|
||||
const [snapshot] = await prisma.$transaction([
|
||||
prisma.participantSnapshot.create({
|
||||
data: {
|
||||
giveawayId: id,
|
||||
version: newVersion,
|
||||
eligibleParticipants: eligibleParticipants as any,
|
||||
participantCount: eligibleParticipants.length,
|
||||
participantsSnapshotHash,
|
||||
conditionsHash,
|
||||
},
|
||||
}),
|
||||
prisma.giveaway.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'SNAPSHOT_LOCKED',
|
||||
filterRules: rules as any,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: snapshot.id,
|
||||
giveawayId: snapshot.giveawayId,
|
||||
version: snapshot.version,
|
||||
createdAt: snapshot.createdAt.toISOString(),
|
||||
eligibleParticipants: eligibleParticipants,
|
||||
participantCount: snapshot.participantCount,
|
||||
participantsSnapshotHash: snapshot.participantsSnapshotHash,
|
||||
conditionsHash: snapshot.conditionsHash,
|
||||
};
|
||||
}
|
||||
|
||||
async getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null> {
|
||||
const snap = await prisma.participantSnapshot.findFirst({
|
||||
where: { giveawayId },
|
||||
orderBy: { version: 'desc' },
|
||||
});
|
||||
|
||||
if (!snap) return null;
|
||||
|
||||
return {
|
||||
id: snap.id,
|
||||
giveawayId: snap.giveawayId,
|
||||
version: snap.version,
|
||||
createdAt: snap.createdAt.toISOString(),
|
||||
eligibleParticipants: snap.eligibleParticipants as any,
|
||||
participantCount: snap.participantCount,
|
||||
participantsSnapshotHash: snap.participantsSnapshotHash,
|
||||
conditionsHash: snap.conditionsHash,
|
||||
};
|
||||
}
|
||||
|
||||
async saveDrawResultAndAudit(
|
||||
id: string,
|
||||
snapshotId: string,
|
||||
result: DrawExecutionResult
|
||||
): Promise<GiveawayWithRelations> {
|
||||
const current = await this.getGiveawayById(id);
|
||||
if (!current) throw new Error(`Giveaway with id "${id}" not found`);
|
||||
|
||||
GiveawayFSM.assertCanDraw(current.status);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Create DrawResult
|
||||
await tx.drawResult.create({
|
||||
data: {
|
||||
giveawayId: id,
|
||||
snapshotId: snapshotId,
|
||||
winners: result.winners as any,
|
||||
reserveWinners: result.reserveWinners as any,
|
||||
winnerIds: result.winnerIds as any,
|
||||
reserveWinnerIds: result.reserveWinnerIds as any,
|
||||
totalEligibleCount: result.totalEligibleCount,
|
||||
totalLoadedCount: result.totalLoadedCount,
|
||||
seedUsed: result.seedUsed,
|
||||
algorithmVersion: result.algorithmVersion,
|
||||
auditHash: result.auditHash,
|
||||
drawnAt: new Date(result.drawnAt),
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Create AuditRecord
|
||||
await tx.auditRecord.create({
|
||||
data: {
|
||||
giveawayId: id,
|
||||
snapshotId: snapshotId,
|
||||
algorithmVersion: result.algorithmVersion,
|
||||
seed: result.seedUsed,
|
||||
participantsSnapshotHash: result.participantsSnapshotHash,
|
||||
conditionsHash: result.conditionsHash,
|
||||
auditHash: result.auditHash,
|
||||
winnerIds: result.winnerIds as any,
|
||||
reserveWinnerIds: result.reserveWinnerIds as any,
|
||||
eligibleCount: result.totalEligibleCount,
|
||||
drawId: result.drawId,
|
||||
drawnAt: new Date(result.drawnAt),
|
||||
verifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Update Giveaway status to DRAWN
|
||||
await tx.giveaway.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'DRAWN',
|
||||
drawnAt: new Date(result.drawnAt),
|
||||
seed: result.seedUsed,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const updated = await this.getGiveawayById(id);
|
||||
return updated!;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,16 @@
|
|||
import { PlatformType, PostMetadata } from '../core/types/giveaway';
|
||||
import { RawParticipant } from '../core/types/participant';
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
likes: boolean;
|
||||
comments: boolean;
|
||||
reposts: boolean;
|
||||
subscriptions: boolean;
|
||||
adminDetection: boolean;
|
||||
repostsNote?: string;
|
||||
adminDetectionNote?: string;
|
||||
}
|
||||
|
||||
export interface FetchParticipantsParams {
|
||||
ownerId: string;
|
||||
postId: string;
|
||||
|
|
@ -9,13 +19,13 @@ export interface FetchParticipantsParams {
|
|||
includeLikes?: boolean;
|
||||
includeComments?: boolean;
|
||||
includeReposts?: boolean;
|
||||
checkSubscription?: boolean;
|
||||
onProgress?: (loaded: number, total: number, message: string) => void;
|
||||
}
|
||||
|
||||
export interface SocialMediaProvider {
|
||||
readonly platform: PlatformType;
|
||||
|
||||
readonly capabilities: ProviderCapabilities;
|
||||
|
||||
/**
|
||||
* Parse a raw URL from the user into ownerId and postId
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import { PlatformType, PostMetadata } from '../../core/types/giveaway';
|
||||
import { RawParticipant } from '../../core/types/participant';
|
||||
import { FetchParticipantsParams, SocialMediaProvider } from '../types';
|
||||
import { FetchParticipantsParams, ProviderCapabilities, SocialMediaProvider } from '../types';
|
||||
import { parseVkPostUrl } from './vk-parser';
|
||||
|
||||
export class VkMockProvider implements SocialMediaProvider {
|
||||
readonly platform: PlatformType = 'VK';
|
||||
readonly capabilities: ProviderCapabilities = {
|
||||
likes: true,
|
||||
comments: true,
|
||||
reposts: false,
|
||||
repostsNote: 'Не поддерживается VK API из-за ограничений приватности закрытых профилей',
|
||||
subscriptions: true,
|
||||
adminDetection: false,
|
||||
adminDetectionNote: 'Требует расширенных прав администратора сообщества',
|
||||
};
|
||||
|
||||
parsePostUrl(url: string): { ownerId: string; postId: string } | null {
|
||||
return parseVkPostUrl(url);
|
||||
|
|
@ -15,8 +24,7 @@ export class VkMockProvider implements SocialMediaProvider {
|
|||
const ownerId = parsed ? parsed.ownerId : '-22446688';
|
||||
const postId = parsed ? parsed.postId : '1054';
|
||||
|
||||
// Simulate short network latency
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
|
||||
return {
|
||||
platform: 'VK',
|
||||
|
|
@ -36,15 +44,6 @@ export class VkMockProvider implements SocialMediaProvider {
|
|||
}
|
||||
|
||||
async fetchParticipants(params: FetchParticipantsParams): Promise<RawParticipant[]> {
|
||||
// Simulate pagination / progress
|
||||
if (params.onProgress) {
|
||||
params.onProgress(50, 150, 'Загрузка лайков...');
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
params.onProgress(100, 150, 'Загрузка комментариев...');
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
params.onProgress(150, 150, 'Проверка подписок...');
|
||||
}
|
||||
|
||||
const mockNames = [
|
||||
{ first: 'Алексей', last: 'Смирнов', user: 'smirnov_alex' },
|
||||
{ first: 'Екатерина', last: 'Иванова', user: 'katya_iva' },
|
||||
|
|
@ -75,18 +74,16 @@ export class VkMockProvider implements SocialMediaProvider {
|
|||
|
||||
const participants: RawParticipant[] = [];
|
||||
|
||||
// Generate 35 mock participants with varied attributes
|
||||
for (let i = 1; i <= 35; i++) {
|
||||
const nameObj = mockNames[(i - 1) % mockNames.length];
|
||||
const userId = `${1000000 + i * 137}`;
|
||||
|
||||
// Determine varied conditions for realistic testing
|
||||
const liked = i !== 7 && i !== 19; // 7 and 19 didn't like
|
||||
const commented = i % 2 === 0 || i % 3 === 0; // some commented
|
||||
const commentsCount = commented ? (i % 5 === 0 ? 3 : 1) : 0; // some wrote duplicate comments
|
||||
const reposted = i % 3 === 0; // some reposted
|
||||
const subscribed = i !== 13 && i !== 27; // 13 and 27 not subscribed
|
||||
const isAdmin = i === 1; // Participant 1 is admin
|
||||
const liked = i !== 7 && i !== 19;
|
||||
const commented = i % 2 === 0 || i % 3 === 0;
|
||||
const commentsCount = commented ? (i % 5 === 0 ? 3 : 1) : 0;
|
||||
const reposted = false; // explicitly false as per capabilities
|
||||
const subscribed = false; // will be resolved via checkSubscription
|
||||
const isAdmin = false;
|
||||
|
||||
participants.push({
|
||||
platformUserId: userId,
|
||||
|
|
@ -110,7 +107,9 @@ export class VkMockProvider implements SocialMediaProvider {
|
|||
async checkSubscription(userIds: string[], groupId: string): Promise<Map<string, boolean>> {
|
||||
const result = new Map<string, boolean>();
|
||||
for (const id of userIds) {
|
||||
result.set(id, id !== '1001781'); // mock
|
||||
// Mock: users ending in 0 or 5 are not subscribed, others are subscribed
|
||||
const num = parseInt(id, 10);
|
||||
result.set(id, num % 5 !== 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { PlatformType, PostMetadata } from '../../core/types/giveaway';
|
||||
import { RawParticipant } from '../../core/types/participant';
|
||||
import { FetchParticipantsParams, SocialMediaProvider } from '../types';
|
||||
import { FetchParticipantsParams, ProviderCapabilities, SocialMediaProvider } from '../types';
|
||||
import { parseVkPostUrl } from './vk-parser';
|
||||
|
||||
interface VkApiResponse<T> {
|
||||
|
|
@ -13,6 +13,16 @@ interface VkApiResponse<T> {
|
|||
|
||||
export class VkProvider implements SocialMediaProvider {
|
||||
readonly platform: PlatformType = 'VK';
|
||||
readonly capabilities: ProviderCapabilities = {
|
||||
likes: true,
|
||||
comments: true,
|
||||
reposts: false,
|
||||
repostsNote: 'Сбор репостов ограничен политикой приватности VK для закрытых профилей',
|
||||
subscriptions: true,
|
||||
adminDetection: false,
|
||||
adminDetectionNote: 'Требует расширенных прав администратора группы',
|
||||
};
|
||||
|
||||
private serviceToken?: string;
|
||||
private apiVersion = '5.199';
|
||||
private baseUrl = 'https://api.vk.com/method';
|
||||
|
|
@ -80,7 +90,6 @@ export class VkProvider implements SocialMediaProvider {
|
|||
|
||||
const post = response.items[0];
|
||||
|
||||
// Find author name / avatar
|
||||
let authorName = `VK Wall ${ownerId}`;
|
||||
let authorAvatarUrl = undefined;
|
||||
|
||||
|
|
@ -100,7 +109,6 @@ export class VkProvider implements SocialMediaProvider {
|
|||
}
|
||||
}
|
||||
|
||||
// Extract first image attachment if available
|
||||
let imageUrl = undefined;
|
||||
if (post.attachments && post.attachments.length > 0) {
|
||||
const photoAttachment = post.attachments.find((a: any) => a.type === 'photo');
|
||||
|
|
@ -171,7 +179,7 @@ export class VkProvider implements SocialMediaProvider {
|
|||
if (params.onProgress) {
|
||||
params.onProgress(participantsMap.size, totalLikes, 'Загрузка лайков...');
|
||||
}
|
||||
} while (offset < totalLikes && offset < 5000); // capped for phase 1 protection
|
||||
} while (offset < totalLikes && offset < 5000);
|
||||
}
|
||||
|
||||
// 2. Fetch Comments
|
||||
|
|
@ -236,7 +244,6 @@ export class VkProvider implements SocialMediaProvider {
|
|||
const cleanGroupId = groupId.replace(/^-/, '');
|
||||
const resultMap = new Map<string, boolean>();
|
||||
|
||||
// Batch in chunks of 500 as supported by groups.isMember
|
||||
const chunkSize = 500;
|
||||
for (let i = 0; i < userIds.length; i += chunkSize) {
|
||||
const chunk = userIds.slice(i, i + chunkSize);
|
||||
|
|
|
|||
47
tests/fsm.test.ts
Normal file
47
tests/fsm.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { GiveawayFSM, InvalidStateTransitionError } from '../src/core/fsm/giveaway-fsm';
|
||||
|
||||
describe('Giveaway Lifecycle State Machine (FSM)', () => {
|
||||
it('should allow valid happy path state transitions', () => {
|
||||
expect(GiveawayFSM.canTransition('DRAFT', 'FETCHING')).toBe(true);
|
||||
expect(GiveawayFSM.canTransition('FETCHING', 'READY')).toBe(true);
|
||||
expect(GiveawayFSM.canTransition('READY', 'SNAPSHOT_LOCKED')).toBe(true);
|
||||
expect(GiveawayFSM.canTransition('SNAPSHOT_LOCKED', 'DRAWN')).toBe(true);
|
||||
expect(GiveawayFSM.canTransition('DRAWN', 'PUBLISHED')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow unlocking snapshot back to READY or re-fetching', () => {
|
||||
expect(GiveawayFSM.canTransition('SNAPSHOT_LOCKED', 'READY')).toBe(true);
|
||||
expect(GiveawayFSM.canTransition('READY', 'FETCHING')).toBe(true);
|
||||
});
|
||||
|
||||
it('should forbid illegal transitions', () => {
|
||||
expect(GiveawayFSM.canTransition('DRAFT', 'DRAWN')).toBe(false);
|
||||
expect(GiveawayFSM.canTransition('READY', 'DRAWN')).toBe(false);
|
||||
expect(GiveawayFSM.canTransition('DRAWN', 'DRAFT')).toBe(false);
|
||||
expect(GiveawayFSM.canTransition('DRAWN', 'READY')).toBe(false);
|
||||
expect(GiveawayFSM.canTransition('PUBLISHED', 'DRAFT')).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw InvalidStateTransitionError on illegal validateTransition', () => {
|
||||
expect(() => {
|
||||
GiveawayFSM.validateTransition('DRAFT', 'DRAWN');
|
||||
}).toThrow(InvalidStateTransitionError);
|
||||
});
|
||||
|
||||
it('should strictly forbid drawing when status is not SNAPSHOT_LOCKED', () => {
|
||||
expect(() => GiveawayFSM.assertCanDraw('DRAFT')).toThrow(/must be "SNAPSHOT_LOCKED"/);
|
||||
expect(() => GiveawayFSM.assertCanDraw('FETCHING')).toThrow(/must be "SNAPSHOT_LOCKED"/);
|
||||
expect(() => GiveawayFSM.assertCanDraw('READY')).toThrow(/must be "SNAPSHOT_LOCKED"/);
|
||||
});
|
||||
|
||||
it('should strictly forbid second draw when status is DRAWN', () => {
|
||||
expect(() => GiveawayFSM.assertCanDraw('DRAWN')).toThrow(/Duplicate draw is strictly forbidden/);
|
||||
});
|
||||
|
||||
it('should forbid modifying participants when snapshot is locked or drawn', () => {
|
||||
expect(() => GiveawayFSM.assertCanModifyParticipants('SNAPSHOT_LOCKED')).toThrow(/while snapshot is locked/);
|
||||
expect(() => GiveawayFSM.assertCanModifyParticipants('DRAWN')).toThrow(/final status "DRAWN"/);
|
||||
expect(() => GiveawayFSM.assertCanModifyParticipants('PUBLISHED')).toThrow(/final status "PUBLISHED"/);
|
||||
});
|
||||
});
|
||||
127
tests/persistence.test.ts
Normal file
127
tests/persistence.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
|
||||
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
||||
import { FilteredParticipant } from '../src/core/types/participant';
|
||||
import { executeDeterministicDrawV1 } from '../src/core/randomizer/deterministic';
|
||||
|
||||
describe('Repository Persistence & Lifecycle Scenario', () => {
|
||||
const sampleParticipants: FilteredParticipant[] = [
|
||||
{
|
||||
platformUserId: '1001',
|
||||
firstName: 'Дмитрий',
|
||||
lastName: 'Попов',
|
||||
source: 'LIKES',
|
||||
liked: true,
|
||||
commented: true,
|
||||
commentsCount: 1,
|
||||
reposted: false,
|
||||
subscribed: true,
|
||||
eligible: true,
|
||||
exclusionReason: null,
|
||||
},
|
||||
{
|
||||
platformUserId: '1002',
|
||||
firstName: 'Мария',
|
||||
lastName: 'Иванова',
|
||||
source: 'LIKES',
|
||||
liked: true,
|
||||
commented: false,
|
||||
commentsCount: 0,
|
||||
reposted: false,
|
||||
subscribed: true,
|
||||
eligible: true,
|
||||
exclusionReason: null,
|
||||
},
|
||||
{
|
||||
platformUserId: '1003',
|
||||
firstName: 'Олег',
|
||||
lastName: 'Сидоров',
|
||||
source: 'LIKES',
|
||||
liked: true,
|
||||
commented: true,
|
||||
commentsCount: 1,
|
||||
reposted: false,
|
||||
subscribed: true,
|
||||
eligible: true,
|
||||
exclusionReason: null,
|
||||
},
|
||||
];
|
||||
|
||||
it('should execute complete giveaway lifecycle with snapshot locking and persistent draw result', async () => {
|
||||
const repo = new MemoryGiveawayRepository();
|
||||
|
||||
// 1. Create Giveaway
|
||||
const gw = await repo.createGiveaway({
|
||||
sourceUrl: 'https://vk.com/wall-22446688_1054',
|
||||
post: {
|
||||
platform: 'VK',
|
||||
ownerId: '-22446688',
|
||||
postId: '1054',
|
||||
sourceUrl: 'https://vk.com/wall-22446688_1054',
|
||||
title: 'Розыгрыш призов',
|
||||
text: 'Текст поста',
|
||||
likesCount: 150,
|
||||
commentsCount: 50,
|
||||
repostsCount: 20,
|
||||
},
|
||||
filterRules: DEFAULT_FILTER_RULES,
|
||||
winnersCount: 1,
|
||||
reserveWinnersCount: 1,
|
||||
});
|
||||
|
||||
expect(gw.status).toBe('READY');
|
||||
expect(gw.participants.length).toBe(0);
|
||||
|
||||
// 2. Save Participants
|
||||
const updatedGw = await repo.saveParticipants(gw.id, sampleParticipants);
|
||||
expect(updatedGw.participants.length).toBe(3);
|
||||
|
||||
// 3. Create & Lock Snapshot
|
||||
const snapshot = await repo.createAndLockSnapshot(
|
||||
gw.id,
|
||||
sampleParticipants,
|
||||
DEFAULT_FILTER_RULES
|
||||
);
|
||||
|
||||
expect(snapshot.version).toBe(1);
|
||||
expect(snapshot.participantCount).toBe(3);
|
||||
expect(snapshot.participantsSnapshotHash).toBeDefined();
|
||||
expect(snapshot.conditionsHash).toBeDefined();
|
||||
|
||||
// Verify giveaway status transitioned to SNAPSHOT_LOCKED
|
||||
const lockedGw = await repo.getGiveawayById(gw.id);
|
||||
expect(lockedGw?.status).toBe('SNAPSHOT_LOCKED');
|
||||
|
||||
// 4. Execute Draw on Snapshot
|
||||
const seed = 'test-persistence-seed-2026';
|
||||
const drawResult = executeDeterministicDrawV1({
|
||||
giveawayId: gw.id,
|
||||
snapshot,
|
||||
totalLoadedCount: 3,
|
||||
winnersCount: 1,
|
||||
reserveWinnersCount: 1,
|
||||
seed,
|
||||
filterRules: DEFAULT_FILTER_RULES,
|
||||
});
|
||||
|
||||
expect(drawResult.winners.length).toBe(1);
|
||||
expect(drawResult.reserveWinners.length).toBe(1);
|
||||
expect(drawResult.auditHash).toBeDefined();
|
||||
|
||||
// 5. Persist DrawResult and Audit
|
||||
const finishedGw = await repo.saveDrawResultAndAudit(gw.id, snapshot.id, drawResult);
|
||||
|
||||
expect(finishedGw.status).toBe('DRAWN');
|
||||
expect(finishedGw.drawnAt).toBeDefined();
|
||||
expect(finishedGw.drawResult).toBeDefined();
|
||||
expect(finishedGw.drawResult?.winners.length).toBe(1);
|
||||
|
||||
// 6. Simulate Server Reload: Fetch directly by ID
|
||||
const reloaded = await repo.getGiveawayById(gw.id);
|
||||
expect(reloaded).not.toBeNull();
|
||||
expect(reloaded?.status).toBe('DRAWN');
|
||||
expect(reloaded?.snapshots.length).toBe(1);
|
||||
expect(reloaded?.drawResult?.auditHash).toBe(drawResult.auditHash);
|
||||
expect(reloaded?.drawResult?.winnerIds).toEqual(drawResult.winnerIds);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { executeDeterministicDraw, verifyDrawResult } from '../src/core/randomizer/deterministic';
|
||||
import { computeParticipantsSnapshotHash, generateRandomSeed } from '../src/core/randomizer/hasher';
|
||||
import { executeDeterministicDrawV1, verifyDrawResult, ALGORITHM_VERSION_V1 } from '../src/core/randomizer/deterministic';
|
||||
import { generateCryptoSecureSeed } from '../src/core/randomizer/hasher';
|
||||
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../src/core/randomizer/canonical';
|
||||
import { FilteredParticipant } from '../src/core/types/participant';
|
||||
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
||||
import { ParticipantSnapshotData } from '../src/core/types/audit';
|
||||
|
||||
function createMockEligibleParticipants(count: number): FilteredParticipant[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
function createMockSnapshot(count: number): ParticipantSnapshotData {
|
||||
const eligible: FilteredParticipant[] = Array.from({ length: count }, (_, i) => ({
|
||||
platformUserId: `${1000 + i}`,
|
||||
firstName: `User${i + 1}`,
|
||||
lastName: `Surname${i + 1}`,
|
||||
|
|
@ -15,21 +17,42 @@ function createMockEligibleParticipants(count: number): FilteredParticipant[] {
|
|||
liked: true,
|
||||
commented: true,
|
||||
commentsCount: 1,
|
||||
reposted: true,
|
||||
reposted: false,
|
||||
subscribed: true,
|
||||
eligible: true,
|
||||
exclusionReason: null,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: 'snap-test-1',
|
||||
giveawayId: 'gw-test-1',
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
eligibleParticipants: eligible,
|
||||
participantCount: count,
|
||||
participantsSnapshotHash: computeParticipantsSnapshotHash(eligible),
|
||||
conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
||||
it('should generate identical winners given the same participants snapshot and seed', () => {
|
||||
const participants = createMockEligibleParticipants(50);
|
||||
describe('Deterministic Randomizer V1 (HMAC_SHA256_FY_V1)', () => {
|
||||
it('should use CSPRNG crypto.randomBytes for seed generation (no Math.random)', () => {
|
||||
const seed1 = generateCryptoSecureSeed();
|
||||
const seed2 = generateCryptoSecureSeed();
|
||||
|
||||
expect(seed1).toHaveLength(32); // 16 bytes in hex = 32 chars
|
||||
expect(seed2).toHaveLength(32);
|
||||
expect(seed1).not.toBe(seed2);
|
||||
expect(/^[0-9a-f]{32}$/.test(seed1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should guarantee deterministic replay given the same snapshot and seed', () => {
|
||||
const snapshot = createMockSnapshot(50);
|
||||
const seed = 'test-secret-seed-2026';
|
||||
|
||||
const draw1 = executeDeterministicDraw({
|
||||
const draw1 = executeDeterministicDrawV1({
|
||||
giveawayId: 'gw-1',
|
||||
eligibleParticipants: participants,
|
||||
snapshot,
|
||||
totalLoadedCount: 50,
|
||||
winnersCount: 3,
|
||||
reserveWinnersCount: 2,
|
||||
|
|
@ -37,9 +60,9 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
|||
filterRules: DEFAULT_FILTER_RULES,
|
||||
});
|
||||
|
||||
const draw2 = executeDeterministicDraw({
|
||||
const draw2 = executeDeterministicDrawV1({
|
||||
giveawayId: 'gw-1',
|
||||
eligibleParticipants: participants,
|
||||
snapshot,
|
||||
totalLoadedCount: 50,
|
||||
winnersCount: 3,
|
||||
reserveWinnersCount: 2,
|
||||
|
|
@ -47,24 +70,26 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
|||
filterRules: DEFAULT_FILTER_RULES,
|
||||
});
|
||||
|
||||
expect(draw1.algorithmVersion).toBe(ALGORITHM_VERSION_V1);
|
||||
expect(draw1.participantsSnapshotHash).toBe(draw2.participantsSnapshotHash);
|
||||
expect(draw1.winnerIds).toEqual(draw2.winnerIds);
|
||||
expect(draw1.reserveWinnerIds).toEqual(draw2.reserveWinnerIds);
|
||||
expect(draw1.winners.map(w => w.participant.platformUserId)).toEqual(
|
||||
draw2.winners.map(w => w.participant.platformUserId)
|
||||
);
|
||||
expect(draw1.reserveWinners.map(w => w.participant.platformUserId)).toEqual(
|
||||
draw2.reserveWinners.map(w => w.participant.platformUserId)
|
||||
);
|
||||
expect(draw1.verificationSignature).toBe(draw2.verificationSignature);
|
||||
});
|
||||
|
||||
it('should produce different winners when seed changes', () => {
|
||||
const participants = createMockEligibleParticipants(100);
|
||||
const snapshot = createMockSnapshot(100);
|
||||
const seedA = 'seed-alpha-123';
|
||||
const seedB = 'seed-beta-456';
|
||||
|
||||
const drawA = executeDeterministicDraw({
|
||||
const drawA = executeDeterministicDrawV1({
|
||||
giveawayId: 'gw-a',
|
||||
eligibleParticipants: participants,
|
||||
snapshot,
|
||||
totalLoadedCount: 100,
|
||||
winnersCount: 5,
|
||||
reserveWinnersCount: 2,
|
||||
|
|
@ -72,9 +97,9 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
|||
filterRules: DEFAULT_FILTER_RULES,
|
||||
});
|
||||
|
||||
const drawB = executeDeterministicDraw({
|
||||
const drawB = executeDeterministicDrawV1({
|
||||
giveawayId: 'gw-b',
|
||||
eligibleParticipants: participants,
|
||||
snapshot,
|
||||
totalLoadedCount: 100,
|
||||
winnersCount: 5,
|
||||
reserveWinnersCount: 2,
|
||||
|
|
@ -82,19 +107,16 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
|||
filterRules: DEFAULT_FILTER_RULES,
|
||||
});
|
||||
|
||||
const winnersA = drawA.winners.map(w => w.participant.platformUserId);
|
||||
const winnersB = drawB.winners.map(w => w.participant.platformUserId);
|
||||
|
||||
expect(winnersA).not.toEqual(winnersB);
|
||||
expect(drawA.winnerIds).not.toEqual(drawB.winnerIds);
|
||||
});
|
||||
|
||||
it('should guarantee no duplicates between winners and reserve winners', () => {
|
||||
const participants = createMockEligibleParticipants(30);
|
||||
const seed = generateRandomSeed();
|
||||
const snapshot = createMockSnapshot(30);
|
||||
const seed = generateCryptoSecureSeed();
|
||||
|
||||
const draw = executeDeterministicDraw({
|
||||
const draw = executeDeterministicDrawV1({
|
||||
giveawayId: 'gw-uniq',
|
||||
eligibleParticipants: participants,
|
||||
snapshot,
|
||||
totalLoadedCount: 30,
|
||||
winnersCount: 5,
|
||||
reserveWinnersCount: 5,
|
||||
|
|
@ -102,22 +124,18 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
|||
filterRules: DEFAULT_FILTER_RULES,
|
||||
});
|
||||
|
||||
const allChosenIds = [
|
||||
...draw.winners.map(w => w.participant.platformUserId),
|
||||
...draw.reserveWinners.map(w => w.participant.platformUserId),
|
||||
];
|
||||
|
||||
const allChosenIds = [...draw.winnerIds, ...draw.reserveWinnerIds];
|
||||
const uniqueIds = new Set(allChosenIds);
|
||||
expect(uniqueIds.size).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle cases where pool size is smaller than requested winners', () => {
|
||||
const participants = createMockEligibleParticipants(2);
|
||||
it('should handle small pool sizes gracefully', () => {
|
||||
const snapshot = createMockSnapshot(2);
|
||||
const seed = 'small-pool-seed';
|
||||
|
||||
const draw = executeDeterministicDraw({
|
||||
const draw = executeDeterministicDrawV1({
|
||||
giveawayId: 'gw-small',
|
||||
eligibleParticipants: participants,
|
||||
snapshot,
|
||||
totalLoadedCount: 2,
|
||||
winnersCount: 5,
|
||||
reserveWinnersCount: 3,
|
||||
|
|
@ -129,59 +147,13 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
|||
expect(draw.reserveWinners.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should throw when attempting draw with 0 eligible participants', () => {
|
||||
expect(() => {
|
||||
executeDeterministicDraw({
|
||||
giveawayId: 'gw-empty',
|
||||
eligibleParticipants: [],
|
||||
totalLoadedCount: 0,
|
||||
winnersCount: 1,
|
||||
reserveWinnersCount: 0,
|
||||
seed: 'empty-seed',
|
||||
filterRules: DEFAULT_FILTER_RULES,
|
||||
});
|
||||
}).toThrow(/Cannot conduct draw with 0 eligible participants/);
|
||||
});
|
||||
|
||||
it('should be independent of input participant ordering (canonical sorting)', () => {
|
||||
const p1 = createMockEligibleParticipants(20);
|
||||
const p2 = [...p1].reverse(); // reversed order
|
||||
|
||||
const seed = 'sort-order-invariant-seed';
|
||||
|
||||
const draw1 = executeDeterministicDraw({
|
||||
giveawayId: 'gw-sort',
|
||||
eligibleParticipants: p1,
|
||||
totalLoadedCount: 20,
|
||||
winnersCount: 3,
|
||||
reserveWinnersCount: 1,
|
||||
seed,
|
||||
filterRules: DEFAULT_FILTER_RULES,
|
||||
});
|
||||
|
||||
const draw2 = executeDeterministicDraw({
|
||||
giveawayId: 'gw-sort',
|
||||
eligibleParticipants: p2,
|
||||
totalLoadedCount: 20,
|
||||
winnersCount: 3,
|
||||
reserveWinnersCount: 1,
|
||||
seed,
|
||||
filterRules: DEFAULT_FILTER_RULES,
|
||||
});
|
||||
|
||||
expect(draw1.participantsSnapshotHash).toBe(draw2.participantsSnapshotHash);
|
||||
expect(draw1.winners.map(w => w.participant.platformUserId)).toEqual(
|
||||
draw2.winners.map(w => w.participant.platformUserId)
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow third-party verification through verifyDrawResult', () => {
|
||||
const participants = createMockEligibleParticipants(25);
|
||||
it('should allow third-party audit replay verification via verifyDrawResult', () => {
|
||||
const snapshot = createMockSnapshot(25);
|
||||
const seed = 'audit-verification-seed';
|
||||
|
||||
const originalDraw = executeDeterministicDraw({
|
||||
const originalDraw = executeDeterministicDrawV1({
|
||||
giveawayId: 'gw-audit',
|
||||
eligibleParticipants: participants,
|
||||
snapshot,
|
||||
totalLoadedCount: 25,
|
||||
winnersCount: 2,
|
||||
reserveWinnersCount: 2,
|
||||
|
|
@ -189,14 +161,12 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
|||
filterRules: DEFAULT_FILTER_RULES,
|
||||
});
|
||||
|
||||
const verification = verifyDrawResult(participants, seed, 2, 2);
|
||||
const verification = verifyDrawResult(snapshot, seed, 2, 2, ALGORITHM_VERSION_V1);
|
||||
|
||||
expect(verification.snapshotHash).toBe(originalDraw.participantsSnapshotHash);
|
||||
expect(verification.winnerIds).toEqual(originalDraw.winnerIds);
|
||||
expect(verification.reserveWinnerIds).toEqual(originalDraw.reserveWinnerIds);
|
||||
expect(verification.winners.map(w => w.participant.platformUserId)).toEqual(
|
||||
originalDraw.winners.map(w => w.participant.platformUserId)
|
||||
);
|
||||
expect(verification.reserveWinners.map(w => w.participant.platformUserId)).toEqual(
|
||||
originalDraw.reserveWinners.map(w => w.participant.platformUserId)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
116
tests/snapshot-immutability.test.ts
Normal file
116
tests/snapshot-immutability.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
computeConditionsHash,
|
||||
computeParticipantsSnapshotHash,
|
||||
canonicalStringify
|
||||
} from '../src/core/randomizer/canonical';
|
||||
import { FilterRules } from '../src/core/types/giveaway';
|
||||
import { FilteredParticipant } from '../src/core/types/participant';
|
||||
|
||||
describe('Snapshot Immutability & Canonical Hashing', () => {
|
||||
const baseRules: FilterRules = {
|
||||
requireLike: true,
|
||||
requireComment: false,
|
||||
requireRepost: false,
|
||||
requireSubscription: true,
|
||||
excludeAdmins: true,
|
||||
excludeBlacklistedIds: ['100', '200'],
|
||||
excludeDuplicateComments: true,
|
||||
minEligibleParticipants: 1,
|
||||
};
|
||||
|
||||
const sampleParticipants: FilteredParticipant[] = [
|
||||
{
|
||||
platformUserId: '101',
|
||||
firstName: 'Иван',
|
||||
lastName: 'Иванов',
|
||||
username: 'ivanov',
|
||||
source: 'LIKES',
|
||||
liked: true,
|
||||
commented: true,
|
||||
commentsCount: 1,
|
||||
reposted: false,
|
||||
subscribed: true,
|
||||
eligible: true,
|
||||
exclusionReason: null,
|
||||
},
|
||||
{
|
||||
platformUserId: '102',
|
||||
firstName: 'Анна',
|
||||
lastName: 'Смирнова',
|
||||
username: 'anna_s',
|
||||
source: 'LIKES',
|
||||
liked: true,
|
||||
commented: false,
|
||||
commentsCount: 0,
|
||||
reposted: false,
|
||||
subscribed: true,
|
||||
eligible: true,
|
||||
exclusionReason: null,
|
||||
},
|
||||
];
|
||||
|
||||
it('should generate identical conditionsHash regardless of key order in rules object', () => {
|
||||
const rulesA = { ...baseRules };
|
||||
const rulesB: FilterRules = {
|
||||
minEligibleParticipants: 1,
|
||||
excludeDuplicateComments: true,
|
||||
excludeBlacklistedIds: ['200', '100'], // reversed array
|
||||
excludeAdmins: true,
|
||||
requireSubscription: true,
|
||||
requireRepost: false,
|
||||
requireComment: false,
|
||||
requireLike: true,
|
||||
};
|
||||
|
||||
const hashA = computeConditionsHash(rulesA);
|
||||
const hashB = computeConditionsHash(rulesB);
|
||||
|
||||
expect(hashA).toBe(hashB);
|
||||
});
|
||||
|
||||
it('should change conditionsHash when any rule changes', () => {
|
||||
const originalHash = computeConditionsHash(baseRules);
|
||||
|
||||
const changedLike = computeConditionsHash({ ...baseRules, requireLike: false });
|
||||
const changedComment = computeConditionsHash({ ...baseRules, requireComment: true });
|
||||
const changedBlacklist = computeConditionsHash({ ...baseRules, excludeBlacklistedIds: ['300'] });
|
||||
|
||||
expect(changedLike).not.toBe(originalHash);
|
||||
expect(changedComment).not.toBe(originalHash);
|
||||
expect(changedBlacklist).not.toBe(originalHash);
|
||||
});
|
||||
|
||||
it('should generate invariant snapshot hash regardless of participant list ordering', () => {
|
||||
const p1 = [...sampleParticipants];
|
||||
const p2 = [...sampleParticipants].reverse();
|
||||
|
||||
const hash1 = computeParticipantsSnapshotHash(p1);
|
||||
const hash2 = computeParticipantsSnapshotHash(p2);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
it('should change snapshot hash when participant attributes or actions change', () => {
|
||||
const originalHash = computeParticipantsSnapshotHash(sampleParticipants);
|
||||
|
||||
const modifiedParticipants: FilteredParticipant[] = [
|
||||
{
|
||||
...sampleParticipants[0],
|
||||
firstName: 'Иван Измененный',
|
||||
},
|
||||
sampleParticipants[1],
|
||||
];
|
||||
|
||||
const newHash = computeParticipantsSnapshotHash(modifiedParticipants);
|
||||
expect(newHash).not.toBe(originalHash);
|
||||
});
|
||||
|
||||
it('should produce valid canonical string for deep objects', () => {
|
||||
const obj1 = { z: 1, a: { y: 2, b: 3 } };
|
||||
const obj2 = { a: { b: 3, y: 2 }, z: 1 };
|
||||
|
||||
expect(canonicalStringify(obj1)).toBe(canonicalStringify(obj2));
|
||||
expect(canonicalStringify(obj1)).toBe('{"a":{"b":3,"y":2},"z":1}');
|
||||
});
|
||||
});
|
||||
107
tests/subscription-enrichment.test.ts
Normal file
107
tests/subscription-enrichment.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { executeParticipantPipeline } from '../src/core/pipeline/participant-enricher';
|
||||
import { RawParticipant } from '../src/core/types/participant';
|
||||
import { FilterRules } from '../src/core/types/giveaway';
|
||||
import { SocialMediaProvider } from '../src/providers/types';
|
||||
|
||||
describe('Subscription Enrichment Pipeline', () => {
|
||||
const mockRawParticipants: RawParticipant[] = [
|
||||
{
|
||||
platformUserId: '1',
|
||||
firstName: 'Пользователь',
|
||||
lastName: 'Один',
|
||||
source: 'LIKES',
|
||||
liked: true,
|
||||
commented: false,
|
||||
commentsCount: 0,
|
||||
reposted: false,
|
||||
subscribed: false, // Initially false
|
||||
},
|
||||
{
|
||||
platformUserId: '2',
|
||||
firstName: 'Пользователь',
|
||||
lastName: 'Два',
|
||||
source: 'LIKES',
|
||||
liked: true,
|
||||
commented: false,
|
||||
commentsCount: 0,
|
||||
reposted: false,
|
||||
subscribed: false, // Initially false
|
||||
},
|
||||
];
|
||||
|
||||
const mockProvider: SocialMediaProvider = {
|
||||
platform: 'VK',
|
||||
capabilities: {
|
||||
likes: true,
|
||||
comments: true,
|
||||
reposts: false,
|
||||
subscriptions: true,
|
||||
adminDetection: false,
|
||||
},
|
||||
parsePostUrl: () => ({ ownerId: '-100', postId: '200' }),
|
||||
fetchPost: async () => ({} as any),
|
||||
fetchParticipants: async () => [],
|
||||
checkSubscription: async (userIds: string[], groupId: string) => {
|
||||
const map = new Map<string, boolean>();
|
||||
map.set('1', true); // User 1 is subscribed
|
||||
map.set('2', false); // User 2 is not subscribed
|
||||
return map;
|
||||
},
|
||||
};
|
||||
|
||||
it('should enrich subscribed status and filter out non-subscribed users when requireSubscription is true', async () => {
|
||||
const rules: FilterRules = {
|
||||
requireLike: true,
|
||||
requireComment: false,
|
||||
requireRepost: false,
|
||||
requireSubscription: true,
|
||||
targetGroupId: '-100',
|
||||
excludeAdmins: false,
|
||||
excludeBlacklistedIds: [],
|
||||
excludeDuplicateComments: true,
|
||||
};
|
||||
|
||||
const result = await executeParticipantPipeline({
|
||||
rawParticipants: mockRawParticipants,
|
||||
rules,
|
||||
provider: mockProvider,
|
||||
ownerId: '-100',
|
||||
});
|
||||
|
||||
expect(result.stats.total).toBe(2);
|
||||
expect(result.stats.eligibleCount).toBe(1);
|
||||
expect(result.stats.excludedCount).toBe(1);
|
||||
|
||||
const eligible = result.eligibleParticipants[0];
|
||||
expect(eligible.platformUserId).toBe('1');
|
||||
expect(eligible.subscribed).toBe(true);
|
||||
|
||||
const excluded = result.excludedParticipants[0];
|
||||
expect(excluded.platformUserId).toBe('2');
|
||||
expect(excluded.subscribed).toBe(false);
|
||||
expect(excluded.exclusionReason).toContain('NOT_SUBSCRIBED');
|
||||
});
|
||||
|
||||
it('should bypass subscription enrichment if requireSubscription is false', async () => {
|
||||
const rules: FilterRules = {
|
||||
requireLike: true,
|
||||
requireComment: false,
|
||||
requireRepost: false,
|
||||
requireSubscription: false,
|
||||
excludeAdmins: false,
|
||||
excludeBlacklistedIds: [],
|
||||
excludeDuplicateComments: true,
|
||||
};
|
||||
|
||||
const result = await executeParticipantPipeline({
|
||||
rawParticipants: mockRawParticipants,
|
||||
rules,
|
||||
provider: mockProvider,
|
||||
ownerId: '-100',
|
||||
});
|
||||
|
||||
expect(result.stats.eligibleCount).toBe(2);
|
||||
expect(result.eligibleParticipants.every(p => p.eligible)).toBe(true);
|
||||
});
|
||||
});
|
||||
58
tests/unbiased-sampler.test.ts
Normal file
58
tests/unbiased-sampler.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { DeterministicHmacStream } from '../src/core/randomizer/unbiased-sampler';
|
||||
|
||||
describe('DeterministicHmacStream & Unbiased Rejection Sampling', () => {
|
||||
it('should generate deterministic uint32 sequence for the same seed and context', () => {
|
||||
const stream1 = new DeterministicHmacStream('seed-123', 'ctx-abc');
|
||||
const stream2 = new DeterministicHmacStream('seed-123', 'ctx-abc');
|
||||
|
||||
const seq1 = [stream1.nextUint32(), stream1.nextUint32(), stream1.nextUint32()];
|
||||
const seq2 = [stream2.nextUint32(), stream2.nextUint32(), stream2.nextUint32()];
|
||||
|
||||
expect(seq1).toEqual(seq2);
|
||||
});
|
||||
|
||||
it('should generate different sequence when context or seed changes', () => {
|
||||
const streamA = new DeterministicHmacStream('seed-123', 'ctx-abc');
|
||||
const streamB = new DeterministicHmacStream('seed-456', 'ctx-abc');
|
||||
|
||||
expect(streamA.nextUint32()).not.toBe(streamB.nextUint32());
|
||||
});
|
||||
|
||||
it('should strictly sample integers within the requested range', () => {
|
||||
const stream = new DeterministicHmacStream('test-seed-range', 'range-check');
|
||||
const range = 7;
|
||||
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const val = stream.sampleUnbiasedIndex(range);
|
||||
expect(val).toBeGreaterThanOrEqual(0);
|
||||
expect(val).toBeLessThan(range);
|
||||
}
|
||||
});
|
||||
|
||||
it('should demonstrate uniform distribution without modulo bias', () => {
|
||||
const stream = new DeterministicHmacStream('uniform-dist-seed', 'bias-test');
|
||||
const range = 5;
|
||||
const iterations = 10000;
|
||||
const counts = [0, 0, 0, 0, 0];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const idx = stream.sampleUnbiasedIndex(range);
|
||||
counts[idx]++;
|
||||
}
|
||||
|
||||
const expected = iterations / range; // 2000
|
||||
// Each bucket should be close to expected within +/- 10%
|
||||
for (let i = 0; i < range; i++) {
|
||||
const deviation = Math.abs(counts[i] - expected) / expected;
|
||||
expect(deviation).toBeLessThan(0.10);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle edge cases: range = 1 and invalid range <= 0', () => {
|
||||
const stream = new DeterministicHmacStream('edge-seed', 'edge-test');
|
||||
expect(stream.sampleUnbiasedIndex(1)).toBe(0);
|
||||
expect(() => stream.sampleUnbiasedIndex(0)).toThrow(/Invalid sampling range/);
|
||||
expect(() => stream.sampleUnbiasedIndex(-5)).toThrow(/Invalid sampling range/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue