Full-stack implementation across 7 sprints: - Backend: 5 Prisma models (StrawPoll, Option, Vote, Comment, Challenge), 4 enums, POLLS_ADMIN role, admin CRUD routes, public voting/SSE/widget endpoints, BullMQ auto-close queue, rate limiting - Admin: StrawPollsPage with inline drawers (campaigns pattern), PollResults bar chart, sidebar under Advocacy - Public: dedicated poll lander with real-time SSE updates, browse page, anonymous voting with token dedup - MkDocs: straw-poll-widget.js hydration (inline vote + card link modes), GrapesJS block types - Social: feed activity (poll_voted), friend badge integration, challenge notifications, notification preferences - Feature flag: enablePolls toggle in Settings, FeatureGate, Zod schema Bunker Admin
398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
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<string, unknown>;
|
|
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<FeedItem[]> {
|
|
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<FeedItem[]> {
|
|
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<FeedItem[]> {
|
|
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<FeedItem[]> {
|
|
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<FeedItem[]> {
|
|
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<FeedItem[]> {
|
|
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<FeedItem[]> {
|
|
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<FeedItem[]> {
|
|
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<FeedItem[]> {
|
|
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,
|
|
}));
|
|
},
|
|
};
|