changemaker.lite/api/src/modules/social/notification.service.ts
bunker-admin 900a0affe5 Add CRM activity enrichment, notification bridging, crash-safe scheduled jobs, and quick wins
Workstream A — CRM & Notifications:
- Add fire-and-forget CRM activity helper (api/src/utils/crm-activity.ts) hooked into
  campaign email, canvass visit, donation, and purchase write sites
- Add 5 operational NotificationType enum values (shift_signup_confirmed, shift_reminder,
  shift_cancelled, canvass_session_summary, reengagement) via Prisma migration
- Bridge notification email queue to in-app notifications for volunteer-facing events
- Extend TYPE_TO_PREF map and NotificationsPage labels for new types

Workstream B — Quick Wins:
- Extract shared role constants (11 roles) to admin/src/utils/role-constants.ts,
  update 4 consuming pages
- Add Ad Analytics sidebar entry in payments submenu
- Gate 6 calendar routes with enableSocialCalendar feature flag
- Add GET /series/:id/count endpoint and fix hardcoded shiftsCount={0} in ShiftsPage
- Add influenceCampaignId to Order model for donation-campaign attribution,
  wire through Stripe checkout metadata

Workstream C — Crash-Safe Scheduled Jobs:
- Create BullMQ scheduled-jobs queue with 10 repeatable job types replacing
  setInterval blocks in server.ts (dynamic imports, concurrency: 2)
- Keep presenceService (1min) and challengeScoringService (5min) as setInterval

Bunker Admin
2026-03-09 14:15:30 -06:00

158 lines
4.9 KiB
TypeScript

import { prisma } from '../../config/database';
import type { NotificationType, Prisma } from '@prisma/client';
import { sseService } from './sse.service';
/** Maps NotificationType → NotificationPreferences field name */
const TYPE_TO_PREF: Record<string, string> = {
friend_request: 'enableFriendRequests',
friend_accepted: 'enableFriendRequests',
poke: 'enableFriendRequests',
comment: 'enableComments',
upload_approved: 'enableUploadApprovals',
upload_rejected: 'enableUploadApprovals',
achievement: 'enableAchievements',
system: 'enableSystemUpdates',
group_call: 'enableSystemUpdates',
impact_story: 'enableSystemUpdates',
referral_completed: 'enableSystemUpdates',
challenge_update: 'enableSystemUpdates',
shared_view_invite: 'enableFriendRequests',
shared_view_accepted: 'enableFriendRequests',
calendar_event_invite: 'enableFriendRequests',
// Operational notification types
shift_signup_confirmed: 'enableSystemUpdates',
shift_reminder: 'enableSystemUpdates',
shift_cancelled: 'enableSystemUpdates',
canvass_session_summary: 'enableSystemUpdates',
reengagement: 'enableSystemUpdates',
};
export const notificationService = {
/** Create a notification (respects user preferences) */
async createNotification(
userId: string,
type: NotificationType,
title: string,
message: string,
metadata?: Record<string, unknown>,
) {
// Check user preferences — skip if they've disabled this type
const prefs = await prisma.notificationPreferences.findUnique({ where: { userId } });
if (prefs) {
const prefField = TYPE_TO_PREF[type];
if (prefField && (prefs as Record<string, unknown>)[prefField] === false) {
return null; // User opted out
}
}
const notification = await prisma.notification.create({
data: {
userId,
type,
title,
message,
metadata: metadata ? (metadata as Prisma.InputJsonValue) : undefined,
},
});
// Push via SSE if user is connected (real-time delivery)
sseService.sendToUser(userId, 'notification', {
id: notification.id,
type: notification.type,
title: notification.title,
message: notification.message,
metadata: notification.metadata,
createdAt: notification.createdAt,
});
return notification;
},
/** List notifications (paginated, optional unread filter) */
async listNotifications(userId: string, page: number, limit: number, unreadOnly: boolean) {
const where: Prisma.NotificationWhereInput = { userId };
if (unreadOnly) where.isRead = false;
const skip = (page - 1) * limit;
const [notifications, total] = await Promise.all([
prisma.notification.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.notification.count({ where }),
]);
return {
notifications,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
/** Get unread notification count */
async getUnreadCount(userId: string): Promise<number> {
return prisma.notification.count({
where: { userId, isRead: false },
});
},
/** Mark a single notification as read */
async markAsRead(userId: string, notificationId: number) {
const notification = await prisma.notification.findFirst({
where: { id: notificationId, userId },
});
if (!notification) {
throw Object.assign(new Error('Notification not found'), { statusCode: 404 });
}
return prisma.notification.update({
where: { id: notificationId },
data: { isRead: true, readAt: new Date() },
});
},
/** Mark all notifications as read */
async markAllAsRead(userId: string) {
const result = await prisma.notification.updateMany({
where: { userId, isRead: false },
data: { isRead: true, readAt: new Date() },
});
return { marked: result.count };
},
/** Delete a notification */
async deleteNotification(userId: string, notificationId: number) {
const notification = await prisma.notification.findFirst({
where: { id: notificationId, userId },
});
if (!notification) {
throw Object.assign(new Error('Notification not found'), { statusCode: 404 });
}
await prisma.notification.delete({ where: { id: notificationId } });
return { success: true };
},
/** Get notification preferences (auto-create with defaults) */
async getPreferences(userId: string) {
let prefs = await prisma.notificationPreferences.findUnique({ where: { userId } });
if (!prefs) {
prefs = await prisma.notificationPreferences.create({
data: { userId },
});
}
return prefs;
},
/** Update notification preferences */
async updatePreferences(userId: string, data: Record<string, boolean | string>) {
return prisma.notificationPreferences.upsert({
where: { userId },
update: { ...data, updatedAt: new Date() },
create: { userId, ...data },
});
},
};