feat(core): Phase 1.4 Production Hardening - double draw atomic protection, snapshot concurrency safety, Zod API validation, payload scalability & pagination, provider safety, normalized errors, idempotency, rate limiting, and 20 concurrent draw integration tests
This commit is contained in:
parent
46bf8aad6e
commit
50973b4f85
26 changed files with 1445 additions and 551 deletions
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -14,7 +14,8 @@
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"tailwind-merge": "^2.5.4"
|
"tailwind-merge": "^2.5.4",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.16.11",
|
"@types/node": "^20.16.11",
|
||||||
|
|
@ -7803,6 +7804,15 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"tailwind-merge": "^2.5.4"
|
"tailwind-merge": "^2.5.4",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.16.11",
|
"@types/node": "^20.16.11",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
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 { executeDeterministicDrawV1 } from '@/core/randomizer/deterministic';
|
|
||||||
import { generateCryptoSecureSeed } from '@/core/randomizer/hasher';
|
|
||||||
import { GiveawayFSM } from '@/core/fsm/giveaway-fsm';
|
import { GiveawayFSM } from '@/core/fsm/giveaway-fsm';
|
||||||
|
import { generateCryptoSecureSeed } from '@/core/randomizer/hasher';
|
||||||
|
import { executeDeterministicDrawV1 } from '@/core/randomizer/deterministic';
|
||||||
|
import { executeDrawSchema } from '@/core/validation/giveaway-schemas';
|
||||||
|
import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http-errors';
|
||||||
|
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|
@ -10,42 +13,35 @@ export async function POST(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const body = await req.json().catch(() => ({}));
|
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
||||||
|
expensiveApiRateLimiter.assertAllowed(`draw-execute:${ip}:${id}`);
|
||||||
|
|
||||||
const giveaway = await GiveawayStore.getById(id);
|
const giveaway = await GiveawayStore.getById(id);
|
||||||
|
|
||||||
if (!giveaway) {
|
if (!giveaway) {
|
||||||
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
|
throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Guard check with FSM
|
// 1. Strict FSM Guard: Draw is permitted ONLY in SNAPSHOT_LOCKED status
|
||||||
if (giveaway.status === 'DRAWN') {
|
GiveawayFSM.assertCanDraw(giveaway.status);
|
||||||
return NextResponse.json({
|
|
||||||
error: 'Розыгрыш уже проведен. Повторный запуск строго запрещен.',
|
|
||||||
}, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fetch locked snapshot (or lock current eligible if ready)
|
// 2. Strict Snapshot requirement: Never create a snapshot implicitly
|
||||||
let snapshot = await GiveawayStore.getLatestSnapshot(id);
|
const snapshot = giveaway.latestSnapshot;
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
const eligible = giveaway.participants.filter(p => p.eligible);
|
throw new ConflictError(
|
||||||
if (eligible.length === 0) {
|
'Cannot execute draw: no locked participant snapshot exists. Lock a snapshot before drawing.'
|
||||||
return NextResponse.json({
|
);
|
||||||
error: 'Нет допущенных участников для создания слепка и розыгрыша'
|
|
||||||
}, { status: 400 });
|
|
||||||
}
|
|
||||||
snapshot = await GiveawayStore.createAndLockSnapshot(id, eligible, giveaway.filterRules);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate status after snapshot lock
|
const rawBody = await req.json().catch(() => ({}));
|
||||||
GiveawayFSM.assertCanDraw('SNAPSHOT_LOCKED');
|
const validated = executeDrawSchema.parse(rawBody);
|
||||||
|
|
||||||
const winnersCount = body.winnersCount || giveaway.winnersCount || 1;
|
const winnersCount = validated.winnersCount;
|
||||||
const reserveWinnersCount = body.reserveWinnersCount ?? giveaway.reserveWinnersCount ?? 0;
|
const reserveWinnersCount = validated.reserveWinnersCount;
|
||||||
|
|
||||||
// Seed must be generated with CSPRNG if not provided
|
|
||||||
const seed = body.seed?.trim() || giveaway.seed || generateCryptoSecureSeed();
|
|
||||||
|
|
||||||
// 3. Execute Provably Fair Randomizer V1
|
// Use CSPRNG crypto.randomBytes seed if none provided (Math.random is strictly forbidden)
|
||||||
|
const seed = (validated.seed && validated.seed.trim()) || generateCryptoSecureSeed();
|
||||||
|
|
||||||
|
// 3. Execute Provably Fair Fisher-Yates Draw V1
|
||||||
const drawResult = executeDeterministicDrawV1({
|
const drawResult = executeDeterministicDrawV1({
|
||||||
giveawayId: id,
|
giveawayId: id,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
|
@ -56,15 +52,15 @@ export async function POST(
|
||||||
filterRules: giveaway.filterRules,
|
filterRules: giveaway.filterRules,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Persist DrawResult and AuditRecord atomically
|
// 4. Save DrawResult & AuditRecord in database with atomic status transition
|
||||||
const updatedGiveaway = await GiveawayStore.saveDrawResult(id, snapshot.id, drawResult);
|
const updatedGiveaway = await GiveawayStore.saveDrawResult(id, snapshot.id, drawResult);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
drawResult,
|
|
||||||
giveaway: updatedGiveaway,
|
giveaway: updatedGiveaway,
|
||||||
|
drawResult,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,37 @@
|
||||||
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 { ProviderFactory } from '@/providers/factory';
|
||||||
import { executeParticipantPipeline } from '@/core/pipeline/participant-enricher';
|
import { executeParticipantPipeline } from '@/core/pipeline/participant-enricher';
|
||||||
import { validateFilterRulesAgainstProviderCapabilities } from '@/core/filtering/rule-validation';
|
import { fetchParticipantsSchema, validateProviderCapabilities } from '@/core/validation/giveaway-schemas';
|
||||||
|
import { handleApiError, NotFoundError } from '@/core/errors/http-errors';
|
||||||
|
import { expensiveApiRateLimiter, generalApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
|
import { IdempotencyStore } from '@/lib/idempotency';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = params;
|
||||||
|
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
||||||
|
generalApiRateLimiter.assertAllowed(`participants-get:${ip}`);
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
|
||||||
|
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '50', 10)));
|
||||||
|
const tabParam = searchParams.get('tab') || 'all';
|
||||||
|
const tab = (tabParam === 'eligible' || tabParam === 'excluded') ? tabParam : 'all';
|
||||||
|
|
||||||
|
const result = await GiveawayStore.getParticipantsPaginated(id, page, pageSize, tab);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|
@ -10,54 +39,65 @@ export async function POST(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const body = await req.json().catch(() => ({}));
|
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
||||||
|
expensiveApiRateLimiter.assertAllowed(`participants-import:${ip}:${id}`);
|
||||||
|
|
||||||
|
const idempotencyKey = req.headers.get('idempotency-key');
|
||||||
|
if (idempotencyKey) {
|
||||||
|
const cached = IdempotencyStore.get(`import-part:${idempotencyKey}`);
|
||||||
|
if (cached) {
|
||||||
|
return NextResponse.json(cached.body, { status: cached.statusCode });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const giveaway = await GiveawayStore.getById(id);
|
const giveaway = await GiveawayStore.getById(id);
|
||||||
|
|
||||||
if (!giveaway) {
|
if (!giveaway) {
|
||||||
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
|
throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules = body.filterRules || giveaway.filterRules;
|
const rawBody = await req.json();
|
||||||
const provider = ProviderRegistry.getProvider(giveaway.platform);
|
const validated = fetchParticipantsSchema.parse(rawBody);
|
||||||
|
|
||||||
// Reject filter rules the selected provider cannot actually verify
|
const provider = ProviderFactory.getVkProvider();
|
||||||
const capabilityCheck = validateFilterRulesAgainstProviderCapabilities(rules, provider.capabilities);
|
validateProviderCapabilities(validated.filterRules, provider.capabilities);
|
||||||
if (!capabilityCheck.valid) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Unsupported filter rules', details: capabilityCheck.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Fetch raw participants
|
// Fetch raw participants from social provider
|
||||||
const rawParticipants = await provider.fetchParticipants({
|
const rawParticipants = await provider.fetchParticipants({
|
||||||
ownerId: giveaway.platformOwnerId,
|
ownerId: giveaway.platformOwnerId,
|
||||||
postId: giveaway.platformPostId,
|
postId: giveaway.platformPostId,
|
||||||
sourceUrl: giveaway.sourceUrl,
|
includeLikes: validated.filterRules.requireLike,
|
||||||
includeLikes: true,
|
includeComments: validated.filterRules.requireComment,
|
||||||
includeComments: rules.requireComment,
|
|
||||||
includeReposts: rules.requireRepost,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Run enrichment pipeline (subscription check + filter engine)
|
// Run participant fetch, enrichment, and filtering pipeline
|
||||||
const filterResult = await executeParticipantPipeline({
|
const { allParticipants, eligibleParticipants, excludedParticipants } =
|
||||||
rawParticipants,
|
await executeParticipantPipeline({
|
||||||
rules,
|
rawParticipants,
|
||||||
provider,
|
rules: validated.filterRules,
|
||||||
ownerId: giveaway.platformOwnerId,
|
provider,
|
||||||
});
|
ownerId: giveaway.platformOwnerId,
|
||||||
|
});
|
||||||
|
|
||||||
// 3. Save participants into persistent database
|
// Save atomic participant state in store
|
||||||
await GiveawayStore.updateParticipants(id, filterResult.allParticipants);
|
const updated = await GiveawayStore.updateParticipants(id, allParticipants);
|
||||||
|
|
||||||
return NextResponse.json({
|
const responseBody = {
|
||||||
success: true,
|
success: true,
|
||||||
stats: filterResult.stats,
|
giveawayId: updated.id,
|
||||||
allParticipants: filterResult.allParticipants,
|
totalCount: allParticipants.length,
|
||||||
eligibleCount: filterResult.eligibleParticipants.length,
|
eligibleCount: eligibleParticipants.length,
|
||||||
excludedCount: filterResult.excludedParticipants.length,
|
excludedCount: excludedParticipants.length,
|
||||||
});
|
allParticipants,
|
||||||
|
eligibleParticipants,
|
||||||
|
excludedParticipants,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (idempotencyKey) {
|
||||||
|
IdempotencyStore.set(`import-part:${idempotencyKey}`, 200, responseBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(responseBody);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +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 { handleApiError, NotFoundError } from '@/core/errors/http-errors';
|
||||||
|
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|
@ -7,14 +9,17 @@ export async function GET(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
||||||
|
generalApiRateLimiter.assertAllowed(`giveaway-get:${ip}`);
|
||||||
|
|
||||||
const giveaway = await GiveawayStore.getById(id);
|
const giveaway = await GiveawayStore.getById(id);
|
||||||
|
|
||||||
if (!giveaway) {
|
if (!giveaway) {
|
||||||
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
|
throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, giveaway });
|
return NextResponse.json({ success: true, giveaway });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
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 { ProviderFactory } from '@/providers/factory';
|
||||||
|
import { applyFilterRules } from '@/core/filtering/filter-engine';
|
||||||
|
import { createSnapshotSchema, validateProviderCapabilities } from '@/core/validation/giveaway-schemas';
|
||||||
|
import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http-errors';
|
||||||
|
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
|
import { IdempotencyStore } from '@/lib/idempotency';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|
@ -7,33 +13,62 @@ export async function POST(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const body = await req.json().catch(() => ({}));
|
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
||||||
|
expensiveApiRateLimiter.assertAllowed(`snapshot-lock:${ip}:${id}`);
|
||||||
|
|
||||||
|
const idempotencyKey = req.headers.get('idempotency-key');
|
||||||
|
if (idempotencyKey) {
|
||||||
|
const cached = IdempotencyStore.get(`lock-snap:${idempotencyKey}`);
|
||||||
|
if (cached) {
|
||||||
|
return NextResponse.json(cached.body, { status: cached.statusCode });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const giveaway = await GiveawayStore.getById(id);
|
const giveaway = await GiveawayStore.getById(id);
|
||||||
|
|
||||||
if (!giveaway) {
|
if (!giveaway) {
|
||||||
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
|
throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const eligibleParticipants = giveaway.participants.filter(p => p.eligible);
|
if (giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED') {
|
||||||
|
throw new ConflictError(`Cannot create new snapshot for giveaway in status "${giveaway.status}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await req.json();
|
||||||
|
const validated = createSnapshotSchema.parse(rawBody);
|
||||||
|
|
||||||
|
const provider = ProviderFactory.getVkProvider();
|
||||||
|
validateProviderCapabilities(validated.filterRules, provider.capabilities);
|
||||||
|
|
||||||
|
// Apply strict filtering on current participants
|
||||||
|
const { eligibleParticipants } = applyFilterRules(
|
||||||
|
giveaway.participants,
|
||||||
|
validated.filterRules
|
||||||
|
);
|
||||||
|
|
||||||
if (eligibleParticipants.length === 0) {
|
if (eligibleParticipants.length === 0) {
|
||||||
return NextResponse.json({
|
throw new ConflictError('Cannot create snapshot with 0 eligible participants. Check your filter rules.');
|
||||||
error: 'Нельзя создать слепок с 0 допущенными участниками'
|
|
||||||
}, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules = body.filterRules || giveaway.filterRules;
|
// Atomically create and lock snapshot in database
|
||||||
|
|
||||||
const snapshot = await GiveawayStore.createAndLockSnapshot(
|
const snapshot = await GiveawayStore.createAndLockSnapshot(
|
||||||
id,
|
id,
|
||||||
eligibleParticipants,
|
eligibleParticipants,
|
||||||
rules
|
validated.filterRules
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
const responseBody = {
|
||||||
success: true,
|
success: true,
|
||||||
|
giveawayId: id,
|
||||||
|
status: 'SNAPSHOT_LOCKED',
|
||||||
snapshot,
|
snapshot,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (idempotencyKey) {
|
||||||
|
IdempotencyStore.set(`lock-snap:${idempotencyKey}`, 200, responseBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(responseBody);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +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 { verifyDrawResult } from '@/core/randomizer/deterministic';
|
import { verifyDrawResult } from '@/core/randomizer/deterministic';
|
||||||
|
import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http-errors';
|
||||||
|
import { expensiveApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|
@ -8,25 +10,29 @@ export async function GET(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const giveaway = await GiveawayStore.getById(id);
|
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
||||||
|
expensiveApiRateLimiter.assertAllowed(`verify-get:${ip}:${id}`);
|
||||||
|
|
||||||
|
const giveaway = await GiveawayStore.getById(id);
|
||||||
if (!giveaway) {
|
if (!giveaway) {
|
||||||
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
|
throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawResult = giveaway.drawResult;
|
const drawResult = giveaway.drawResult;
|
||||||
if (!drawResult) {
|
if (!drawResult) {
|
||||||
return NextResponse.json({
|
throw new ConflictError('Giveaway has not been drawn yet. Nothing to verify.');
|
||||||
error: 'Giveaway has not been drawn yet. Nothing to verify.'
|
|
||||||
}, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strict snapshot lookup: DO NOT fallback to latestSnapshot
|
// Strict snapshot lookup: DO NOT fallback to latestSnapshot
|
||||||
const snapshot = giveaway.snapshots.find(s => s.id === drawResult.snapshotId);
|
const snapshot = giveaway.snapshots.find(s => s.id === drawResult.snapshotId);
|
||||||
|
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
error: `Integrity Error: Participant snapshot "${drawResult.snapshotId}" referenced by draw does not exist in storage`,
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INTEGRITY_ERROR',
|
||||||
|
message: `Participant snapshot "${drawResult.snapshotId}" referenced by draw does not exist in storage`,
|
||||||
|
},
|
||||||
verified: false,
|
verified: false,
|
||||||
snapshotFound: false,
|
snapshotFound: false,
|
||||||
}, { status: 404 });
|
}, { status: 404 });
|
||||||
|
|
@ -52,6 +58,7 @@ export async function GET(
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
verified: verification.verified,
|
verified: verification.verified,
|
||||||
giveawayId: id,
|
giveawayId: id,
|
||||||
drawId: drawResult.drawId,
|
drawId: drawResult.drawId,
|
||||||
|
|
@ -71,6 +78,6 @@ export async function GET(
|
||||||
drawnAt: drawResult.drawnAt,
|
drawnAt: drawResult.drawnAt,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,63 @@
|
||||||
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 { DEFAULT_FILTER_RULES } from '@/core/types/giveaway';
|
import { createGiveawaySchema } from '@/core/validation/giveaway-schemas';
|
||||||
|
import { handleApiError } from '@/core/errors/http-errors';
|
||||||
|
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
|
import { IdempotencyStore } from '@/lib/idempotency';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const list = await GiveawayStore.listAll();
|
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
||||||
return NextResponse.json({ success: true, giveaways: list });
|
generalApiRateLimiter.assertAllowed(`giveaways-list:${ip}`);
|
||||||
|
|
||||||
|
// Return lightweight summary for scalability (no massive participant/snapshot payloads)
|
||||||
|
const summaries = await GiveawayStore.listSummaries();
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
giveaways: summaries,
|
||||||
|
totalCount: summaries.length,
|
||||||
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
||||||
const { sourceUrl, post, filterRules = DEFAULT_FILTER_RULES, winnersCount = 1, reserveWinnersCount = 0, seed } = body;
|
generalApiRateLimiter.assertAllowed(`giveaway-create:${ip}`);
|
||||||
|
|
||||||
if (!sourceUrl || !post) {
|
const idempotencyKey = req.headers.get('idempotency-key');
|
||||||
return NextResponse.json({ error: 'sourceUrl and post are required' }, { status: 400 });
|
if (idempotencyKey) {
|
||||||
|
const cached = IdempotencyStore.get(`create-gw:${idempotencyKey}`);
|
||||||
|
if (cached) {
|
||||||
|
return NextResponse.json(cached.body, { status: cached.statusCode });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawBody = await req.json();
|
||||||
|
const validated = createGiveawaySchema.parse(rawBody);
|
||||||
|
|
||||||
const giveaway = await GiveawayStore.create({
|
const giveaway = await GiveawayStore.create({
|
||||||
sourceUrl,
|
sourceUrl: validated.sourceUrl,
|
||||||
post,
|
post: validated.post,
|
||||||
filterRules,
|
filterRules: validated.filterRules,
|
||||||
winnersCount,
|
winnersCount: validated.winnersCount,
|
||||||
reserveWinnersCount,
|
reserveWinnersCount: validated.reserveWinnersCount,
|
||||||
seed,
|
seed: validated.seed,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true, giveaway });
|
const responseBody = {
|
||||||
|
success: true,
|
||||||
|
giveaway,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (idempotencyKey) {
|
||||||
|
IdempotencyStore.set(`create-gw:${idempotencyKey}`, 201, responseBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(responseBody, { status: 201 });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,25 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { ProviderRegistry } from '@/providers/registry';
|
import { ProviderFactory } from '@/providers/factory';
|
||||||
import { PlatformType } from '@/core/types/giveaway';
|
import { postPreviewSchema } from '@/core/validation/giveaway-schemas';
|
||||||
|
import { handleApiError } from '@/core/errors/http-errors';
|
||||||
|
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const ip = req.headers.get('x-forwarded-for') || 'anonymous';
|
||||||
const { url, platform = 'VK' } = body;
|
generalApiRateLimiter.assertAllowed(`post-preview:${ip}`);
|
||||||
|
|
||||||
if (!url || typeof url !== 'string') {
|
const rawBody = await req.json();
|
||||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
const validated = postPreviewSchema.parse(rawBody);
|
||||||
}
|
|
||||||
|
|
||||||
const provider = ProviderRegistry.getProvider(platform as PlatformType);
|
const provider = ProviderFactory.getVkProvider();
|
||||||
const parsed = provider.parsePostUrl(url);
|
const post = await provider.fetchPost(validated.url);
|
||||||
|
|
||||||
if (!parsed) {
|
return NextResponse.json({
|
||||||
return NextResponse.json({
|
success: true,
|
||||||
error: 'Неверный формат ссылки на запись VK. Пример: https://vk.com/wall-123456_789'
|
post,
|
||||||
}, { status: 400 });
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const postMetadata = await provider.fetchPost(url);
|
|
||||||
return NextResponse.json({ success: true, post: postMetadata });
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json(
|
return handleApiError(error);
|
||||||
{ error: error.message || 'Ошибка при загрузке данных поста' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@ import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { StoredGiveaway } from '@/lib/giveaway-store';
|
import { GiveawaySummary } from '@/lib/repository/giveaway-repository';
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [giveaways, setGiveaways] = useState<StoredGiveaway[]>([]);
|
const [giveaways, setGiveaways] = useState<GiveawaySummary[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const fetchGiveaways = async () => {
|
const fetchGiveaways = async () => {
|
||||||
|
|
@ -39,7 +39,7 @@ export default function DashboardPage() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const completedCount = giveaways.filter(g => g.status === 'DRAWN' || g.status === 'PUBLISHED').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.eligibleParticipantsCount || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|
@ -152,7 +152,7 @@ export default function DashboardPage() {
|
||||||
{gw.title || 'Розыгрыш по записи VK'}
|
{gw.title || 'Розыгрыш по записи VK'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-slate-400 truncate mt-0.5 max-w-md">
|
<p className="text-xs text-slate-400 truncate mt-0.5 max-w-md">
|
||||||
{gw.description || gw.sourceUrl}
|
{gw.sourceUrl}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-slate-400">
|
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-slate-400">
|
||||||
<span>Создан: {new Date(gw.createdAt).toLocaleDateString('ru-RU')}</span>
|
<span>Создан: {new Date(gw.createdAt).toLocaleDateString('ru-RU')}</span>
|
||||||
|
|
|
||||||
143
src/core/errors/http-errors.ts
Normal file
143
src/core/errors/http-errors.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
|
export abstract class AppError extends Error {
|
||||||
|
abstract readonly statusCode: number;
|
||||||
|
abstract readonly code: string;
|
||||||
|
readonly details?: any;
|
||||||
|
|
||||||
|
constructor(message: string, details?: any) {
|
||||||
|
super(message);
|
||||||
|
this.name = this.constructor.name;
|
||||||
|
this.details = details;
|
||||||
|
Object.setPrototypeOf(this, new.target.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends AppError {
|
||||||
|
readonly statusCode = 400;
|
||||||
|
readonly code = 'VALIDATION_ERROR';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedError extends AppError {
|
||||||
|
readonly statusCode = 401;
|
||||||
|
readonly code = 'UNAUTHORIZED';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForbiddenError extends AppError {
|
||||||
|
readonly statusCode = 403;
|
||||||
|
readonly code = 'FORBIDDEN';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends AppError {
|
||||||
|
readonly statusCode = 404;
|
||||||
|
readonly code = 'NOT_FOUND';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictError extends AppError {
|
||||||
|
readonly statusCode = 409;
|
||||||
|
readonly code = 'CONFLICT';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RateLimitError extends AppError {
|
||||||
|
readonly statusCode = 429;
|
||||||
|
readonly code = 'RATE_LIMIT_EXCEEDED';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DependencyUnavailableError extends AppError {
|
||||||
|
readonly statusCode = 503;
|
||||||
|
readonly code = 'DEPENDENCY_UNAVAILABLE';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InternalError extends AppError {
|
||||||
|
readonly statusCode = 500;
|
||||||
|
readonly code = 'INTERNAL_SERVER_ERROR';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes any error into a consistent JSON response
|
||||||
|
*/
|
||||||
|
export function handleApiError(error: unknown): NextResponse {
|
||||||
|
// Handle Zod validation errors
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
const formattedIssues = error.issues.map(i => ({
|
||||||
|
path: i.path.join('.'),
|
||||||
|
message: i.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'Invalid request payload',
|
||||||
|
details: formattedIssues,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle known AppErrors
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
details: error.details || null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: error.statusCode }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SyntaxError (Malformed JSON in request body)
|
||||||
|
if (error instanceof SyntaxError && 'body' in error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INVALID_JSON',
|
||||||
|
message: 'Malformed JSON payload in request body',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Prisma Known Request Errors
|
||||||
|
const anyErr = error as any;
|
||||||
|
if (anyErr?.code === 'P2002') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'Resource conflict or duplicate constraint violation',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unexpected runtime errors
|
||||||
|
console.error('Unhandled server error:', error);
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
const errorMessage = isProd
|
||||||
|
? 'An unexpected internal server error occurred'
|
||||||
|
: (error instanceof Error ? error.message : 'Internal Server Error');
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: errorMessage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/core/validation/giveaway-schemas.ts
Normal file
92
src/core/validation/giveaway-schemas.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { FilterRules } from '../types/giveaway';
|
||||||
|
import { ProviderCapabilities } from '../../providers/types';
|
||||||
|
import { ValidationError } from '../errors/http-errors';
|
||||||
|
|
||||||
|
export const filterRulesSchema = z.object({
|
||||||
|
requireLike: z.boolean().default(false),
|
||||||
|
requireComment: z.boolean().default(false),
|
||||||
|
requireRepost: z.boolean().default(false),
|
||||||
|
requireSubscription: z.boolean().default(false),
|
||||||
|
excludeAdmins: z.boolean().default(false),
|
||||||
|
excludeDuplicateComments: z.boolean().default(true),
|
||||||
|
excludeBlacklistedIds: z.array(z.string().max(128)).max(1000).default([]),
|
||||||
|
targetGroupId: z.string().max(128).optional(),
|
||||||
|
minEligibleParticipants: z.number().int().min(1).max(100000).default(1),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const defaultRulesObject = {
|
||||||
|
requireLike: true,
|
||||||
|
requireComment: false,
|
||||||
|
requireRepost: false,
|
||||||
|
requireSubscription: false,
|
||||||
|
excludeAdmins: false,
|
||||||
|
excludeDuplicateComments: true,
|
||||||
|
excludeBlacklistedIds: [] as string[],
|
||||||
|
minEligibleParticipants: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const postMetadataSchema = z.object({
|
||||||
|
platform: z.enum(['VK', 'TELEGRAM', 'YOUTUBE']),
|
||||||
|
ownerId: z.string().min(1).max(128),
|
||||||
|
postId: z.string().min(1).max(128),
|
||||||
|
sourceUrl: z.string().url().max(2048),
|
||||||
|
title: z.string().max(512),
|
||||||
|
text: z.string().max(10000).default(''),
|
||||||
|
imageUrl: z.string().url().max(2048).nullish().transform(v => v ?? undefined),
|
||||||
|
authorName: z.string().max(256).optional(),
|
||||||
|
authorAvatarUrl: z.string().url().max(2048).nullish().transform(v => v ?? undefined),
|
||||||
|
likesCount: z.number().int().min(0).default(0),
|
||||||
|
commentsCount: z.number().int().min(0).default(0),
|
||||||
|
repostsCount: z.number().int().min(0).default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createGiveawaySchema = z.object({
|
||||||
|
sourceUrl: z.string().min(1).max(2048),
|
||||||
|
post: postMetadataSchema,
|
||||||
|
filterRules: filterRulesSchema.default(defaultRulesObject),
|
||||||
|
winnersCount: z.number().int().min(1).max(100).default(1),
|
||||||
|
reserveWinnersCount: z.number().int().min(0).max(100).default(0),
|
||||||
|
seed: z.string().max(512).optional(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const fetchParticipantsSchema = z.object({
|
||||||
|
filterRules: filterRulesSchema.default(defaultRulesObject),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const createSnapshotSchema = z.object({
|
||||||
|
filterRules: filterRulesSchema.default(defaultRulesObject),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const executeDrawSchema = z.object({
|
||||||
|
winnersCount: z.number().int().min(1).max(100).default(1),
|
||||||
|
reserveWinnersCount: z.number().int().min(0).max(100).default(0),
|
||||||
|
seed: z.string().max(512).optional(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const postPreviewSchema = z.object({
|
||||||
|
url: z.string().min(1).max(2048),
|
||||||
|
platform: z.enum(['VK', 'TELEGRAM', 'YOUTUBE']).default('VK'),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates requested filter rules against provider capabilities
|
||||||
|
*/
|
||||||
|
export function validateProviderCapabilities(
|
||||||
|
rules: FilterRules,
|
||||||
|
capabilities: ProviderCapabilities
|
||||||
|
): void {
|
||||||
|
if (rules.requireRepost && !capabilities.reposts) {
|
||||||
|
throw new ValidationError(
|
||||||
|
'Repost verification is not supported due to VK API privacy limitations',
|
||||||
|
{ condition: 'requireRepost' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.excludeAdmins && !capabilities.adminDetection) {
|
||||||
|
throw new ValidationError(
|
||||||
|
'Admin detection requires VK ID organizer authorization',
|
||||||
|
{ condition: 'excludeAdmins' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import { IGiveawayRepository, GiveawayWithRelations, CreateGiveawayInput } from './repository/giveaway-repository';
|
import {
|
||||||
|
IGiveawayRepository,
|
||||||
|
GiveawayWithRelations,
|
||||||
|
GiveawaySummary,
|
||||||
|
PaginatedParticipantsResult,
|
||||||
|
CreateGiveawayInput
|
||||||
|
} from './repository/giveaway-repository';
|
||||||
import { PrismaGiveawayRepository } from './repository/prisma-repository';
|
import { PrismaGiveawayRepository } from './repository/prisma-repository';
|
||||||
import { MemoryGiveawayRepository } from './repository/memory-repository';
|
import { MemoryGiveawayRepository } from './repository/memory-repository';
|
||||||
import { FilterRules } from '../core/types/giveaway';
|
import { FilterRules } from '../core/types/giveaway';
|
||||||
|
|
@ -7,7 +13,6 @@ import { DrawExecutionResult, ParticipantSnapshotData } from '../core/types/audi
|
||||||
|
|
||||||
export type StoredGiveaway = GiveawayWithRelations;
|
export type StoredGiveaway = GiveawayWithRelations;
|
||||||
|
|
||||||
// Select initial repository based on explicit STORAGE_DRIVER configuration
|
|
||||||
function createDefaultRepository(): IGiveawayRepository {
|
function createDefaultRepository(): IGiveawayRepository {
|
||||||
if (process.env.STORAGE_DRIVER === 'memory') {
|
if (process.env.STORAGE_DRIVER === 'memory') {
|
||||||
return new MemoryGiveawayRepository();
|
return new MemoryGiveawayRepository();
|
||||||
|
|
@ -18,9 +23,6 @@ function createDefaultRepository(): IGiveawayRepository {
|
||||||
let activeRepository: IGiveawayRepository = createDefaultRepository();
|
let activeRepository: IGiveawayRepository = createDefaultRepository();
|
||||||
|
|
||||||
export class GiveawayStore {
|
export class GiveawayStore {
|
||||||
/**
|
|
||||||
* Set custom repository (e.g. MemoryGiveawayRepository in tests)
|
|
||||||
*/
|
|
||||||
static setRepository(repo: IGiveawayRepository): void {
|
static setRepository(repo: IGiveawayRepository): void {
|
||||||
activeRepository = repo;
|
activeRepository = repo;
|
||||||
}
|
}
|
||||||
|
|
@ -29,9 +31,6 @@ export class GiveawayStore {
|
||||||
return activeRepository;
|
return activeRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset repository to environment default
|
|
||||||
*/
|
|
||||||
static resetToDefault(): void {
|
static resetToDefault(): void {
|
||||||
activeRepository = createDefaultRepository();
|
activeRepository = createDefaultRepository();
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +47,19 @@ export class GiveawayStore {
|
||||||
return await activeRepository.listGiveaways();
|
return await activeRepository.listGiveaways();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async listSummaries(): Promise<GiveawaySummary[]> {
|
||||||
|
return await activeRepository.listGiveawaysSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getParticipantsPaginated(
|
||||||
|
id: string,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 50,
|
||||||
|
tab: 'all' | 'eligible' | 'excluded' = 'all'
|
||||||
|
): Promise<PaginatedParticipantsResult> {
|
||||||
|
return await activeRepository.getParticipantsPaginated(id, page, pageSize, tab);
|
||||||
|
}
|
||||||
|
|
||||||
static async updateParticipants(id: string, participants: FilteredParticipant[]): Promise<StoredGiveaway> {
|
static async updateParticipants(id: string, participants: FilteredParticipant[]): Promise<StoredGiveaway> {
|
||||||
return await activeRepository.saveParticipants(id, participants);
|
return await activeRepository.saveParticipants(id, participants);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
src/lib/idempotency.ts
Normal file
34
src/lib/idempotency.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
interface IdempotentResponse {
|
||||||
|
statusCode: number;
|
||||||
|
body: any;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IdempotencyStore {
|
||||||
|
private static store = new Map<string, IdempotentResponse>();
|
||||||
|
private static readonly TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
public static get(key: string): IdempotentResponse | null {
|
||||||
|
const cached = this.store.get(key);
|
||||||
|
if (!cached) return null;
|
||||||
|
|
||||||
|
if (Date.now() - cached.createdAt > this.TTL_MS) {
|
||||||
|
this.store.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static set(key: string, statusCode: number, body: any): void {
|
||||||
|
this.store.set(key, {
|
||||||
|
statusCode,
|
||||||
|
body,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static clear(): void {
|
||||||
|
this.store.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/lib/rate-limiter.ts
Normal file
70
src/lib/rate-limiter.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { RateLimitError } from '../core/errors/http-errors';
|
||||||
|
|
||||||
|
interface RateLimitRecord {
|
||||||
|
timestamps: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RateLimiterOptions {
|
||||||
|
windowMs: number;
|
||||||
|
maxRequests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SlidingWindowRateLimiter {
|
||||||
|
private records = new Map<string, RateLimitRecord>();
|
||||||
|
private readonly windowMs: number;
|
||||||
|
private readonly maxRequests: number;
|
||||||
|
|
||||||
|
constructor(options: RateLimiterOptions) {
|
||||||
|
this.windowMs = options.windowMs;
|
||||||
|
this.maxRequests = options.maxRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
public check(key: string): { allowed: boolean; remaining: number; resetInMs: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - this.windowMs;
|
||||||
|
|
||||||
|
let record = this.records.get(key);
|
||||||
|
if (!record) {
|
||||||
|
record = { timestamps: [] };
|
||||||
|
this.records.set(key, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge timestamps outside current window
|
||||||
|
record.timestamps = record.timestamps.filter(ts => ts > windowStart);
|
||||||
|
|
||||||
|
if (record.timestamps.length >= this.maxRequests) {
|
||||||
|
const oldest = record.timestamps[0];
|
||||||
|
const resetInMs = Math.max(0, oldest + this.windowMs - now);
|
||||||
|
return { allowed: false, remaining: 0, resetInMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
record.timestamps.push(now);
|
||||||
|
const remaining = this.maxRequests - record.timestamps.length;
|
||||||
|
return { allowed: true, remaining, resetInMs: this.windowMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
public assertAllowed(key: string): void {
|
||||||
|
const result = this.check(key);
|
||||||
|
if (!result.allowed) {
|
||||||
|
throw new RateLimitError(
|
||||||
|
`Rate limit exceeded. Please retry after ${Math.ceil(result.resetInMs / 1000)} seconds.`,
|
||||||
|
{ retryAfterMs: result.resetInMs }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.records.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global default limiter instances for expensive operations
|
||||||
|
export const expensiveApiRateLimiter = new SlidingWindowRateLimiter({
|
||||||
|
windowMs: 10_000, // 10 seconds
|
||||||
|
maxRequests: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const generalApiRateLimiter = new SlidingWindowRateLimiter({
|
||||||
|
windowMs: 60_000, // 1 minute
|
||||||
|
maxRequests: 120,
|
||||||
|
});
|
||||||
|
|
@ -1,16 +1,7 @@
|
||||||
import { FilterRules, GiveawayStatusType, PlatformType, PostMetadata } from '../../core/types/giveaway';
|
import { FilterRules, GiveawayStatusType, PlatformType, PostMetadata } from '../../core/types/giveaway';
|
||||||
import { FilteredParticipant, RawParticipant } from '../../core/types/participant';
|
import { FilteredParticipant } from '../../core/types/participant';
|
||||||
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
|
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 {
|
export interface GiveawayWithRelations {
|
||||||
id: string;
|
id: string;
|
||||||
platform: PlatformType;
|
platform: PlatformType;
|
||||||
|
|
@ -33,17 +24,74 @@ export interface GiveawayWithRelations {
|
||||||
drawnAt: string | null;
|
drawnAt: string | null;
|
||||||
participants: FilteredParticipant[];
|
participants: FilteredParticipant[];
|
||||||
snapshots: ParticipantSnapshotData[];
|
snapshots: ParticipantSnapshotData[];
|
||||||
latestSnapshot?: ParticipantSnapshotData | null;
|
latestSnapshot: ParticipantSnapshotData | null;
|
||||||
drawResult?: DrawExecutionResult | null;
|
drawResult: DrawExecutionResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiveawaySummary {
|
||||||
|
id: string;
|
||||||
|
platform: PlatformType;
|
||||||
|
sourceUrl: string;
|
||||||
|
platformOwnerId: string;
|
||||||
|
platformPostId: string;
|
||||||
|
title: string;
|
||||||
|
postImageUrl: string | null;
|
||||||
|
postLikesCount: number;
|
||||||
|
postCommentsCount: number;
|
||||||
|
postRepostsCount: number;
|
||||||
|
status: GiveawayStatusType;
|
||||||
|
winnersCount: number;
|
||||||
|
reserveWinnersCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
drawnAt: string | null;
|
||||||
|
totalParticipantsCount: number;
|
||||||
|
eligibleParticipantsCount: number;
|
||||||
|
hasDrawResult: boolean;
|
||||||
|
algorithmVersion: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedParticipantsResult {
|
||||||
|
participants: FilteredParticipant[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
eligibleCount: number;
|
||||||
|
excludedCount: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGiveawayInput {
|
||||||
|
sourceUrl: string;
|
||||||
|
post: PostMetadata;
|
||||||
|
filterRules: FilterRules;
|
||||||
|
winnersCount?: number;
|
||||||
|
reserveWinnersCount?: number;
|
||||||
|
seed?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IGiveawayRepository {
|
export interface IGiveawayRepository {
|
||||||
createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations>;
|
createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations>;
|
||||||
getGiveawayById(id: string): Promise<GiveawayWithRelations | null>;
|
getGiveawayById(id: string): Promise<GiveawayWithRelations | null>;
|
||||||
listGiveaways(): Promise<GiveawayWithRelations[]>;
|
listGiveaways(): Promise<GiveawayWithRelations[]>;
|
||||||
|
listGiveawaysSummary(): Promise<GiveawaySummary[]>;
|
||||||
|
getParticipantsPaginated(
|
||||||
|
id: string,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
tab?: 'all' | 'eligible' | 'excluded'
|
||||||
|
): Promise<PaginatedParticipantsResult>;
|
||||||
updateStatus(id: string, status: GiveawayStatusType): Promise<GiveawayWithRelations>;
|
updateStatus(id: string, status: GiveawayStatusType): Promise<GiveawayWithRelations>;
|
||||||
saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations>;
|
saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations>;
|
||||||
createAndLockSnapshot(id: string, eligibleParticipants: FilteredParticipant[], rules: FilterRules): Promise<ParticipantSnapshotData>;
|
createAndLockSnapshot(
|
||||||
|
id: string,
|
||||||
|
eligibleParticipants: FilteredParticipant[],
|
||||||
|
rules: FilterRules
|
||||||
|
): Promise<ParticipantSnapshotData>;
|
||||||
getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null>;
|
getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null>;
|
||||||
saveDrawResultAndAudit(id: string, snapshotId: string, result: DrawExecutionResult): Promise<GiveawayWithRelations>;
|
saveDrawResultAndAudit(
|
||||||
|
id: string,
|
||||||
|
snapshotId: string,
|
||||||
|
result: DrawExecutionResult
|
||||||
|
): Promise<GiveawayWithRelations>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
import {
|
import {
|
||||||
IGiveawayRepository,
|
IGiveawayRepository,
|
||||||
CreateGiveawayInput,
|
CreateGiveawayInput,
|
||||||
GiveawayWithRelations
|
GiveawayWithRelations,
|
||||||
|
GiveawaySummary,
|
||||||
|
PaginatedParticipantsResult
|
||||||
} from './giveaway-repository';
|
} from './giveaway-repository';
|
||||||
import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway';
|
import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway';
|
||||||
import { FilteredParticipant } from '../../core/types/participant';
|
import { FilteredParticipant } from '../../core/types/participant';
|
||||||
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
|
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
|
||||||
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical';
|
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical';
|
||||||
import { GiveawayFSM } from '../../core/fsm/giveaway-fsm';
|
import { GiveawayFSM } from '../../core/fsm/giveaway-fsm';
|
||||||
|
import { ConflictError, NotFoundError } from '../../core/errors/http-errors';
|
||||||
|
|
||||||
export class MemoryGiveawayRepository implements IGiveawayRepository {
|
export class MemoryGiveawayRepository implements IGiveawayRepository {
|
||||||
private giveaways: Map<string, GiveawayWithRelations> = new Map();
|
private giveaways: Map<string, GiveawayWithRelations> = new Map();
|
||||||
private snapshots: Map<string, ParticipantSnapshotData[]> = new Map();
|
private snapshots: Map<string, ParticipantSnapshotData[]> = new Map();
|
||||||
|
private drawLocks: Set<string> = new Set();
|
||||||
|
|
||||||
async createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations> {
|
async createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations> {
|
||||||
const id = 'gw_' + Math.random().toString(36).slice(2, 10);
|
const id = 'gw_' + Math.random().toString(36).slice(2, 10);
|
||||||
|
|
@ -24,7 +28,7 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
|
||||||
platformOwnerId: input.post.ownerId,
|
platformOwnerId: input.post.ownerId,
|
||||||
platformPostId: input.post.postId,
|
platformPostId: input.post.postId,
|
||||||
title: input.post.title,
|
title: input.post.title,
|
||||||
description: input.post.text,
|
description: input.post.text || null,
|
||||||
postImageUrl: input.post.imageUrl || null,
|
postImageUrl: input.post.imageUrl || null,
|
||||||
postLikesCount: input.post.likesCount,
|
postLikesCount: input.post.likesCount,
|
||||||
postCommentsCount: input.post.commentsCount,
|
postCommentsCount: input.post.commentsCount,
|
||||||
|
|
@ -57,7 +61,6 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
|
||||||
|
|
||||||
let drawResult = gw.drawResult;
|
let drawResult = gw.drawResult;
|
||||||
if (drawResult) {
|
if (drawResult) {
|
||||||
// Strictly bind to the snapshot referenced by snapshotId
|
|
||||||
const boundSnapshot = snaps.find(s => s.id === drawResult?.snapshotId) || latest;
|
const boundSnapshot = snaps.find(s => s.id === drawResult?.snapshotId) || latest;
|
||||||
drawResult = {
|
drawResult = {
|
||||||
...drawResult,
|
...drawResult,
|
||||||
|
|
@ -79,26 +82,84 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
|
||||||
return all.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
return all.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listGiveawaysSummary(): Promise<GiveawaySummary[]> {
|
||||||
|
const all = await this.listGiveaways();
|
||||||
|
return all.map(gw => ({
|
||||||
|
id: gw.id,
|
||||||
|
platform: gw.platform,
|
||||||
|
sourceUrl: gw.sourceUrl,
|
||||||
|
platformOwnerId: gw.platformOwnerId,
|
||||||
|
platformPostId: gw.platformPostId,
|
||||||
|
title: gw.title,
|
||||||
|
postImageUrl: gw.postImageUrl,
|
||||||
|
postLikesCount: gw.postLikesCount,
|
||||||
|
postCommentsCount: gw.postCommentsCount,
|
||||||
|
postRepostsCount: gw.postRepostsCount,
|
||||||
|
status: gw.status,
|
||||||
|
winnersCount: gw.winnersCount,
|
||||||
|
reserveWinnersCount: gw.reserveWinnersCount,
|
||||||
|
createdAt: gw.createdAt,
|
||||||
|
updatedAt: gw.updatedAt,
|
||||||
|
drawnAt: gw.drawnAt,
|
||||||
|
totalParticipantsCount: gw.participants.length,
|
||||||
|
eligibleParticipantsCount: gw.drawResult?.totalEligibleCount || gw.participants.filter(p => p.eligible).length,
|
||||||
|
hasDrawResult: Boolean(gw.drawResult),
|
||||||
|
algorithmVersion: gw.drawResult?.algorithmVersion || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getParticipantsPaginated(
|
||||||
|
id: string,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 50,
|
||||||
|
tab: 'all' | 'eligible' | 'excluded' = 'all'
|
||||||
|
): Promise<PaginatedParticipantsResult> {
|
||||||
|
const gw = this.giveaways.get(id);
|
||||||
|
if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
|
|
||||||
|
const all = gw.participants;
|
||||||
|
const eligibleCount = all.filter(p => p.eligible).length;
|
||||||
|
const excludedCount = all.filter(p => !p.eligible).length;
|
||||||
|
const totalCount = all.length;
|
||||||
|
|
||||||
|
let filtered = all;
|
||||||
|
if (tab === 'eligible') filtered = all.filter(p => p.eligible);
|
||||||
|
if (tab === 'excluded') filtered = all.filter(p => !p.eligible);
|
||||||
|
|
||||||
|
const relevantCount = filtered.length;
|
||||||
|
const totalPages = Math.ceil(relevantCount / pageSize) || 1;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const paginatedItems = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
participants: paginatedItems,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalCount,
|
||||||
|
eligibleCount,
|
||||||
|
excludedCount,
|
||||||
|
totalPages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async updateStatus(id: string, newStatus: GiveawayStatusType): Promise<GiveawayWithRelations> {
|
async updateStatus(id: string, newStatus: GiveawayStatusType): Promise<GiveawayWithRelations> {
|
||||||
const gw = await this.getGiveawayById(id);
|
const gw = this.giveaways.get(id);
|
||||||
if (!gw) throw new Error(`Giveaway with id "${id}" not found`);
|
if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
|
|
||||||
GiveawayFSM.validateTransition(gw.status, newStatus);
|
GiveawayFSM.validateTransition(gw.status, newStatus);
|
||||||
gw.status = newStatus;
|
gw.status = newStatus;
|
||||||
gw.updatedAt = new Date().toISOString();
|
gw.updatedAt = new Date().toISOString();
|
||||||
this.giveaways.set(id, gw);
|
|
||||||
return gw;
|
return gw;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations> {
|
async saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations> {
|
||||||
const gw = await this.getGiveawayById(id);
|
const gw = this.giveaways.get(id);
|
||||||
if (!gw) throw new Error(`Giveaway with id "${id}" not found`);
|
if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
|
|
||||||
GiveawayFSM.assertCanModifyParticipants(gw.status);
|
GiveawayFSM.assertCanModifyParticipants(gw.status);
|
||||||
gw.participants = participants;
|
gw.participants = participants;
|
||||||
gw.status = 'READY';
|
gw.status = 'READY';
|
||||||
gw.updatedAt = new Date().toISOString();
|
gw.updatedAt = new Date().toISOString();
|
||||||
this.giveaways.set(id, gw);
|
|
||||||
return gw;
|
return gw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,15 +168,15 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
|
||||||
eligibleParticipants: FilteredParticipant[],
|
eligibleParticipants: FilteredParticipant[],
|
||||||
rules: FilterRules
|
rules: FilterRules
|
||||||
): Promise<ParticipantSnapshotData> {
|
): Promise<ParticipantSnapshotData> {
|
||||||
const gw = await this.getGiveawayById(id);
|
const gw = this.giveaways.get(id);
|
||||||
if (!gw) throw new Error(`Giveaway with id "${id}" not found`);
|
if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
|
|
||||||
if (gw.status === 'DRAWN' || gw.status === 'PUBLISHED') {
|
if (gw.status === 'DRAWN' || gw.status === 'PUBLISHED') {
|
||||||
throw new Error(`Cannot lock snapshot in final status "${gw.status}"`);
|
throw new ConflictError(`Cannot lock snapshot in final status "${gw.status}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eligibleParticipants.length === 0) {
|
if (eligibleParticipants.length === 0) {
|
||||||
throw new Error('Cannot create snapshot with 0 eligible participants');
|
throw new ConflictError('Cannot create snapshot with 0 eligible participants');
|
||||||
}
|
}
|
||||||
|
|
||||||
const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants);
|
const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants);
|
||||||
|
|
@ -144,7 +205,6 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
|
||||||
gw.filterRules = rules;
|
gw.filterRules = rules;
|
||||||
gw.latestSnapshot = snapshot;
|
gw.latestSnapshot = snapshot;
|
||||||
gw.updatedAt = new Date().toISOString();
|
gw.updatedAt = new Date().toISOString();
|
||||||
this.giveaways.set(id, gw);
|
|
||||||
|
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
@ -159,17 +219,30 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
|
||||||
snapshotId: string,
|
snapshotId: string,
|
||||||
result: DrawExecutionResult
|
result: DrawExecutionResult
|
||||||
): Promise<GiveawayWithRelations> {
|
): Promise<GiveawayWithRelations> {
|
||||||
const gw = await this.getGiveawayById(id);
|
// Atomic test & set lock check
|
||||||
if (!gw) throw new Error(`Giveaway with id "${id}" not found`);
|
if (this.drawLocks.has(id)) {
|
||||||
|
throw new ConflictError(`Giveaway "${id}" has already been drawn or is concurrently drawing.`);
|
||||||
|
}
|
||||||
|
|
||||||
GiveawayFSM.assertCanDraw(gw.status);
|
const gw = this.giveaways.get(id);
|
||||||
|
if (!gw) throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
|
|
||||||
|
if (gw.status !== 'SNAPSHOT_LOCKED') {
|
||||||
|
throw new ConflictError(`Giveaway is in status "${gw.status}", but draw requires "SNAPSHOT_LOCKED"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gw.drawResult) {
|
||||||
|
throw new ConflictError(`Giveaway "${id}" has already been drawn.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire lock and transition
|
||||||
|
this.drawLocks.add(id);
|
||||||
|
|
||||||
gw.drawResult = result;
|
gw.drawResult = result;
|
||||||
gw.status = 'DRAWN';
|
gw.status = 'DRAWN';
|
||||||
gw.drawnAt = result.drawnAt;
|
gw.drawnAt = result.drawnAt;
|
||||||
gw.seed = result.seedUsed;
|
gw.seed = result.seedUsed;
|
||||||
gw.updatedAt = new Date().toISOString();
|
gw.updatedAt = new Date().toISOString();
|
||||||
this.giveaways.set(id, gw);
|
|
||||||
|
|
||||||
return gw;
|
return gw;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,16 @@ import { prisma } from '../prisma';
|
||||||
import {
|
import {
|
||||||
IGiveawayRepository,
|
IGiveawayRepository,
|
||||||
CreateGiveawayInput,
|
CreateGiveawayInput,
|
||||||
GiveawayWithRelations
|
GiveawayWithRelations,
|
||||||
|
GiveawaySummary,
|
||||||
|
PaginatedParticipantsResult
|
||||||
} from './giveaway-repository';
|
} from './giveaway-repository';
|
||||||
import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway';
|
import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway';
|
||||||
import { FilteredParticipant } from '../../core/types/participant';
|
import { FilteredParticipant } from '../../core/types/participant';
|
||||||
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
|
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
|
||||||
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical';
|
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical';
|
||||||
import { GiveawayFSM } from '../../core/fsm/giveaway-fsm';
|
import { GiveawayFSM } from '../../core/fsm/giveaway-fsm';
|
||||||
|
import { ConflictError, NotFoundError } from '../../core/errors/http-errors';
|
||||||
|
|
||||||
export class PrismaGiveawayRepository implements IGiveawayRepository {
|
export class PrismaGiveawayRepository implements IGiveawayRepository {
|
||||||
private mapPrismaGiveaway(raw: any): GiveawayWithRelations {
|
private mapPrismaGiveaway(raw: any): GiveawayWithRelations {
|
||||||
|
|
@ -46,7 +49,6 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
||||||
|
|
||||||
let drawResult: DrawExecutionResult | null = null;
|
let drawResult: DrawExecutionResult | null = null;
|
||||||
if (raw.drawResult) {
|
if (raw.drawResult) {
|
||||||
// Strictly bind to the snapshot attached to drawResult
|
|
||||||
const boundSnapshot = raw.drawResult.snapshot
|
const boundSnapshot = raw.drawResult.snapshot
|
||||||
? {
|
? {
|
||||||
participantsSnapshotHash: raw.drawResult.snapshot.participantsSnapshotHash,
|
participantsSnapshotHash: raw.drawResult.snapshot.participantsSnapshotHash,
|
||||||
|
|
@ -166,9 +168,119 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
||||||
return list.map(item => this.mapPrismaGiveaway(item));
|
return list.map(item => this.mapPrismaGiveaway(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listGiveawaysSummary(): Promise<GiveawaySummary[]> {
|
||||||
|
const list = await prisma.giveaway.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
platform: true,
|
||||||
|
sourceUrl: true,
|
||||||
|
platformOwnerId: true,
|
||||||
|
platformPostId: true,
|
||||||
|
title: true,
|
||||||
|
postImageUrl: true,
|
||||||
|
postLikesCount: true,
|
||||||
|
postCommentsCount: true,
|
||||||
|
postRepostsCount: true,
|
||||||
|
status: true,
|
||||||
|
winnersCount: true,
|
||||||
|
reserveWinnersCount: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
drawnAt: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
participants: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
drawResult: {
|
||||||
|
select: {
|
||||||
|
algorithmVersion: true,
|
||||||
|
totalEligibleCount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return list.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
platform: item.platform as PlatformType,
|
||||||
|
sourceUrl: item.sourceUrl,
|
||||||
|
platformOwnerId: item.platformOwnerId,
|
||||||
|
platformPostId: item.platformPostId,
|
||||||
|
title: item.title,
|
||||||
|
postImageUrl: item.postImageUrl,
|
||||||
|
postLikesCount: item.postLikesCount,
|
||||||
|
postCommentsCount: item.postCommentsCount,
|
||||||
|
postRepostsCount: item.postRepostsCount,
|
||||||
|
status: item.status as GiveawayStatusType,
|
||||||
|
winnersCount: item.winnersCount,
|
||||||
|
reserveWinnersCount: item.reserveWinnersCount,
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
updatedAt: item.updatedAt.toISOString(),
|
||||||
|
drawnAt: item.drawnAt ? item.drawnAt.toISOString() : null,
|
||||||
|
totalParticipantsCount: item._count.participants,
|
||||||
|
eligibleParticipantsCount: item.drawResult?.totalEligibleCount || 0,
|
||||||
|
hasDrawResult: Boolean(item.drawResult),
|
||||||
|
algorithmVersion: item.drawResult?.algorithmVersion || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getParticipantsPaginated(
|
||||||
|
id: string,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 50,
|
||||||
|
tab: 'all' | 'eligible' | 'excluded' = 'all'
|
||||||
|
): Promise<PaginatedParticipantsResult> {
|
||||||
|
const whereClause: any = { giveawayId: id };
|
||||||
|
if (tab === 'eligible') whereClause.eligible = true;
|
||||||
|
if (tab === 'excluded') whereClause.eligible = false;
|
||||||
|
|
||||||
|
const [totalCount, eligibleCount, excludedCount, items] = await prisma.$transaction([
|
||||||
|
prisma.participant.count({ where: { giveawayId: id } }),
|
||||||
|
prisma.participant.count({ where: { giveawayId: id, eligible: true } }),
|
||||||
|
prisma.participant.count({ where: { giveawayId: id, eligible: false } }),
|
||||||
|
prisma.participant.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { platformUserId: 'asc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const participants: FilteredParticipant[] = items.map(p => ({
|
||||||
|
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 relevantCount = tab === 'eligible' ? eligibleCount : tab === 'excluded' ? excludedCount : totalCount;
|
||||||
|
const totalPages = Math.ceil(relevantCount / pageSize) || 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
participants,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalCount,
|
||||||
|
eligibleCount,
|
||||||
|
excludedCount,
|
||||||
|
totalPages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async updateStatus(id: string, newStatus: GiveawayStatusType): Promise<GiveawayWithRelations> {
|
async updateStatus(id: string, newStatus: GiveawayStatusType): Promise<GiveawayWithRelations> {
|
||||||
const current = await this.getGiveawayById(id);
|
const current = await this.getGiveawayById(id);
|
||||||
if (!current) throw new Error(`Giveaway with id "${id}" not found`);
|
if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
|
|
||||||
GiveawayFSM.validateTransition(current.status, newStatus);
|
GiveawayFSM.validateTransition(current.status, newStatus);
|
||||||
|
|
||||||
|
|
@ -189,7 +301,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
||||||
|
|
||||||
async saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations> {
|
async saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations> {
|
||||||
const current = await this.getGiveawayById(id);
|
const current = await this.getGiveawayById(id);
|
||||||
if (!current) throw new Error(`Giveaway with id "${id}" not found`);
|
if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
|
|
||||||
GiveawayFSM.assertCanModifyParticipants(current.status);
|
GiveawayFSM.assertCanModifyParticipants(current.status);
|
||||||
|
|
||||||
|
|
@ -233,56 +345,75 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
||||||
rules: FilterRules
|
rules: FilterRules
|
||||||
): Promise<ParticipantSnapshotData> {
|
): Promise<ParticipantSnapshotData> {
|
||||||
const current = await this.getGiveawayById(id);
|
const current = await this.getGiveawayById(id);
|
||||||
if (!current) throw new Error(`Giveaway with id "${id}" not found`);
|
if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
|
|
||||||
if (current.status === 'DRAWN' || current.status === 'PUBLISHED') {
|
if (current.status === 'DRAWN' || current.status === 'PUBLISHED') {
|
||||||
throw new Error(`Cannot lock snapshot in final status "${current.status}"`);
|
throw new ConflictError(`Cannot lock snapshot in final status "${current.status}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eligibleParticipants.length === 0) {
|
if (eligibleParticipants.length === 0) {
|
||||||
throw new Error('Cannot create snapshot with 0 eligible participants');
|
throw new ConflictError('Cannot create snapshot with 0 eligible participants');
|
||||||
}
|
}
|
||||||
|
|
||||||
const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants);
|
const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants);
|
||||||
const conditionsHash = computeConditionsHash(rules);
|
const conditionsHash = computeConditionsHash(rules);
|
||||||
|
|
||||||
const latestVersion = current.snapshots.length > 0
|
try {
|
||||||
? Math.max(...current.snapshots.map(s => s.version))
|
return await prisma.$transaction(async (tx) => {
|
||||||
: 0;
|
// Atomic status guard
|
||||||
const newVersion = latestVersion + 1;
|
const updateRes = await tx.giveaway.updateMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
status: { in: ['READY', 'SNAPSHOT_LOCKED'] },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: 'SNAPSHOT_LOCKED',
|
||||||
|
filterRules: rules as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const [snapshot] = await prisma.$transaction([
|
if (updateRes.count === 0) {
|
||||||
prisma.participantSnapshot.create({
|
throw new ConflictError(`Concurrent modification or invalid status for giveaway "${id}"`);
|
||||||
data: {
|
}
|
||||||
giveawayId: id,
|
|
||||||
version: newVersion,
|
|
||||||
eligibleParticipants: eligibleParticipants as any,
|
|
||||||
filterRulesSnapshot: rules as any,
|
|
||||||
participantCount: eligibleParticipants.length,
|
|
||||||
participantsSnapshotHash,
|
|
||||||
conditionsHash,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.giveaway.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
status: 'SNAPSHOT_LOCKED',
|
|
||||||
filterRules: rules as any,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
const latestSnap = await tx.participantSnapshot.findFirst({
|
||||||
id: snapshot.id,
|
where: { giveawayId: id },
|
||||||
giveawayId: snapshot.giveawayId,
|
orderBy: { version: 'desc' },
|
||||||
version: snapshot.version,
|
});
|
||||||
createdAt: snapshot.createdAt.toISOString(),
|
|
||||||
eligibleParticipants: eligibleParticipants,
|
const newVersion = (latestSnap?.version || 0) + 1;
|
||||||
filterRulesSnapshot: rules,
|
|
||||||
participantCount: snapshot.participantCount,
|
const snapshot = await tx.participantSnapshot.create({
|
||||||
participantsSnapshotHash: snapshot.participantsSnapshotHash,
|
data: {
|
||||||
conditionsHash: snapshot.conditionsHash,
|
giveawayId: id,
|
||||||
};
|
version: newVersion,
|
||||||
|
eligibleParticipants: eligibleParticipants as any,
|
||||||
|
filterRulesSnapshot: rules as any,
|
||||||
|
participantCount: eligibleParticipants.length,
|
||||||
|
participantsSnapshotHash,
|
||||||
|
conditionsHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: snapshot.id,
|
||||||
|
giveawayId: snapshot.giveawayId,
|
||||||
|
version: snapshot.version,
|
||||||
|
createdAt: snapshot.createdAt.toISOString(),
|
||||||
|
eligibleParticipants,
|
||||||
|
filterRulesSnapshot: rules,
|
||||||
|
participantCount: snapshot.participantCount,
|
||||||
|
participantsSnapshotHash: snapshot.participantsSnapshotHash,
|
||||||
|
conditionsHash: snapshot.conditionsHash,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof ConflictError) throw err;
|
||||||
|
if (err?.code === 'P2002') {
|
||||||
|
throw new ConflictError('Concurrent snapshot creation conflict. Please retry.');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null> {
|
async getLatestSnapshot(giveawayId: string): Promise<ParticipantSnapshotData | null> {
|
||||||
|
|
@ -312,61 +443,78 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
||||||
result: DrawExecutionResult
|
result: DrawExecutionResult
|
||||||
): Promise<GiveawayWithRelations> {
|
): Promise<GiveawayWithRelations> {
|
||||||
const current = await this.getGiveawayById(id);
|
const current = await this.getGiveawayById(id);
|
||||||
if (!current) throw new Error(`Giveaway with id "${id}" not found`);
|
if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
||||||
|
|
||||||
GiveawayFSM.assertCanDraw(current.status);
|
GiveawayFSM.assertCanDraw(current.status);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
try {
|
||||||
// 1. Create DrawResult with original drawId
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.drawResult.create({
|
// 1. Atomic conditional transition SNAPSHOT_LOCKED -> DRAWN
|
||||||
data: {
|
const updatedStatus = await tx.giveaway.updateMany({
|
||||||
drawId: result.drawId,
|
where: {
|
||||||
giveawayId: id,
|
id,
|
||||||
snapshotId: snapshotId,
|
status: 'SNAPSHOT_LOCKED',
|
||||||
winners: result.winners as any,
|
},
|
||||||
reserveWinners: result.reserveWinners as any,
|
data: {
|
||||||
winnerIds: result.winnerIds as any,
|
status: 'DRAWN',
|
||||||
reserveWinnerIds: result.reserveWinnerIds as any,
|
drawnAt: new Date(result.drawnAt),
|
||||||
totalEligibleCount: result.totalEligibleCount,
|
seed: result.seedUsed,
|
||||||
totalLoadedCount: result.totalLoadedCount,
|
},
|
||||||
seedUsed: result.seedUsed,
|
});
|
||||||
algorithmVersion: result.algorithmVersion,
|
|
||||||
deterministicProofHash: result.deterministicProofHash,
|
|
||||||
auditEventHash: result.auditEventHash,
|
|
||||||
drawnAt: new Date(result.drawnAt),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Create AuditRecord
|
if (updatedStatus.count === 0) {
|
||||||
await tx.auditRecord.create({
|
throw new ConflictError(
|
||||||
data: {
|
`Cannot draw giveaway "${id}": giveaway is not in SNAPSHOT_LOCKED status or has already been drawn`
|
||||||
giveawayId: id,
|
);
|
||||||
snapshotId: snapshotId,
|
}
|
||||||
algorithmVersion: result.algorithmVersion,
|
|
||||||
seed: result.seedUsed,
|
|
||||||
participantsSnapshotHash: result.participantsSnapshotHash,
|
|
||||||
conditionsHash: result.conditionsHash,
|
|
||||||
deterministicProofHash: result.deterministicProofHash,
|
|
||||||
auditEventHash: result.auditEventHash,
|
|
||||||
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
|
// 2. Create DrawResult with unique constraint on giveawayId and snapshotId
|
||||||
await tx.giveaway.update({
|
await tx.drawResult.create({
|
||||||
where: { id },
|
data: {
|
||||||
data: {
|
drawId: result.drawId,
|
||||||
status: 'DRAWN',
|
giveawayId: id,
|
||||||
drawnAt: new Date(result.drawnAt),
|
snapshotId: snapshotId,
|
||||||
seed: result.seedUsed,
|
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,
|
||||||
|
deterministicProofHash: result.deterministicProofHash,
|
||||||
|
auditEventHash: result.auditEventHash,
|
||||||
|
drawnAt: new Date(result.drawnAt),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Create AuditRecord
|
||||||
|
await tx.auditRecord.create({
|
||||||
|
data: {
|
||||||
|
giveawayId: id,
|
||||||
|
snapshotId: snapshotId,
|
||||||
|
algorithmVersion: result.algorithmVersion,
|
||||||
|
seed: result.seedUsed,
|
||||||
|
participantsSnapshotHash: result.participantsSnapshotHash,
|
||||||
|
conditionsHash: result.conditionsHash,
|
||||||
|
deterministicProofHash: result.deterministicProofHash,
|
||||||
|
auditEventHash: result.auditEventHash,
|
||||||
|
winnerIds: result.winnerIds as any,
|
||||||
|
reserveWinnerIds: result.reserveWinnerIds as any,
|
||||||
|
eligibleCount: result.totalEligibleCount,
|
||||||
|
drawId: result.drawId,
|
||||||
|
drawnAt: new Date(result.drawnAt),
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
} catch (err: any) {
|
||||||
|
if (err instanceof ConflictError) throw err;
|
||||||
|
if (err?.code === 'P2002') {
|
||||||
|
throw new ConflictError(`Giveaway "${id}" has already been drawn.`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await this.getGiveawayById(id);
|
const updated = await this.getGiveawayById(id);
|
||||||
return updated!;
|
return updated!;
|
||||||
|
|
|
||||||
29
src/providers/factory.ts
Normal file
29
src/providers/factory.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { SocialMediaProvider } from './types';
|
||||||
|
import { VkProvider } from './vk/vk-provider';
|
||||||
|
import { VkMockProvider } from './vk/vk-mock-provider';
|
||||||
|
import { DependencyUnavailableError } from '../core/errors/http-errors';
|
||||||
|
|
||||||
|
export class ProviderFactory {
|
||||||
|
public static getVkProvider(): SocialMediaProvider {
|
||||||
|
// 1. Explicit mock configuration
|
||||||
|
if (process.env.USE_VK_MOCK === 'true') {
|
||||||
|
return new VkMockProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Real provider when token is present
|
||||||
|
const serviceToken = process.env.VK_SERVICE_TOKEN;
|
||||||
|
if (serviceToken && serviceToken.trim().length > 0) {
|
||||||
|
return new VkProvider(serviceToken.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Test environment fallback
|
||||||
|
if (process.env.NODE_ENV === 'test') {
|
||||||
|
return new VkMockProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Production/Default without token: STRICT FAIL
|
||||||
|
throw new DependencyUnavailableError(
|
||||||
|
'VK provider credentials are not configured. Configure VK_SERVICE_TOKEN or set USE_VK_MOCK=true for staging/test.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,303 +1,90 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { NextRequest } from 'next/server';
|
import {
|
||||||
import { GiveawayStore } from '../src/lib/giveaway-store';
|
createGiveawaySchema,
|
||||||
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
|
executeDrawSchema,
|
||||||
import { ProviderRegistry } from '../src/providers/registry';
|
validateProviderCapabilities
|
||||||
import { POST as giveawaysPost } from '../src/app/api/giveaways/route';
|
} from '../src/core/validation/giveaway-schemas';
|
||||||
import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route';
|
import { ValidationError } from '../src/core/errors/http-errors';
|
||||||
import { POST as snapshotPost } from '../src/app/api/giveaways/[id]/snapshot/route';
|
|
||||||
import { POST as previewPost } from '../src/app/api/posts/preview/route';
|
|
||||||
import { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route';
|
|
||||||
import { GET as giveawayGet } from '../src/app/api/giveaways/[id]/route';
|
|
||||||
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
||||||
import { FilteredParticipant } from '../src/core/types/participant';
|
|
||||||
|
|
||||||
async function createGiveaway(overrides: Partial<{ filterRules: typeof DEFAULT_FILTER_RULES; winnersCount: number; reserveWinnersCount: number; seed: string }> = {}) {
|
describe('Zod API Validation & Capability Rules', () => {
|
||||||
return GiveawayStore.create({
|
it('should accept valid executeDraw payload', () => {
|
||||||
sourceUrl: 'https://vk.com/wall-100_1',
|
const valid = executeDrawSchema.parse({
|
||||||
post: {
|
winnersCount: 5,
|
||||||
platform: 'VK',
|
reserveWinnersCount: 2,
|
||||||
ownerId: '-100',
|
seed: 'valid-custom-seed',
|
||||||
postId: '1',
|
});
|
||||||
sourceUrl: 'https://vk.com/wall-100_1',
|
|
||||||
title: 'Test',
|
|
||||||
text: 'Test',
|
|
||||||
likesCount: 10,
|
|
||||||
commentsCount: 5,
|
|
||||||
repostsCount: 2,
|
|
||||||
},
|
|
||||||
filterRules: overrides.filterRules || DEFAULT_FILTER_RULES,
|
|
||||||
winnersCount: overrides.winnersCount ?? 1,
|
|
||||||
reserveWinnersCount: overrides.reserveWinnersCount ?? 0,
|
|
||||||
seed: overrides.seed,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sampleParticipants: FilteredParticipant[] = Array.from({ length: 5 }, (_, i) => ({
|
expect(valid.winnersCount).toBe(5);
|
||||||
platformUserId: `${1000 + i}`,
|
expect(valid.reserveWinnersCount).toBe(2);
|
||||||
firstName: 'User',
|
|
||||||
lastName: `${i}`,
|
|
||||||
source: 'LIKES',
|
|
||||||
liked: true,
|
|
||||||
commented: false,
|
|
||||||
commentsCount: 0,
|
|
||||||
reposted: false,
|
|
||||||
subscribed: true,
|
|
||||||
eligible: true,
|
|
||||||
exclusionReason: null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('API input validation', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
GiveawayStore.setRepository(new MemoryGiveawayRepository());
|
|
||||||
ProviderRegistry.useMockVk();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/giveaways', () => {
|
it('should reject winnersCount outside 1..100', () => {
|
||||||
it('returns 400 when sourceUrl is missing', async () => {
|
expect(() => executeDrawSchema.parse({ winnersCount: 0 })).toThrow();
|
||||||
const req = new NextRequest('http://localhost/api/giveaways', {
|
expect(() => executeDrawSchema.parse({ winnersCount: 101 })).toThrow();
|
||||||
method: 'POST',
|
expect(() => executeDrawSchema.parse({ winnersCount: -5 })).toThrow();
|
||||||
body: JSON.stringify({ post: {} }),
|
|
||||||
});
|
|
||||||
const res = await giveawaysPost(req);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when post is missing', async () => {
|
|
||||||
const req = new NextRequest('http://localhost/api/giveaways', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ sourceUrl: 'https://vk.com/wall-1_1' }),
|
|
||||||
});
|
|
||||||
const res = await giveawaysPost(req);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 500 for malformed JSON body', async () => {
|
|
||||||
const req = new NextRequest('http://localhost/api/giveaways', {
|
|
||||||
method: 'POST',
|
|
||||||
body: 'not-json',
|
|
||||||
});
|
|
||||||
const res = await giveawaysPost(req);
|
|
||||||
expect(res.status).toBe(500);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/posts/preview', () => {
|
it('should reject reserveWinnersCount outside 0..100', () => {
|
||||||
it('returns 400 for invalid VK URL', async () => {
|
expect(() => executeDrawSchema.parse({ reserveWinnersCount: -1 })).toThrow();
|
||||||
const req = new NextRequest('http://localhost/api/posts/preview', {
|
expect(() => executeDrawSchema.parse({ reserveWinnersCount: 105 })).toThrow();
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ url: 'https://google.com' }),
|
|
||||||
});
|
|
||||||
const res = await previewPost(req);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when URL is missing', async () => {
|
|
||||||
const req = new NextRequest('http://localhost/api/posts/preview', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
const res = await previewPost(req);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 500 for malformed JSON', async () => {
|
|
||||||
const req = new NextRequest('http://localhost/api/posts/preview', {
|
|
||||||
method: 'POST',
|
|
||||||
body: '{ broken',
|
|
||||||
});
|
|
||||||
const res = await previewPost(req);
|
|
||||||
expect(res.status).toBe(500);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/giveaways/:id/draw', () => {
|
it('should reject seed longer than 512 characters', () => {
|
||||||
it('returns 404 for non-existent giveaway', async () => {
|
const oversizedSeed = 'a'.repeat(513);
|
||||||
const req = new NextRequest('http://localhost/api/giveaways/does-not-exist/draw', {
|
expect(() => executeDrawSchema.parse({ seed: oversizedSeed })).toThrow();
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
const res = await drawPost(req, { params: { id: 'does-not-exist' } });
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when drawing a giveaway that is already DRAWN', async () => {
|
|
||||||
const gw = await createGiveaway();
|
|
||||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
|
||||||
const snapshot = await GiveawayStore.createAndLockSnapshot(gw.id, sampleParticipants, DEFAULT_FILTER_RULES);
|
|
||||||
|
|
||||||
// First draw
|
|
||||||
const firstReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
const firstRes = await drawPost(firstReq, { params: { id: gw.id } });
|
|
||||||
expect(firstRes.status).toBe(200);
|
|
||||||
|
|
||||||
// Second draw attempt
|
|
||||||
const secondReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
const secondRes = await drawPost(secondReq, { params: { id: gw.id } });
|
|
||||||
expect(secondRes.status).toBe(400);
|
|
||||||
const data = await secondRes.json();
|
|
||||||
expect(data.error).toMatch(/уже проведен|already drawn/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 400 when there are 0 eligible participants', async () => {
|
|
||||||
const gw = await createGiveaway();
|
|
||||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants.map(p => ({ ...p, eligible: false, exclusionReason: 'TEST' })));
|
|
||||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
const res = await drawPost(req, { params: { id: gw.id } });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
const data = await res.json();
|
|
||||||
expect(data.error).toMatch(/Нет допущенных|0 eligible/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('currently accepts winnersCount = -1 (documented validation gap)', async () => {
|
|
||||||
const gw = await createGiveaway();
|
|
||||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
|
||||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ winnersCount: -1 }),
|
|
||||||
});
|
|
||||||
const res = await drawPost(req, { params: { id: gw.id } });
|
|
||||||
// Current behavior: does not reject negative winnersCount; it caps to pool size.
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const data = await res.json();
|
|
||||||
expect(data.drawResult.winners).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('currently treats winnersCount = 0 as the giveaway default (documented validation gap)', async () => {
|
|
||||||
const gw = await createGiveaway();
|
|
||||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
|
||||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ winnersCount: 0 }),
|
|
||||||
});
|
|
||||||
const res = await drawPost(req, { params: { id: gw.id } });
|
|
||||||
// The route uses `body.winnersCount || giveaway.winnersCount || 1`, so 0 is ignored.
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const data = await res.json();
|
|
||||||
expect(data.drawResult.winners).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caps winnersCount = 999999999 to pool size', async () => {
|
|
||||||
const gw = await createGiveaway();
|
|
||||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
|
||||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ winnersCount: 999999999 }),
|
|
||||||
});
|
|
||||||
const res = await drawPost(req, { params: { id: gw.id } });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const data = await res.json();
|
|
||||||
expect(data.drawResult.winners).toHaveLength(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('currently accepts reserveWinnersCount < 0 (documented validation gap)', async () => {
|
|
||||||
const gw = await createGiveaway();
|
|
||||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
|
||||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ reserveWinnersCount: -5 }),
|
|
||||||
});
|
|
||||||
const res = await drawPost(req, { params: { id: gw.id } });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const data = await res.json();
|
|
||||||
expect(data.drawResult.reserveWinners).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses generated seed when empty seed is provided', async () => {
|
|
||||||
const gw = await createGiveaway({ seed: undefined });
|
|
||||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
|
||||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ seed: ' ' }),
|
|
||||||
});
|
|
||||||
const res = await drawPost(req, { params: { id: gw.id } });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const data = await res.json();
|
|
||||||
expect(data.drawResult.seedUsed).toBeTruthy();
|
|
||||||
expect(data.drawResult.seedUsed.trim().length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('currently accepts huge seed strings (documented validation gap)', async () => {
|
|
||||||
const gw = await createGiveaway();
|
|
||||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
|
||||||
const hugeSeed = 'a'.repeat(100_000);
|
|
||||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ seed: hugeSeed }),
|
|
||||||
});
|
|
||||||
const res = await drawPost(req, { params: { id: gw.id } });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const data = await res.json();
|
|
||||||
expect(data.drawResult.seedUsed).toBe(hugeSeed);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('currently swallows malformed JSON body and proceeds (documented validation gap)', async () => {
|
|
||||||
const gw = await createGiveaway();
|
|
||||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants);
|
|
||||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: 'not-json',
|
|
||||||
});
|
|
||||||
const res = await drawPost(req, { params: { id: gw.id } });
|
|
||||||
// `.catch(() => ({}))` silently turns malformed JSON into an empty body.
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/giveaways/:id/participants', () => {
|
it('should reject URL longer than 2048 characters in createGiveaway', () => {
|
||||||
it('currently accepts unknown filter rules in body (documented validation gap)', async () => {
|
const longUrl = 'https://vk.com/wall-1_1?' + 'x'.repeat(2100);
|
||||||
const gw = await createGiveaway();
|
expect(() =>
|
||||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, {
|
createGiveawaySchema.parse({
|
||||||
method: 'POST',
|
sourceUrl: longUrl,
|
||||||
body: JSON.stringify({
|
post: {
|
||||||
filterRules: {
|
platform: 'VK',
|
||||||
...DEFAULT_FILTER_RULES,
|
ownerId: '-1',
|
||||||
unknownRule: true,
|
postId: '1',
|
||||||
anotherBadField: 'x',
|
sourceUrl: 'https://vk.com/wall-1_1',
|
||||||
},
|
title: 'Title',
|
||||||
}),
|
likesCount: 0,
|
||||||
});
|
commentsCount: 0,
|
||||||
const res = await participantsPost(req, { params: { id: gw.id } });
|
repostsCount: 0,
|
||||||
expect(res.status).toBe(200);
|
},
|
||||||
});
|
})
|
||||||
|
).toThrow();
|
||||||
it('returns 404 for non-existent giveaway', async () => {
|
|
||||||
const req = new NextRequest('http://localhost/api/giveaways/missing/participants', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
const res = await participantsPost(req, { params: { id: 'missing' } });
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/giveaways/:id/snapshot', () => {
|
it('should throw ValidationError when unsupported repost condition is requested', () => {
|
||||||
it('returns 400 when there are 0 eligible participants', async () => {
|
const vkCapabilities = {
|
||||||
const gw = await createGiveaway();
|
likes: true,
|
||||||
await GiveawayStore.updateParticipants(gw.id, sampleParticipants.map(p => ({ ...p, eligible: false, exclusionReason: 'TEST' })));
|
comments: true,
|
||||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/snapshot`, {
|
reposts: false,
|
||||||
method: 'POST',
|
subscriptions: true,
|
||||||
body: JSON.stringify({}),
|
adminDetection: false,
|
||||||
});
|
};
|
||||||
const res = await snapshotPost(req, { params: { id: gw.id } });
|
|
||||||
expect(res.status).toBe(400);
|
expect(() =>
|
||||||
});
|
validateProviderCapabilities(
|
||||||
|
{ ...DEFAULT_FILTER_RULES, requireRepost: true },
|
||||||
|
vkCapabilities
|
||||||
|
)
|
||||||
|
).toThrow(ValidationError);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/giveaways/:id', () => {
|
it('should throw ValidationError when admin detection is requested without capability', () => {
|
||||||
it('returns 404 for non-existent giveaway', async () => {
|
const vkCapabilities = {
|
||||||
const req = new NextRequest('http://localhost/api/giveaways/missing');
|
likes: true,
|
||||||
const res = await giveawayGet(req, { params: { id: 'missing' } });
|
comments: true,
|
||||||
expect(res.status).toBe(404);
|
reposts: false,
|
||||||
});
|
subscriptions: true,
|
||||||
|
adminDetection: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
validateProviderCapabilities(
|
||||||
|
{ ...DEFAULT_FILTER_RULES, excludeAdmins: true },
|
||||||
|
vkCapabilities
|
||||||
|
)
|
||||||
|
).toThrow(ValidationError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
84
tests/concurrency-draw.test.ts
Normal file
84
tests/concurrency-draw.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
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';
|
||||||
|
import { ConflictError } from '../src/core/errors/http-errors';
|
||||||
|
|
||||||
|
describe('Concurrency Double Draw Protection', () => {
|
||||||
|
const participants: FilteredParticipant[] = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
platformUserId: `user_${i + 1}`,
|
||||||
|
firstName: `User`,
|
||||||
|
lastName: `${i + 1}`,
|
||||||
|
source: 'LIKES',
|
||||||
|
liked: true,
|
||||||
|
commented: false,
|
||||||
|
commentsCount: 0,
|
||||||
|
reposted: false,
|
||||||
|
subscribed: true,
|
||||||
|
eligible: true,
|
||||||
|
exclusionReason: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should allow exactly 1 success out of 20 concurrent draw requests, with 19 receiving 409 Conflict', async () => {
|
||||||
|
const repo = new MemoryGiveawayRepository();
|
||||||
|
|
||||||
|
// 1. Create giveaway & lock snapshot
|
||||||
|
const gw = await repo.createGiveaway({
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_500',
|
||||||
|
post: {
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-100',
|
||||||
|
postId: '500',
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_500',
|
||||||
|
title: 'Concurrent Test Giveaway',
|
||||||
|
text: 'Description',
|
||||||
|
likesCount: 100,
|
||||||
|
commentsCount: 20,
|
||||||
|
repostsCount: 10,
|
||||||
|
},
|
||||||
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES);
|
||||||
|
|
||||||
|
// 2. Launch 20 concurrent draw attempts
|
||||||
|
const concurrentDrawPromises = Array.from({ length: 20 }, async (_, index) => {
|
||||||
|
try {
|
||||||
|
const seed = `concurrent-seed-${index}`;
|
||||||
|
const drawResult = executeDeterministicDrawV1({
|
||||||
|
giveawayId: gw.id,
|
||||||
|
snapshot,
|
||||||
|
totalLoadedCount: 20,
|
||||||
|
winnersCount: 1,
|
||||||
|
reserveWinnersCount: 1,
|
||||||
|
seed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await repo.saveDrawResultAndAudit(gw.id, snapshot.id, drawResult);
|
||||||
|
return { status: 200, success: true, result: saved };
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof ConflictError || err?.message?.includes('already been drawn') || err?.message?.includes('SNAPSHOT_LOCKED')) {
|
||||||
|
return { status: 409, success: false, error: err.message };
|
||||||
|
}
|
||||||
|
return { status: 500, success: false, error: err.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(concurrentDrawPromises);
|
||||||
|
|
||||||
|
const successCount = results.filter(r => r.status === 200).length;
|
||||||
|
const conflictCount = results.filter(r => r.status === 409).length;
|
||||||
|
const serverErrorCount = results.filter(r => r.status === 500).length;
|
||||||
|
|
||||||
|
expect(successCount).toBe(1);
|
||||||
|
expect(conflictCount).toBe(19);
|
||||||
|
expect(serverErrorCount).toBe(0);
|
||||||
|
|
||||||
|
// Verify giveaway is in DRAWN state with exactly 1 draw result
|
||||||
|
const finalized = await repo.getGiveawayById(gw.id);
|
||||||
|
expect(finalized?.status).toBe('DRAWN');
|
||||||
|
expect(finalized?.drawResult).toBeDefined();
|
||||||
|
expect(finalized?.drawResult?.winners.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
88
tests/concurrency-snapshot.test.ts
Normal file
88
tests/concurrency-snapshot.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
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 { ConflictError } from '../src/core/errors/http-errors';
|
||||||
|
|
||||||
|
describe('Concurrency Snapshot Locking & Participant Isolation', () => {
|
||||||
|
const participants: FilteredParticipant[] = [
|
||||||
|
{
|
||||||
|
platformUserId: '101',
|
||||||
|
firstName: 'Участник',
|
||||||
|
lastName: '1',
|
||||||
|
source: 'LIKES',
|
||||||
|
liked: true,
|
||||||
|
commented: false,
|
||||||
|
commentsCount: 0,
|
||||||
|
reposted: false,
|
||||||
|
subscribed: true,
|
||||||
|
eligible: true,
|
||||||
|
exclusionReason: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platformUserId: '102',
|
||||||
|
firstName: 'Участник',
|
||||||
|
lastName: '2',
|
||||||
|
source: 'LIKES',
|
||||||
|
liked: true,
|
||||||
|
commented: false,
|
||||||
|
commentsCount: 0,
|
||||||
|
reposted: false,
|
||||||
|
subscribed: true,
|
||||||
|
eligible: true,
|
||||||
|
exclusionReason: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('should not allow locking snapshot when status is DRAWN', async () => {
|
||||||
|
const repo = new MemoryGiveawayRepository();
|
||||||
|
const gw = await repo.createGiveaway({
|
||||||
|
sourceUrl: 'https://vk.com/wall-1_1',
|
||||||
|
post: {
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-1',
|
||||||
|
postId: '1',
|
||||||
|
sourceUrl: 'https://vk.com/wall-1_1',
|
||||||
|
title: 'Title',
|
||||||
|
text: 'Text',
|
||||||
|
likesCount: 10,
|
||||||
|
commentsCount: 2,
|
||||||
|
repostsCount: 1,
|
||||||
|
},
|
||||||
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Valid transition: READY -> SNAPSHOT_LOCKED -> DRAWN
|
||||||
|
await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES);
|
||||||
|
await repo.updateStatus(gw.id, 'DRAWN');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES)
|
||||||
|
).rejects.toThrow(ConflictError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow modifying participants when status is SNAPSHOT_LOCKED', async () => {
|
||||||
|
const repo = new MemoryGiveawayRepository();
|
||||||
|
const gw = await repo.createGiveaway({
|
||||||
|
sourceUrl: 'https://vk.com/wall-1_2',
|
||||||
|
post: {
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-1',
|
||||||
|
postId: '2',
|
||||||
|
sourceUrl: 'https://vk.com/wall-1_2',
|
||||||
|
title: 'Title',
|
||||||
|
text: 'Text',
|
||||||
|
likesCount: 10,
|
||||||
|
commentsCount: 2,
|
||||||
|
repostsCount: 1,
|
||||||
|
},
|
||||||
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repo.saveParticipants(gw.id, participants)
|
||||||
|
).rejects.toThrow(/Cannot modify participants/);
|
||||||
|
});
|
||||||
|
});
|
||||||
18
tests/idempotency.test.ts
Normal file
18
tests/idempotency.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { IdempotencyStore } from '../src/lib/idempotency';
|
||||||
|
|
||||||
|
describe('Idempotency Key Store', () => {
|
||||||
|
it('should store and return cached idempotent response', () => {
|
||||||
|
const key = 'test-idemp-key-1';
|
||||||
|
const payload = { result: 'ok', id: '123' };
|
||||||
|
|
||||||
|
expect(IdempotencyStore.get(key)).toBeNull();
|
||||||
|
|
||||||
|
IdempotencyStore.set(key, 201, payload);
|
||||||
|
|
||||||
|
const cached = IdempotencyStore.get(key);
|
||||||
|
expect(cached).not.toBeNull();
|
||||||
|
expect(cached?.statusCode).toBe(201);
|
||||||
|
expect(cached?.body).toEqual(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
91
tests/payload-scalability.test.ts
Normal file
91
tests/payload-scalability.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
describe('Payload Scalability & Pagination', () => {
|
||||||
|
const participants: FilteredParticipant[] = Array.from({ length: 120 }, (_, i) => ({
|
||||||
|
platformUserId: `${1000 + i}`,
|
||||||
|
firstName: `User`,
|
||||||
|
lastName: `${i + 1}`,
|
||||||
|
source: 'LIKES',
|
||||||
|
liked: true,
|
||||||
|
commented: i % 2 === 0,
|
||||||
|
commentsCount: i % 2 === 0 ? 1 : 0,
|
||||||
|
reposted: false,
|
||||||
|
subscribed: true,
|
||||||
|
eligible: i % 3 !== 0, // 80 eligible, 40 excluded
|
||||||
|
exclusionReason: i % 3 === 0 ? 'Not eligible' : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('listGiveawaysSummary should return lightweight objects without raw participants or snapshots arrays', async () => {
|
||||||
|
const repo = new MemoryGiveawayRepository();
|
||||||
|
const gw = await repo.createGiveaway({
|
||||||
|
sourceUrl: 'https://vk.com/wall-1_100',
|
||||||
|
post: {
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-1',
|
||||||
|
postId: '100',
|
||||||
|
sourceUrl: 'https://vk.com/wall-1_100',
|
||||||
|
title: 'Scalability Test',
|
||||||
|
text: 'Text',
|
||||||
|
likesCount: 120,
|
||||||
|
commentsCount: 60,
|
||||||
|
repostsCount: 10,
|
||||||
|
},
|
||||||
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.saveParticipants(gw.id, participants);
|
||||||
|
|
||||||
|
const summaries = await repo.listGiveawaysSummary();
|
||||||
|
expect(summaries.length).toBe(1);
|
||||||
|
|
||||||
|
const summary = summaries[0];
|
||||||
|
expect(summary.id).toBe(gw.id);
|
||||||
|
expect(summary.totalParticipantsCount).toBe(120);
|
||||||
|
// Ensure raw heavy fields are not present in summary
|
||||||
|
expect((summary as any).participants).toBeUndefined();
|
||||||
|
expect((summary as any).snapshots).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getParticipantsPaginated should correctly paginate and filter tabs', async () => {
|
||||||
|
const repo = new MemoryGiveawayRepository();
|
||||||
|
const gw = await repo.createGiveaway({
|
||||||
|
sourceUrl: 'https://vk.com/wall-1_200',
|
||||||
|
post: {
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-1',
|
||||||
|
postId: '200',
|
||||||
|
sourceUrl: 'https://vk.com/wall-1_200',
|
||||||
|
title: 'Pagination Test',
|
||||||
|
text: 'Text',
|
||||||
|
likesCount: 120,
|
||||||
|
commentsCount: 60,
|
||||||
|
repostsCount: 10,
|
||||||
|
},
|
||||||
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.saveParticipants(gw.id, participants);
|
||||||
|
|
||||||
|
// Page 1: 50 items
|
||||||
|
const page1 = await repo.getParticipantsPaginated(gw.id, 1, 50, 'all');
|
||||||
|
expect(page1.participants.length).toBe(50);
|
||||||
|
expect(page1.totalCount).toBe(120);
|
||||||
|
expect(page1.totalPages).toBe(3);
|
||||||
|
|
||||||
|
// Page 3: 20 items
|
||||||
|
const page3 = await repo.getParticipantsPaginated(gw.id, 3, 50, 'all');
|
||||||
|
expect(page3.participants.length).toBe(20);
|
||||||
|
|
||||||
|
// Tab 'eligible': 80 items total -> 50 on page 1, 30 on page 2
|
||||||
|
const eligiblePage1 = await repo.getParticipantsPaginated(gw.id, 1, 50, 'eligible');
|
||||||
|
expect(eligiblePage1.participants.length).toBe(50);
|
||||||
|
expect(eligiblePage1.totalPages).toBe(2);
|
||||||
|
expect(eligiblePage1.participants.every(p => p.eligible)).toBe(true);
|
||||||
|
|
||||||
|
const eligiblePage2 = await repo.getParticipantsPaginated(gw.id, 2, 50, 'eligible');
|
||||||
|
expect(eligiblePage2.participants.length).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
38
tests/provider-safety.test.ts
Normal file
38
tests/provider-safety.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { ProviderFactory } from '../src/providers/factory';
|
||||||
|
import { VkMockProvider } from '../src/providers/vk/vk-mock-provider';
|
||||||
|
import { VkProvider } from '../src/providers/vk/vk-provider';
|
||||||
|
import { DependencyUnavailableError } from '../src/core/errors/http-errors';
|
||||||
|
|
||||||
|
describe('Provider Safety (No unconfigured mocks in Production)', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.USE_VK_MOCK;
|
||||||
|
delete process.env.VK_SERVICE_TOKEN;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return VkMockProvider when USE_VK_MOCK=true is explicitly set', () => {
|
||||||
|
process.env.USE_VK_MOCK = 'true';
|
||||||
|
const provider = ProviderFactory.getVkProvider();
|
||||||
|
expect(provider).toBeInstanceOf(VkMockProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return VkProvider when VK_SERVICE_TOKEN is present', () => {
|
||||||
|
process.env.VK_SERVICE_TOKEN = 'mock_service_token_123';
|
||||||
|
const provider = ProviderFactory.getVkProvider();
|
||||||
|
expect(provider).toBeInstanceOf(VkProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw DependencyUnavailableError in production when VK credentials and USE_VK_MOCK are missing', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
delete process.env.USE_VK_MOCK;
|
||||||
|
delete process.env.VK_SERVICE_TOKEN;
|
||||||
|
|
||||||
|
expect(() => ProviderFactory.getVkProvider()).toThrow(DependencyUnavailableError);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
tests/rate-limiter.test.ts
Normal file
24
tests/rate-limiter.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { SlidingWindowRateLimiter } from '../src/lib/rate-limiter';
|
||||||
|
import { RateLimitError } from '../src/core/errors/http-errors';
|
||||||
|
|
||||||
|
describe('Sliding Window Rate Limiter', () => {
|
||||||
|
it('should allow requests within limit and block when threshold exceeded', () => {
|
||||||
|
const limiter = new SlidingWindowRateLimiter({
|
||||||
|
windowMs: 1000,
|
||||||
|
maxRequests: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const key = 'test-user-ip';
|
||||||
|
|
||||||
|
expect(limiter.check(key).allowed).toBe(true);
|
||||||
|
expect(limiter.check(key).allowed).toBe(true);
|
||||||
|
expect(limiter.check(key).allowed).toBe(true);
|
||||||
|
|
||||||
|
const fourth = limiter.check(key);
|
||||||
|
expect(fourth.allowed).toBe(false);
|
||||||
|
expect(fourth.remaining).toBe(0);
|
||||||
|
|
||||||
|
expect(() => limiter.assertAllowed(key)).toThrow(RateLimitError);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue