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