- 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
499 lines
14 KiB
TypeScript
499 lines
14 KiB
TypeScript
import { prisma } from '../../config/database';
|
|
import { SignupStatus } from '@prisma/client';
|
|
import { notificationService } from './notification.service';
|
|
import { logger } from '../../utils/logger';
|
|
|
|
/** Achievement definition */
|
|
interface AchievementDef {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
icon: string;
|
|
category: 'shifts' | 'canvass' | 'campaigns' | 'social';
|
|
threshold: number;
|
|
/** Function to compute current progress for a user */
|
|
getProgress: (userId: string) => Promise<number>;
|
|
}
|
|
|
|
/** Static achievement registry */
|
|
const ACHIEVEMENTS: AchievementDef[] = [
|
|
// Shift achievements
|
|
{
|
|
id: 'FIRST_SHIFT',
|
|
name: 'First Steps',
|
|
description: 'Sign up for your first volunteer shift',
|
|
icon: 'calendar',
|
|
category: 'shifts',
|
|
threshold: 1,
|
|
getProgress: async (userId) => {
|
|
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
|
|
if (!user) return 0;
|
|
return prisma.shiftSignup.count({
|
|
where: { userEmail: user.email, status: SignupStatus.CONFIRMED },
|
|
});
|
|
},
|
|
},
|
|
{
|
|
id: 'SHIFT_STREAK_3',
|
|
name: 'Reliable Volunteer',
|
|
description: 'Sign up for 3 volunteer shifts',
|
|
icon: 'calendar',
|
|
category: 'shifts',
|
|
threshold: 3,
|
|
getProgress: async (userId) => {
|
|
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
|
|
if (!user) return 0;
|
|
return prisma.shiftSignup.count({
|
|
where: { userEmail: user.email, status: SignupStatus.CONFIRMED },
|
|
});
|
|
},
|
|
},
|
|
{
|
|
id: 'SHIFT_STREAK_10',
|
|
name: 'Shift Champion',
|
|
description: 'Sign up for 10 volunteer shifts',
|
|
icon: 'trophy',
|
|
category: 'shifts',
|
|
threshold: 10,
|
|
getProgress: async (userId) => {
|
|
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
|
|
if (!user) return 0;
|
|
return prisma.shiftSignup.count({
|
|
where: { userEmail: user.email, status: SignupStatus.CONFIRMED },
|
|
});
|
|
},
|
|
},
|
|
// Canvass achievements
|
|
{
|
|
id: 'FIRST_CANVASS',
|
|
name: 'Door Knocker',
|
|
description: 'Complete your first canvass session',
|
|
icon: 'environment',
|
|
category: 'canvass',
|
|
threshold: 1,
|
|
getProgress: async (userId) =>
|
|
prisma.canvassSession.count({
|
|
where: { userId, status: 'COMPLETED' },
|
|
}),
|
|
},
|
|
{
|
|
id: 'CANVASS_50_DOORS',
|
|
name: 'Neighbourhood Explorer',
|
|
description: 'Record 50 canvass visits',
|
|
icon: 'home',
|
|
category: 'canvass',
|
|
threshold: 50,
|
|
getProgress: async (userId) =>
|
|
prisma.canvassVisit.count({
|
|
where: { session: { userId } },
|
|
}),
|
|
},
|
|
{
|
|
id: 'CANVASS_100_DOORS',
|
|
name: 'Community Connector',
|
|
description: 'Record 100 canvass visits',
|
|
icon: 'home',
|
|
category: 'canvass',
|
|
threshold: 100,
|
|
getProgress: async (userId) =>
|
|
prisma.canvassVisit.count({
|
|
where: { session: { userId } },
|
|
}),
|
|
},
|
|
{
|
|
id: 'CANVASS_500_DOORS',
|
|
name: 'Door-to-Door Legend',
|
|
description: 'Record 500 canvass visits',
|
|
icon: 'star',
|
|
category: 'canvass',
|
|
threshold: 500,
|
|
getProgress: async (userId) =>
|
|
prisma.canvassVisit.count({
|
|
where: { session: { userId } },
|
|
}),
|
|
},
|
|
// Campaign achievements
|
|
{
|
|
id: 'FIRST_CAMPAIGN_EMAIL',
|
|
name: 'Voice Heard',
|
|
description: 'Send your first advocacy email',
|
|
icon: 'mail',
|
|
category: 'campaigns',
|
|
threshold: 1,
|
|
getProgress: async (userId) =>
|
|
prisma.campaignEmail.count({
|
|
where: { userId },
|
|
}),
|
|
},
|
|
{
|
|
id: 'CAMPAIGN_CHAMPION',
|
|
name: 'Campaign Champion',
|
|
description: 'Participate in 5 different campaigns',
|
|
icon: 'fire',
|
|
category: 'campaigns',
|
|
threshold: 5,
|
|
getProgress: async (userId) => {
|
|
const result = await prisma.campaignEmail.findMany({
|
|
where: { userId },
|
|
distinct: ['campaignId'],
|
|
select: { campaignId: true },
|
|
});
|
|
return result.length;
|
|
},
|
|
},
|
|
// Social achievements
|
|
{
|
|
id: 'SOCIAL_BUTTERFLY',
|
|
name: 'Social Butterfly',
|
|
description: 'Make 10 friends on the platform',
|
|
icon: 'team',
|
|
category: 'social',
|
|
threshold: 10,
|
|
getProgress: async (userId) =>
|
|
prisma.friendship.count({
|
|
where: {
|
|
status: 'accepted',
|
|
OR: [{ userId }, { friendId: userId }],
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
id: 'TEAM_PLAYER',
|
|
name: 'Team Player',
|
|
description: 'Be a member of 3 groups',
|
|
icon: 'usergroup-add',
|
|
category: 'social',
|
|
threshold: 3,
|
|
getProgress: async (userId) =>
|
|
prisma.socialGroupMember.count({
|
|
where: { userId },
|
|
}),
|
|
},
|
|
// Referral achievements
|
|
{
|
|
id: 'FIRST_REFERRAL',
|
|
name: 'Ambassador',
|
|
description: 'Refer your first friend to the platform',
|
|
icon: 'usergroup-add',
|
|
category: 'social',
|
|
threshold: 1,
|
|
getProgress: async (userId) =>
|
|
prisma.referral.count({ where: { referrerId: userId } }),
|
|
},
|
|
{
|
|
id: 'REFERRAL_5',
|
|
name: 'Recruiter',
|
|
description: 'Refer 5 friends to the platform',
|
|
icon: 'usergroup-add',
|
|
category: 'social',
|
|
threshold: 5,
|
|
getProgress: async (userId) =>
|
|
prisma.referral.count({ where: { referrerId: userId } }),
|
|
},
|
|
{
|
|
id: 'REFERRAL_25',
|
|
name: 'Movement Builder',
|
|
description: 'Refer 25 friends to the platform',
|
|
icon: 'star',
|
|
category: 'social',
|
|
threshold: 25,
|
|
getProgress: async (userId) =>
|
|
prisma.referral.count({ where: { referrerId: userId } }),
|
|
},
|
|
// Campaign milestone achievement
|
|
{
|
|
id: 'MILESTONE_CONTRIBUTOR',
|
|
name: 'Milestone Maker',
|
|
description: 'Participate in a campaign that reaches a milestone',
|
|
icon: 'trophy',
|
|
category: 'campaigns',
|
|
threshold: 1,
|
|
getProgress: async (userId) => {
|
|
const result = await prisma.campaignEmail.findMany({
|
|
where: {
|
|
userId,
|
|
campaign: { milestones: { some: {} } },
|
|
},
|
|
distinct: ['campaignId'],
|
|
select: { campaignId: true },
|
|
});
|
|
return result.length;
|
|
},
|
|
},
|
|
// Spotlight achievement
|
|
{
|
|
id: 'SPOTLIGHT_STAR',
|
|
name: 'Spotlight Star',
|
|
description: 'Be featured as a volunteer spotlight',
|
|
icon: 'star',
|
|
category: 'social',
|
|
threshold: 1,
|
|
getProgress: async (userId) =>
|
|
prisma.volunteerSpotlight.count({
|
|
where: { userId, status: 'FEATURED' },
|
|
}),
|
|
},
|
|
// Challenge achievements
|
|
{
|
|
id: 'FIRST_CHALLENGE',
|
|
name: 'Challenger',
|
|
description: 'Join your first team challenge',
|
|
icon: 'trophy',
|
|
category: 'social',
|
|
threshold: 1,
|
|
getProgress: async (userId) =>
|
|
prisma.challengeTeamMember.count({ where: { userId } }),
|
|
},
|
|
{
|
|
id: 'CHALLENGE_WINNER',
|
|
name: 'Champion',
|
|
description: 'Win a team challenge',
|
|
icon: 'crown',
|
|
category: 'social',
|
|
threshold: 1,
|
|
getProgress: async (userId) => {
|
|
// Count challenges where user's team has the highest score and challenge is COMPLETED
|
|
const teams = await prisma.challengeTeamMember.findMany({
|
|
where: { userId },
|
|
include: {
|
|
team: {
|
|
include: {
|
|
challenge: { select: { id: true, status: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
let wins = 0;
|
|
for (const membership of teams) {
|
|
if (membership.team.challenge.status !== 'COMPLETED') continue;
|
|
const topTeam = await prisma.challengeTeam.findFirst({
|
|
where: { challengeId: membership.team.challenge.id },
|
|
orderBy: { score: 'desc' },
|
|
select: { id: true },
|
|
});
|
|
if (topTeam?.id === membership.teamId) wins++;
|
|
}
|
|
return wins;
|
|
},
|
|
},
|
|
];
|
|
|
|
/** Achievement map for quick lookup */
|
|
const ACHIEVEMENT_MAP = new Map(ACHIEVEMENTS.map((a) => [a.id, a]));
|
|
|
|
export const achievementsService = {
|
|
/** Get all achievement definitions */
|
|
getDefinitions() {
|
|
return ACHIEVEMENTS.map(({ getProgress, ...rest }) => rest);
|
|
},
|
|
|
|
/** Get a user's achievements with progress */
|
|
async listForUser(userId: string) {
|
|
const unlocked = await prisma.userAchievement.findMany({
|
|
where: { userId },
|
|
});
|
|
const unlockedMap = new Map(unlocked.map((u) => [u.achievementId, u]));
|
|
|
|
const results = await Promise.all(
|
|
ACHIEVEMENTS.map(async ({ getProgress, ...def }) => {
|
|
const record = unlockedMap.get(def.id);
|
|
let progress: number;
|
|
|
|
if (record) {
|
|
// Already unlocked — use stored progress (at least threshold)
|
|
progress = Math.max(record.progress ?? def.threshold, def.threshold);
|
|
} else {
|
|
// Compute current progress
|
|
progress = await getProgress(userId);
|
|
}
|
|
|
|
return {
|
|
...def,
|
|
progress,
|
|
unlocked: !!record,
|
|
unlockedAt: record?.unlockedAt ?? null,
|
|
};
|
|
}),
|
|
);
|
|
|
|
return results;
|
|
},
|
|
|
|
/** Check and unlock achievements for a user after a specific event */
|
|
async checkAndUnlock(userId: string, categories?: string[]) {
|
|
const toCheck = categories
|
|
? ACHIEVEMENTS.filter((a) => categories.includes(a.category))
|
|
: ACHIEVEMENTS;
|
|
|
|
for (const achievement of toCheck) {
|
|
try {
|
|
// Skip if already unlocked
|
|
const existing = await prisma.userAchievement.findUnique({
|
|
where: { userId_achievementId: { userId, achievementId: achievement.id } },
|
|
});
|
|
if (existing) continue;
|
|
|
|
const progress = await achievement.getProgress(userId);
|
|
if (progress >= achievement.threshold) {
|
|
await prisma.userAchievement.create({
|
|
data: {
|
|
userId,
|
|
achievementId: achievement.id,
|
|
unlockedAt: new Date(),
|
|
progress,
|
|
notified: false,
|
|
},
|
|
});
|
|
|
|
// Create in-app notification
|
|
await notificationService.createNotification(
|
|
userId,
|
|
'achievement',
|
|
'Achievement Unlocked!',
|
|
`You earned "${achievement.name}" — ${achievement.description}`,
|
|
{ achievementId: achievement.id, icon: achievement.icon },
|
|
);
|
|
|
|
logger.info(`Achievement unlocked: ${achievement.id} for user ${userId}`);
|
|
}
|
|
} catch (err) {
|
|
logger.warn(`Failed to check achievement ${achievement.id} for ${userId}:`, err);
|
|
}
|
|
}
|
|
},
|
|
|
|
/** Get leaderboard by metric */
|
|
async getLeaderboard(type: 'canvass' | 'shifts' | 'campaigns', limit = 10) {
|
|
if (type === 'canvass') {
|
|
// Rank by total canvass visits
|
|
const results = await prisma.$queryRaw<{ userId: string; count: bigint }[]>`
|
|
SELECT cs."userId", COUNT(cv.id) AS count
|
|
FROM canvass_sessions cs
|
|
JOIN canvass_visits cv ON cv."sessionId" = cs.id
|
|
WHERE cs."userId" IS NOT NULL
|
|
GROUP BY cs."userId"
|
|
ORDER BY count DESC
|
|
LIMIT ${limit}
|
|
`;
|
|
|
|
return this.hydrateLeaderboard(results.map((r) => ({
|
|
userId: r.userId,
|
|
score: Number(r.count),
|
|
})));
|
|
}
|
|
|
|
if (type === 'shifts') {
|
|
// Rank by total confirmed shift signups
|
|
const results = await prisma.$queryRaw<{ userId: string; count: bigint }[]>`
|
|
SELECT ss."userId", COUNT(ss.id) AS count
|
|
FROM shift_signups ss
|
|
WHERE ss."userId" IS NOT NULL AND ss.status = 'CONFIRMED'
|
|
GROUP BY ss."userId"
|
|
ORDER BY count DESC
|
|
LIMIT ${limit}
|
|
`;
|
|
|
|
return this.hydrateLeaderboard(results.map((r) => ({
|
|
userId: r.userId,
|
|
score: Number(r.count),
|
|
})));
|
|
}
|
|
|
|
// campaigns — rank by distinct campaigns participated
|
|
const results = await prisma.$queryRaw<{ userId: string; count: bigint }[]>`
|
|
SELECT ce."userId", COUNT(DISTINCT ce."campaignId") AS count
|
|
FROM campaign_emails ce
|
|
WHERE ce."userId" IS NOT NULL
|
|
GROUP BY ce."userId"
|
|
ORDER BY count DESC
|
|
LIMIT ${limit}
|
|
`;
|
|
|
|
return this.hydrateLeaderboard(results.map((r) => ({
|
|
userId: r.userId,
|
|
score: Number(r.count),
|
|
})));
|
|
},
|
|
|
|
/** Hydrate leaderboard entries with user data */
|
|
async hydrateLeaderboard(entries: { userId: string; score: number }[]) {
|
|
if (entries.length === 0) return [];
|
|
|
|
// Filter out users who hide from activity feeds
|
|
const hiddenIds = new Set(
|
|
(await prisma.privacySettings.findMany({
|
|
where: {
|
|
userId: { in: entries.map((e) => e.userId) },
|
|
showInFriendActivity: false,
|
|
},
|
|
select: { userId: true },
|
|
})).map((p) => p.userId),
|
|
);
|
|
|
|
const visibleEntries = entries.filter((e) => !hiddenIds.has(e.userId));
|
|
|
|
const users = await prisma.user.findMany({
|
|
where: { id: { in: visibleEntries.map((e) => e.userId) } },
|
|
select: { id: true, name: true, email: true },
|
|
});
|
|
const userMap = new Map(users.map((u) => [u.id, u]));
|
|
|
|
return visibleEntries.map((e, i) => ({
|
|
rank: i + 1,
|
|
userId: e.userId,
|
|
name: userMap.get(e.userId)?.name || null,
|
|
email: userMap.get(e.userId)?.email || '',
|
|
score: e.score,
|
|
}));
|
|
},
|
|
|
|
/** Get a user's rank in a specific leaderboard */
|
|
async getUserRank(userId: string, type: 'canvass' | 'shifts' | 'campaigns') {
|
|
const leaderboard = await this.getLeaderboard(type, 1000);
|
|
const idx = leaderboard.findIndex((e) => e.userId === userId);
|
|
return idx >= 0 ? idx + 1 : null;
|
|
},
|
|
|
|
/** Get volunteer stats for a user (computed on-the-fly) */
|
|
async getVolunteerStats(userId: string) {
|
|
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
|
|
if (!user) return null;
|
|
|
|
const [shiftSignups, canvassSessions, canvassVisits, campaignEmails, distinctCampaigns, friendCount, groupCount] = await Promise.all([
|
|
prisma.shiftSignup.count({
|
|
where: { userEmail: user.email, status: SignupStatus.CONFIRMED },
|
|
}),
|
|
prisma.canvassSession.count({
|
|
where: { userId, status: 'COMPLETED' },
|
|
}),
|
|
prisma.canvassVisit.count({
|
|
where: { session: { userId } },
|
|
}),
|
|
prisma.campaignEmail.count({
|
|
where: { userId },
|
|
}),
|
|
prisma.campaignEmail.findMany({
|
|
where: { userId },
|
|
distinct: ['campaignId'],
|
|
select: { campaignId: true },
|
|
}),
|
|
prisma.friendship.count({
|
|
where: { status: 'accepted', OR: [{ userId }, { friendId: userId }] },
|
|
}),
|
|
prisma.socialGroupMember.count({
|
|
where: { userId },
|
|
}),
|
|
]);
|
|
|
|
return {
|
|
shiftSignups,
|
|
canvassSessions,
|
|
canvassVisits,
|
|
campaignEmails,
|
|
campaignsParticipated: distinctCampaigns.length,
|
|
friendCount,
|
|
groupCount,
|
|
};
|
|
},
|
|
};
|