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

246 lines
8.0 KiB
TypeScript

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