- Ticketed events: full CRUD, ticket tiers (free/paid/donation), Stripe checkout, QR-based check-in scanner, public event pages, ticket confirmation emails - Event formats: IN_PERSON/ONLINE/HYBRID with auto Jitsi meeting room lifecycle, ticket-gated meeting access, moderator JWT tokens, feature-flag guarded - Social engagement: challenges with scoring/leaderboards, referral tracking, volunteer spotlight, impact stories, campaign celebrations, wall of fame - Social calendar: personal calendar layers, shared calendar items with recurrence, scheduling polls, mobile day view - MCP server: events tool pack with full admin CRUD + meeting token generation - Unified calendar: eventFormat-aware tags, online event indicators - Updated docs site, pangolin configs, and various admin UI improvements Bunker Admin
168 lines
4.0 KiB
TypeScript
168 lines
4.0 KiB
TypeScript
import { prisma } from '../config/database';
|
|
import { logger } from '../utils/logger';
|
|
import type { ChallengeMetric } from '@prisma/client';
|
|
|
|
async function computeScore(
|
|
userId: string,
|
|
metric: ChallengeMetric,
|
|
startsAt: Date,
|
|
endsAt: Date,
|
|
): Promise<number> {
|
|
const between = { gte: startsAt, lte: endsAt };
|
|
|
|
switch (metric) {
|
|
case 'DOORS_KNOCKED':
|
|
return prisma.canvassVisit.count({
|
|
where: {
|
|
session: { userId },
|
|
visitedAt: between,
|
|
},
|
|
});
|
|
|
|
case 'EMAILS_SENT':
|
|
return prisma.campaignEmail.count({
|
|
where: {
|
|
userId,
|
|
sentAt: between,
|
|
},
|
|
});
|
|
|
|
case 'SHIFTS_ATTENDED':
|
|
return prisma.shiftSignup.count({
|
|
where: {
|
|
userId,
|
|
status: 'CONFIRMED',
|
|
shift: { startTime: { gte: startsAt.toISOString(), lte: endsAt.toISOString() } },
|
|
},
|
|
});
|
|
|
|
case 'RESPONSES_SUBMITTED':
|
|
return prisma.representativeResponse.count({
|
|
where: {
|
|
submittedByUserId: userId,
|
|
createdAt: between,
|
|
},
|
|
});
|
|
|
|
case 'REFERRALS_MADE':
|
|
return prisma.referral.count({
|
|
where: {
|
|
referrerId: userId,
|
|
completedAt: between,
|
|
},
|
|
});
|
|
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
async function scoreChallenge(challengeId: string): Promise<void> {
|
|
const challenge = await prisma.challenge.findUnique({
|
|
where: { id: challengeId },
|
|
include: {
|
|
teams: {
|
|
include: {
|
|
members: { select: { id: true, userId: true, score: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!challenge) return;
|
|
|
|
let anyChanged = false;
|
|
const participantUserIds: string[] = [];
|
|
|
|
for (const team of challenge.teams) {
|
|
let teamTotal = 0;
|
|
|
|
for (const member of team.members) {
|
|
const newScore = await computeScore(
|
|
member.userId,
|
|
challenge.metric,
|
|
challenge.startsAt,
|
|
challenge.endsAt,
|
|
);
|
|
|
|
if (newScore !== member.score) {
|
|
await prisma.challengeTeamMember.update({
|
|
where: { id: member.id },
|
|
data: { score: newScore },
|
|
});
|
|
anyChanged = true;
|
|
}
|
|
|
|
teamTotal += newScore;
|
|
participantUserIds.push(member.userId);
|
|
}
|
|
|
|
if (teamTotal !== team.score) {
|
|
await prisma.challengeTeam.update({
|
|
where: { id: team.id },
|
|
data: { score: teamTotal, lastScoredAt: new Date() },
|
|
});
|
|
anyChanged = true;
|
|
}
|
|
}
|
|
|
|
if (anyChanged && participantUserIds.length > 0) {
|
|
try {
|
|
const { sseService } = await import('../modules/social/sse.service');
|
|
sseService.sendToUsers(participantUserIds, 'challenge_scores_updated', {
|
|
challengeId,
|
|
});
|
|
} catch {
|
|
// SSE not available
|
|
}
|
|
}
|
|
}
|
|
|
|
async function processLifecycle(): Promise<void> {
|
|
const now = new Date();
|
|
|
|
// UPCOMING -> ACTIVE
|
|
const toActivate = await prisma.challenge.findMany({
|
|
where: { status: 'UPCOMING', startsAt: { lte: now } },
|
|
});
|
|
for (const c of toActivate) {
|
|
await prisma.challenge.update({
|
|
where: { id: c.id },
|
|
data: { status: 'ACTIVE' },
|
|
});
|
|
logger.info(`Challenge ${c.id} "${c.title}" activated`);
|
|
}
|
|
|
|
// ACTIVE -> COMPLETED (past end date)
|
|
const toComplete = await prisma.challenge.findMany({
|
|
where: { status: 'ACTIVE', endsAt: { lte: now } },
|
|
});
|
|
for (const c of toComplete) {
|
|
await scoreChallenge(c.id);
|
|
await prisma.challenge.update({
|
|
where: { id: c.id },
|
|
data: { status: 'COMPLETED' },
|
|
});
|
|
logger.info(`Challenge ${c.id} "${c.title}" completed`);
|
|
}
|
|
|
|
// Score remaining ACTIVE challenges
|
|
const active = await prisma.challenge.findMany({
|
|
where: { status: 'ACTIVE' },
|
|
select: { id: true },
|
|
});
|
|
for (const c of active) {
|
|
await scoreChallenge(c.id);
|
|
}
|
|
|
|
logger.info(
|
|
`Challenge lifecycle: ${toActivate.length} activated, ${toComplete.length} completed, ${active.length} active scored`,
|
|
);
|
|
}
|
|
|
|
export const challengeScoringService = {
|
|
computeScore,
|
|
scoreChallenge,
|
|
processLifecycle,
|
|
};
|