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

This commit is contained in:
root
2026-07-22 22:31:39 -04:00
parent bcabd17220
commit f6fcd7ce54
26 changed files with 1171 additions and 361 deletions
+19 -30
View File
@@ -159,7 +159,6 @@ cron.schedule('0 9 * * *', async () => {
cron.schedule('0 8 * * *', async () => {
const now = new Date()
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
// Fetch all candidate logs (date-due or has an odometer target), ordered newest-first per vehicle+type.
// We keep only the LATEST log per vehicle+type so that once the owner logs a new service the
@@ -213,17 +212,6 @@ cron.schedule('0 8 * * *', async () => {
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
if (!isOverdue && !isDueSoon) continue
// Dedup: don't send more than once per day for the same log
const alreadySent = await prisma.notification.findFirst({
where: {
type: 'VEHICLE_MAINTENANCE_DUE',
companyId: company.id,
createdAt: { gte: oneDayAgo },
data: { path: ['maintenanceLogId'], equals: log.id },
},
})
if (alreadySent) continue
// Build human-readable description
const dueParts: string[] = []
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
@@ -236,26 +224,27 @@ cron.schedule('0 8 * * *', async () => {
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`
await prisma.notification.create({
const reminderDate = now.toISOString().slice(0, 10)
await sendNotification({
type: 'VEHICLE_MAINTENANCE_DUE',
title,
body,
data: {
type: 'VEHICLE_MAINTENANCE_DUE',
title,
body,
data: {
vehicleId: vehicle.id,
maintenanceLogId: log.id,
maintenanceType: log.type,
isOverdue,
daysLeft,
kmLeft,
isOverdueByDate,
isOverdueByOdometer,
},
companyId: company.id,
employeeId: recipient.id,
channel: 'IN_APP',
status: 'DELIVERED',
vehicleId: vehicle.id,
maintenanceLogId: log.id,
maintenanceType: log.type,
isOverdue,
daysLeft,
kmLeft,
isOverdueByDate,
isOverdueByOdometer,
},
companyId: company.id,
employeeId: recipient.id,
channels: ['IN_APP'],
sourceType: 'maintenance_log',
sourceId: log.id,
idempotencyKey: `maintenance:${log.id}:${recipient.id}:${reminderDate}`,
})
}
})
@@ -1,11 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
notification: {
notificationRecipient: {
findMany: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
notificationDelivery: {
findMany: vi.fn(),
},
notificationPreference: {
findMany: vi.fn(),
upsert: vi.fn(),
@@ -17,25 +20,35 @@ vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
import * as repo from './notification.repo'
describe('notification.repo query boundaries', () => {
it('scopes company in-app notification lists and applies unread filtering only when requested', async () => {
await repo.findCompany('company_1', 'true')
it('scopes company in-app notification lists to the authenticated employee recipient', async () => {
prismaMock.notificationRecipient.findMany.mockResolvedValue([])
await repo.findCompany('employee_1', 'true')
expect(prismaMock.notification.findMany).toHaveBeenCalledWith({
where: { companyId: 'company_1', channel: 'IN_APP', readAt: null },
expect(prismaMock.notificationRecipient.findMany).toHaveBeenCalledWith({
where: {
employeeId: 'employee_1',
archivedAt: null,
deliveries: { some: { channel: 'IN_APP' } },
readAt: null,
},
include: {
notificationEvent: true,
deliveries: { where: { channel: 'IN_APP' } },
},
orderBy: { createdAt: 'desc' },
take: 50,
})
})
it('marks a single company notification read with tenant scoping', async () => {
it('marks a single company notification read through recipient state only', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T13:00:00.000Z'))
await repo.markRead('notification_1', 'company_1')
await repo.markRead('recipient_1', 'employee_1')
expect(prismaMock.notification.updateMany).toHaveBeenCalledWith({
where: { id: 'notification_1', companyId: 'company_1' },
data: { readAt: new Date('2026-06-09T13:00:00.000Z'), status: 'READ' },
expect(prismaMock.notificationRecipient.updateMany).toHaveBeenCalledWith({
where: { id: 'recipient_1', employeeId: 'employee_1' },
data: { readAt: new Date('2026-06-09T13:00:00.000Z') },
})
vi.useRealTimers()
})
@@ -43,17 +56,32 @@ describe('notification.repo query boundaries', () => {
it('keeps renter bulk read updates constrained to renter in-app notifications', async () => {
await repo.markAllRenterRead('renter_1')
expect(prismaMock.notification.updateMany).toHaveBeenCalledWith({
where: { renterId: 'renter_1', channel: 'IN_APP', readAt: null },
data: { readAt: expect.any(Date), status: 'READ' },
expect(prismaMock.notificationRecipient.updateMany).toHaveBeenCalledWith({
where: {
renterId: 'renter_1',
readAt: null,
archivedAt: null,
deliveries: { some: { channel: 'IN_APP' } },
},
data: { readAt: expect.any(Date) },
})
})
it('defaults company notification history to a capped newest-first query', async () => {
prismaMock.notificationDelivery.findMany.mockResolvedValue([])
await repo.findCompanyHistory('company_1')
expect(prismaMock.notification.findMany).toHaveBeenCalledWith({
where: { companyId: 'company_1' },
expect(prismaMock.notificationDelivery.findMany).toHaveBeenCalledWith({
where: {
notificationRecipient: {
notificationEvent: { companyId: 'company_1' },
},
},
include: {
notificationRecipient: {
include: { notificationEvent: true },
},
},
orderBy: { createdAt: 'desc' },
take: 200,
})
@@ -61,7 +89,7 @@ describe('notification.repo query boundaries', () => {
it('upserts employee preferences by composite key without cross-employee mutation', async () => {
await repo.upsertEmployeePreferences('employee_1', [
{ notificationType: 'RESERVATION_CREATED', channel: 'EMAIL', enabled: false },
{ notificationType: 'NEW_BOOKING', channel: 'EMAIL', enabled: false },
{ notificationType: 'PAYMENT_RECEIVED', channel: 'IN_APP', enabled: true },
])
@@ -70,13 +98,13 @@ describe('notification.repo query boundaries', () => {
where: {
employeeId_notificationType_channel: {
employeeId: 'employee_1',
notificationType: 'RESERVATION_CREATED',
notificationType: 'NEW_BOOKING',
channel: 'EMAIL',
},
},
create: {
employeeId: 'employee_1',
notificationType: 'RESERVATION_CREATED',
notificationType: 'NEW_BOOKING',
channel: 'EMAIL',
enabled: false,
},
@@ -86,21 +114,21 @@ describe('notification.repo query boundaries', () => {
it('upserts renter preferences by renter composite key', async () => {
await repo.upsertRenterPreferences('renter_1', [
{ notificationType: 'RESERVATION_CONFIRMED', channel: 'PUSH', enabled: true },
{ notificationType: 'BOOKING_CONFIRMED', channel: 'EMAIL', enabled: true },
])
expect(prismaMock.notificationPreference.upsert).toHaveBeenCalledWith({
where: {
renterId_notificationType_channel: {
renterId: 'renter_1',
notificationType: 'RESERVATION_CONFIRMED',
channel: 'PUSH',
notificationType: 'BOOKING_CONFIRMED',
channel: 'EMAIL',
},
},
create: {
renterId: 'renter_1',
notificationType: 'RESERVATION_CONFIRMED',
channel: 'PUSH',
notificationType: 'BOOKING_CONFIRMED',
channel: 'EMAIL',
enabled: true,
},
update: { enabled: true },
@@ -3,22 +3,109 @@ import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
// ─── Company notifications ────────────────────────────────────
export function findCompany(companyId: string, unread?: string) {
const where: any = { companyId, channel: 'IN_APP' }
function presentRecipientNotification(recipient: any) {
const event = recipient.notificationEvent
const inAppDelivery = recipient.deliveries?.find((delivery: any) => delivery.channel === 'IN_APP')
const firstDelivery = inAppDelivery ?? recipient.deliveries?.[0] ?? null
return {
id: recipient.id,
notificationEventId: event.id,
type: event.type,
title: event.title,
body: event.body,
data: event.data,
channel: firstDelivery?.channel ?? 'IN_APP',
status: recipient.readAt ? 'READ' : (firstDelivery?.status ?? 'DELIVERED'),
sentAt: firstDelivery?.sentAt ?? firstDelivery?.deliveredAt ?? null,
readAt: recipient.readAt,
createdAt: recipient.createdAt,
providerMessageId: firstDelivery?.providerMessageId ?? null,
locale: event.locale,
}
}
function presentDeliveryHistory(delivery: any) {
const recipient = delivery.notificationRecipient
const event = recipient.notificationEvent
return {
id: delivery.id,
recipientNotificationId: recipient.id,
notificationEventId: event.id,
type: event.type,
title: event.title,
body: event.body,
data: event.data,
channel: delivery.channel,
status: delivery.status,
attemptCount: delivery.attemptCount,
provider: delivery.provider,
lastAttemptAt: delivery.lastAttemptAt,
nextAttemptAt: delivery.nextAttemptAt,
sentAt: delivery.sentAt,
deliveredAt: delivery.deliveredAt,
failureCode: delivery.failureCode,
failureReason: delivery.failureReason,
preferenceDecision: delivery.preferenceDecision,
readAt: recipient.readAt,
createdAt: delivery.createdAt,
providerMessageId: delivery.providerMessageId,
locale: event.locale,
sourceType: event.sourceType,
sourceId: event.sourceId,
recipient: {
type: recipient.recipientType,
employeeId: recipient.employeeId,
renterId: recipient.renterId,
},
}
}
export async function findCompany(employeeId: string, unread?: string) {
const where: any = {
employeeId,
archivedAt: null,
deliveries: { some: { channel: 'IN_APP' } },
}
if (unread === 'true') where.readAt = null
return prisma.notification.findMany({ where, orderBy: { createdAt: 'desc' }, take: 50 })
const rows = await prisma.notificationRecipient.findMany({
where,
include: {
notificationEvent: true,
deliveries: { where: { channel: 'IN_APP' } },
},
orderBy: { createdAt: 'desc' },
take: 50,
})
return rows.map(presentRecipientNotification)
}
export function countUnread(companyId: string) {
return prisma.notification.count({ where: { companyId, channel: 'IN_APP', readAt: null } })
export function countUnread(employeeId: string) {
return prisma.notificationRecipient.count({
where: {
employeeId,
readAt: null,
archivedAt: null,
deliveries: { some: { channel: 'IN_APP' } },
},
})
}
export function markRead(id: string, companyId: string) {
return prisma.notification.updateMany({ where: { id, companyId }, data: { readAt: new Date(), status: 'READ' } })
export function markRead(id: string, employeeId: string) {
return prisma.notificationRecipient.updateMany({ where: { id, employeeId }, data: { readAt: new Date() } })
}
export function markAllRead(companyId: string) {
return prisma.notification.updateMany({ where: { companyId, readAt: null }, data: { readAt: new Date(), status: 'READ' } })
export function markAllRead(employeeId: string) {
return prisma.notificationRecipient.updateMany({
where: {
employeeId,
readAt: null,
archivedAt: null,
deliveries: { some: { channel: 'IN_APP' } },
},
data: { readAt: new Date() },
})
}
export function findEmployeePreferences(employeeId: string) {
@@ -39,28 +126,58 @@ export function findCompanyHistory(
companyId: string,
opts?: { channel?: string; status?: string; limit?: number },
) {
const where: any = { companyId }
const where: any = {
notificationRecipient: {
notificationEvent: { companyId },
},
}
if (opts?.channel) where.channel = opts.channel
if (opts?.status) where.status = opts.status
return prisma.notification.findMany({
return prisma.notificationDelivery.findMany({
where,
include: {
notificationRecipient: {
include: { notificationEvent: true },
},
},
orderBy: { createdAt: 'desc' },
take: opts?.limit ?? 200,
})
}).then((rows) => rows.map(presentDeliveryHistory))
}
// ─── Renter notifications ─────────────────────────────────────
export function findRenter(renterId: string) {
return prisma.notification.findMany({ where: { renterId, channel: 'IN_APP' }, orderBy: { createdAt: 'desc' }, take: 50 })
export async function findRenter(renterId: string) {
const rows = await prisma.notificationRecipient.findMany({
where: {
renterId,
archivedAt: null,
deliveries: { some: { channel: 'IN_APP' } },
},
include: {
notificationEvent: true,
deliveries: { where: { channel: 'IN_APP' } },
},
orderBy: { createdAt: 'desc' },
take: 50,
})
return rows.map(presentRecipientNotification)
}
export function markRenterRead(id: string, renterId: string) {
return prisma.notification.updateMany({ where: { id, renterId }, data: { readAt: new Date(), status: 'READ' } })
return prisma.notificationRecipient.updateMany({ where: { id, renterId }, data: { readAt: new Date() } })
}
export function markAllRenterRead(renterId: string) {
return prisma.notification.updateMany({ where: { renterId, channel: 'IN_APP', readAt: null }, data: { readAt: new Date(), status: 'READ' } })
return prisma.notificationRecipient.updateMany({
where: {
renterId,
readAt: null,
archivedAt: null,
deliveries: { some: { channel: 'IN_APP' } },
},
data: { readAt: new Date() },
})
}
export function findRenterPreferences(renterId: string) {
@@ -3,6 +3,7 @@ import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription'
import { requireRenterAuth } from '../../middleware/requireRenterAuth'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok } from '../../http/respond'
import * as service from './notification.service'
@@ -18,27 +19,27 @@ const companyWriteAuth = [requireCompanyAuth, requireTenant, requireSubscription
router.get('/company', ...companyReadAuth, async (req, res, next) => {
try {
const { unread } = parseQuery(unreadQuerySchema, req)
ok(res, await service.listCompany(req.companyId, unread))
ok(res, await service.listCompany(req.employee.id, unread))
} catch (err) { next(err) }
})
router.get('/unread-count', ...companyReadAuth, async (req, res, next) => {
try {
ok(res, { unread: await service.countUnread(req.companyId) })
ok(res, { unread: await service.countUnread(req.employee.id) })
} catch (err) { next(err) }
})
router.post('/company/:id/read', ...companyWriteAuth, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.markRead(id, req.companyId)
await service.markRead(id, req.employee.id)
ok(res, { success: true })
} catch (err) { next(err) }
})
router.post('/company/read-all', ...companyWriteAuth, async (req, res, next) => {
try {
await service.markAllRead(req.companyId)
await service.markAllRead(req.employee.id)
ok(res, { success: true })
} catch (err) { next(err) }
})
@@ -57,7 +58,7 @@ router.patch('/company/preferences', ...companyWriteAuth, async (req, res, next)
} catch (err) { next(err) }
})
router.get('/history', ...companyReadAuth, async (req, res, next) => {
router.get('/history', ...companyReadAuth, requireRole('MANAGER'), async (req, res, next) => {
try {
const { channel, status, limit } = parseQuery(historyQuerySchema, req)
ok(res, await service.listCompanyHistory(req.companyId, { channel, status, limit }))
@@ -3,10 +3,12 @@ import { historyQuerySchema, idParamSchema, preferencesSchema, unreadQuerySchema
describe('notification schema contracts', () => {
it('requires complete preference entries', () => {
expect(preferencesSchema.parse([{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL', enabled: true }])).toEqual([
{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL', enabled: true },
expect(preferencesSchema.parse([{ notificationType: 'NEW_BOOKING', channel: 'EMAIL', enabled: true }])).toEqual([
{ notificationType: 'NEW_BOOKING', channel: 'EMAIL', enabled: true },
])
expect(() => preferencesSchema.parse([{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL' }])).toThrow()
expect(() => preferencesSchema.parse([{ notificationType: 'NEW_BOOKING', channel: 'EMAIL' }])).toThrow()
expect(() => preferencesSchema.parse([{ notificationType: 'RESERVATION_CREATED', channel: 'EMAIL', enabled: true }])).toThrow()
expect(() => preferencesSchema.parse([{ notificationType: 'NEW_BOOKING', channel: 'PUSH', enabled: true }])).toThrow()
})
it('coerces history limits and caps bulk history requests', () => {
@@ -1,8 +1,44 @@
import { z } from 'zod'
export const notificationTypeSchema = z.enum([
'ACCOUNT_CREATED',
'NEW_BOOKING',
'BOOKING_CANCELLED',
'PAYMENT_RECEIVED',
'PAYMENT_FAILED',
'DOCUMENTS_REQUIRED',
'PAYMENT_REQUIRED',
'SUBSCRIPTION_TRIAL_ENDING',
'SUBSCRIPTION_SUSPENDED',
'VEHICLE_MAINTENANCE_DUE',
'OFFER_EXPIRING',
'NEW_REVIEW_RECEIVED',
'BOOKING_CONFIRMED',
'PICKUP_REMINDER_24H',
'PICKUP_REMINDER_2H',
'VEHICLE_READY',
'RETURN_REMINDER',
'BOOKING_CANCELLED_BY_COMPANY',
'REFUND_PROCESSED',
'NEW_OFFER_FROM_SAVED_COMPANY',
'REVIEW_REQUEST',
])
export const notificationChannelSchema = z.enum(['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH'])
export const implementedNotificationChannelSchema = z.enum(['EMAIL', 'IN_APP'])
export const notificationDeliveryStatusSchema = z.enum([
'PENDING',
'QUEUED',
'SENT',
'DELIVERED',
'FAILED',
'SKIPPED',
'DEAD_LETTER',
])
export const preferenceItemSchema = z.object({
notificationType: z.string(),
channel: z.string(),
notificationType: notificationTypeSchema,
channel: implementedNotificationChannelSchema,
enabled: z.boolean(),
})
@@ -17,7 +53,7 @@ export const unreadQuerySchema = z.object({
})
export const historyQuerySchema = z.object({
channel: z.string().optional(),
status: z.string().optional(),
channel: notificationChannelSchema.optional(),
status: notificationDeliveryStatusSchema.optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
})
@@ -26,21 +26,21 @@ describe('notification.service', () => {
vi.mocked(repo.findCompanyHistory).mockResolvedValue([{ id: 'h1' }] as never)
vi.mocked(repo.countUnread).mockResolvedValue(4 as never)
await expect(service.listCompany('company_1', 'true')).resolves.toEqual([{ id: 'n1' }])
await expect(service.listCompany('employee_1', 'true')).resolves.toEqual([{ id: 'n1' }])
await expect(service.listCompanyHistory('company_1', { channel: 'EMAIL', status: 'FAILED', limit: 25 })).resolves.toEqual([{ id: 'h1' }])
await expect(service.countUnread('company_1')).resolves.toBe(4)
await expect(service.countUnread('employee_1')).resolves.toBe(4)
expect(repo.findCompany).toHaveBeenCalledWith('company_1', 'true')
expect(repo.findCompany).toHaveBeenCalledWith('employee_1', 'true')
expect(repo.findCompanyHistory).toHaveBeenCalledWith('company_1', { channel: 'EMAIL', status: 'FAILED', limit: 25 })
expect(repo.countUnread).toHaveBeenCalledWith('company_1')
expect(repo.countUnread).toHaveBeenCalledWith('employee_1')
})
it('marks company notifications read only within the company tenant', async () => {
await service.markRead('notification_1', 'company_1')
await service.markAllRead('company_1')
it('marks company notifications read only for the authenticated employee recipient', async () => {
await service.markRead('notification_1', 'employee_1')
await service.markAllRead('employee_1')
expect(repo.markRead).toHaveBeenCalledWith('notification_1', 'company_1')
expect(repo.markAllRead).toHaveBeenCalledWith('company_1')
expect(repo.markRead).toHaveBeenCalledWith('notification_1', 'employee_1')
expect(repo.markAllRead).toHaveBeenCalledWith('employee_1')
})
it('reads and writes employee preferences through the employee identity, not the company id', async () => {
@@ -1,10 +1,10 @@
import * as repo from './notification.repo'
export const listCompany = (companyId: string, unread?: string) => repo.findCompany(companyId, unread)
export const listCompany = (employeeId: string, unread?: string) => repo.findCompany(employeeId, unread)
export const listCompanyHistory = (companyId: string, opts?: { channel?: string; status?: string; limit?: number }) => repo.findCompanyHistory(companyId, opts)
export const countUnread = (companyId: string) => repo.countUnread(companyId)
export const markRead = (id: string, companyId: string) => repo.markRead(id, companyId)
export const markAllRead = (companyId: string) => repo.markAllRead(companyId)
export const countUnread = (employeeId: string) => repo.countUnread(employeeId)
export const markRead = (id: string, employeeId: string) => repo.markRead(id, employeeId)
export const markAllRead = (employeeId: string) => repo.markAllRead(employeeId)
export const getPreferences = (employeeId: string) => repo.findEmployeePreferences(employeeId)
export const setPreferences = (employeeId: string, prefs: any[]) => repo.upsertEmployeePreferences(employeeId, prefs)
+107 -94
View File
@@ -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()
})
})
+273 -125
View File
@@ -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: {
@@ -119,12 +119,12 @@ describe('operations validation API contracts', () => {
it('accepts valid company notification preferences and passes employee identity', async () => {
const res = await request(app).patch('/api/v1/notifications/company/preferences').send([
{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL', enabled: true },
{ notificationType: 'NEW_BOOKING', channel: 'EMAIL', enabled: true },
])
expect(res.status).toBe(200)
expect(notificationService.setPreferences).toHaveBeenCalledWith('employee_1', [
{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL', enabled: true },
{ notificationType: 'NEW_BOOKING', channel: 'EMAIL', enabled: true },
])
})
@@ -146,6 +146,7 @@ describe('operations API contracts', () => {
expect(res.status).toBe(200)
expect(res.body).toEqual({ data: { unread: 7 } })
expect(notificationService.countUnread).toHaveBeenCalledWith('employee_1')
})
it('GET /api/v1/notifications/history coerces limit and passes history filters to the service', async () => {
+59 -16
View File
@@ -182,35 +182,78 @@ export async function createCompanyNotification(
employeeId?: string,
overrides: Record<string, unknown> = {},
) {
return prisma.notification.create({
const notificationEvent = await prisma.notificationEvent.create({
data: {
companyId,
employeeId,
type: 'PAYMENT_RECEIVED',
title: 'Payment received',
body: 'A payment was recorded.',
channel: 'IN_APP',
status: 'PENDING',
...overrides,
type: (overrides.type ?? 'PAYMENT_RECEIVED') as any,
title: (overrides.title ?? 'Payment received') as string,
body: (overrides.body ?? 'A payment was recorded.') as string,
data: (overrides.data ?? {}) as any,
sourceType: (overrides.sourceType ?? 'test') as string,
sourceId: (overrides.sourceId ?? `source-${uid()}`) as string,
idempotencyKey: (overrides.idempotencyKey ?? `test-${uid()}`) as string,
} as any,
})
const recipient = await prisma.notificationRecipient.create({
data: {
notificationEventId: notificationEvent.id,
recipientType: 'EMPLOYEE',
employeeId,
readAt: (overrides.readAt ?? null) as Date | null,
} as any,
})
await prisma.notificationDelivery.create({
data: {
notificationRecipientId: recipient.id,
channel: (overrides.channel ?? 'IN_APP') as any,
status: (overrides.status ?? 'DELIVERED') === 'READ' ? 'DELIVERED' : (overrides.status ?? 'DELIVERED') as any,
deliveredAt: overrides.sentAt ? (overrides.sentAt as Date) : new Date(),
} as any,
})
return recipient
}
export async function createRenterNotification(
renterId: string,
overrides: Record<string, unknown> = {},
) {
return prisma.notification.create({
const companyId = (overrides.companyId as string | undefined)
?? (await prisma.company.findFirstOrThrow({ select: { id: true } })).id
const notificationEvent = await prisma.notificationEvent.create({
data: {
renterId,
type: 'BOOKING_CONFIRMED',
title: 'Booking confirmed',
body: 'Your booking is confirmed.',
channel: 'IN_APP',
status: 'PENDING',
...overrides,
companyId,
type: (overrides.type ?? 'BOOKING_CONFIRMED') as any,
title: (overrides.title ?? 'Booking confirmed') as string,
body: (overrides.body ?? 'Your booking is confirmed.') as string,
data: (overrides.data ?? {}) as any,
sourceType: (overrides.sourceType ?? 'test') as string,
sourceId: (overrides.sourceId ?? `source-${uid()}`) as string,
idempotencyKey: (overrides.idempotencyKey ?? `test-${uid()}`) as string,
} as any,
})
const recipient = await prisma.notificationRecipient.create({
data: {
notificationEventId: notificationEvent.id,
recipientType: 'RENTER',
renterId,
readAt: (overrides.readAt ?? null) as Date | null,
} as any,
})
await prisma.notificationDelivery.create({
data: {
notificationRecipientId: recipient.id,
channel: (overrides.channel ?? 'IN_APP') as any,
status: (overrides.status ?? 'DELIVERED') === 'READ' ? 'DELIVERED' : (overrides.status ?? 'DELIVERED') as any,
deliveredAt: overrides.sentAt ? (overrides.sentAt as Date) : new Date(),
} as any,
})
return recipient
}
export function signEmployeeToken(employeeId: string, _companyId: string, _role: string = 'OWNER') {
@@ -115,7 +115,7 @@ describe('Notifications API', () => {
it('updates renter notification preferences', async () => {
const payload = [
{ notificationType: 'BOOKING_CONFIRMED', channel: 'PUSH', enabled: true },
{ notificationType: 'BOOKING_CONFIRMED', channel: 'EMAIL', enabled: true },
]
const patchRes = await request(app)
@@ -136,7 +136,7 @@ describe('Notifications API', () => {
expect.objectContaining({
renterId,
notificationType: 'BOOKING_CONFIRMED',
channel: 'PUSH',
channel: 'EMAIL',
enabled: true,
}),
]),
@@ -82,7 +82,7 @@ describe('Operations API integration', () => {
expect(marked.status).toBe(200)
expect(marked.body.data).toEqual({ success: true })
const stored = await prisma.notification.findUniqueOrThrow({ where: { id: notification.id } })
const stored = await prisma.notificationRecipient.findUniqueOrThrow({ where: { id: notification.id } })
expect(stored.readAt).toBeInstanceOf(Date)
})