diff --git a/SERVICE_INTEGRATIONS.md b/SERVICE_INTEGRATIONS.md index 36003df5..b0f97888 100644 --- a/SERVICE_INTEGRATIONS.md +++ b/SERVICE_INTEGRATIONS.md @@ -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 diff --git a/api/prisma/migrations/20260331100000_add_calendar_item_source_types/migration.sql b/api/prisma/migrations/20260331100000_add_calendar_item_source_types/migration.sql new file mode 100644 index 00000000..e9151c1b --- /dev/null +++ b/api/prisma/migrations/20260331100000_add_calendar_item_source_types/migration.sql @@ -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'; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index d003bd01..c7172b33 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -4968,6 +4968,9 @@ enum CalendarItemSource { MANUAL ICS_FEED POLL + SHIFT + MEETING + TICKETED_EVENT } enum CalendarRecurrenceFrequency { diff --git a/api/src/modules/jitsi/jitsi.routes.ts b/api/src/modules/jitsi/jitsi.routes.ts index f444d059..ced367ea 100644 --- a/api/src/modules/jitsi/jitsi.routes.ts +++ b/api/src/modules/jitsi/jitsi.routes.ts @@ -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); diff --git a/api/src/modules/ticketed-events/ticketed-events.service.ts b/api/src/modules/ticketed-events/ticketed-events.service.ts index 592e7cb1..759f66f9 100644 --- a/api/src/modules/ticketed-events/ticketed-events.service.ts +++ b/api/src/modules/ticketed-events/ticketed-events.service.ts @@ -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); - } - }, }; diff --git a/api/src/services/event-listeners/calendar-sync.listener.ts b/api/src/services/event-listeners/calendar-sync.listener.ts index 551aead6..db39c756 100644 --- a/api/src/services/event-listeners/calendar-sync.listener.ts +++ b/api/src/services/event-listeners/calendar-sync.listener.ts @@ -156,7 +156,7 @@ async function deleteBySource(sourceType: string, sourceId: string): Promise { - 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); }); } diff --git a/api/src/services/event-listeners/crm-activity.listener.ts b/api/src/services/event-listeners/crm-activity.listener.ts index e401d6d0..2945e7f5 100644 --- a/api/src/services/event-listeners/crm-activity.listener.ts +++ b/api/src/services/event-listeners/crm-activity.listener.ts @@ -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); diff --git a/api/src/services/event-listeners/gancio.listener.ts b/api/src/services/event-listeners/gancio.listener.ts index 745735a5..620ebbe4 100644 --- a/api/src/services/event-listeners/gancio.listener.ts +++ b/api/src/services/event-listeners/gancio.listener.ts @@ -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); + } + }); } diff --git a/api/src/services/event-listeners/rocketchat.listener.ts b/api/src/services/event-listeners/rocketchat.listener.ts index db298c9b..603d210f 100644 --- a/api/src/services/event-listeners/rocketchat.listener.ts +++ b/api/src/services/event-listeners/rocketchat.listener.ts @@ -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, + }); + }); } diff --git a/api/src/services/rocketchat-webhook.service.ts b/api/src/services/rocketchat-webhook.service.ts index 726f9e77..f64caa2b 100644 --- a/api/src/services/rocketchat-webhook.service.ts +++ b/api/src/services/rocketchat-webhook.service.ts @@ -58,6 +58,63 @@ class RocketChatWebhookService { await this.notify('#campaigns', text, '#9b59b6'); } + async onCampaignPublished(data: { + campaignTitle: string; + campaignSlug: string; + }): Promise { + const text = `:newspaper: Campaign published: *${data.campaignTitle}* → /campaigns/${data.campaignSlug}`; + await this.notify('#campaigns', text, '#27ae60'); + } + + async onDonationReceived(data: { + donorName: string; + amount: string; + }): Promise { + const text = `:money_with_wings: **${data.donorName}** donated **$${data.amount}**`; + await this.notify('#campaigns', text, '#f1c40f'); + } + + async onSubscriptionActivated(data: { + userName: string; + planName: string; + }): Promise { + 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 { + 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 { + const text = `:white_check_mark: User approved: **${data.userName}** (${data.role})`; + await this.notify('#campaigns', text, '#2ecc71'); + } + + async onVideoPublished(data: { + videoTitle: string; + }): Promise { + const text = `:film_projector: New video published: *${data.videoTitle}*`; + await this.notify('#campaigns', text, '#3498db'); + } + + async onTicketedEventPublished(data: { + eventTitle: string; + eventDate: string; + }): Promise { + 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.