import { prisma } from '../../config/database'; import { SpotlightStatus } from '@prisma/client'; import { notificationService } from './notification.service'; import { achievementsService } from './achievements.service'; import { logger } from '../../utils/logger'; import { AppError } from '../../middleware/error-handler'; export const spotlightService = { /** Admin: nominate a volunteer for spotlight */ async nominate( data: { userId: string; headline?: string; story?: string }, nominatedByUserId: string, ) { // Verify the user exists const user = await prisma.user.findUnique({ where: { id: data.userId } }); if (!user) throw new AppError(404, 'User not found', 'USER_NOT_FOUND'); const spotlight = await prisma.volunteerSpotlight.create({ data: { userId: data.userId, status: SpotlightStatus.NOMINATED, headline: data.headline, story: data.story, nominatedByUserId, }, include: { user: { select: { id: true, name: true, email: true } } }, }); logger.info(`Volunteer ${data.userId} nominated for spotlight by ${nominatedByUserId}`); return spotlight; }, /** Admin: approve a nomination */ async approve(id: string, approvedByUserId: string) { const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } }); if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND'); if (spotlight.status !== SpotlightStatus.NOMINATED) { throw new AppError(400, 'Only nominated spotlights can be approved', 'INVALID_STATUS'); } const updated = await prisma.volunteerSpotlight.update({ where: { id }, data: { status: SpotlightStatus.APPROVED, approvedByUserId, approvedAt: new Date(), }, include: { user: { select: { id: true, name: true, email: true } } }, }); await notificationService.createNotification( spotlight.userId, 'achievement', 'Spotlight Approved', 'Your volunteer spotlight nomination has been approved!', { spotlightId: id }, ); return updated; }, /** Admin: feature a spotlight for a specific month */ async feature(id: string, month: string) { const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } }); if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND'); if (spotlight.status !== SpotlightStatus.APPROVED) { throw new AppError(400, 'Only approved spotlights can be featured', 'INVALID_STATUS'); } const updated = await prisma.volunteerSpotlight.update({ where: { id }, data: { status: SpotlightStatus.FEATURED, featuredMonth: month, }, include: { user: { select: { id: true, name: true, email: true } } }, }); await notificationService.createNotification( spotlight.userId, 'achievement', 'You\'re Featured!', `You have been featured as a Volunteer Spotlight for ${month}!`, { spotlightId: id, featuredMonth: month }, ); logger.info(`Spotlight ${id} featured for ${month}`); return updated; }, /** Admin: archive a spotlight */ async archive(id: string) { const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } }); if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND'); return prisma.volunteerSpotlight.update({ where: { id }, data: { status: SpotlightStatus.ARCHIVED }, include: { user: { select: { id: true, name: true, email: true } } }, }); }, /** Admin: update headline/story */ async update(id: string, data: { headline?: string; story?: string }) { const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } }); if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND'); return prisma.volunteerSpotlight.update({ where: { id }, data, include: { user: { select: { id: true, name: true, email: true } } }, }); }, /** Admin: delete a spotlight */ async delete(id: string) { const spotlight = await prisma.volunteerSpotlight.findUnique({ where: { id } }); if (!spotlight) throw new AppError(404, 'Spotlight not found', 'SPOTLIGHT_NOT_FOUND'); await prisma.volunteerSpotlight.delete({ where: { id } }); return { success: true }; }, /** Admin: list all spotlights (paginated, filterable) */ async listAll(page: number, limit: number, status?: SpotlightStatus) { const where = status ? { status } : {}; const skip = (page - 1) * limit; const [spotlights, total] = await Promise.all([ prisma.volunteerSpotlight.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: limit, include: { user: { select: { id: true, name: true, email: true } }, nominatedBy: { select: { id: true, name: true } }, approvedBy: { select: { id: true, name: true } }, }, }), prisma.volunteerSpotlight.count({ where }), ]); return { spotlights, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, }; }, /** Public: get currently featured spotlights (this month) */ async getCurrentFeatured() { const now = new Date(); const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; return prisma.volunteerSpotlight.findMany({ where: { status: SpotlightStatus.FEATURED, featuredMonth: currentMonth, }, include: { user: { select: { id: true, name: true } }, }, orderBy: { createdAt: 'desc' }, }); }, /** Public: get leaderboard filtered by showOnLeaderboard privacy setting */ async getPublicLeaderboard(type: 'canvass' | 'shifts' | 'campaigns', limit: number) { // Get a larger set from achievements service, then filter by privacy const entries = await achievementsService.getLeaderboard(type, limit * 3); if (entries.length === 0) return []; // Filter by showOnLeaderboard const userIds = entries.map((e) => e.userId); const hiddenIds = new Set( (await prisma.privacySettings.findMany({ where: { userId: { in: userIds }, showOnLeaderboard: false, }, select: { userId: true }, })).map((p) => p.userId), ); const visible = entries .filter((e) => !hiddenIds.has(e.userId)) .slice(0, limit) .map((e, i) => ({ ...e, rank: i + 1 })); return visible; }, /** Public: wall of fame — all featured spotlights */ async getWallOfFame(page: number, limit: number) { const skip = (page - 1) * limit; const [spotlights, total] = await Promise.all([ prisma.volunteerSpotlight.findMany({ where: { status: SpotlightStatus.FEATURED }, orderBy: { featuredMonth: 'desc' }, skip, take: limit, include: { user: { select: { id: true, name: true } }, }, }), prisma.volunteerSpotlight.count({ where: { status: SpotlightStatus.FEATURED } }), ]); return { spotlights, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, }; }, /** User: get opt-in status for leaderboard */ async getOptInStatus(userId: string) { const settings = await prisma.privacySettings.findUnique({ where: { userId }, select: { showOnLeaderboard: true }, }); return { showOnLeaderboard: settings?.showOnLeaderboard ?? true }; }, /** User: opt in to leaderboard */ async optIn(userId: string) { await prisma.privacySettings.upsert({ where: { userId }, update: { showOnLeaderboard: true }, create: { userId, showOnLeaderboard: true }, }); return { showOnLeaderboard: true }; }, /** User: opt out of leaderboard */ async optOut(userId: string) { await prisma.privacySettings.upsert({ where: { userId }, update: { showOnLeaderboard: false }, create: { userId, showOnLeaderboard: false }, }); return { showOnLeaderboard: false }; }, };