- 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
348 lines
11 KiB
TypeScript
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;
|
|
},
|
|
};
|