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
158 lines
4.9 KiB
TypeScript
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 },
|
|
});
|
|
},
|
|
};
|