changemaker.lite/api/src/modules/social/challenge.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

348 lines
11 KiB
TypeScript

import { prisma } from '../../config/database';
import { challengeScoringService } from '../../services/challenge-scoring.service';
import type { ChallengeStatus } from '@prisma/client';
import type { CreateChallengeInput, UpdateChallengeInput } from './challenge.schemas';
const USER_SELECT = { id: true, email: true, name: true } as const;
export const challengeService = {
// ── Admin ──────────────────────────────────────────────────────────
async create(data: CreateChallengeInput, userId: string) {
return prisma.challenge.create({
data: {
title: data.title,
description: data.description,
metric: data.metric,
startsAt: new Date(data.startsAt),
endsAt: new Date(data.endsAt),
minTeamSize: data.minTeamSize,
maxTeamSize: data.maxTeamSize,
maxTeams: data.maxTeams,
createdByUserId: userId,
status: 'DRAFT',
},
});
},
async update(id: string, data: UpdateChallengeInput) {
const challenge = await prisma.challenge.findUnique({ where: { id } });
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
if (challenge.status !== 'DRAFT' && challenge.status !== 'UPCOMING') {
throw Object.assign(new Error('Can only edit DRAFT or UPCOMING challenges'), { statusCode: 400 });
}
return prisma.challenge.update({
where: { id },
data: {
...(data.title !== undefined && { title: data.title }),
...(data.description !== undefined && { description: data.description }),
...(data.metric !== undefined && { metric: data.metric }),
...(data.startsAt !== undefined && { startsAt: new Date(data.startsAt) }),
...(data.endsAt !== undefined && { endsAt: new Date(data.endsAt) }),
...(data.minTeamSize !== undefined && { minTeamSize: data.minTeamSize }),
...(data.maxTeamSize !== undefined && { maxTeamSize: data.maxTeamSize }),
...(data.maxTeams !== undefined && { maxTeams: data.maxTeams }),
},
});
},
async delete(id: string) {
const challenge = await prisma.challenge.findUnique({ where: { id } });
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
if (challenge.status !== 'DRAFT' && challenge.status !== 'CANCELLED') {
throw Object.assign(new Error('Can only delete DRAFT or CANCELLED challenges'), { statusCode: 400 });
}
await prisma.challenge.delete({ where: { id } });
return { success: true };
},
async activate(id: string) {
const challenge = await prisma.challenge.findUnique({ where: { id } });
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
if (challenge.status !== 'DRAFT') {
throw Object.assign(new Error('Can only activate DRAFT challenges'), { statusCode: 400 });
}
const now = new Date();
const newStatus: ChallengeStatus = challenge.startsAt <= now ? 'ACTIVE' : 'UPCOMING';
return prisma.challenge.update({
where: { id },
data: { status: newStatus },
});
},
async complete(id: string) {
const challenge = await prisma.challenge.findUnique({ where: { id } });
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
if (challenge.status !== 'ACTIVE') {
throw Object.assign(new Error('Can only complete ACTIVE challenges'), { statusCode: 400 });
}
await challengeScoringService.scoreChallenge(id);
return prisma.challenge.update({
where: { id },
data: { status: 'COMPLETED' },
});
},
async cancel(id: string) {
const challenge = await prisma.challenge.findUnique({ where: { id } });
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
if (challenge.status === 'COMPLETED' || challenge.status === 'CANCELLED') {
throw Object.assign(new Error('Challenge is already completed or cancelled'), { statusCode: 400 });
}
return prisma.challenge.update({
where: { id },
data: { status: 'CANCELLED' },
});
},
async rescore(id: string) {
const challenge = await prisma.challenge.findUnique({ where: { id } });
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
await challengeScoringService.scoreChallenge(id);
return { success: true };
},
// ── Queries ────────────────────────────────────────────────────────
async findById(id: string) {
const challenge = await prisma.challenge.findUnique({
where: { id },
include: {
createdBy: { select: USER_SELECT },
teams: {
include: {
captain: { select: USER_SELECT },
members: {
include: { user: { select: USER_SELECT } },
orderBy: { score: 'desc' },
},
},
orderBy: { score: 'desc' },
},
},
});
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
return challenge;
},
async listChallenges(page: number, limit: number, status?: ChallengeStatus) {
const where = status ? { status } : {};
const skip = (page - 1) * limit;
const [challenges, total] = await Promise.all([
prisma.challenge.findMany({
where,
include: {
_count: { select: { teams: true } },
createdBy: { select: USER_SELECT },
},
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.challenge.count({ where }),
]);
return {
challenges,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async getLeaderboard(challengeId: string) {
const teams = await prisma.challengeTeam.findMany({
where: { challengeId },
include: {
captain: { select: USER_SELECT },
members: {
include: { user: { select: USER_SELECT } },
orderBy: { score: 'desc' },
},
},
orderBy: { score: 'desc' },
});
return { teams };
},
async getTeamDetail(teamId: string) {
const team = await prisma.challengeTeam.findUnique({
where: { id: teamId },
include: {
challenge: true,
captain: { select: USER_SELECT },
members: {
include: { user: { select: USER_SELECT } },
orderBy: { score: 'desc' },
},
},
});
if (!team) {
throw Object.assign(new Error('Team not found'), { statusCode: 404 });
}
return team;
},
// ── User actions ───────────────────────────────────────────────────
async createTeam(challengeId: string, userId: string, teamName: string) {
const challenge = await prisma.challenge.findUnique({
where: { id: challengeId },
include: { _count: { select: { teams: true } } },
});
if (!challenge) {
throw Object.assign(new Error('Challenge not found'), { statusCode: 404 });
}
if (challenge.status !== 'UPCOMING' && challenge.status !== 'ACTIVE') {
throw Object.assign(new Error('Challenge is not accepting teams'), { statusCode: 400 });
}
if (challenge.maxTeams && challenge._count.teams >= challenge.maxTeams) {
throw Object.assign(new Error('Maximum number of teams reached'), { statusCode: 400 });
}
// Check user not already on a team for this challenge
const existing = await prisma.challengeTeamMember.findFirst({
where: { userId, team: { challengeId } },
});
if (existing) {
throw Object.assign(new Error('You are already on a team for this challenge'), { statusCode: 409 });
}
const team = await prisma.challengeTeam.create({
data: {
challengeId,
name: teamName,
captainUserId: userId,
members: {
create: { userId },
},
},
include: {
captain: { select: USER_SELECT },
members: { include: { user: { select: USER_SELECT } } },
},
});
return team;
},
async joinTeam(teamId: string, userId: string) {
const team = await prisma.challengeTeam.findUnique({
where: { id: teamId },
include: {
challenge: true,
_count: { select: { members: true } },
},
});
if (!team) {
throw Object.assign(new Error('Team not found'), { statusCode: 404 });
}
if (team.challenge.status !== 'UPCOMING' && team.challenge.status !== 'ACTIVE') {
throw Object.assign(new Error('Challenge is not accepting members'), { statusCode: 400 });
}
if (team._count.members >= team.challenge.maxTeamSize) {
throw Object.assign(new Error('Team is full'), { statusCode: 400 });
}
// Check user not already on another team for this challenge
const existing = await prisma.challengeTeamMember.findFirst({
where: { userId, team: { challengeId: team.challengeId } },
});
if (existing) {
throw Object.assign(new Error('You are already on a team for this challenge'), { statusCode: 409 });
}
await prisma.challengeTeamMember.create({
data: { teamId, userId },
});
return prisma.challengeTeam.findUnique({
where: { id: teamId },
include: {
captain: { select: USER_SELECT },
members: { include: { user: { select: USER_SELECT } } },
},
});
},
async leaveTeam(teamId: string, userId: string) {
const member = await prisma.challengeTeamMember.findUnique({
where: { teamId_userId: { teamId, userId } },
});
if (!member) {
throw Object.assign(new Error('You are not on this team'), { statusCode: 404 });
}
const team = await prisma.challengeTeam.findUnique({
where: { id: teamId },
include: { _count: { select: { members: true } } },
});
if (!team) {
throw Object.assign(new Error('Team not found'), { statusCode: 404 });
}
// If captain and last member, delete entire team
if (team.captainUserId === userId && team._count.members === 1) {
await prisma.challengeTeam.delete({ where: { id: teamId } });
return { success: true, teamDeleted: true };
}
// Remove member
await prisma.challengeTeamMember.delete({
where: { teamId_userId: { teamId, userId } },
});
// If captain, transfer to next member
if (team.captainUserId === userId) {
const nextMember = await prisma.challengeTeamMember.findFirst({
where: { teamId },
orderBy: { joinedAt: 'asc' },
});
if (nextMember) {
await prisma.challengeTeam.update({
where: { id: teamId },
data: { captainUserId: nextMember.userId },
});
}
}
return { success: true, teamDeleted: false };
},
async getMyTeam(challengeId: string, userId: string) {
const member = await prisma.challengeTeamMember.findFirst({
where: { userId, team: { challengeId } },
include: {
team: {
include: {
captain: { select: USER_SELECT },
members: {
include: { user: { select: USER_SELECT } },
orderBy: { score: 'desc' },
},
},
},
},
});
return member?.team ?? null;
},
};