bunker-admin 902adce646 Add Straw Polls feature: quick opinion polling with public landers, MkDocs widgets, and social integration
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
2026-03-31 10:16:56 -06:00

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