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

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