changemaker.lite/api/src/services/challenge-scoring.service.ts
bunker-admin 08d8066157 Add ticketed events, Jitsi meeting integration, social features, and calendar system
- 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
2026-03-06 14:33:33 -07:00

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,
};