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": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-next": "^14.2.15",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"prisma": "^5.20.0",
|
"prisma": "^5.20.0",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@ enum GiveawayStatus {
|
||||||
DRAFT
|
DRAFT
|
||||||
FETCHING
|
FETCHING
|
||||||
READY
|
READY
|
||||||
COMPLETED
|
SNAPSHOT_LOCKED
|
||||||
|
DRAWN
|
||||||
|
PUBLISHED
|
||||||
CANCELLED
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,6 +52,7 @@ model Giveaway {
|
||||||
drawnAt DateTime?
|
drawnAt DateTime?
|
||||||
|
|
||||||
participants Participant[]
|
participants Participant[]
|
||||||
|
snapshots ParticipantSnapshot[]
|
||||||
drawResult DrawResult?
|
drawResult DrawResult?
|
||||||
auditRecord AuditRecord?
|
auditRecord AuditRecord?
|
||||||
|
|
||||||
|
|
@ -79,16 +82,39 @@ model Participant {
|
||||||
@@index([giveawayId, eligible])
|
@@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 {
|
model DrawResult {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
giveawayId String @unique
|
giveawayId String @unique
|
||||||
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
|
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
|
winners Json // Array of Winner entities
|
||||||
reserveWinners Json // Array of Winner entities
|
reserveWinners Json // Array of Winner entities
|
||||||
|
winnerIds Json // string[]
|
||||||
|
reserveWinnerIds Json // string[]
|
||||||
totalEligibleCount Int
|
totalEligibleCount Int
|
||||||
totalLoadedCount Int
|
totalLoadedCount Int
|
||||||
seedUsed String
|
seedUsed String
|
||||||
algorithm String @default("HMAC-SHA256-FISHER-YATES")
|
algorithmVersion String @default("HMAC_SHA256_FY_V1")
|
||||||
|
auditHash String
|
||||||
drawnAt DateTime @default(now())
|
drawnAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,11 +122,17 @@ model AuditRecord {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
giveawayId String @unique
|
giveawayId String @unique
|
||||||
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
|
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
|
||||||
participantsSnapshotHash String
|
snapshotId String @unique
|
||||||
|
snapshot ParticipantSnapshot @relation(fields: [snapshotId], references: [id], onDelete: Restrict)
|
||||||
|
algorithmVersion String @default("HMAC_SHA256_FY_V1")
|
||||||
seed String
|
seed String
|
||||||
algorithm String @default("HMAC-SHA256-FISHER-YATES")
|
participantsSnapshotHash String
|
||||||
filterRulesSnapshot Json
|
conditionsHash String
|
||||||
winnersSnapshot Json
|
auditHash String
|
||||||
|
winnerIds Json // string[]
|
||||||
|
reserveWinnerIds Json // string[]
|
||||||
|
eligibleCount Int
|
||||||
|
drawId String
|
||||||
|
drawnAt DateTime @default(now())
|
||||||
verifiedAt DateTime @default(now())
|
verifiedAt DateTime @default(now())
|
||||||
verificationSignature String
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { GiveawayStore } from '@/lib/giveaway-store';
|
import { GiveawayStore } from '@/lib/giveaway-store';
|
||||||
import { executeDeterministicDraw } from '@/core/randomizer/deterministic';
|
import { executeDeterministicDrawV1 } from '@/core/randomizer/deterministic';
|
||||||
import { generateRandomSeed } from '@/core/randomizer/hasher';
|
import { generateCryptoSecureSeed } from '@/core/randomizer/hasher';
|
||||||
|
import { GiveawayFSM } from '@/core/fsm/giveaway-fsm';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|
@ -16,22 +17,38 @@ export async function POST(
|
||||||
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const eligibleParticipants = giveaway.participants.filter(p => p.eligible);
|
// 1. Guard check with FSM
|
||||||
|
if (giveaway.status === 'DRAWN') {
|
||||||
if (eligibleParticipants.length === 0) {
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
error: 'Нет допущенных участников для проведения розыгрыша'
|
error: 'Розыгрыш уже проведен. Повторный запуск строго запрещен.',
|
||||||
}, { status: 400 });
|
}, { 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 winnersCount = body.winnersCount || giveaway.winnersCount || 1;
|
||||||
const reserveWinnersCount = body.reserveWinnersCount ?? giveaway.reserveWinnersCount ?? 0;
|
const reserveWinnersCount = body.reserveWinnersCount ?? giveaway.reserveWinnersCount ?? 0;
|
||||||
const seed = body.seed?.trim() || giveaway.seed || generateRandomSeed();
|
|
||||||
|
|
||||||
// Execute provably fair draw
|
// Seed must be generated with CSPRNG if not provided
|
||||||
const drawResult = executeDeterministicDraw({
|
const seed = body.seed?.trim() || giveaway.seed || generateCryptoSecureSeed();
|
||||||
|
|
||||||
|
// 3. Execute Provably Fair Randomizer V1
|
||||||
|
const drawResult = executeDeterministicDrawV1({
|
||||||
giveawayId: id,
|
giveawayId: id,
|
||||||
eligibleParticipants,
|
snapshot,
|
||||||
totalLoadedCount: giveaway.participants.length,
|
totalLoadedCount: giveaway.participants.length,
|
||||||
winnersCount,
|
winnersCount,
|
||||||
reserveWinnersCount,
|
reserveWinnersCount,
|
||||||
|
|
@ -39,8 +56,8 @@ export async function POST(
|
||||||
filterRules: giveaway.filterRules,
|
filterRules: giveaway.filterRules,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Persist result
|
// 4. Persist DrawResult and AuditRecord atomically
|
||||||
const updatedGiveaway = await GiveawayStore.saveDrawResult(id, drawResult);
|
const updatedGiveaway = await GiveawayStore.saveDrawResult(id, snapshot.id, drawResult);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { GiveawayStore } from '@/lib/giveaway-store';
|
import { GiveawayStore } from '@/lib/giveaway-store';
|
||||||
import { ProviderRegistry } from '@/providers/registry';
|
import { ProviderRegistry } from '@/providers/registry';
|
||||||
import { applyFilterRules } from '@/core/filtering/filter-engine';
|
import { executeParticipantPipeline } from '@/core/pipeline/participant-enricher';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|
@ -19,7 +19,7 @@ export async function POST(
|
||||||
const rules = body.filterRules || giveaway.filterRules;
|
const rules = body.filterRules || giveaway.filterRules;
|
||||||
const provider = ProviderRegistry.getProvider(giveaway.platform);
|
const provider = ProviderRegistry.getProvider(giveaway.platform);
|
||||||
|
|
||||||
// Fetch raw participants from provider
|
// 1. Fetch raw participants
|
||||||
const rawParticipants = await provider.fetchParticipants({
|
const rawParticipants = await provider.fetchParticipants({
|
||||||
ownerId: giveaway.platformOwnerId,
|
ownerId: giveaway.platformOwnerId,
|
||||||
postId: giveaway.platformPostId,
|
postId: giveaway.platformPostId,
|
||||||
|
|
@ -27,13 +27,17 @@ export async function POST(
|
||||||
includeLikes: true,
|
includeLikes: true,
|
||||||
includeComments: rules.requireComment,
|
includeComments: rules.requireComment,
|
||||||
includeReposts: rules.requireRepost,
|
includeReposts: rules.requireRepost,
|
||||||
checkSubscription: rules.requireSubscription,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply filtering engine
|
// 2. Run enrichment pipeline (subscription check + filter engine)
|
||||||
const filterResult = applyFilterRules(rawParticipants, rules);
|
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);
|
await GiveawayStore.updateParticipants(id, filterResult.allParticipants);
|
||||||
|
|
||||||
return NextResponse.json({
|
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,
|
ShieldCheck,
|
||||||
Trophy,
|
Trophy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
CheckCircle2,
|
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
Users,
|
|
||||||
Calendar,
|
Calendar,
|
||||||
Sparkles,
|
RefreshCw,
|
||||||
RefreshCw
|
Lock
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { StoredGiveaway } from '@/lib/giveaway-store';
|
import { StoredGiveaway } from '@/lib/giveaway-store';
|
||||||
|
|
||||||
|
|
@ -66,6 +64,7 @@ export default function GiveawayDetailPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawResult = giveaway.drawResult;
|
const drawResult = giveaway.drawResult;
|
||||||
|
const snapshot = giveaway.latestSnapshot;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<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">
|
<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
|
VKontakte
|
||||||
</span>
|
</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 font-mono">
|
||||||
<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">
|
{giveaway.status}
|
||||||
Завершен
|
|
||||||
</span>
|
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -181,7 +174,18 @@ export default function GiveawayDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
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="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">
|
<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>
|
<p className="font-mono text-blue-400 break-all">{drawResult.seedUsed}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
|
<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>
|
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 sm:col-span-2">
|
<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>
|
<span className="text-slate-400">Канонический auditHash:</span>
|
||||||
<p className="font-mono text-indigo-300 break-all">{drawResult.verificationSignature}</p>
|
<p className="font-mono text-indigo-300 break-all">{drawResult.auditHash}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
|
@ -20,16 +19,14 @@ import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Info,
|
Info,
|
||||||
Check,
|
Check,
|
||||||
Award,
|
AlertCircle,
|
||||||
AlertCircle
|
Lock
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { FilterRules, DEFAULT_FILTER_RULES, PostMetadata } from '@/core/types/giveaway';
|
import { FilterRules, DEFAULT_FILTER_RULES, PostMetadata } from '@/core/types/giveaway';
|
||||||
import { FilteredParticipant, Winner } from '@/core/types/participant';
|
import { FilteredParticipant, Winner } from '@/core/types/participant';
|
||||||
import { DrawExecutionResult } from '@/core/types/audit';
|
import { DrawExecutionResult, ParticipantSnapshotData } from '@/core/types/audit';
|
||||||
|
|
||||||
export default function NewGiveawayWizardPage() {
|
export default function NewGiveawayWizardPage() {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Wizard state
|
// Wizard state
|
||||||
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1);
|
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 [rules, setRules] = useState<FilterRules>({ ...DEFAULT_FILTER_RULES });
|
||||||
const [blacklistInput, setBlacklistInput] = useState('');
|
const [blacklistInput, setBlacklistInput] = useState('');
|
||||||
|
|
||||||
// Step 3: Participants
|
// Step 3: Participants & Snapshot
|
||||||
const [loadingParticipants, setLoadingParticipants] = useState(false);
|
const [loadingParticipants, setLoadingParticipants] = useState(false);
|
||||||
const [participants, setParticipants] = useState<FilteredParticipant[]>([]);
|
const [participants, setParticipants] = useState<FilteredParticipant[]>([]);
|
||||||
const [participantTab, setParticipantTab] = useState<'all' | 'eligible' | 'excluded'>('eligible');
|
const [participantTab, setParticipantTab] = useState<'all' | 'eligible' | 'excluded'>('eligible');
|
||||||
|
const [lockingSnapshot, setLockingSnapshot] = useState(false);
|
||||||
|
const [lockedSnapshot, setLockedSnapshot] = useState<ParticipantSnapshotData | null>(null);
|
||||||
|
|
||||||
// Step 4: Draw parameters
|
// Step 4: Draw parameters
|
||||||
const [winnersCount, setWinnersCount] = useState<number>(1);
|
const [winnersCount, setWinnersCount] = useState<number>(1);
|
||||||
|
|
@ -79,7 +78,6 @@ export default function NewGiveawayWizardPage() {
|
||||||
|
|
||||||
setPostData(data.post);
|
setPostData(data.post);
|
||||||
|
|
||||||
// Create initial draft giveaway
|
|
||||||
const createRes = await fetch('/api/giveaways', {
|
const createRes = await fetch('/api/giveaways', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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 () => {
|
const handleFetchParticipants = async () => {
|
||||||
if (!createdGiveawayId) return;
|
if (!createdGiveawayId) return;
|
||||||
setLoadingParticipants(true);
|
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
|
// Step 4 handler: Execute Draw
|
||||||
const handleExecuteDraw = async () => {
|
const handleExecuteDraw = async () => {
|
||||||
if (!createdGiveawayId) return;
|
if (!createdGiveawayId) return;
|
||||||
|
|
@ -345,7 +367,7 @@ export default function NewGiveawayWizardPage() {
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-white mb-1">Шаг 2: Условия участия</h2>
|
<h2 className="text-xl font-bold text-white mb-1">Шаг 2: Условия участия</h2>
|
||||||
<p className="text-xs sm:text-sm text-slate-400">
|
<p className="text-xs sm:text-sm text-slate-400">
|
||||||
Отметьте действия, которые участники должны выполнить для участия в розыгрыше
|
Отметьте условия, которые будут проверены у участников
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -383,31 +405,12 @@ export default function NewGiveawayWizardPage() {
|
||||||
Оставил комментарий
|
Оставил комментарий
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
Требовать наличие как минимум 1 комментария
|
Требовать наличие комментария под постом
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Condition: Repost */}
|
{/* 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"
|
|
||||||
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 */}
|
|
||||||
<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">
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -419,28 +422,12 @@ export default function NewGiveawayWizardPage() {
|
||||||
<div className="text-sm font-semibold text-white flex items-center gap-2">
|
<div className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
<Users className="w-4 h-4 text-indigo-400" />
|
<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>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
Проверять членство в группе организатора
|
Реальная пакетная проверка членства через groups.isMember
|
||||||
</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">
|
|
||||||
Не допускать к победе руководителей и контакты сообщества
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -463,6 +450,50 @@ export default function NewGiveawayWizardPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Blacklist IDs */}
|
{/* Blacklist IDs */}
|
||||||
|
|
@ -494,24 +525,24 @@ export default function NewGiveawayWizardPage() {
|
||||||
{loadingParticipants ? (
|
{loadingParticipants ? (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
Загрузка участников...
|
Загрузка и проверка...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Загрузить и отфильтровать участников →'
|
'Загрузить и проверить условия →'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ================= STEP 3: Participants Table ================= */}
|
{/* ================= STEP 3: Participants Table & Lock Snapshot ================= */}
|
||||||
{step === 3 && (
|
{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="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 className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-white mb-1">Шаг 3: Проверка участников</h2>
|
<h2 className="text-xl font-bold text-white mb-1">Шаг 3: Проверка участников</h2>
|
||||||
<p className="text-xs sm:text-sm text-slate-400">
|
<p className="text-xs sm:text-sm text-slate-400">
|
||||||
Список загруженных пользователей и проверка выполнения условий
|
Список пользователей после применения фильтров и проверки подписок
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -559,7 +590,6 @@ export default function NewGiveawayWizardPage() {
|
||||||
<th className="py-3 px-4">VK ID</th>
|
<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-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>
|
<th className="py-3 px-4 text-right">Статус</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -596,13 +626,6 @@ export default function NewGiveawayWizardPage() {
|
||||||
<span className="text-slate-400">—</span>
|
<span className="text-slate-400">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<td className="py-3 px-4 text-center">
|
||||||
{p.subscribed ? (
|
{p.subscribed ? (
|
||||||
<Check className="w-4 h-4 text-indigo-400 mx-auto" />
|
<Check className="w-4 h-4 text-indigo-400 mx-auto" />
|
||||||
|
|
@ -640,11 +663,21 @@ export default function NewGiveawayWizardPage() {
|
||||||
← Назад к условиям
|
← Назад к условиям
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setStep(4)}
|
onClick={handleLockSnapshotAndProceed}
|
||||||
disabled={eligibleParticipants.length === 0}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -656,10 +689,23 @@ export default function NewGiveawayWizardPage() {
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-white mb-1">Шаг 4: Настройки жеребьевки</h2>
|
<h2 className="text-xl font-bold text-white mb-1">Шаг 4: Настройки жеребьевки</h2>
|
||||||
<p className="text-xs sm:text-sm text-slate-400">
|
<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>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{/* Winners Count */}
|
{/* Winners Count */}
|
||||||
<div className="space-y-2 p-4 bg-slate-950 border border-slate-800 rounded-xl">
|
<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" />
|
<Shuffle className="w-3.5 h-3.5 text-blue-400" />
|
||||||
Seed розыгрыша (опционально)
|
Seed розыгрыша (опционально)
|
||||||
</label>
|
</label>
|
||||||
<span className="text-[10px] text-slate-400">Для предварительной публикации</span>
|
<span className="text-[10px] text-slate-400">CSPRNG / crypto.randomBytes</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Оставьте пустым для автогенерации крипто-seed"
|
placeholder="Оставьте пустым для генерации крипто-стойкого CSPRNG seed"
|
||||||
value={seed}
|
value={seed}
|
||||||
onChange={(e) => setSeed(e.target.value)}
|
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"
|
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">
|
<p className="text-[11px] text-slate-400">
|
||||||
Если seed не указан, система сгенерирует случайный крипто-ключ в момент запуска
|
Если seed не задан вручную, система сгенерирует 128-битный криптографический ключ Node.js CSPRNG
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -720,7 +766,7 @@ export default function NewGiveawayWizardPage() {
|
||||||
onClick={() => setStep(3)}
|
onClick={() => setStep(3)}
|
||||||
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white transition-colors"
|
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
← Назад к участникам
|
← Назад к списку
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleExecuteDraw}
|
onClick={handleExecuteDraw}
|
||||||
|
|
@ -756,7 +802,7 @@ export default function NewGiveawayWizardPage() {
|
||||||
🎉 Поздравляем победителей!
|
🎉 Поздравляем победителей!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs sm:text-sm text-slate-300 max-w-lg mx-auto mb-6">
|
<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>
|
</p>
|
||||||
|
|
||||||
{/* Main Winners Cards */}
|
{/* Main Winners Cards */}
|
||||||
|
|
@ -803,7 +849,7 @@ export default function NewGiveawayWizardPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reserve Winners (if any) */}
|
{/* Reserve Winners */}
|
||||||
{drawResult.reserveWinners.length > 0 && (
|
{drawResult.reserveWinners.length > 0 && (
|
||||||
<div className="mt-6 pt-6 border-t border-slate-800 max-w-2xl mx-auto text-left">
|
<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">
|
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">
|
||||||
|
|
@ -837,14 +883,7 @@ export default function NewGiveawayWizardPage() {
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const proofText = `Розыгрыш Randomayzer
|
const proofText = JSON.stringify(drawResult, null, 2);
|
||||||
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(', ')}`;
|
|
||||||
navigator.clipboard.writeText(proofText);
|
navigator.clipboard.writeText(proofText);
|
||||||
setCopiedProof(true);
|
setCopiedProof(true);
|
||||||
setTimeout(() => setCopiedProof(false), 2000);
|
setTimeout(() => setCopiedProof(false), 2000);
|
||||||
|
|
@ -859,7 +898,7 @@ Verification Signature: ${drawResult.verificationSignature}
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Copy className="w-3.5 h-3.5" />
|
<Copy className="w-3.5 h-3.5" />
|
||||||
Скопировать протокол
|
Скопировать JSON аудита
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -871,14 +910,24 @@ Verification Signature: ${drawResult.verificationSignature}
|
||||||
<p className="font-mono text-blue-400 break-all">{drawResult.seedUsed}</p>
|
<p className="font-mono text-blue-400 break-all">{drawResult.seedUsed}</p>
|
||||||
</div>
|
</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">
|
<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>
|
<span className="text-slate-400 font-medium">Хеш участников (SHA-256):</span>
|
||||||
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
|
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
|
||||||
</div>
|
</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">
|
<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>
|
<span className="text-slate-400 font-medium">Канонический auditHash:</span>
|
||||||
<p className="font-mono text-indigo-300 break-all">{drawResult.verificationSignature}</p>
|
<p className="font-mono text-indigo-300 break-all">{drawResult.auditHash}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ExternalLink
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { StoredGiveaway } from '@/lib/giveaway-store';
|
import { StoredGiveaway } from '@/lib/giveaway-store';
|
||||||
|
|
||||||
|
|
@ -39,7 +38,7 @@ export default function DashboardPage() {
|
||||||
fetchGiveaways();
|
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);
|
const totalEligible = giveaways.reduce((acc, g) => acc + (g.drawResult?.totalEligibleCount || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -55,8 +54,8 @@ export default function DashboardPage() {
|
||||||
Честный рандомайзер с доказуемым результатом
|
Честный рандомайзер с доказуемым результатом
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-300 text-sm sm:text-base mb-6 leading-relaxed">
|
<p className="text-slate-300 text-sm sm:text-base mb-6 leading-relaxed">
|
||||||
Выбирайте победителей по лайкам, комментариям и репостам.
|
Выбирайте победителей по лайкам и комментариям.
|
||||||
Каждый розыгрыш фиксируется криптографическим хешем и seed для 100% прозрачности.
|
Каждый розыгрыш фиксируется неизменяемым слепком (Snapshot) и криптографическим auditHash (HMAC-SHA256).
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -127,7 +126,7 @@ export default function DashboardPage() {
|
||||||
<Gift className="w-10 h-10 text-slate-400 mx-auto mb-3" />
|
<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-sm text-slate-300 font-medium mb-1">Пока нет созданных розыгрышей</p>
|
||||||
<p className="text-xs text-slate-400 mb-4 max-w-sm mx-auto">
|
<p className="text-xs text-slate-400 mb-4 max-w-sm mx-auto">
|
||||||
Вставьте ссылку на пост ВКонтакте, чтобы загрузить участников и определить победителя
|
Вставьте ссылку на пост ВКонтакте, чтобы загрузить участников и зафиксировать результат
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/giveaways/new"
|
href="/giveaways/new"
|
||||||
|
|
@ -166,15 +165,20 @@ export default function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 shrink-0 self-end sm:self-center">
|
<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">
|
<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" />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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" />
|
<Clock className="w-3 h-3" />
|
||||||
Готов к проведению
|
{gw.status}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -202,8 +206,8 @@ export default function DashboardPage() {
|
||||||
Как гарантируется честность результатов?
|
Как гарантируется честность результатов?
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-slate-400 leading-relaxed">
|
<p className="text-xs text-slate-400 leading-relaxed">
|
||||||
Перед жеребьевкой список участников сортируется и хешируется по стандарту SHA-256.
|
Перед жеребьевкой список участников фиксируется в неизменяемый слепок и хешируется по стандарту SHA-256.
|
||||||
Победитель определяется алгоритмом HMAC-SHA256 на основе фиксированного seed. Любой зритель может воспроизвести результат и проверить неизменность выборки.
|
Победитель определяется алгоритмом HMAC_SHA256_FY_V1 с rejection sampling. Любой зритель может воспроизвести результат и проверить неизменность выборки.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 { FilteredParticipant, Winner } from '../types/participant';
|
||||||
import { DrawExecutionParams, DrawExecutionResult } from '../types/audit';
|
import { DrawExecutionParams, DrawExecutionResult, CURRENT_RANDOMIZER_ALGORITHM, ParticipantSnapshotData } from '../types/audit';
|
||||||
import { computeParticipantsSnapshotHash } from './hasher';
|
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.
|
* Generates an individual audit proof hash for a winner position
|
||||||
* Given a seed, snapshot hash, and step/index, generates a deterministic 32-bit unsigned integer.
|
|
||||||
*/
|
*/
|
||||||
export function getDeterministicUint32(seed: string, snapshotHash: string, step: number): number {
|
export function generateWinnerProofHash(
|
||||||
const hmac = createHmac('sha256', seed);
|
seed: string,
|
||||||
hmac.update(`${snapshotHash}:step:${step}`);
|
snapshotHash: string,
|
||||||
const hashBuffer = hmac.digest();
|
position: number,
|
||||||
// Read first 4 bytes as unsigned 32-bit big endian integer
|
participantId: string
|
||||||
return hashBuffer.readUInt32BE(0);
|
): string {
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates an audit proof hash for a specific winner step
|
|
||||||
*/
|
|
||||||
export function generateWinnerProofHash(seed: string, snapshotHash: string, position: number, participantId: string): string {
|
|
||||||
return createHash('sha256')
|
return createHash('sha256')
|
||||||
.update(`${seed}:${snapshotHash}:pos:${position}:id:${participantId}`)
|
.update(`${seed}:${snapshotHash}:pos:${position}:id:${participantId}`)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a deterministic draw on a set of eligible participants.
|
* Executes deterministic Fisher-Yates selection V1 (HMAC_SHA256_FY_V1)
|
||||||
* Guarantees that the same eligible participants + same seed ALWAYS produce the exact same winners.
|
* with unbiased rejection sampling.
|
||||||
*/
|
*/
|
||||||
export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecutionResult {
|
export function executeDeterministicDrawV1(params: DrawExecutionParams): DrawExecutionResult {
|
||||||
const {
|
const {
|
||||||
giveawayId,
|
giveawayId,
|
||||||
eligibleParticipants,
|
snapshot,
|
||||||
totalLoadedCount,
|
totalLoadedCount,
|
||||||
winnersCount,
|
winnersCount,
|
||||||
reserveWinnersCount,
|
reserveWinnersCount,
|
||||||
seed,
|
seed,
|
||||||
filterRules,
|
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
if (eligibleParticipants.length === 0) {
|
const eligible = snapshot.eligibleParticipants;
|
||||||
throw new Error('Cannot conduct draw with 0 eligible participants');
|
|
||||||
|
if (!eligible || eligible.length === 0) {
|
||||||
|
throw new Error('Cannot conduct draw with 0 eligible participants in snapshot');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Canonical sort to guarantee stability
|
// 1. Canonical sort to ensure exact invariant input order
|
||||||
const sortedParticipants = [...eligibleParticipants].sort((a, b) =>
|
const pool = [...eligible].sort((a, b) =>
|
||||||
a.platformUserId.localeCompare(b.platformUserId)
|
a.platformUserId.localeCompare(b.platformUserId)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. Compute canonical snapshot hash
|
const snapshotHash = snapshot.participantsSnapshotHash;
|
||||||
const snapshotHash = computeParticipantsSnapshotHash(sortedParticipants);
|
const conditionsHash = snapshot.conditionsHash;
|
||||||
|
|
||||||
// 3. Clone pool for sampling without replacement
|
// 2. Initialize unbiased HMAC stream keyed by seed and snapshotHash
|
||||||
const pool = [...sortedParticipants];
|
const stream = new DeterministicHmacStream(seed, snapshotHash);
|
||||||
const winners: Winner[] = [];
|
|
||||||
const reserveWinners: Winner[] = [];
|
|
||||||
|
|
||||||
const totalNeeded = Math.min(winnersCount + reserveWinnersCount, pool.length);
|
const totalNeeded = Math.min(winnersCount + reserveWinnersCount, pool.length);
|
||||||
const actualWinnersCount = Math.min(winnersCount, totalNeeded);
|
const actualWinnersCount = Math.min(winnersCount, totalNeeded);
|
||||||
const actualReserveCount = Math.max(0, totalNeeded - actualWinnersCount);
|
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++) {
|
for (let i = 0; i < actualWinnersCount; i++) {
|
||||||
const randUint = getDeterministicUint32(seed, snapshotHash, step);
|
const selectedIndex = stream.sampleUnbiasedIndex(pool.length);
|
||||||
const selectedIndex = randUint % pool.length;
|
|
||||||
const selectedParticipant = pool.splice(selectedIndex, 1)[0];
|
const selectedParticipant = pool.splice(selectedIndex, 1)[0];
|
||||||
|
|
||||||
const proofHash = generateWinnerProofHash(seed, snapshotHash, i + 1, selectedParticipant.platformUserId);
|
const proofHash = generateWinnerProofHash(seed, snapshotHash, i + 1, selectedParticipant.platformUserId);
|
||||||
|
|
||||||
winners.push({
|
winners.push({
|
||||||
|
|
@ -79,71 +73,88 @@ export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecu
|
||||||
selectionIndex: selectedIndex,
|
selectionIndex: selectedIndex,
|
||||||
proofHash,
|
proofHash,
|
||||||
});
|
});
|
||||||
|
winnerIds.push(selectedParticipant.platformUserId);
|
||||||
step++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Select Reserve Winners
|
// 4. Select Reserve Winners
|
||||||
for (let i = 0; i < actualReserveCount; i++) {
|
for (let i = 0; i < actualReserveCount; i++) {
|
||||||
const randUint = getDeterministicUint32(seed, snapshotHash, step);
|
const pos = winners.length + i + 1;
|
||||||
const selectedIndex = randUint % pool.length;
|
const selectedIndex = stream.sampleUnbiasedIndex(pool.length);
|
||||||
const selectedParticipant = pool.splice(selectedIndex, 1)[0];
|
const selectedParticipant = pool.splice(selectedIndex, 1)[0];
|
||||||
|
const proofHash = generateWinnerProofHash(seed, snapshotHash, pos, selectedParticipant.platformUserId);
|
||||||
const proofHash = generateWinnerProofHash(seed, snapshotHash, winners.length + i + 1, selectedParticipant.platformUserId);
|
|
||||||
|
|
||||||
reserveWinners.push({
|
reserveWinners.push({
|
||||||
position: winners.length + i + 1,
|
position: pos,
|
||||||
isReserve: true,
|
isReserve: true,
|
||||||
participant: selectedParticipant,
|
participant: selectedParticipant,
|
||||||
selectionIndex: selectedIndex,
|
selectionIndex: selectedIndex,
|
||||||
proofHash,
|
proofHash,
|
||||||
});
|
});
|
||||||
|
reserveWinnerIds.push(selectedParticipant.platformUserId);
|
||||||
step++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const drawId = 'draw_' + randomBytes(8).toString('hex');
|
||||||
const drawnAt = new Date().toISOString();
|
const drawnAt = new Date().toISOString();
|
||||||
|
|
||||||
// 6. Compute overall verification signature
|
// 5. Compute canonical auditHash
|
||||||
const verificationSignature = createHash('sha256')
|
const auditHash = computeAuditHash({
|
||||||
.update(JSON.stringify({
|
algorithmVersion: ALGORITHM_VERSION_V1,
|
||||||
giveawayId,
|
giveawayId,
|
||||||
snapshotHash,
|
snapshotId: snapshot.id,
|
||||||
seed,
|
seed,
|
||||||
algorithm: ALGORITHM_NAME,
|
participantsSnapshotHash: snapshotHash,
|
||||||
winnerIds: winners.map(w => w.participant.platformUserId),
|
conditionsHash,
|
||||||
reserveIds: reserveWinners.map(w => w.participant.platformUserId),
|
winnerIds,
|
||||||
}))
|
reserveWinnerIds,
|
||||||
.digest('hex');
|
eligibleCount: eligible.length,
|
||||||
|
drawId,
|
||||||
|
drawnAt,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
drawId,
|
||||||
giveawayId,
|
giveawayId,
|
||||||
|
snapshotId: snapshot.id,
|
||||||
winners,
|
winners,
|
||||||
reserveWinners,
|
reserveWinners,
|
||||||
totalEligibleCount: eligibleParticipants.length,
|
winnerIds,
|
||||||
|
reserveWinnerIds,
|
||||||
|
totalEligibleCount: eligible.length,
|
||||||
totalLoadedCount,
|
totalLoadedCount,
|
||||||
seedUsed: seed,
|
seedUsed: seed,
|
||||||
participantsSnapshotHash: snapshotHash,
|
participantsSnapshotHash: snapshotHash,
|
||||||
algorithm: ALGORITHM_NAME,
|
conditionsHash,
|
||||||
|
algorithmVersion: ALGORITHM_VERSION_V1,
|
||||||
drawnAt,
|
drawnAt,
|
||||||
verificationSignature,
|
auditHash,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-runs the draw algorithm with the given snapshot of participants and seed
|
* Universal entrypoint (routes to active algorithm version V1)
|
||||||
* to verify if the produced outcome matches the claimed outcome.
|
*/
|
||||||
|
export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecutionResult {
|
||||||
|
return executeDeterministicDrawV1(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-runs draw algorithm on a snapshot to verify identical outcome
|
||||||
*/
|
*/
|
||||||
export function verifyDrawResult(
|
export function verifyDrawResult(
|
||||||
eligibleParticipants: FilteredParticipant[],
|
snapshot: ParticipantSnapshotData,
|
||||||
seed: string,
|
seed: string,
|
||||||
claimedWinnersCount: number,
|
claimedWinnersCount: number,
|
||||||
claimedReserveCount: number
|
claimedReserveCount: number,
|
||||||
): { winners: Winner[]; reserveWinners: Winner[]; snapshotHash: string } {
|
algorithmVersion: string = ALGORITHM_VERSION_V1
|
||||||
const result = executeDeterministicDraw({
|
): { winners: Winner[]; reserveWinners: Winner[]; winnerIds: string[]; reserveWinnerIds: string[]; auditHash: string } {
|
||||||
giveawayId: 'verification',
|
if (algorithmVersion !== ALGORITHM_VERSION_V1) {
|
||||||
eligibleParticipants,
|
throw new Error(`Unsupported algorithm version for replay: ${algorithmVersion}`);
|
||||||
totalLoadedCount: eligibleParticipants.length,
|
}
|
||||||
|
|
||||||
|
const result = executeDeterministicDrawV1({
|
||||||
|
giveawayId: snapshot.giveawayId,
|
||||||
|
snapshot,
|
||||||
|
totalLoadedCount: snapshot.participantCount,
|
||||||
winnersCount: claimedWinnersCount,
|
winnersCount: claimedWinnersCount,
|
||||||
reserveWinnersCount: claimedReserveCount,
|
reserveWinnersCount: claimedReserveCount,
|
||||||
seed,
|
seed,
|
||||||
|
|
@ -153,6 +164,8 @@ export function verifyDrawResult(
|
||||||
return {
|
return {
|
||||||
winners: result.winners,
|
winners: result.winners,
|
||||||
reserveWinners: result.reserveWinners,
|
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 { 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.
|
* Generates a cryptographically secure random seed (128-bit / 32 hex chars) using CSPRNG.
|
||||||
* Participants are sorted canonically by platformUserId to guarantee identical hash
|
* Math.random() is strictly forbidden in security-sensitive giveaway workflows.
|
||||||
* regardless of initial retrieval order.
|
|
||||||
*/
|
*/
|
||||||
export function computeParticipantsSnapshotHash(participants: FilteredParticipant[]): string {
|
export function generateCryptoSecureSeed(): string {
|
||||||
// Canonical sort by platformUserId
|
return randomBytes(16).toString('hex');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { FilterRules } from './giveaway';
|
||||||
import { FilteredParticipant, Winner } from './participant';
|
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 {
|
export interface DrawExecutionParams {
|
||||||
giveawayId: string;
|
giveawayId: string;
|
||||||
eligibleParticipants: FilteredParticipant[];
|
snapshot: ParticipantSnapshotData;
|
||||||
totalLoadedCount: number;
|
totalLoadedCount: number;
|
||||||
winnersCount: number;
|
winnersCount: number;
|
||||||
reserveWinnersCount: number;
|
reserveWinnersCount: number;
|
||||||
|
|
@ -12,24 +25,36 @@ export interface DrawExecutionParams {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DrawExecutionResult {
|
export interface DrawExecutionResult {
|
||||||
|
drawId: string;
|
||||||
giveawayId: string;
|
giveawayId: string;
|
||||||
|
snapshotId: string;
|
||||||
winners: Winner[];
|
winners: Winner[];
|
||||||
reserveWinners: Winner[];
|
reserveWinners: Winner[];
|
||||||
|
winnerIds: string[];
|
||||||
|
reserveWinnerIds: string[];
|
||||||
totalEligibleCount: number;
|
totalEligibleCount: number;
|
||||||
totalLoadedCount: number;
|
totalLoadedCount: number;
|
||||||
seedUsed: string;
|
seedUsed: string;
|
||||||
participantsSnapshotHash: string;
|
participantsSnapshotHash: string;
|
||||||
algorithm: string;
|
conditionsHash: string;
|
||||||
|
algorithmVersion: string;
|
||||||
drawnAt: string; // ISO String
|
drawnAt: string; // ISO String
|
||||||
verificationSignature: string;
|
auditHash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditVerificationData {
|
export interface AuditRecordData {
|
||||||
participantsSnapshotHash: string;
|
id: string;
|
||||||
|
giveawayId: string;
|
||||||
|
snapshotId: string;
|
||||||
|
algorithmVersion: string;
|
||||||
seed: string;
|
seed: string;
|
||||||
algorithm: string;
|
participantsSnapshotHash: string;
|
||||||
filterRulesSnapshot: FilterRules;
|
conditionsHash: string;
|
||||||
winners: Winner[];
|
auditHash: string;
|
||||||
reserveWinners: Winner[];
|
winnerIds: string[];
|
||||||
|
reserveWinnerIds: string[];
|
||||||
|
eligibleCount: number;
|
||||||
|
drawId: string;
|
||||||
drawnAt: string;
|
drawnAt: string;
|
||||||
|
verifiedAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
export type PlatformType = 'VK' | 'TELEGRAM' | 'YOUTUBE';
|
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';
|
export type ParticipantSourceType = 'LIKES' | 'COMMENTS' | 'REPOSTS' | 'COMBINED';
|
||||||
|
|
||||||
|
|
@ -21,7 +28,7 @@ export const DEFAULT_FILTER_RULES: FilterRules = {
|
||||||
requireComment: false,
|
requireComment: false,
|
||||||
requireRepost: false,
|
requireRepost: false,
|
||||||
requireSubscription: false,
|
requireSubscription: false,
|
||||||
excludeAdmins: true,
|
excludeAdmins: false,
|
||||||
excludeBlacklistedIds: [],
|
excludeBlacklistedIds: [],
|
||||||
excludeDuplicateComments: true,
|
excludeDuplicateComments: true,
|
||||||
minEligibleParticipants: 1,
|
minEligibleParticipants: 1,
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,80 @@
|
||||||
import { FilterRules, GiveawayStatusType, PlatformType, PostMetadata } from '../core/types/giveaway';
|
import { IGiveawayRepository, GiveawayWithRelations, CreateGiveawayInput } from './repository/giveaway-repository';
|
||||||
import { FilteredParticipant, RawParticipant, Winner } from '../core/types/participant';
|
import { PrismaGiveawayRepository } from './repository/prisma-repository';
|
||||||
import { DrawExecutionResult } from '../core/types/audit';
|
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 {
|
export type StoredGiveaway = GiveawayWithRelations;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-memory runtime cache/store for fast UI state & standalone dev mode
|
let activeRepository: IGiveawayRepository = new PrismaGiveawayRepository();
|
||||||
const memoryStore = new Map<string, StoredGiveaway>();
|
|
||||||
|
|
||||||
export class GiveawayStore {
|
export class GiveawayStore {
|
||||||
static async create(data: {
|
/**
|
||||||
sourceUrl: string;
|
* Set custom repository (e.g. MemoryGiveawayRepository in tests)
|
||||||
post: PostMetadata;
|
*/
|
||||||
filterRules: FilterRules;
|
static setRepository(repo: IGiveawayRepository): void {
|
||||||
winnersCount?: number;
|
activeRepository = repo;
|
||||||
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();
|
|
||||||
|
|
||||||
const giveaway: StoredGiveaway = {
|
static getRepository(): IGiveawayRepository {
|
||||||
id,
|
return activeRepository;
|
||||||
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: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
memoryStore.set(id, giveaway);
|
static async create(input: CreateGiveawayInput): Promise<StoredGiveaway> {
|
||||||
return giveaway;
|
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> {
|
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[]> {
|
static async listAll(): Promise<StoredGiveaway[]> {
|
||||||
return Array.from(memoryStore.values()).sort(
|
try {
|
||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
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> {
|
static async updateParticipants(id: string, participants: FilteredParticipant[]): Promise<StoredGiveaway> {
|
||||||
const gw = memoryStore.get(id);
|
return await activeRepository.saveParticipants(id, participants);
|
||||||
if (!gw) throw new Error('Giveaway not found');
|
|
||||||
|
|
||||||
gw.participants = participants;
|
|
||||||
gw.updatedAt = new Date().toISOString();
|
|
||||||
memoryStore.set(id, gw);
|
|
||||||
return gw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async saveDrawResult(id: string, result: DrawExecutionResult): Promise<StoredGiveaway> {
|
static async createAndLockSnapshot(
|
||||||
const gw = memoryStore.get(id);
|
id: string,
|
||||||
if (!gw) throw new Error('Giveaway not found');
|
eligibleParticipants: FilteredParticipant[],
|
||||||
|
rules: FilterRules
|
||||||
|
): Promise<ParticipantSnapshotData> {
|
||||||
|
return await activeRepository.createAndLockSnapshot(id, eligibleParticipants, rules);
|
||||||
|
}
|
||||||
|
|
||||||
gw.drawResult = result;
|
static async getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null> {
|
||||||
gw.status = 'COMPLETED';
|
return await activeRepository.getLatestSnapshot(giveawayId);
|
||||||
gw.drawnAt = result.drawnAt;
|
}
|
||||||
gw.seed = result.seedUsed;
|
|
||||||
gw.updatedAt = new Date().toISOString();
|
static async saveDrawResult(id: string, snapshotId: string, result: DrawExecutionResult): Promise<StoredGiveaway> {
|
||||||
memoryStore.set(id, gw);
|
return await activeRepository.saveDrawResultAndAudit(id, snapshotId, result);
|
||||||
return gw;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { PlatformType, PostMetadata } from '../core/types/giveaway';
|
||||||
import { RawParticipant } from '../core/types/participant';
|
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 {
|
export interface FetchParticipantsParams {
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
postId: string;
|
postId: string;
|
||||||
|
|
@ -9,12 +19,12 @@ export interface FetchParticipantsParams {
|
||||||
includeLikes?: boolean;
|
includeLikes?: boolean;
|
||||||
includeComments?: boolean;
|
includeComments?: boolean;
|
||||||
includeReposts?: boolean;
|
includeReposts?: boolean;
|
||||||
checkSubscription?: boolean;
|
|
||||||
onProgress?: (loaded: number, total: number, message: string) => void;
|
onProgress?: (loaded: number, total: number, message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SocialMediaProvider {
|
export interface SocialMediaProvider {
|
||||||
readonly platform: PlatformType;
|
readonly platform: PlatformType;
|
||||||
|
readonly capabilities: ProviderCapabilities;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a raw URL from the user into ownerId and postId
|
* Parse a raw URL from the user into ownerId and postId
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
import { PlatformType, PostMetadata } from '../../core/types/giveaway';
|
import { PlatformType, PostMetadata } from '../../core/types/giveaway';
|
||||||
import { RawParticipant } from '../../core/types/participant';
|
import { RawParticipant } from '../../core/types/participant';
|
||||||
import { FetchParticipantsParams, SocialMediaProvider } from '../types';
|
import { FetchParticipantsParams, ProviderCapabilities, SocialMediaProvider } from '../types';
|
||||||
import { parseVkPostUrl } from './vk-parser';
|
import { parseVkPostUrl } from './vk-parser';
|
||||||
|
|
||||||
export class VkMockProvider implements SocialMediaProvider {
|
export class VkMockProvider implements SocialMediaProvider {
|
||||||
readonly platform: PlatformType = 'VK';
|
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 {
|
parsePostUrl(url: string): { ownerId: string; postId: string } | null {
|
||||||
return parseVkPostUrl(url);
|
return parseVkPostUrl(url);
|
||||||
|
|
@ -15,8 +24,7 @@ export class VkMockProvider implements SocialMediaProvider {
|
||||||
const ownerId = parsed ? parsed.ownerId : '-22446688';
|
const ownerId = parsed ? parsed.ownerId : '-22446688';
|
||||||
const postId = parsed ? parsed.postId : '1054';
|
const postId = parsed ? parsed.postId : '1054';
|
||||||
|
|
||||||
// Simulate short network latency
|
await new Promise(r => setTimeout(r, 200));
|
||||||
await new Promise(r => setTimeout(r, 400));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
platform: 'VK',
|
platform: 'VK',
|
||||||
|
|
@ -36,15 +44,6 @@ export class VkMockProvider implements SocialMediaProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchParticipants(params: FetchParticipantsParams): Promise<RawParticipant[]> {
|
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 = [
|
const mockNames = [
|
||||||
{ first: 'Алексей', last: 'Смирнов', user: 'smirnov_alex' },
|
{ first: 'Алексей', last: 'Смирнов', user: 'smirnov_alex' },
|
||||||
{ first: 'Екатерина', last: 'Иванова', user: 'katya_iva' },
|
{ first: 'Екатерина', last: 'Иванова', user: 'katya_iva' },
|
||||||
|
|
@ -75,18 +74,16 @@ export class VkMockProvider implements SocialMediaProvider {
|
||||||
|
|
||||||
const participants: RawParticipant[] = [];
|
const participants: RawParticipant[] = [];
|
||||||
|
|
||||||
// Generate 35 mock participants with varied attributes
|
|
||||||
for (let i = 1; i <= 35; i++) {
|
for (let i = 1; i <= 35; i++) {
|
||||||
const nameObj = mockNames[(i - 1) % mockNames.length];
|
const nameObj = mockNames[(i - 1) % mockNames.length];
|
||||||
const userId = `${1000000 + i * 137}`;
|
const userId = `${1000000 + i * 137}`;
|
||||||
|
|
||||||
// Determine varied conditions for realistic testing
|
const liked = i !== 7 && i !== 19;
|
||||||
const liked = i !== 7 && i !== 19; // 7 and 19 didn't like
|
const commented = i % 2 === 0 || i % 3 === 0;
|
||||||
const commented = i % 2 === 0 || i % 3 === 0; // some commented
|
const commentsCount = commented ? (i % 5 === 0 ? 3 : 1) : 0;
|
||||||
const commentsCount = commented ? (i % 5 === 0 ? 3 : 1) : 0; // some wrote duplicate comments
|
const reposted = false; // explicitly false as per capabilities
|
||||||
const reposted = i % 3 === 0; // some reposted
|
const subscribed = false; // will be resolved via checkSubscription
|
||||||
const subscribed = i !== 13 && i !== 27; // 13 and 27 not subscribed
|
const isAdmin = false;
|
||||||
const isAdmin = i === 1; // Participant 1 is admin
|
|
||||||
|
|
||||||
participants.push({
|
participants.push({
|
||||||
platformUserId: userId,
|
platformUserId: userId,
|
||||||
|
|
@ -110,7 +107,9 @@ export class VkMockProvider implements SocialMediaProvider {
|
||||||
async checkSubscription(userIds: string[], groupId: string): Promise<Map<string, boolean>> {
|
async checkSubscription(userIds: string[], groupId: string): Promise<Map<string, boolean>> {
|
||||||
const result = new Map<string, boolean>();
|
const result = new Map<string, boolean>();
|
||||||
for (const id of userIds) {
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { PlatformType, PostMetadata } from '../../core/types/giveaway';
|
import { PlatformType, PostMetadata } from '../../core/types/giveaway';
|
||||||
import { RawParticipant } from '../../core/types/participant';
|
import { RawParticipant } from '../../core/types/participant';
|
||||||
import { FetchParticipantsParams, SocialMediaProvider } from '../types';
|
import { FetchParticipantsParams, ProviderCapabilities, SocialMediaProvider } from '../types';
|
||||||
import { parseVkPostUrl } from './vk-parser';
|
import { parseVkPostUrl } from './vk-parser';
|
||||||
|
|
||||||
interface VkApiResponse<T> {
|
interface VkApiResponse<T> {
|
||||||
|
|
@ -13,6 +13,16 @@ interface VkApiResponse<T> {
|
||||||
|
|
||||||
export class VkProvider implements SocialMediaProvider {
|
export class VkProvider implements SocialMediaProvider {
|
||||||
readonly platform: PlatformType = 'VK';
|
readonly platform: PlatformType = 'VK';
|
||||||
|
readonly capabilities: ProviderCapabilities = {
|
||||||
|
likes: true,
|
||||||
|
comments: true,
|
||||||
|
reposts: false,
|
||||||
|
repostsNote: 'Сбор репостов ограничен политикой приватности VK для закрытых профилей',
|
||||||
|
subscriptions: true,
|
||||||
|
adminDetection: false,
|
||||||
|
adminDetectionNote: 'Требует расширенных прав администратора группы',
|
||||||
|
};
|
||||||
|
|
||||||
private serviceToken?: string;
|
private serviceToken?: string;
|
||||||
private apiVersion = '5.199';
|
private apiVersion = '5.199';
|
||||||
private baseUrl = 'https://api.vk.com/method';
|
private baseUrl = 'https://api.vk.com/method';
|
||||||
|
|
@ -80,7 +90,6 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
|
|
||||||
const post = response.items[0];
|
const post = response.items[0];
|
||||||
|
|
||||||
// Find author name / avatar
|
|
||||||
let authorName = `VK Wall ${ownerId}`;
|
let authorName = `VK Wall ${ownerId}`;
|
||||||
let authorAvatarUrl = undefined;
|
let authorAvatarUrl = undefined;
|
||||||
|
|
||||||
|
|
@ -100,7 +109,6 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract first image attachment if available
|
|
||||||
let imageUrl = undefined;
|
let imageUrl = undefined;
|
||||||
if (post.attachments && post.attachments.length > 0) {
|
if (post.attachments && post.attachments.length > 0) {
|
||||||
const photoAttachment = post.attachments.find((a: any) => a.type === 'photo');
|
const photoAttachment = post.attachments.find((a: any) => a.type === 'photo');
|
||||||
|
|
@ -171,7 +179,7 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
if (params.onProgress) {
|
if (params.onProgress) {
|
||||||
params.onProgress(participantsMap.size, totalLikes, 'Загрузка лайков...');
|
params.onProgress(participantsMap.size, totalLikes, 'Загрузка лайков...');
|
||||||
}
|
}
|
||||||
} while (offset < totalLikes && offset < 5000); // capped for phase 1 protection
|
} while (offset < totalLikes && offset < 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch Comments
|
// 2. Fetch Comments
|
||||||
|
|
@ -236,7 +244,6 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
const cleanGroupId = groupId.replace(/^-/, '');
|
const cleanGroupId = groupId.replace(/^-/, '');
|
||||||
const resultMap = new Map<string, boolean>();
|
const resultMap = new Map<string, boolean>();
|
||||||
|
|
||||||
// Batch in chunks of 500 as supported by groups.isMember
|
|
||||||
const chunkSize = 500;
|
const chunkSize = 500;
|
||||||
for (let i = 0; i < userIds.length; i += chunkSize) {
|
for (let i = 0; i < userIds.length; i += chunkSize) {
|
||||||
const chunk = userIds.slice(i, 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 { describe, it, expect } from 'vitest';
|
||||||
import { executeDeterministicDraw, verifyDrawResult } from '../src/core/randomizer/deterministic';
|
import { executeDeterministicDrawV1, verifyDrawResult, ALGORITHM_VERSION_V1 } from '../src/core/randomizer/deterministic';
|
||||||
import { computeParticipantsSnapshotHash, generateRandomSeed } from '../src/core/randomizer/hasher';
|
import { generateCryptoSecureSeed } from '../src/core/randomizer/hasher';
|
||||||
|
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../src/core/randomizer/canonical';
|
||||||
import { FilteredParticipant } from '../src/core/types/participant';
|
import { FilteredParticipant } from '../src/core/types/participant';
|
||||||
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
||||||
|
import { ParticipantSnapshotData } from '../src/core/types/audit';
|
||||||
|
|
||||||
function createMockEligibleParticipants(count: number): FilteredParticipant[] {
|
function createMockSnapshot(count: number): ParticipantSnapshotData {
|
||||||
return Array.from({ length: count }, (_, i) => ({
|
const eligible: FilteredParticipant[] = Array.from({ length: count }, (_, i) => ({
|
||||||
platformUserId: `${1000 + i}`,
|
platformUserId: `${1000 + i}`,
|
||||||
firstName: `User${i + 1}`,
|
firstName: `User${i + 1}`,
|
||||||
lastName: `Surname${i + 1}`,
|
lastName: `Surname${i + 1}`,
|
||||||
|
|
@ -15,21 +17,42 @@ function createMockEligibleParticipants(count: number): FilteredParticipant[] {
|
||||||
liked: true,
|
liked: true,
|
||||||
commented: true,
|
commented: true,
|
||||||
commentsCount: 1,
|
commentsCount: 1,
|
||||||
reposted: true,
|
reposted: false,
|
||||||
subscribed: true,
|
subscribed: true,
|
||||||
eligible: true,
|
eligible: true,
|
||||||
exclusionReason: null,
|
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', () => {
|
describe('Deterministic Randomizer V1 (HMAC_SHA256_FY_V1)', () => {
|
||||||
it('should generate identical winners given the same participants snapshot and seed', () => {
|
it('should use CSPRNG crypto.randomBytes for seed generation (no Math.random)', () => {
|
||||||
const participants = createMockEligibleParticipants(50);
|
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 seed = 'test-secret-seed-2026';
|
||||||
|
|
||||||
const draw1 = executeDeterministicDraw({
|
const draw1 = executeDeterministicDrawV1({
|
||||||
giveawayId: 'gw-1',
|
giveawayId: 'gw-1',
|
||||||
eligibleParticipants: participants,
|
snapshot,
|
||||||
totalLoadedCount: 50,
|
totalLoadedCount: 50,
|
||||||
winnersCount: 3,
|
winnersCount: 3,
|
||||||
reserveWinnersCount: 2,
|
reserveWinnersCount: 2,
|
||||||
|
|
@ -37,9 +60,9 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
||||||
filterRules: DEFAULT_FILTER_RULES,
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
});
|
});
|
||||||
|
|
||||||
const draw2 = executeDeterministicDraw({
|
const draw2 = executeDeterministicDrawV1({
|
||||||
giveawayId: 'gw-1',
|
giveawayId: 'gw-1',
|
||||||
eligibleParticipants: participants,
|
snapshot,
|
||||||
totalLoadedCount: 50,
|
totalLoadedCount: 50,
|
||||||
winnersCount: 3,
|
winnersCount: 3,
|
||||||
reserveWinnersCount: 2,
|
reserveWinnersCount: 2,
|
||||||
|
|
@ -47,24 +70,26 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
||||||
filterRules: DEFAULT_FILTER_RULES,
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(draw1.algorithmVersion).toBe(ALGORITHM_VERSION_V1);
|
||||||
expect(draw1.participantsSnapshotHash).toBe(draw2.participantsSnapshotHash);
|
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(
|
expect(draw1.winners.map(w => w.participant.platformUserId)).toEqual(
|
||||||
draw2.winners.map(w => w.participant.platformUserId)
|
draw2.winners.map(w => w.participant.platformUserId)
|
||||||
);
|
);
|
||||||
expect(draw1.reserveWinners.map(w => w.participant.platformUserId)).toEqual(
|
expect(draw1.reserveWinners.map(w => w.participant.platformUserId)).toEqual(
|
||||||
draw2.reserveWinners.map(w => w.participant.platformUserId)
|
draw2.reserveWinners.map(w => w.participant.platformUserId)
|
||||||
);
|
);
|
||||||
expect(draw1.verificationSignature).toBe(draw2.verificationSignature);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should produce different winners when seed changes', () => {
|
it('should produce different winners when seed changes', () => {
|
||||||
const participants = createMockEligibleParticipants(100);
|
const snapshot = createMockSnapshot(100);
|
||||||
const seedA = 'seed-alpha-123';
|
const seedA = 'seed-alpha-123';
|
||||||
const seedB = 'seed-beta-456';
|
const seedB = 'seed-beta-456';
|
||||||
|
|
||||||
const drawA = executeDeterministicDraw({
|
const drawA = executeDeterministicDrawV1({
|
||||||
giveawayId: 'gw-a',
|
giveawayId: 'gw-a',
|
||||||
eligibleParticipants: participants,
|
snapshot,
|
||||||
totalLoadedCount: 100,
|
totalLoadedCount: 100,
|
||||||
winnersCount: 5,
|
winnersCount: 5,
|
||||||
reserveWinnersCount: 2,
|
reserveWinnersCount: 2,
|
||||||
|
|
@ -72,9 +97,9 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
||||||
filterRules: DEFAULT_FILTER_RULES,
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
});
|
});
|
||||||
|
|
||||||
const drawB = executeDeterministicDraw({
|
const drawB = executeDeterministicDrawV1({
|
||||||
giveawayId: 'gw-b',
|
giveawayId: 'gw-b',
|
||||||
eligibleParticipants: participants,
|
snapshot,
|
||||||
totalLoadedCount: 100,
|
totalLoadedCount: 100,
|
||||||
winnersCount: 5,
|
winnersCount: 5,
|
||||||
reserveWinnersCount: 2,
|
reserveWinnersCount: 2,
|
||||||
|
|
@ -82,19 +107,16 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
||||||
filterRules: DEFAULT_FILTER_RULES,
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
});
|
});
|
||||||
|
|
||||||
const winnersA = drawA.winners.map(w => w.participant.platformUserId);
|
expect(drawA.winnerIds).not.toEqual(drawB.winnerIds);
|
||||||
const winnersB = drawB.winners.map(w => w.participant.platformUserId);
|
|
||||||
|
|
||||||
expect(winnersA).not.toEqual(winnersB);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should guarantee no duplicates between winners and reserve winners', () => {
|
it('should guarantee no duplicates between winners and reserve winners', () => {
|
||||||
const participants = createMockEligibleParticipants(30);
|
const snapshot = createMockSnapshot(30);
|
||||||
const seed = generateRandomSeed();
|
const seed = generateCryptoSecureSeed();
|
||||||
|
|
||||||
const draw = executeDeterministicDraw({
|
const draw = executeDeterministicDrawV1({
|
||||||
giveawayId: 'gw-uniq',
|
giveawayId: 'gw-uniq',
|
||||||
eligibleParticipants: participants,
|
snapshot,
|
||||||
totalLoadedCount: 30,
|
totalLoadedCount: 30,
|
||||||
winnersCount: 5,
|
winnersCount: 5,
|
||||||
reserveWinnersCount: 5,
|
reserveWinnersCount: 5,
|
||||||
|
|
@ -102,22 +124,18 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
||||||
filterRules: DEFAULT_FILTER_RULES,
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
});
|
});
|
||||||
|
|
||||||
const allChosenIds = [
|
const allChosenIds = [...draw.winnerIds, ...draw.reserveWinnerIds];
|
||||||
...draw.winners.map(w => w.participant.platformUserId),
|
|
||||||
...draw.reserveWinners.map(w => w.participant.platformUserId),
|
|
||||||
];
|
|
||||||
|
|
||||||
const uniqueIds = new Set(allChosenIds);
|
const uniqueIds = new Set(allChosenIds);
|
||||||
expect(uniqueIds.size).toBe(10);
|
expect(uniqueIds.size).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle cases where pool size is smaller than requested winners', () => {
|
it('should handle small pool sizes gracefully', () => {
|
||||||
const participants = createMockEligibleParticipants(2);
|
const snapshot = createMockSnapshot(2);
|
||||||
const seed = 'small-pool-seed';
|
const seed = 'small-pool-seed';
|
||||||
|
|
||||||
const draw = executeDeterministicDraw({
|
const draw = executeDeterministicDrawV1({
|
||||||
giveawayId: 'gw-small',
|
giveawayId: 'gw-small',
|
||||||
eligibleParticipants: participants,
|
snapshot,
|
||||||
totalLoadedCount: 2,
|
totalLoadedCount: 2,
|
||||||
winnersCount: 5,
|
winnersCount: 5,
|
||||||
reserveWinnersCount: 3,
|
reserveWinnersCount: 3,
|
||||||
|
|
@ -129,59 +147,13 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
||||||
expect(draw.reserveWinners.length).toBe(0);
|
expect(draw.reserveWinners.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw when attempting draw with 0 eligible participants', () => {
|
it('should allow third-party audit replay verification via verifyDrawResult', () => {
|
||||||
expect(() => {
|
const snapshot = createMockSnapshot(25);
|
||||||
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);
|
|
||||||
const seed = 'audit-verification-seed';
|
const seed = 'audit-verification-seed';
|
||||||
|
|
||||||
const originalDraw = executeDeterministicDraw({
|
const originalDraw = executeDeterministicDrawV1({
|
||||||
giveawayId: 'gw-audit',
|
giveawayId: 'gw-audit',
|
||||||
eligibleParticipants: participants,
|
snapshot,
|
||||||
totalLoadedCount: 25,
|
totalLoadedCount: 25,
|
||||||
winnersCount: 2,
|
winnersCount: 2,
|
||||||
reserveWinnersCount: 2,
|
reserveWinnersCount: 2,
|
||||||
|
|
@ -189,14 +161,12 @@ describe('Deterministic Randomizer & Provably Fair Engine', () => {
|
||||||
filterRules: DEFAULT_FILTER_RULES,
|
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(
|
expect(verification.winners.map(w => w.participant.platformUserId)).toEqual(
|
||||||
originalDraw.winners.map(w => w.participant.platformUserId)
|
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