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