531 lines
18 KiB
TypeScript
531 lines
18 KiB
TypeScript
import {
|
|
IGiveawayRepository,
|
|
CreateGiveawayInput,
|
|
GiveawayWithRelations,
|
|
GiveawaySummary,
|
|
PaginatedParticipantsResult
|
|
} from './giveaway-repository';
|
|
import { prisma } from '../prisma';
|
|
import { FilterRules, GiveawayStatusType, PlatformType } from '../../core/types/giveaway';
|
|
import { FilteredParticipant } from '../../core/types/participant';
|
|
import { DrawExecutionResult, ParticipantSnapshotData } from '../../core/types/audit';
|
|
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../../core/randomizer/canonical';
|
|
import { GiveawayFSM } from '../../core/fsm/giveaway-fsm';
|
|
import { ConflictError, NotFoundError } from '../../core/errors/http-errors';
|
|
|
|
export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|
private mapPrismaGiveaway(raw: any): GiveawayWithRelations {
|
|
const participants: FilteredParticipant[] = (raw.participants || []).map((p: any) => ({
|
|
platformUserId: p.platformUserId,
|
|
firstName: p.firstName,
|
|
lastName: p.lastName,
|
|
username: p.username || undefined,
|
|
avatarUrl: p.avatarUrl || undefined,
|
|
source: p.source,
|
|
liked: p.liked,
|
|
commented: p.commented,
|
|
commentsCount: p.commentsCount,
|
|
reposted: p.reposted,
|
|
subscribed: p.subscribed,
|
|
eligible: p.eligible,
|
|
exclusionReason: p.exclusionReason,
|
|
}));
|
|
|
|
const snapshots: ParticipantSnapshotData[] = (raw.snapshots || []).map((s: any) => ({
|
|
id: s.id,
|
|
giveawayId: s.giveawayId,
|
|
version: s.version,
|
|
createdAt: s.createdAt.toISOString(),
|
|
eligibleParticipants: (s.eligibleParticipants as any) || [],
|
|
filterRulesSnapshot: (s.filterRulesSnapshot as any) || {},
|
|
participantCount: s.participantCount,
|
|
participantsSnapshotHash: s.participantsSnapshotHash,
|
|
conditionsHash: s.conditionsHash,
|
|
}));
|
|
|
|
const latestSnapshot = snapshots.length > 0 ? snapshots[0] : null;
|
|
|
|
let drawResult: DrawExecutionResult | null = null;
|
|
if (raw.drawResult) {
|
|
const boundSnapshot = snapshots.find(s => s.id === raw.drawResult.snapshotId) || latestSnapshot;
|
|
|
|
drawResult = {
|
|
drawId: raw.drawResult.drawId,
|
|
giveawayId: raw.drawResult.giveawayId,
|
|
snapshotId: raw.drawResult.snapshotId,
|
|
winners: raw.drawResult.winners as any,
|
|
reserveWinners: raw.drawResult.reserveWinners as any,
|
|
winnerIds: raw.drawResult.winnerIds as any,
|
|
reserveWinnerIds: raw.drawResult.reserveWinnerIds as any,
|
|
totalEligibleCount: raw.drawResult.totalEligibleCount,
|
|
totalLoadedCount: raw.drawResult.totalLoadedCount,
|
|
seedUsed: raw.drawResult.seedUsed,
|
|
algorithmVersion: raw.drawResult.algorithmVersion as any,
|
|
deterministicProofHash: raw.drawResult.deterministicProofHash,
|
|
auditEventHash: raw.drawResult.auditEventHash,
|
|
drawnAt: raw.drawResult.drawnAt.toISOString(),
|
|
participantsSnapshotHash: boundSnapshot?.participantsSnapshotHash || '',
|
|
conditionsHash: boundSnapshot?.conditionsHash || '',
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: raw.id,
|
|
platform: raw.platform as PlatformType,
|
|
sourceUrl: raw.sourceUrl,
|
|
platformOwnerId: raw.platformOwnerId,
|
|
platformPostId: raw.platformPostId,
|
|
title: raw.title,
|
|
description: raw.description,
|
|
postImageUrl: raw.postImageUrl,
|
|
postLikesCount: raw.postLikesCount,
|
|
postCommentsCount: raw.postCommentsCount,
|
|
postRepostsCount: raw.postRepostsCount,
|
|
status: raw.status as GiveawayStatusType,
|
|
filterRules: raw.filterRules as any,
|
|
winnersCount: raw.winnersCount,
|
|
reserveWinnersCount: raw.reserveWinnersCount,
|
|
seed: raw.seed,
|
|
organizerId: raw.organizerId || null,
|
|
createdAt: raw.createdAt.toISOString(),
|
|
updatedAt: raw.updatedAt.toISOString(),
|
|
drawnAt: raw.drawnAt ? raw.drawnAt.toISOString() : null,
|
|
participants,
|
|
snapshots,
|
|
latestSnapshot,
|
|
drawResult,
|
|
};
|
|
}
|
|
|
|
async createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations> {
|
|
const created = await prisma.giveaway.create({
|
|
data: {
|
|
platform: input.post.platform,
|
|
sourceUrl: input.sourceUrl,
|
|
platformOwnerId: input.post.ownerId,
|
|
platformPostId: input.post.postId,
|
|
title: input.post.title,
|
|
description: input.post.text,
|
|
postImageUrl: input.post.imageUrl,
|
|
postLikesCount: input.post.likesCount,
|
|
postCommentsCount: input.post.commentsCount,
|
|
postRepostsCount: input.post.repostsCount,
|
|
status: 'READY',
|
|
filterRules: input.filterRules as any,
|
|
winnersCount: input.winnersCount || 1,
|
|
reserveWinnersCount: input.reserveWinnersCount || 0,
|
|
seed: input.seed,
|
|
organizerId: input.organizerId || null,
|
|
},
|
|
include: {
|
|
participants: true,
|
|
snapshots: true,
|
|
drawResult: {
|
|
include: { snapshot: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
return this.mapPrismaGiveaway(created);
|
|
}
|
|
|
|
async getGiveawayById(id: string): Promise<GiveawayWithRelations | null> {
|
|
const raw = await prisma.giveaway.findUnique({
|
|
where: { id },
|
|
include: {
|
|
participants: true,
|
|
snapshots: {
|
|
orderBy: { version: 'desc' },
|
|
},
|
|
drawResult: {
|
|
include: { snapshot: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
return raw ? this.mapPrismaGiveaway(raw) : null;
|
|
}
|
|
|
|
async listGiveaways(): Promise<GiveawayWithRelations[]> {
|
|
const list = await prisma.giveaway.findMany({
|
|
orderBy: { createdAt: 'desc' },
|
|
include: {
|
|
participants: true,
|
|
snapshots: {
|
|
orderBy: { version: 'desc' },
|
|
},
|
|
drawResult: {
|
|
include: { snapshot: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
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,
|
|
organizerId: 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,
|
|
organizerId: item.organizerId || null,
|
|
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> {
|
|
const current = await this.getGiveawayById(id);
|
|
if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
|
|
|
GiveawayFSM.validateTransition(current.status, newStatus);
|
|
|
|
const updated = await prisma.giveaway.update({
|
|
where: { id },
|
|
data: { status: newStatus as any },
|
|
include: {
|
|
participants: true,
|
|
snapshots: true,
|
|
drawResult: {
|
|
include: { snapshot: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
return this.mapPrismaGiveaway(updated);
|
|
}
|
|
|
|
async saveParticipants(id: string, participants: FilteredParticipant[]): Promise<GiveawayWithRelations> {
|
|
await prisma.$transaction(async (tx) => {
|
|
// Atomic conditional status guard: only allow replacing participants if status is READY
|
|
const updateRes = await tx.giveaway.updateMany({
|
|
where: {
|
|
id,
|
|
status: 'READY',
|
|
},
|
|
data: {
|
|
status: 'READY',
|
|
updatedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
if (updateRes.count === 0) {
|
|
const check = await tx.giveaway.findUnique({ where: { id } });
|
|
if (!check) {
|
|
throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
|
}
|
|
throw new ConflictError(
|
|
`Cannot modify participants: giveaway "${id}" is in status "${check.status}", but requires "READY"`
|
|
);
|
|
}
|
|
|
|
await tx.participant.deleteMany({ where: { giveawayId: id } });
|
|
|
|
if (participants.length > 0) {
|
|
await tx.participant.createMany({
|
|
data: participants.map(p => ({
|
|
giveawayId: id,
|
|
platformUserId: p.platformUserId,
|
|
firstName: p.firstName,
|
|
lastName: p.lastName,
|
|
username: p.username,
|
|
avatarUrl: p.avatarUrl,
|
|
source: p.source as any,
|
|
liked: p.liked,
|
|
commented: p.commented,
|
|
commentsCount: p.commentsCount,
|
|
reposted: p.reposted,
|
|
subscribed: p.subscribed,
|
|
eligible: p.eligible,
|
|
exclusionReason: p.exclusionReason,
|
|
})),
|
|
});
|
|
}
|
|
});
|
|
|
|
const updated = await this.getGiveawayById(id);
|
|
return updated!;
|
|
}
|
|
|
|
async createAndLockSnapshot(
|
|
id: string,
|
|
eligibleParticipants: FilteredParticipant[],
|
|
rules: FilterRules
|
|
): Promise<ParticipantSnapshotData> {
|
|
const current = await this.getGiveawayById(id);
|
|
if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
|
|
|
if (current.status === 'DRAWN' || current.status === 'PUBLISHED') {
|
|
throw new ConflictError(`Cannot lock snapshot in final status "${current.status}"`);
|
|
}
|
|
|
|
if (eligibleParticipants.length === 0) {
|
|
throw new ConflictError('Cannot create snapshot with 0 eligible participants');
|
|
}
|
|
|
|
const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants);
|
|
const conditionsHash = computeConditionsHash(rules);
|
|
|
|
try {
|
|
return await prisma.$transaction(async (tx) => {
|
|
// Atomic status guard
|
|
const updateRes = await tx.giveaway.updateMany({
|
|
where: {
|
|
id,
|
|
status: { in: ['READY', 'SNAPSHOT_LOCKED'] },
|
|
},
|
|
data: {
|
|
status: 'SNAPSHOT_LOCKED',
|
|
filterRules: rules as any,
|
|
},
|
|
});
|
|
|
|
if (updateRes.count === 0) {
|
|
throw new ConflictError(`Concurrent modification or invalid status for giveaway "${id}"`);
|
|
}
|
|
|
|
const latestSnap = await tx.participantSnapshot.findFirst({
|
|
where: { giveawayId: id },
|
|
orderBy: { version: 'desc' },
|
|
});
|
|
|
|
const newVersion = (latestSnap?.version || 0) + 1;
|
|
|
|
const snapshot = await tx.participantSnapshot.create({
|
|
data: {
|
|
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> {
|
|
const snap = await prisma.participantSnapshot.findFirst({
|
|
where: { giveawayId },
|
|
orderBy: { version: 'desc' },
|
|
});
|
|
|
|
if (!snap) return null;
|
|
|
|
return {
|
|
id: snap.id,
|
|
giveawayId: snap.giveawayId,
|
|
version: snap.version,
|
|
createdAt: snap.createdAt.toISOString(),
|
|
eligibleParticipants: snap.eligibleParticipants as any,
|
|
filterRulesSnapshot: snap.filterRulesSnapshot as any,
|
|
participantCount: snap.participantCount,
|
|
participantsSnapshotHash: snap.participantsSnapshotHash,
|
|
conditionsHash: snap.conditionsHash,
|
|
};
|
|
}
|
|
|
|
async saveDrawResultAndAudit(
|
|
id: string,
|
|
snapshotId: string,
|
|
result: DrawExecutionResult
|
|
): Promise<GiveawayWithRelations> {
|
|
const current = await this.getGiveawayById(id);
|
|
if (!current) throw new NotFoundError(`Giveaway with id "${id}" not found`);
|
|
|
|
GiveawayFSM.assertCanDraw(current.status);
|
|
|
|
try {
|
|
await prisma.$transaction(async (tx) => {
|
|
// 1. Atomic conditional transition SNAPSHOT_LOCKED -> DRAWN
|
|
const updatedStatus = await tx.giveaway.updateMany({
|
|
where: {
|
|
id,
|
|
status: 'SNAPSHOT_LOCKED',
|
|
},
|
|
data: {
|
|
status: 'DRAWN',
|
|
drawnAt: new Date(result.drawnAt),
|
|
seed: result.seedUsed,
|
|
},
|
|
});
|
|
|
|
if (updatedStatus.count === 0) {
|
|
throw new ConflictError(
|
|
`Cannot draw giveaway "${id}": giveaway is not in SNAPSHOT_LOCKED status or has already been drawn`
|
|
);
|
|
}
|
|
|
|
// 2. Create DrawResult with unique constraint on giveawayId and snapshotId
|
|
await tx.drawResult.create({
|
|
data: {
|
|
drawId: result.drawId,
|
|
giveawayId: id,
|
|
snapshotId: snapshotId,
|
|
winners: result.winners as any,
|
|
reserveWinners: result.reserveWinners as any,
|
|
winnerIds: result.winnerIds as any,
|
|
reserveWinnerIds: result.reserveWinnerIds as any,
|
|
totalEligibleCount: result.totalEligibleCount,
|
|
totalLoadedCount: result.totalLoadedCount,
|
|
seedUsed: result.seedUsed,
|
|
algorithmVersion: result.algorithmVersion,
|
|
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);
|
|
return updated!;
|
|
}
|
|
}
|