import { prisma } from '../../config/database'; import { redis } from '../../config/redis'; import { friendshipService } from './friendship.service'; /** A unified feed item representing any activity type */ export interface FeedItem { id: string; type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted' | 'impact_story' | 'volunteer_featured' | 'referral_completed' | 'challenge_completed' | 'poll_voted'; userId: string; userName: string | null; userEmail: string; title: string; description: string; metadata: Record; timestamp: Date; } const FEED_CACHE_TTL = 120; // 2 minutes const FEED_MAX_AGE_DAYS = 30; const FEED_MAX_ITEMS = 50; export const feedService = { /** * Get a combined feed of friends' recent activities. * Aggregates shift signups, campaign emails, canvass sessions, and response submissions. * Results are cached in Redis for 2 minutes. */ async getFriendFeed(userId: string, page: number, limit: number) { const cacheKey = `social:feed:${userId}:${page}:${limit}`; // Check cache const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } // Get friend IDs + their privacy settings const friendIds = await friendshipService.getFriendIds(userId); if (friendIds.length === 0) { return { items: [], pagination: { page, limit, total: 0, totalPages: 0 } }; } // Filter out friends who have showInFriendActivity disabled const privacySettings = await prisma.privacySettings.findMany({ where: { userId: { in: friendIds }, showInFriendActivity: false }, select: { userId: true }, }); const hiddenIds = new Set(privacySettings.map((p) => p.userId)); const visibleFriendIds = friendIds.filter((id) => !hiddenIds.has(id)); if (visibleFriendIds.length === 0) { return { items: [], pagination: { page, limit, total: 0, totalPages: 0 } }; } const since = new Date(); since.setDate(since.getDate() - FEED_MAX_AGE_DAYS); // Query all activity types in parallel const [shiftSignups, campaignEmails, canvassSessions, responses, impactStories, spotlights, referrals, challenges, pollVotes] = await Promise.all([ this.getShiftSignupActivities(visibleFriendIds, since), this.getCampaignEmailActivities(visibleFriendIds, since), this.getCanvassSessionActivities(visibleFriendIds, since), this.getResponseActivities(visibleFriendIds, since), this.getImpactStoryActivities(since), this.getSpotlightActivities(since), this.getReferralActivities(visibleFriendIds, since), this.getChallengeActivities(since), this.getStrawPollVoteActivities(visibleFriendIds, since), ]); // Merge and sort by timestamp descending const allItems: FeedItem[] = [ ...shiftSignups, ...campaignEmails, ...canvassSessions, ...responses, ...impactStories, ...spotlights, ...referrals, ...challenges, ...pollVotes, ].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Cap total items const cappedItems = allItems.slice(0, FEED_MAX_ITEMS); const total = cappedItems.length; const totalPages = Math.ceil(total / limit); const skip = (page - 1) * limit; const items = cappedItems.slice(skip, skip + limit); const result = { items, pagination: { page, limit, total, totalPages }, }; // Cache for 2 minutes await redis.setex(cacheKey, FEED_CACHE_TTL, JSON.stringify(result)); return result; }, /** Get own activity for profile display */ async getMyActivity(userId: string, page: number, limit: number) { const since = new Date(); since.setDate(since.getDate() - FEED_MAX_AGE_DAYS); const [shiftSignups, campaignEmails, canvassSessions, responses, referrals] = await Promise.all([ this.getShiftSignupActivities([userId], since), this.getCampaignEmailActivities([userId], since), this.getCanvassSessionActivities([userId], since), this.getResponseActivities([userId], since), this.getReferralActivities([userId], since), ]); const allItems: FeedItem[] = [ ...shiftSignups, ...campaignEmails, ...canvassSessions, ...responses, ...referrals, ].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); const total = allItems.length; const totalPages = Math.ceil(total / limit); const skip = (page - 1) * limit; const items = allItems.slice(skip, skip + limit); return { items, pagination: { page, limit, total, totalPages } }; }, // --- Activity type queries --- async getShiftSignupActivities(userIds: string[], since: Date): Promise { const signups = await prisma.shiftSignup.findMany({ where: { userId: { in: userIds }, signupDate: { gte: since }, status: 'CONFIRMED', }, include: { user: { select: { id: true, name: true, email: true } }, shift: { select: { id: true, title: true, startTime: true } }, }, orderBy: { signupDate: 'desc' }, take: FEED_MAX_ITEMS, }); return signups .filter((s) => s.user) .map((s) => ({ id: `shift_signup:${s.id}`, type: 'shift_signup' as const, userId: s.user!.id, userName: s.user!.name, userEmail: s.user!.email, title: 'Signed up for a shift', description: s.shift.title, metadata: { shiftId: s.shift.id, shiftTitle: s.shift.title, startTime: s.shift.startTime }, timestamp: s.signupDate, })); }, async getCampaignEmailActivities(userIds: string[], since: Date): Promise { const emails = await prisma.campaignEmail.findMany({ where: { userId: { in: userIds }, sentAt: { gte: since }, }, include: { user: { select: { id: true, name: true, email: true } }, campaign: { select: { id: true, title: true, slug: true } }, }, orderBy: { sentAt: 'desc' }, take: FEED_MAX_ITEMS, }); return emails .filter((e) => e.user) .map((e) => ({ id: `campaign_email:${e.id}`, type: 'campaign_email' as const, userId: e.user!.id, userName: e.user!.name, userEmail: e.user!.email, title: 'Participated in a campaign', description: e.campaign.title, metadata: { campaignId: e.campaign.id, campaignSlug: e.campaign.slug }, timestamp: e.sentAt!, })); }, async getCanvassSessionActivities(userIds: string[], since: Date): Promise { const sessions = await prisma.canvassSession.findMany({ where: { userId: { in: userIds }, startedAt: { gte: since }, status: 'COMPLETED', }, include: { user: { select: { id: true, name: true, email: true } }, cut: { select: { id: true, name: true } }, _count: { select: { visits: true } }, }, orderBy: { startedAt: 'desc' }, take: FEED_MAX_ITEMS, }); return sessions.map((s) => ({ id: `canvass_session:${s.id}`, type: 'canvass_session' as const, userId: s.user.id, userName: s.user.name, userEmail: s.user.email, title: 'Completed a canvass session', description: `${s._count.visits} doors in ${s.cut.name}`, metadata: { cutId: s.cut.id, cutName: s.cut.name, visitCount: s._count.visits }, timestamp: s.startedAt, })); }, async getResponseActivities(userIds: string[], since: Date): Promise { const responses = await prisma.representativeResponse.findMany({ where: { submittedByUserId: { in: userIds }, createdAt: { gte: since }, status: 'APPROVED', }, include: { submittedByUser: { select: { id: true, name: true, email: true } }, campaign: { select: { id: true, title: true, slug: true } }, }, orderBy: { createdAt: 'desc' }, take: FEED_MAX_ITEMS, }); return responses .filter((r) => r.submittedByUser) .map((r) => ({ id: `response:${r.id}`, type: 'response_submitted' as const, userId: r.submittedByUser!.id, userName: r.submittedByUser!.name, userEmail: r.submittedByUser!.email, title: 'Submitted a representative response', description: `Response from ${r.representativeName} on ${r.campaign.title}`, metadata: { campaignId: r.campaign.id, representativeName: r.representativeName }, timestamp: r.createdAt, })); }, async getImpactStoryActivities(since: Date): Promise { const stories = await prisma.impactStory.findMany({ where: { status: 'PUBLISHED', publishedAt: { gte: since }, }, include: { campaign: { select: { id: true, title: true, slug: true } }, createdBy: { select: { id: true, name: true, email: true } }, }, orderBy: { publishedAt: 'desc' }, take: FEED_MAX_ITEMS, }); return stories.map((s) => ({ id: `impact_story:${s.id}`, type: 'impact_story' as const, userId: s.createdBy?.id || '', userName: s.createdBy?.name || null, userEmail: s.createdBy?.email || '', title: s.title, description: `${s.campaign.title}${s.milestoneValue ? ` — ${s.milestoneValue} ${s.milestoneMetric || 'milestone'}` : ''}`, metadata: { campaignId: s.campaign.id, campaignSlug: s.campaign.slug, storyType: s.type }, timestamp: s.publishedAt || s.createdAt, })); }, async getSpotlightActivities(since: Date): Promise { const spotlights = await prisma.volunteerSpotlight.findMany({ where: { status: 'FEATURED', updatedAt: { gte: since }, }, include: { user: { select: { id: true, name: true, email: true } }, }, orderBy: { updatedAt: 'desc' }, take: FEED_MAX_ITEMS, }); return spotlights.map((s) => ({ id: `spotlight:${s.id}`, type: 'volunteer_featured' as const, userId: s.user.id, userName: s.user.name, userEmail: s.user.email, title: 'Featured as Volunteer Spotlight', description: s.headline || `Spotlight for ${s.featuredMonth || 'this month'}`, metadata: { spotlightId: s.id, featuredMonth: s.featuredMonth }, timestamp: s.updatedAt, })); }, async getReferralActivities(userIds: string[], since: Date): Promise { const referrals = await prisma.referral.findMany({ where: { referrerId: { in: userIds }, completedAt: { gte: since }, }, include: { referrer: { select: { id: true, name: true, email: true } }, referredUser: { select: { id: true, name: true } }, }, orderBy: { completedAt: 'desc' }, take: FEED_MAX_ITEMS, }); return referrals.map((r) => ({ id: `referral:${r.id}`, type: 'referral_completed' as const, userId: r.referrer.id, userName: r.referrer.name, userEmail: r.referrer.email, title: 'Referred a new member', description: `${r.referredUser.name || 'A new member'} joined the platform`, metadata: { referredUserId: r.referredUser.id }, timestamp: r.completedAt, })); }, async getChallengeActivities(since: Date): Promise { const challenges = await prisma.challenge.findMany({ where: { status: 'COMPLETED', updatedAt: { gte: since }, }, include: { teams: { orderBy: { score: 'desc' }, take: 1, include: { captain: { select: { id: true, name: true, email: true } }, }, }, }, orderBy: { updatedAt: 'desc' }, take: FEED_MAX_ITEMS, }); return challenges .filter((c) => c.teams.length > 0) .map((c) => { const winner = c.teams[0]; return { id: `challenge:${c.id}`, type: 'challenge_completed' as const, userId: winner.captain.id, userName: winner.captain.name, userEmail: winner.captain.email, title: `Challenge completed: ${c.title}`, description: `Team "${winner.name}" won with ${winner.score} points`, metadata: { challengeId: c.id, winningTeamId: winner.id, metric: c.metric }, timestamp: c.updatedAt, }; }); }, async getStrawPollVoteActivities(userIds: string[], since: Date): Promise { const votes = await prisma.strawPollVote.findMany({ where: { userId: { in: userIds }, createdAt: { gte: since }, }, include: { user: { select: { id: true, name: true, email: true } }, poll: { select: { id: true, slug: true, title: true } }, option: { select: { label: true } }, }, orderBy: { createdAt: 'desc' }, take: 20, }); return votes .filter((v) => v.user) .map((v) => ({ id: `poll_vote:${v.id}`, type: 'poll_voted' as const, userId: v.user!.id, userName: v.user!.name, userEmail: v.user!.email, title: `Voted on "${v.poll.title}"`, description: `Chose: ${v.option.label}`, metadata: { pollId: v.poll.id, pollSlug: v.poll.slug }, timestamp: v.createdAt, })); }, };