Extend EventBus: RC notifications, CRM activity, Gancio migration, calendar source types

- Add 7 new RC notification types: campaign published, donations, subscriptions,
  SMS escalations, user approved, video published, ticketed events
- Add CRM activity entries for subscription activated and email bounced
- Migrate ticketed-events Gancio sync from inline calls to EventBus listener
- Add meeting.created/deleted events from jitsi.routes.ts
- Add SHIFT, MEETING, TICKETED_EVENT to CalendarItemSource enum (Prisma migration)
- Update calendar-sync listener to use proper source types instead of MANUAL
- Total: 45 listener subscriptions across 6 modules, zero inline sync calls remaining

Bunker Admin
This commit is contained in:
bunker-admin 2026-03-31 10:04:44 -06:00
parent 075a7c8c4a
commit 68434c51a6
10 changed files with 295 additions and 95 deletions

View File

@ -83,10 +83,16 @@ Service Handler (shift created, donation completed, etc.)
- [x] Media video publish/unpublish/view (3 event types)
- [x] Ticketed event publish/cancel (EventBus publishes alongside existing Gancio calls)
- [x] Impact story publish (social.impact-story.published)
- [ ] Meeting create/update/delete (not yet migrated — meetings module needs review)
- [x] Meeting create/delete (jitsi.routes.ts — meeting.created, meeting.deleted)
### Phase 4b: Extended Listeners (2026-03-31)
- [x] RC listener: +7 subscriptions (campaign.published, donations, subscriptions, SMS escalation, user.approved, video.published, ticketed-event.published)
- [x] CRM listener: +2 subscriptions (subscription activated, email bounced)
- [x] RC webhook service: +7 new formatter methods
- [x] Prisma migration: SHIFT, MEETING, TICKETED_EVENT added to CalendarItemSource enum
- [x] Calendar sync listener: uses proper source types (SHIFT, MEETING, TICKETED_EVENT)
### Phase 5: Future
- [ ] Add SHIFT, MEETING, TICKETED_EVENT to CalendarItemSource enum (Prisma migration)
- [ ] Migrate remaining Gancio calls (ticketed-events, meeting-planner) to EventBus
- [ ] Add engagement scoring listener
- [ ] Add Homepage dashboard data listener

View File

@ -0,0 +1,4 @@
-- Add SHIFT, MEETING, TICKETED_EVENT to CalendarItemSource enum
ALTER TYPE "CalendarItemSource" ADD VALUE IF NOT EXISTS 'SHIFT';
ALTER TYPE "CalendarItemSource" ADD VALUE IF NOT EXISTS 'MEETING';
ALTER TYPE "CalendarItemSource" ADD VALUE IF NOT EXISTS 'TICKETED_EVENT';

View File

@ -4968,6 +4968,9 @@ enum CalendarItemSource {
MANUAL
ICS_FEED
POLL
SHIFT
MEETING
TICKETED_EVENT
}
enum CalendarRecurrenceFrequency {

View File

@ -9,6 +9,7 @@ import { prisma } from '../../config/database';
import { siteSettingsService } from '../settings/settings.service';
import { isServiceOnline } from '../../utils/health-check';
import { generateSlug, generateModeratorToken } from './jitsi.utils';
import { eventBus } from '../../services/event-bus.service';
const router = Router();
@ -172,6 +173,14 @@ router.post(
},
});
eventBus.publish('meeting.created', {
meetingId: meeting.id,
title: meeting.title,
scheduledAt: meeting.startTime?.toISOString() ?? new Date().toISOString(),
jitsiRoomName: meeting.jitsiRoom,
createdByUserId: meeting.createdByUserId,
});
res.status(201).json(meeting);
} catch (err) {
logger.error('Create meeting failed:', err);
@ -226,6 +235,12 @@ router.delete(
}
await prisma.meeting.delete({ where: { id: meetingId } });
eventBus.publish('meeting.deleted', {
meetingId: meeting.id,
title: meeting.title,
});
res.json({ success: true });
} catch (err) {
logger.error('Delete meeting failed:', err);

View File

@ -1,6 +1,5 @@
import { prisma } from '../../config/database';
import { TicketedEventStatus, TicketedEventVisibility, EventFormat, Prisma } from '@prisma/client';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler';
import { unifiedCalendarService } from '../events/unified-calendar.service';
import { siteSettingsService } from '../settings/settings.service';
@ -385,8 +384,7 @@ export const ticketedEventsService = {
include: { ticketTiers: true },
});
// Gancio sync + calendar cache bust (fire-and-forget)
this.syncToGancio(updated).catch(() => {});
// Calendar cache bust (fire-and-forget)
unifiedCalendarService.bustCache().catch(() => {});
eventBus.publish('ticketed-event.published', {
@ -415,8 +413,18 @@ export const ticketedEventsService = {
include: { ticketTiers: true },
});
this.syncToGancio(updated).catch(() => {});
unifiedCalendarService.bustCache().catch(() => {});
eventBus.publish('ticketed-event.published', {
eventId: updated.id,
title: updated.title,
date: updated.date.toISOString().split('T')[0],
startTime: updated.startTime,
endTime: updated.endTime,
location: updated.venueAddress || updated.venueName || undefined,
gancioEventId: updated.gancioEventId ?? undefined,
});
return updated;
},
@ -456,10 +464,7 @@ export const ticketedEventsService = {
data: { status: 'CANCELLED' },
});
// Delete from Gancio if synced + bust calendar cache
if (event.gancioEventId) {
this.deleteFromGancio(event.gancioEventId).catch(() => {});
}
// Calendar cache bust (fire-and-forget)
unifiedCalendarService.bustCache().catch(() => {});
eventBus.publish('ticketed-event.cancelled', {
@ -501,7 +506,10 @@ export const ticketedEventsService = {
}
if (event.gancioEventId) {
this.deleteFromGancio(event.gancioEventId).catch(() => {});
eventBus.publish('ticketed-event.cancelled', {
eventId: event.id,
title: event.title,
});
}
await prisma.ticketedEvent.delete({ where: { id } });
@ -783,78 +791,4 @@ export const ticketedEventsService = {
};
},
// --- Gancio Sync ---
async syncToGancio(event: {
id: string;
title: string;
description?: string | null;
venueAddress?: string | null;
venueName?: string | null;
eventFormat?: EventFormat;
date: Date;
startTime: string;
endTime: string;
gancioEventId?: number | null;
}) {
try {
const { gancioClient } = await import('../../services/gancio.client');
if (!gancioClient.enabled) return;
// Determine location based on event format
let location: string | null;
const format = event.eventFormat || 'IN_PERSON';
if (format === 'ONLINE') {
location = 'Online Event';
} else if (format === 'HYBRID') {
const venue = event.venueAddress || event.venueName || '';
location = venue ? `${venue} (also streaming online)` : 'Online + In-Person';
} else {
location = event.venueAddress || event.venueName || null;
}
const tags = ['ticketed', 'community'];
if (format === 'ONLINE') tags.push('online');
if (format === 'HYBRID') tags.push('hybrid');
if (event.gancioEventId) {
await gancioClient.updateEvent(event.gancioEventId, {
title: event.title,
description: event.description,
location,
date: event.date,
startTime: event.startTime,
endTime: event.endTime,
});
} else {
const gancioId = await gancioClient.createEvent({
title: event.title,
description: event.description,
location,
date: event.date,
startTime: event.startTime,
endTime: event.endTime,
tags,
});
if (gancioId) {
await prisma.ticketedEvent.update({
where: { id: event.id },
data: { gancioEventId: gancioId },
});
}
}
} catch (err) {
logger.warn('Gancio sync failed for ticketed event:', err instanceof Error ? err.message : err);
}
},
async deleteFromGancio(gancioEventId: number) {
try {
const { gancioClient } = await import('../../services/gancio.client');
if (!gancioClient.enabled) return;
await gancioClient.deleteEvent(gancioEventId);
} catch (err) {
logger.warn(`Gancio delete failed for event ${gancioEventId}:`, err instanceof Error ? err.message : err);
}
},
};

View File

@ -156,7 +156,7 @@ async function deleteBySource(sourceType: string, sourceId: string): Promise<voi
export function registerCalendarSyncListener(): void {
// Shift created → Calendar item
eventBus.subscribe('shift.created', 'calendar:shift-created', async (payload) => {
await upsertCalendarItem(payload.createdByUserId, 'MANUAL', payload.shiftId, {
await upsertCalendarItem(payload.createdByUserId, 'SHIFT', payload.shiftId, {
title: `Shift: ${payload.title}`,
date: payload.date,
startTime: payload.startTime,
@ -175,7 +175,7 @@ export function registerCalendarSyncListener(): void {
select: { userId: true },
});
if (!existing) return;
await upsertCalendarItem(existing.userId, 'MANUAL', payload.shiftId, {
await upsertCalendarItem(existing.userId, 'SHIFT', payload.shiftId, {
title: `Shift: ${payload.title}`,
date: payload.date,
startTime: payload.startTime,
@ -189,7 +189,7 @@ export function registerCalendarSyncListener(): void {
// Shift deleted → Remove calendar item
eventBus.subscribe('shift.deleted', 'calendar:shift-deleted', async (payload) => {
await deleteBySource('MANUAL', payload.shiftId);
await deleteBySource('SHIFT', payload.shiftId);
});
// Meeting created → Calendar item
@ -199,7 +199,7 @@ export function registerCalendarSyncListener(): void {
const endHour = parseInt(time.split(':')[0]) + 1;
const endTime = `${String(endHour).padStart(2, '0')}:${time.split(':')[1]}`;
await upsertCalendarItem(payload.createdByUserId, 'MANUAL', payload.meetingId, {
await upsertCalendarItem(payload.createdByUserId, 'MEETING', payload.meetingId, {
title: `Meeting: ${payload.title}`,
date,
startTime: time,
@ -223,7 +223,7 @@ export function registerCalendarSyncListener(): void {
const endHour = parseInt(time.split(':')[0]) + 1;
const endTime = `${String(endHour).padStart(2, '0')}:${time.split(':')[1]}`;
await upsertCalendarItem(existing.userId, 'MANUAL', payload.meetingId, {
await upsertCalendarItem(existing.userId, 'MEETING', payload.meetingId, {
title: `Meeting: ${payload.title}`,
date,
startTime: time,
@ -236,7 +236,7 @@ export function registerCalendarSyncListener(): void {
// Meeting deleted → Remove calendar item
eventBus.subscribe('meeting.deleted', 'calendar:meeting-deleted', async (payload) => {
await deleteBySource('MANUAL', payload.meetingId);
await deleteBySource('MEETING', payload.meetingId);
});
// Ticketed event published → Calendar item
@ -249,7 +249,7 @@ export function registerCalendarSyncListener(): void {
select: { createdByUserId: true },
});
if (!event) return;
await upsertCalendarItem(event.createdByUserId, 'MANUAL', payload.eventId, {
await upsertCalendarItem(event.createdByUserId, 'TICKETED_EVENT', payload.eventId, {
title: `Event: ${payload.title}`,
date: payload.date,
startTime: payload.startTime,
@ -263,6 +263,6 @@ export function registerCalendarSyncListener(): void {
// Ticketed event cancelled → Remove calendar item
eventBus.subscribe('ticketed-event.cancelled', 'calendar:ticketed-event-cancel', async (payload) => {
await deleteBySource('MANUAL', payload.eventId);
await deleteBySource('TICKETED_EVENT', payload.eventId);
});
}

View File

@ -190,6 +190,27 @@ export function registerCrmActivityListener(): void {
}
});
// Subscription activated
eventBus.subscribe('payment.subscription.activated', 'crm:subscription', async (payload) => {
const contactId = await findContactByEmail(payload.email);
if (!contactId) return;
await createActivity(contactId, 'PURCHASE', `Subscribed to "${payload.planName}"`, undefined, {
subscriptionId: payload.subscriptionId,
planName: payload.planName,
});
});
// Listmonk email bounced → flag contact
eventBus.subscribe('listmonk.email.bounced', 'crm:email-bounced', async (payload) => {
const contactId = await findContactByEmail(payload.subscriberEmail);
if (!contactId) return;
await createActivity(contactId, 'NOTE_ADDED', `Email bounced (${payload.bounceType})`, `Email address may be invalid — bounced on campaign #${payload.campaignId}`, {
listmonkCampaignId: payload.campaignId,
bounceType: payload.bounceType,
action: 'bounced',
});
});
// Listmonk email opened → activity
eventBus.subscribe('listmonk.email.opened', 'crm:email-opened', async (payload) => {
const contactId = await findContactByEmail(payload.subscriberEmail);

View File

@ -1,8 +1,9 @@
/**
* Gancio EventBus Listener
*
* Syncs shift events to the Gancio public event calendar.
* This replaces the inline gancioClient calls in shifts.service.ts.
* Syncs shift and ticketed events to the Gancio public event calendar.
* This replaces the inline gancioClient calls in shifts.service.ts and
* ticketed-events.service.ts.
*
* Feature guard: GANCIO_SYNC_ENABLED=true (checked inside gancioClient)
*/
@ -90,4 +91,100 @@ export function registerGancioListener(): void {
logger.debug('Gancio sync: shift delete failed:', err);
}
});
// =========================================================================
// TICKETED EVENT LISTENERS
// =========================================================================
// Ticketed event published → Create or update Gancio event
eventBus.subscribe('ticketed-event.published', 'gancio:ticketed-event-published', async (payload) => {
try {
const gancio = await getGancioClient();
if (!gancio.enabled) return;
const { prisma } = await import('../../config/database');
const event = await prisma.ticketedEvent.findUnique({
where: { id: payload.eventId },
select: {
id: true,
title: true,
description: true,
venueAddress: true,
venueName: true,
eventFormat: true,
date: true,
startTime: true,
endTime: true,
gancioEventId: true,
},
});
if (!event) return;
// Determine location based on event format
const format = event.eventFormat || 'IN_PERSON';
let location: string | null;
if (format === 'ONLINE') {
location = 'Online Event';
} else if (format === 'HYBRID') {
const venue = event.venueAddress || event.venueName || '';
location = venue ? `${venue} (also streaming online)` : 'Online + In-Person';
} else {
location = event.venueAddress || event.venueName || null;
}
const tags = ['ticketed', 'community'];
if (format === 'ONLINE') tags.push('online');
if (format === 'HYBRID') tags.push('hybrid');
if (event.gancioEventId) {
// Update existing Gancio event
await gancio.updateEvent(event.gancioEventId, {
title: event.title,
description: event.description,
location,
date: event.date,
startTime: event.startTime,
endTime: event.endTime,
});
} else {
// Create new Gancio event and store ID back
const gancioId = await gancio.createEvent({
title: event.title,
description: event.description,
location,
date: event.date,
startTime: event.startTime,
endTime: event.endTime,
tags,
});
if (gancioId) {
await prisma.ticketedEvent.update({
where: { id: event.id },
data: { gancioEventId: gancioId },
});
}
}
} catch (err) {
logger.debug('Gancio sync: ticketed event publish failed:', err);
}
});
// Ticketed event cancelled → Delete Gancio event
eventBus.subscribe('ticketed-event.cancelled', 'gancio:ticketed-event-cancelled', async (payload) => {
try {
const gancio = await getGancioClient();
if (!gancio.enabled) return;
const { prisma } = await import('../../config/database');
const event = await prisma.ticketedEvent.findUnique({
where: { id: payload.eventId },
select: { gancioEventId: true },
});
if (!event?.gancioEventId) return;
await gancio.deleteEvent(event.gancioEventId);
} catch (err) {
logger.debug('Gancio sync: ticketed event cancel failed:', err);
}
});
}

View File

@ -52,4 +52,67 @@ export function registerRocketChatListener(): void {
representativeName: payload.representativeName,
});
});
// --- Campaigns ---
eventBus.subscribe('campaign.published', 'rocketchat:campaign-published', (payload) => {
rocketchatWebhookService.onCampaignPublished({
campaignTitle: payload.title,
campaignSlug: payload.slug,
});
});
// --- Payments ---
eventBus.subscribe('payment.donation.completed', 'rocketchat:donation', (payload) => {
rocketchatWebhookService.onDonationReceived({
donorName: payload.name || payload.email,
amount: (payload.amountCents / 100).toFixed(2),
});
});
eventBus.subscribe('payment.subscription.activated', 'rocketchat:subscription', (payload) => {
rocketchatWebhookService.onSubscriptionActivated({
userName: payload.name || payload.email,
planName: payload.planName,
});
});
// --- SMS escalations (QUESTION/NEGATIVE responses) ---
eventBus.subscribe('sms.message.received', 'rocketchat:sms-escalation', (payload) => {
if (payload.responseType === 'QUESTION' || payload.responseType === 'NEGATIVE') {
rocketchatWebhookService.onSmsEscalation({
phone: payload.phone,
responseType: payload.responseType,
body: payload.body,
});
}
});
// --- Users ---
eventBus.subscribe('user.approved', 'rocketchat:user-approved', (payload) => {
rocketchatWebhookService.onUserApproved({
userName: payload.name,
role: payload.role,
});
});
// --- Media ---
eventBus.subscribe('media.video.published', 'rocketchat:video-published', (payload) => {
rocketchatWebhookService.onVideoPublished({
videoTitle: payload.title,
});
});
// --- Ticketed Events ---
eventBus.subscribe('ticketed-event.published', 'rocketchat:ticketed-event', (payload) => {
rocketchatWebhookService.onTicketedEventPublished({
eventTitle: payload.title,
eventDate: payload.date,
});
});
}

View File

@ -58,6 +58,63 @@ class RocketChatWebhookService {
await this.notify('#campaigns', text, '#9b59b6');
}
async onCampaignPublished(data: {
campaignTitle: string;
campaignSlug: string;
}): Promise<void> {
const text = `:newspaper: Campaign published: *${data.campaignTitle}* → /campaigns/${data.campaignSlug}`;
await this.notify('#campaigns', text, '#27ae60');
}
async onDonationReceived(data: {
donorName: string;
amount: string;
}): Promise<void> {
const text = `:money_with_wings: **${data.donorName}** donated **$${data.amount}**`;
await this.notify('#campaigns', text, '#f1c40f');
}
async onSubscriptionActivated(data: {
userName: string;
planName: string;
}): Promise<void> {
const text = `:star: **${data.userName}** subscribed to *${data.planName}*`;
await this.notify('#campaigns', text, '#9b59b6');
}
async onSmsEscalation(data: {
phone: string;
responseType: string;
body: string;
}): Promise<void> {
const preview = data.body.length > 100 ? data.body.slice(0, 100) + '...' : data.body;
const text = `:warning: SMS ${data.responseType} from ${data.phone}: "${preview}"`;
await this.notify('#campaigns', text, '#e74c3c');
}
async onUserApproved(data: {
userName: string;
role: string;
}): Promise<void> {
const text = `:white_check_mark: User approved: **${data.userName}** (${data.role})`;
await this.notify('#campaigns', text, '#2ecc71');
}
async onVideoPublished(data: {
videoTitle: string;
}): Promise<void> {
const text = `:film_projector: New video published: *${data.videoTitle}*`;
await this.notify('#campaigns', text, '#3498db');
}
async onTicketedEventPublished(data: {
eventTitle: string;
eventDate: string;
}): Promise<void> {
const text = `:ticket: Event published: *${data.eventTitle}* (${data.eventDate})`;
await this.notify('#campaigns', text, '#e67e22');
}
/**
* Ensure default notification channels exist in Rocket.Chat.
* Called during service startup.