- 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
246 lines
8.0 KiB
TypeScript
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 };
|
|
},
|
|
};
|