fix notifications
Build & Push / Pipeline Tests (push) Failing after 1m8s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Failing after 52s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m7s
Build & Push / Pipeline Tests (push) Failing after 1m8s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Failing after 52s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m7s
This commit is contained in:
@@ -1,26 +1,36 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('resend', () => ({ Resend: vi.fn() }))
|
||||
vi.mock('twilio', () => ({ default: vi.fn(() => ({ messages: { create: vi.fn() } })) }))
|
||||
vi.mock('firebase-admin', () => ({
|
||||
default: {
|
||||
apps: [],
|
||||
initializeApp: vi.fn(),
|
||||
credential: { cert: vi.fn() },
|
||||
messaging: vi.fn(() => ({ send: vi.fn() })),
|
||||
},
|
||||
}))
|
||||
vi.mock('../lib/prisma', () => ({
|
||||
prisma: {
|
||||
notification: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
const prismaMock = vi.hoisted(() => {
|
||||
const tx = {
|
||||
notificationEvent: {
|
||||
create: vi.fn().mockResolvedValue({ id: 'event_1' }),
|
||||
},
|
||||
},
|
||||
}))
|
||||
vi.mock('../lib/redis', () => ({
|
||||
redis: { publish: vi.fn(), on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
notificationRecipient: {
|
||||
create: vi.fn().mockResolvedValue({ id: 'recipient_1' }),
|
||||
},
|
||||
notificationDelivery: {
|
||||
create: vi.fn().mockImplementation(({ data }) => Promise.resolve({ id: `delivery_${data.channel}`, ...data })),
|
||||
},
|
||||
notificationOutbox: {
|
||||
create: vi.fn().mockResolvedValue({ id: 'outbox_1' }),
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
tx,
|
||||
prisma: {
|
||||
employee: { findFirst: vi.fn(), findMany: vi.fn() },
|
||||
renter: { findUnique: vi.fn() },
|
||||
notificationPreference: { findFirst: vi.fn() },
|
||||
companyNotificationPreference: { findUnique: vi.fn() },
|
||||
notificationEvent: { findUniqueOrThrow: vi.fn() },
|
||||
$transaction: vi.fn((callback) => callback(tx)),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('resend', () => ({ Resend: vi.fn() }))
|
||||
vi.mock('../lib/prisma', () => ({ prisma: prismaMock.prisma }))
|
||||
vi.mock('./notificationLocalizationService', async () => {
|
||||
const actual = await vi.importActual<typeof import('./notificationLocalizationService')>('./notificationLocalizationService')
|
||||
return {
|
||||
@@ -31,104 +41,107 @@ vi.mock('./notificationLocalizationService', async () => {
|
||||
})
|
||||
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { redis } from '../lib/redis'
|
||||
import { sendNotification } from './notificationService'
|
||||
import { createNotification, sendNotification } from './notificationService'
|
||||
import { resolveNotificationLocale, resolveNotificationTemplate } from './notificationLocalizationService'
|
||||
|
||||
describe('notificationService delivery boundaries', () => {
|
||||
describe('notificationService command boundaries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(prisma.notification.create).mockResolvedValue({ id: 'notification_1', title: 'Hello', body: 'Body' } as never)
|
||||
vi.mocked(prisma.notification.update).mockResolvedValue({ id: 'notification_1' } as never)
|
||||
vi.mocked(prisma.employee.findFirst).mockResolvedValue({
|
||||
id: 'employee_1',
|
||||
email: 'agent@example.test',
|
||||
preferredLanguage: 'en',
|
||||
} as never)
|
||||
vi.mocked(prisma.notificationEvent.findUniqueOrThrow).mockResolvedValue({ recipients: [] } as never)
|
||||
vi.mocked(prisma.notificationPreference.findFirst).mockResolvedValue(null as never)
|
||||
vi.mocked(prisma.companyNotificationPreference.findUnique).mockResolvedValue(null as never)
|
||||
vi.mocked(resolveNotificationLocale).mockResolvedValue('en')
|
||||
vi.mocked(resolveNotificationTemplate).mockResolvedValue(null as never)
|
||||
})
|
||||
|
||||
it('persists and publishes in-app notifications for employee recipients', async () => {
|
||||
const result = await sendNotification({
|
||||
type: 'SYSTEM_ALERT' as never,
|
||||
title: 'Maintenance window',
|
||||
body: 'The dashboard will be unavailable.',
|
||||
it('creates an event, explicit recipient, delivery rows, and an outbox row without inline provider calls', async () => {
|
||||
const result = await createNotification({
|
||||
companyId: 'company_1',
|
||||
employeeId: 'employee_1',
|
||||
channels: ['IN_APP' as never],
|
||||
data: { severity: 'low' },
|
||||
type: 'NEW_BOOKING',
|
||||
audience: { type: 'EMPLOYEE', employeeId: 'employee_1' },
|
||||
channels: ['IN_APP', 'EMAIL'],
|
||||
source: { type: 'reservation', id: 'reservation_1' },
|
||||
idempotencyKey: 'reservation_1:new_booking:employee_1',
|
||||
title: 'New booking',
|
||||
body: 'A renter booked a vehicle.',
|
||||
data: { reservationId: 'reservation_1' },
|
||||
})
|
||||
|
||||
expect(result).toEqual([{ channel: 'IN_APP', success: true }])
|
||||
expect(resolveNotificationLocale).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
employeeId: 'employee_1',
|
||||
}))
|
||||
expect(prisma.notification.create).toHaveBeenCalledWith({
|
||||
expect(result.duplicate).toBe(false)
|
||||
expect(prisma.notificationEvent.findUniqueOrThrow).not.toHaveBeenCalled()
|
||||
expect(prismaMock.tx.notificationEvent.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
type: 'SYSTEM_ALERT',
|
||||
title: 'Maintenance window',
|
||||
body: 'The dashboard will be unavailable.',
|
||||
channel: 'IN_APP',
|
||||
status: 'PENDING',
|
||||
companyId: 'company_1',
|
||||
type: 'NEW_BOOKING',
|
||||
sourceType: 'reservation',
|
||||
sourceId: 'reservation_1',
|
||||
}),
|
||||
})
|
||||
expect(prismaMock.tx.notificationRecipient.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
notificationEventId: 'event_1',
|
||||
recipientType: 'EMPLOYEE',
|
||||
employeeId: 'employee_1',
|
||||
renterId: null,
|
||||
}),
|
||||
},
|
||||
})
|
||||
expect(redis.publish).toHaveBeenCalledWith(
|
||||
'notifications:employee_1',
|
||||
expect.stringContaining('"status":"DELIVERED"'),
|
||||
)
|
||||
expect(prisma.notification.update).toHaveBeenCalledWith({
|
||||
where: { id: 'notification_1' },
|
||||
data: expect.objectContaining({ status: 'SENT', providerMessageId: null }),
|
||||
})
|
||||
})
|
||||
|
||||
it('uses resolved templates and records failed channels without throwing', async () => {
|
||||
vi.mocked(resolveNotificationTemplate).mockResolvedValue({
|
||||
templateKey: 'reservation.confirmed',
|
||||
locale: 'fr',
|
||||
subject: 'Réservation confirmée',
|
||||
body: 'Votre réservation est confirmée.',
|
||||
usedFallback: false,
|
||||
version: 2,
|
||||
} as never)
|
||||
|
||||
const result = await sendNotification({
|
||||
type: 'RESERVATION_UPDATE' as never,
|
||||
templateKey: 'reservation.confirmed',
|
||||
templateVariables: { firstName: 'Aya' },
|
||||
companyId: 'company_1',
|
||||
channels: ['EMAIL' as never],
|
||||
email: 'aya@example.test',
|
||||
})
|
||||
|
||||
expect(result).toEqual([{ channel: 'EMAIL', success: false, error: 'No email provider is configured' }])
|
||||
expect(resolveNotificationTemplate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
templateKey: 'reservation.confirmed',
|
||||
channel: 'EMAIL',
|
||||
locale: 'en',
|
||||
variables: { firstName: 'Aya' },
|
||||
}))
|
||||
expect(prisma.notification.create).toHaveBeenCalledWith({
|
||||
expect(prismaMock.tx.notificationDelivery.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
title: 'Réservation confirmée',
|
||||
body: 'Votre réservation est confirmée.',
|
||||
templateKey: 'reservation.confirmed',
|
||||
locale: 'fr',
|
||||
notificationRecipientId: 'recipient_1',
|
||||
channel: 'IN_APP',
|
||||
status: 'QUEUED',
|
||||
preferenceDecision: 'ENABLED_BY_PRODUCT_DEFAULT',
|
||||
}),
|
||||
})
|
||||
expect(prismaMock.tx.notificationOutbox.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
notificationEventId: 'event_1',
|
||||
payload: expect.objectContaining({
|
||||
notificationEventId: 'event_1',
|
||||
type: 'NEW_BOOKING',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(prisma.notification.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails incomplete notification content before persistence', async () => {
|
||||
it('records skipped delivery decisions for unsupported optional channels', async () => {
|
||||
const result = await sendNotification({
|
||||
type: 'SYSTEM_ALERT' as never,
|
||||
title: 'Missing body',
|
||||
channels: ['IN_APP' as never],
|
||||
type: 'BOOKING_CONFIRMED',
|
||||
title: 'Booking confirmed',
|
||||
body: 'Your booking is confirmed.',
|
||||
companyId: 'company_1',
|
||||
employeeId: 'employee_1',
|
||||
channels: ['PUSH'],
|
||||
data: { reservationId: 'reservation_1' },
|
||||
})
|
||||
|
||||
expect(result).toEqual([{ channel: 'IN_APP', success: false, error: 'Notification content is incomplete for channel IN_APP' }])
|
||||
expect(prisma.notification.create).not.toHaveBeenCalled()
|
||||
expect(redis.publish).not.toHaveBeenCalled()
|
||||
expect(result).toEqual([
|
||||
{ channel: 'PUSH', success: false, error: 'Delivery channel disabled by notification policy or preference.' },
|
||||
])
|
||||
expect(prismaMock.tx.notificationDelivery.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
channel: 'PUSH',
|
||||
status: 'SKIPPED',
|
||||
preferenceDecision: 'SKIPPED_UNSUPPORTED_CHANNEL',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('fails before persistence when an explicit recipient is missing', async () => {
|
||||
const result = await sendNotification({
|
||||
type: 'NEW_BOOKING',
|
||||
title: 'Missing recipient',
|
||||
body: 'No target.',
|
||||
companyId: 'company_1',
|
||||
channels: ['IN_APP'],
|
||||
})
|
||||
|
||||
expect(result).toEqual([{ channel: 'IN_APP', success: false, error: 'An explicit employee or renter recipient is required' }])
|
||||
expect(prismaMock.tx.notificationEvent.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { Resend } from 'resend'
|
||||
import twilio from 'twilio'
|
||||
import admin from 'firebase-admin'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { redis } from '../lib/redis'
|
||||
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
|
||||
import {
|
||||
renderLocalizedEmailHtml,
|
||||
@@ -75,35 +72,6 @@ if (smtpHost && smtpPort && smtpUser && smtpPass) {
|
||||
}
|
||||
}
|
||||
|
||||
const twilioClient =
|
||||
process.env.TWILIO_ACCOUNT_SID &&
|
||||
process.env.TWILIO_AUTH_TOKEN &&
|
||||
process.env.TWILIO_ACCOUNT_SID !== 'AC...' &&
|
||||
process.env.TWILIO_AUTH_TOKEN !== 'your-twilio-auth-token'
|
||||
? twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
|
||||
: null
|
||||
|
||||
const firebasePrivateKey = process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
|
||||
const hasFirebaseConfig =
|
||||
!!process.env.FIREBASE_PROJECT_ID &&
|
||||
!!process.env.FIREBASE_CLIENT_EMAIL &&
|
||||
!!firebasePrivateKey &&
|
||||
!firebasePrivateKey.includes('BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY')
|
||||
|
||||
if (hasFirebaseConfig && !admin.apps.length) {
|
||||
try {
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
privateKey: firebasePrivateKey,
|
||||
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
|
||||
}),
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.warn('[Notifications] Firebase init skipped:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
interface SendNotificationOptions {
|
||||
type: NotificationType
|
||||
title?: string
|
||||
@@ -120,6 +88,143 @@ interface SendNotificationOptions {
|
||||
locale?: string
|
||||
templateKey?: string
|
||||
templateVariables?: NotificationTemplateVariables
|
||||
idempotencyKey?: string
|
||||
sourceType?: string
|
||||
sourceId?: string
|
||||
}
|
||||
|
||||
type NotificationAudience =
|
||||
| { type: 'EMPLOYEE'; employeeId: string }
|
||||
| { type: 'RENTER'; renterId: string }
|
||||
| { type: 'COMPANY_EMPLOYEES' }
|
||||
|
||||
type NotificationPolicy = {
|
||||
mandatory?: boolean
|
||||
legalOrSecurity?: boolean
|
||||
}
|
||||
|
||||
interface CreateNotificationOptions {
|
||||
companyId: string
|
||||
type: NotificationType
|
||||
audience: NotificationAudience
|
||||
templateKey?: string
|
||||
variables?: NotificationTemplateVariables
|
||||
channels?: NotificationChannel[]
|
||||
source: {
|
||||
type: string
|
||||
id: string
|
||||
}
|
||||
idempotencyKey: string
|
||||
policy?: NotificationPolicy
|
||||
title?: string
|
||||
body?: string
|
||||
data?: Record<string, unknown>
|
||||
locale?: string
|
||||
}
|
||||
|
||||
const IMPLEMENTED_CHANNELS = new Set<NotificationChannel>(['IN_APP', 'EMAIL'])
|
||||
const PRODUCT_DEFAULT_ENABLED_CHANNELS = new Set<NotificationChannel>(['IN_APP', 'EMAIL'])
|
||||
|
||||
function uniqueChannels(channels: NotificationChannel[] | undefined): NotificationChannel[] {
|
||||
return Array.from(new Set(channels?.length ? channels : ['IN_APP']))
|
||||
}
|
||||
|
||||
function buildLegacyIdempotencyKey(opts: SendNotificationOptions) {
|
||||
const target = opts.employeeId ? `employee:${opts.employeeId}` : opts.renterId ? `renter:${opts.renterId}` : `company:${opts.companyId ?? 'none'}`
|
||||
const sourceId = opts.sourceId ?? String(opts.data?.id ?? opts.data?.reservationId ?? opts.data?.bookingId ?? target)
|
||||
return [
|
||||
'legacy-notification',
|
||||
opts.type,
|
||||
opts.companyId ?? 'global',
|
||||
target,
|
||||
opts.sourceType ?? 'legacy',
|
||||
sourceId,
|
||||
opts.templateKey ?? 'inline',
|
||||
uniqueChannels(opts.channels).join(','),
|
||||
].join(':')
|
||||
}
|
||||
|
||||
async function resolveAudienceRecipients(companyId: string, audience: NotificationAudience) {
|
||||
if (audience.type === 'EMPLOYEE') {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: audience.employeeId, companyId, isActive: true },
|
||||
select: { id: true, email: true, preferredLanguage: true },
|
||||
})
|
||||
return employee ? [{ recipientType: 'EMPLOYEE' as const, employeeId: employee.id, renterId: null, email: employee.email, locale: employee.preferredLanguage }] : []
|
||||
}
|
||||
|
||||
if (audience.type === 'RENTER') {
|
||||
const renter = await prisma.renter.findUnique({
|
||||
where: { id: audience.renterId },
|
||||
select: { id: true, email: true, preferredLocale: true },
|
||||
})
|
||||
return renter ? [{ recipientType: 'RENTER' as const, employeeId: null, renterId: renter.id, email: renter.email, locale: renter.preferredLocale }] : []
|
||||
}
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { companyId, isActive: true },
|
||||
select: { id: true, email: true, preferredLanguage: true },
|
||||
})
|
||||
return employees.map((employee) => ({
|
||||
recipientType: 'EMPLOYEE' as const,
|
||||
employeeId: employee.id,
|
||||
renterId: null,
|
||||
email: employee.email,
|
||||
locale: employee.preferredLanguage,
|
||||
}))
|
||||
}
|
||||
|
||||
async function resolvePreferenceDecision(input: {
|
||||
companyId: string
|
||||
employeeId: string | null
|
||||
renterId: string | null
|
||||
type: NotificationType
|
||||
channel: NotificationChannel
|
||||
policy?: NotificationPolicy
|
||||
}) {
|
||||
if (input.policy?.legalOrSecurity) {
|
||||
return { enabled: true, decision: 'BYPASSED_LEGAL_OR_SECURITY_REQUIREMENT' }
|
||||
}
|
||||
if (input.policy?.mandatory) {
|
||||
return { enabled: true, decision: 'BYPASSED_MANDATORY_SYSTEM_POLICY' }
|
||||
}
|
||||
if (!IMPLEMENTED_CHANNELS.has(input.channel)) {
|
||||
return { enabled: false, decision: 'SKIPPED_UNSUPPORTED_CHANNEL' }
|
||||
}
|
||||
|
||||
const personalWhere = input.employeeId
|
||||
? { employeeId: input.employeeId, notificationType: input.type, channel: input.channel }
|
||||
: { renterId: input.renterId!, notificationType: input.type, channel: input.channel }
|
||||
|
||||
const personal = await prisma.notificationPreference.findFirst({ where: personalWhere as any })
|
||||
if (personal) {
|
||||
return {
|
||||
enabled: personal.enabled,
|
||||
decision: personal.enabled ? 'ENABLED_BY_USER_PREFERENCE' : 'SKIPPED_BY_USER_PREFERENCE',
|
||||
}
|
||||
}
|
||||
|
||||
const companyDefault = await prisma.companyNotificationPreference.findUnique({
|
||||
where: {
|
||||
companyId_notificationType_channel: {
|
||||
companyId: input.companyId,
|
||||
notificationType: input.type,
|
||||
channel: input.channel,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (companyDefault) {
|
||||
return {
|
||||
enabled: companyDefault.enabled,
|
||||
decision: companyDefault.enabled ? 'ENABLED_BY_COMPANY_DEFAULT' : 'SKIPPED_BY_COMPANY_DEFAULT',
|
||||
}
|
||||
}
|
||||
|
||||
const enabled = PRODUCT_DEFAULT_ENABLED_CHANNELS.has(input.channel)
|
||||
return {
|
||||
enabled,
|
||||
decision: enabled ? 'ENABLED_BY_PRODUCT_DEFAULT' : 'SKIPPED_BY_PRODUCT_DEFAULT',
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSmtpReplyTo() {
|
||||
@@ -202,126 +307,169 @@ async function sendEmailWithProviders(opts: {
|
||||
}
|
||||
|
||||
export async function sendNotification(opts: SendNotificationOptions) {
|
||||
const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = []
|
||||
if (!opts.companyId) {
|
||||
return uniqueChannels(opts.channels).map((channel) => ({
|
||||
channel,
|
||||
success: false,
|
||||
error: 'companyId is required',
|
||||
}))
|
||||
}
|
||||
|
||||
const audience: NotificationAudience | null = opts.employeeId
|
||||
? { type: 'EMPLOYEE', employeeId: opts.employeeId }
|
||||
: opts.renterId
|
||||
? { type: 'RENTER', renterId: opts.renterId }
|
||||
: null
|
||||
|
||||
if (!audience) {
|
||||
return uniqueChannels(opts.channels).map((channel) => ({
|
||||
channel,
|
||||
success: false,
|
||||
error: 'An explicit employee or renter recipient is required',
|
||||
}))
|
||||
}
|
||||
|
||||
try {
|
||||
const command = await createNotification({
|
||||
companyId: opts.companyId,
|
||||
type: opts.type,
|
||||
audience,
|
||||
templateKey: opts.templateKey,
|
||||
variables: opts.templateVariables,
|
||||
channels: opts.channels,
|
||||
source: {
|
||||
type: opts.sourceType ?? 'legacy',
|
||||
id: opts.sourceId ?? String(opts.data?.id ?? opts.data?.reservationId ?? opts.data?.bookingId ?? audience.type),
|
||||
},
|
||||
idempotencyKey: opts.idempotencyKey ?? buildLegacyIdempotencyKey(opts),
|
||||
title: opts.title,
|
||||
body: opts.body,
|
||||
data: opts.data,
|
||||
locale: opts.locale,
|
||||
})
|
||||
|
||||
return command.deliveries.map((delivery) => ({
|
||||
channel: delivery.channel,
|
||||
success: delivery.status !== 'SKIPPED',
|
||||
error: delivery.status === 'SKIPPED' ? delivery.failureReason ?? delivery.preferenceDecision ?? 'Delivery skipped' : undefined,
|
||||
}))
|
||||
} catch (err: any) {
|
||||
return uniqueChannels(opts.channels).map((channel) => ({
|
||||
channel,
|
||||
success: false,
|
||||
error: err.message,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNotification(opts: CreateNotificationOptions) {
|
||||
const resolvedLocale = await resolveNotificationLocale({
|
||||
companyId: opts.companyId,
|
||||
employeeId: opts.employeeId,
|
||||
renterId: opts.renterId,
|
||||
billingAccountId: opts.billingAccountId,
|
||||
locale: opts.locale,
|
||||
})
|
||||
const channels = uniqueChannels(opts.channels)
|
||||
const renderChannel: NotificationChannel = channels.includes('IN_APP') ? 'IN_APP' : channels[0]!
|
||||
const rendered = opts.templateKey
|
||||
? await resolveNotificationTemplate({
|
||||
templateKey: opts.templateKey,
|
||||
channel: renderChannel,
|
||||
locale: resolvedLocale,
|
||||
variables: opts.variables,
|
||||
})
|
||||
: null
|
||||
|
||||
for (const channel of opts.channels) {
|
||||
try {
|
||||
const rendered = opts.templateKey
|
||||
? await resolveNotificationTemplate({
|
||||
templateKey: opts.templateKey,
|
||||
channel,
|
||||
locale: resolvedLocale,
|
||||
variables: opts.templateVariables,
|
||||
})
|
||||
: null
|
||||
const title = rendered?.subject ?? opts.title
|
||||
const body = rendered?.body ?? opts.body
|
||||
|
||||
const title = rendered?.subject ?? opts.title
|
||||
const body = rendered?.body ?? opts.body
|
||||
if (!title || !body) {
|
||||
throw new Error('Notification content is incomplete')
|
||||
}
|
||||
|
||||
if (!title || !body) {
|
||||
throw new Error(`Notification content is incomplete for channel ${channel}`)
|
||||
}
|
||||
const recipients = await resolveAudienceRecipients(opts.companyId, opts.audience)
|
||||
if (recipients.length === 0) {
|
||||
throw new Error('No eligible notification recipients were resolved')
|
||||
}
|
||||
|
||||
const notification = await prisma.notification.create({
|
||||
try {
|
||||
return await prisma.$transaction(async (tx: any) => {
|
||||
const createdEvent = await tx.notificationEvent.create({
|
||||
data: {
|
||||
companyId: opts.companyId,
|
||||
type: opts.type,
|
||||
templateKey: rendered?.templateKey ?? opts.templateKey ?? null,
|
||||
locale: rendered?.locale ?? resolvedLocale,
|
||||
title,
|
||||
body,
|
||||
data: (opts.data ?? {}) as any,
|
||||
channel,
|
||||
templateKey: rendered?.templateKey ?? opts.templateKey ?? null,
|
||||
locale: rendered?.locale ?? resolvedLocale,
|
||||
status: 'PENDING',
|
||||
companyId: opts.companyId ?? null,
|
||||
employeeId: opts.employeeId ?? null,
|
||||
renterId: opts.renterId ?? null,
|
||||
sourceType: opts.source.type,
|
||||
sourceId: opts.source.id,
|
||||
idempotencyKey: opts.idempotencyKey,
|
||||
},
|
||||
})
|
||||
|
||||
let providerMessageId: string | null = null
|
||||
let success = false
|
||||
|
||||
if (channel === 'EMAIL' && opts.email) {
|
||||
const emailResult = await sendEmailWithProviders({
|
||||
to: opts.email,
|
||||
subject: title,
|
||||
html: renderLocalizedEmailHtml(body, rendered?.locale ?? resolvedLocale),
|
||||
text: body,
|
||||
const deliveries: any[] = []
|
||||
for (const recipient of recipients) {
|
||||
const createdRecipient = await tx.notificationRecipient.create({
|
||||
data: {
|
||||
notificationEventId: createdEvent.id,
|
||||
recipientType: recipient.recipientType,
|
||||
employeeId: recipient.employeeId,
|
||||
renterId: recipient.renterId,
|
||||
},
|
||||
})
|
||||
providerMessageId = emailResult.providerMessageId
|
||||
success = true
|
||||
}
|
||||
|
||||
if (channel === 'SMS' && opts.phone) {
|
||||
if (!twilioClient) throw new Error('Twilio is not configured')
|
||||
const msg = await twilioClient.messages.create({
|
||||
body,
|
||||
from: process.env.TWILIO_PHONE_NUMBER!,
|
||||
to: opts.phone,
|
||||
})
|
||||
providerMessageId = msg.sid
|
||||
success = true
|
||||
}
|
||||
|
||||
if (channel === 'WHATSAPP' && opts.phone) {
|
||||
if (!twilioClient) throw new Error('Twilio is not configured')
|
||||
const msg = await twilioClient.messages.create({
|
||||
body,
|
||||
from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`,
|
||||
to: `whatsapp:${opts.phone}`,
|
||||
})
|
||||
providerMessageId = msg.sid
|
||||
success = true
|
||||
}
|
||||
|
||||
if (channel === 'PUSH' && opts.fcmToken) {
|
||||
if (!admin.apps.length) throw new Error('Firebase is not configured')
|
||||
const response = await admin.messaging().send({
|
||||
token: opts.fcmToken,
|
||||
notification: { title, body },
|
||||
data: Object.fromEntries(
|
||||
Object.entries(opts.data ?? {}).map(([k, v]) => [k, String(v)])
|
||||
),
|
||||
})
|
||||
providerMessageId = response
|
||||
success = true
|
||||
}
|
||||
|
||||
if (channel === 'IN_APP') {
|
||||
// Emit via Socket.io through Redis pub/sub
|
||||
const targetId = opts.employeeId ?? opts.renterId
|
||||
if (targetId) {
|
||||
await redis.publish(
|
||||
`notifications:${targetId}`,
|
||||
JSON.stringify({ ...notification, status: 'DELIVERED' })
|
||||
)
|
||||
for (const channel of channels) {
|
||||
const preference = await resolvePreferenceDecision({
|
||||
companyId: opts.companyId,
|
||||
employeeId: recipient.employeeId,
|
||||
renterId: recipient.renterId,
|
||||
type: opts.type,
|
||||
channel,
|
||||
policy: opts.policy,
|
||||
})
|
||||
const status = preference.enabled ? 'QUEUED' : 'SKIPPED'
|
||||
const delivery = await tx.notificationDelivery.create({
|
||||
data: {
|
||||
notificationRecipientId: createdRecipient.id,
|
||||
channel,
|
||||
status,
|
||||
preferenceDecision: preference.decision,
|
||||
failureCode: preference.enabled ? null : preference.decision,
|
||||
failureReason: preference.enabled ? null : 'Delivery channel disabled by notification policy or preference.',
|
||||
},
|
||||
})
|
||||
deliveries.push(delivery)
|
||||
}
|
||||
success = true
|
||||
}
|
||||
|
||||
await prisma.notification.update({
|
||||
where: { id: notification.id },
|
||||
await tx.notificationOutbox.create({
|
||||
data: {
|
||||
status: success ? 'SENT' : 'FAILED',
|
||||
sentAt: success ? new Date() : null,
|
||||
providerMessageId,
|
||||
notificationEventId: createdEvent.id,
|
||||
payload: {
|
||||
notificationEventId: createdEvent.id,
|
||||
type: opts.type,
|
||||
channels,
|
||||
source: opts.source,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
results.push({ channel, success })
|
||||
} catch (err: any) {
|
||||
results.push({ channel, success: false, error: err.message })
|
||||
return { event: createdEvent, deliveries, duplicate: false }
|
||||
})
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'P2002') throw err
|
||||
|
||||
const event = await prisma.notificationEvent.findUniqueOrThrow({
|
||||
where: { companyId_idempotencyKey: { companyId: opts.companyId, idempotencyKey: opts.idempotencyKey } },
|
||||
include: { recipients: { include: { deliveries: true } } },
|
||||
})
|
||||
|
||||
return {
|
||||
event,
|
||||
deliveries: event.recipients.flatMap((recipient: any) => recipient.deliveries),
|
||||
duplicate: true,
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export async function sendTransactionalEmail(opts: {
|
||||
|
||||
Reference in New Issue
Block a user