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
+1
View File
@@ -45,6 +45,7 @@
"react": "^18.3.1",
"resend": "^3.2.0",
"socket.io": "^4.7.5",
"stripe": "^22.3.2",
"swagger-ui-express": "^5.0.1",
"turbo": "2.10.0",
"twilio": "^5.1.0",
+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)
})
@@ -25,15 +25,15 @@ type PreferenceItem = {
}
const COMPANY_EVENTS = [
'NEW_RESERVATION',
'RESERVATION_CANCELLED',
'NEW_BOOKING',
'BOOKING_CANCELLED',
'PAYMENT_RECEIVED',
'SUBSCRIPTION_TRIAL_ENDING',
'MAINTENANCE_DUE',
'VEHICLE_MAINTENANCE_DUE',
'OFFER_EXPIRING',
]
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
const COMPANY_CHANNELS = ['EMAIL', 'IN_APP']
const CHANNEL_BADGE: Record<string, string> = {
EMAIL: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
@@ -45,9 +45,12 @@ const CHANNEL_BADGE: Record<string, string> = {
const STATUS_BADGE: Record<string, string> = {
PENDING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
QUEUED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
SENT: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
DELIVERED: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
FAILED: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
SKIPPED: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
DEAD_LETTER: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
READ: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
}
@@ -89,14 +92,13 @@ export default function DashboardNotificationsPage() {
failedLoad: 'Failed to load notifications',
failedSave: 'Failed to save preferences',
noHistory: 'No notification history found.',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record<string, string>,
statuses: { PENDING: 'Pending', SENT: 'Sent', DELIVERED: 'Delivered', FAILED: 'Failed', READ: 'Read' } as Record<string, string>,
channels: { EMAIL: 'Email', IN_APP: 'In-app' } as Record<string, string>,
statuses: { PENDING: 'Pending', QUEUED: 'Queued', SENT: 'Sent', DELIVERED: 'Delivered', FAILED: 'Failed', SKIPPED: 'Skipped', DEAD_LETTER: 'Dead letter', READ: 'Read' } as Record<string, string>,
events: {
NEW_RESERVATION: 'New reservation',
RESERVATION_CANCELLED: 'Reservation cancelled',
NEW_BOOKING: 'New booking',
BOOKING_CANCELLED: 'Booking cancelled',
PAYMENT_RECEIVED: 'Payment received',
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
MAINTENANCE_DUE: 'Maintenance due',
OFFER_EXPIRING: 'Offer expiring',
BOOKING_CONFIRMED: 'Booking confirmed',
VEHICLE_MAINTENANCE_DUE: 'Vehicle maintenance due',
@@ -125,14 +127,13 @@ export default function DashboardNotificationsPage() {
failedLoad: 'Échec du chargement des notifications',
failedSave: 'Échec de l\'enregistrement des préférences',
noHistory: 'Aucun historique de notification trouvé.',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l\'application', PUSH: 'Push' } as Record<string, string>,
statuses: { PENDING: 'En attente', SENT: 'Envoyé', DELIVERED: 'Délivré', FAILED: 'Échoué', READ: 'Lu' } as Record<string, string>,
channels: { EMAIL: 'Email', IN_APP: 'Dans l\'application' } as Record<string, string>,
statuses: { PENDING: 'En attente', QUEUED: 'En file', SENT: 'Envoyé', DELIVERED: 'Délivré', FAILED: 'Échoué', SKIPPED: 'Ignoré', DEAD_LETTER: 'Échec final', READ: 'Lu' } as Record<string, string>,
events: {
NEW_RESERVATION: 'Nouvelle réservation',
RESERVATION_CANCELLED: 'Réservation annulée',
NEW_BOOKING: 'Nouvelle réservation',
BOOKING_CANCELLED: 'Réservation annulée',
PAYMENT_RECEIVED: 'Paiement reçu',
SUBSCRIPTION_TRIAL_ENDING: 'Fin d\'essai proche',
MAINTENANCE_DUE: 'Maintenance due',
OFFER_EXPIRING: 'Offre expirante',
BOOKING_CONFIRMED: 'Réservation confirmée',
VEHICLE_MAINTENANCE_DUE: 'Maintenance véhicule due',
@@ -161,14 +162,13 @@ export default function DashboardNotificationsPage() {
failedLoad: 'فشل تحميل الإشعارات',
failedSave: 'فشل حفظ التفضيلات',
noHistory: 'لا يوجد سجل إشعارات.',
channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>,
statuses: { PENDING: 'قيد الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', READ: 'مقروء' } as Record<string, string>,
channels: { EMAIL: 'البريد', IN_APP: 'داخل التطبيق' } as Record<string, string>,
statuses: { PENDING: 'قيد الانتظار', QUEUED: 'في قائمة الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', SKIPPED: 'تم التجاوز', DEAD_LETTER: 'فشل نهائي', READ: 'مقروء' } as Record<string, string>,
events: {
NEW_RESERVATION: 'حجز جديد',
RESERVATION_CANCELLED: 'إلغاء حجز',
NEW_BOOKING: 'حجز جديد',
BOOKING_CANCELLED: 'إلغاء حجز',
PAYMENT_RECEIVED: 'تم استلام الدفع',
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
MAINTENANCE_DUE: 'صيانة مستحقة',
OFFER_EXPIRING: 'عرض على وشك الانتهاء',
BOOKING_CONFIRMED: 'تأكيد الحجز',
VEHICLE_MAINTENANCE_DUE: 'صيانة المركبة مستحقة',
@@ -2,14 +2,14 @@ import { describe, expect, it } from 'vitest'
import { APPROVED_BASELINE_MENU_KEYS, getEmployeeLogoutRedirectUrl, hasRenderableMenuItems, shouldUseGeneratedMenu } from './Sidebar'
describe('Sidebar menu rendering helpers', () => {
it('keeps the safe fallback menu to the approved seven baseline items in order', () => {
it('keeps dashboard first, then sorts the remaining safe fallback items alphabetically', () => {
expect(APPROVED_BASELINE_MENU_KEYS).toEqual([
'dashboard',
'reservations',
'fleet',
'customers',
'reports',
'billing',
'customers',
'fleet',
'reports',
'reservations',
'settings',
])
})
@@ -101,7 +101,13 @@ const OWNER_SYSTEM_NAV_ITEMS = [
{ href: '/subscription', key: 'subscription', icon: 'CreditCard', minRole: 'OWNER' },
] as const
export const APPROVED_BASELINE_MENU_KEYS = NAV_ITEMS.map((item) => item.key)
export const APPROVED_BASELINE_MENU_KEYS = [...NAV_ITEMS]
.sort((a, b) => {
if (a.key === 'dashboard') return -1
if (b.key === 'dashboard') return 1
return a.key.localeCompare(b.key)
})
.map((item) => item.key)
const ICON_MAP = {
LayoutDashboard,
@@ -152,6 +158,24 @@ export function getEmployeeLogoutRedirectUrl() {
return websiteUrl
}
function sortSidebarMenuItemsAlphabetically(
items: GeneratedMenuItem[],
resolveLabel: (item: GeneratedMenuItem) => string,
): GeneratedMenuItem[] {
return [...items]
.map((item) => ({
...item,
children: sortSidebarMenuItemsAlphabetically(item.children, resolveLabel),
}))
.sort((a, b) => {
if (a.systemKey === 'dashboard') return -1
if (b.systemKey === 'dashboard') return 1
const labelCompare = resolveLabel(a).localeCompare(resolveLabel(b), undefined, { sensitivity: 'base' })
if (labelCompare !== 0) return labelCompare
return (a.systemKey ?? a.id).localeCompare(b.systemKey ?? b.id)
})
}
function notifyParent(message: Record<string, unknown>) {
if (typeof window === 'undefined' || window.parent === window) return
window.parent.postMessage(message, '*')
@@ -172,6 +196,7 @@ export default function Sidebar() {
const [menuItems, setMenuItems] = useState<GeneratedMenuItem[] | null>(null)
const [menuAccessLevel, setMenuAccessLevel] = useState<SubscriptionAccessLevel | null>(null)
const [menuLoadState, setMenuLoadState] = useState<'loading' | 'loaded' | 'failed'>('loading')
const [unreadCount, setUnreadCount] = useState(0)
const [open, setOpen] = useState(false)
const [mounted, setMounted] = useState(false)
@@ -276,6 +301,36 @@ export default function Sidebar() {
// Close sidebar when navigating on mobile
useEffect(() => { setOpen(false) }, [pathname])
async function refreshUnreadCount() {
try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
setUnreadCount(data.unread)
} catch {
setUnreadCount(0)
}
}
useEffect(() => {
refreshUnreadCount()
}, [pathname])
useEffect(() => {
const refresh = () => { void refreshUnreadCount() }
const refreshWhenVisible = () => {
if (document.visibilityState === 'visible') refresh()
}
window.addEventListener('notifications:updated', refresh)
window.addEventListener('online', refresh)
window.addEventListener('focus', refresh)
document.addEventListener('visibilitychange', refreshWhenVisible)
return () => {
window.removeEventListener('notifications:updated', refresh)
window.removeEventListener('online', refresh)
window.removeEventListener('focus', refresh)
document.removeEventListener('visibilitychange', refreshWhenVisible)
}
}, [])
const isActive = (item: typeof NAV_ITEMS[number]) => {
if ('exact' in item && item.exact) return appPath === item.href
return appPath.startsWith(item.href)
@@ -328,6 +383,10 @@ export default function Sidebar() {
...generatedMenuItems,
...ownerSystemMenuItems.filter((item) => !generatedRoutes.has(toDashboardAppPath(item.routeOrUrl))),
]
const sortedResolvedMenuItems = sortSidebarMenuItemsAlphabetically(
resolvedMenuItems,
(item) => item.systemKey ? (dict.nav[item.systemKey] ?? item.label) : item.label,
)
function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode {
return items.map((item) => {
@@ -360,6 +419,9 @@ export default function Sidebar() {
}
const active = mounted && isGeneratedItemActive(item)
const badge = item.systemKey === 'notifications' && unreadCount > 0
? unreadCount > 99 ? '99+' : String(unreadCount)
: null
const className = [
'relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200',
paddingClass,
@@ -388,7 +450,12 @@ export default function Sidebar() {
return (
<Link key={item.id} href={href} className={className}>
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
{label}
<span className="min-w-0 flex-1 truncate">{label}</span>
{badge ? (
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-orange-500 px-1.5 text-[11px] font-bold leading-none text-white">
{badge}
</span>
) : null}
</Link>
)
})
@@ -454,7 +521,7 @@ export default function Sidebar() {
</a>
<nav className="flex-1 space-y-1.5 overflow-y-auto px-3 py-5">
{renderGeneratedMenu(resolvedMenuItems)}
{renderGeneratedMenu(sortedResolvedMenuItems)}
</nav>
<div className="border-t border-blue-200/70 px-3 py-4 dark:border-blue-400/10">
@@ -0,0 +1,105 @@
-- Durable, recipient-safe notification model. The legacy notifications table is
-- intentionally retained for rollback and audit during the migration window.
CREATE TYPE "NotificationRecipientType" AS ENUM ('EMPLOYEE', 'RENTER');
CREATE TYPE "NotificationDeliveryStatus" AS ENUM ('PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER');
CREATE TYPE "NotificationOutboxStatus" AS ENUM ('PENDING', 'PUBLISHED', 'FAILED');
CREATE TABLE "notification_events" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"type" "NotificationType" NOT NULL,
"templateKey" TEXT,
"locale" TEXT NOT NULL DEFAULT 'en',
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"data" JSONB,
"sourceType" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
"idempotencyKey" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_events_pkey" PRIMARY KEY ("id"),
CONSTRAINT "notification_events_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE "notification_recipients" (
"id" TEXT NOT NULL,
"notificationEventId" TEXT NOT NULL,
"recipientType" "NotificationRecipientType" NOT NULL,
"employeeId" TEXT,
"renterId" TEXT,
"readAt" TIMESTAMP(3),
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_recipients_pkey" PRIMARY KEY ("id"),
CONSTRAINT "notification_recipients_notificationEventId_fkey" FOREIGN KEY ("notificationEventId") REFERENCES "notification_events"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "notification_recipients_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "notification_recipients_renterId_fkey" FOREIGN KEY ("renterId") REFERENCES "renters"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "notification_recipients_exactly_one_identity_chk" CHECK (
(CASE WHEN "employeeId" IS NULL THEN 0 ELSE 1 END) +
(CASE WHEN "renterId" IS NULL THEN 0 ELSE 1 END) = 1
),
CONSTRAINT "notification_recipients_type_identity_chk" CHECK (
("recipientType" = 'EMPLOYEE' AND "employeeId" IS NOT NULL AND "renterId" IS NULL) OR
("recipientType" = 'RENTER' AND "renterId" IS NOT NULL AND "employeeId" IS NULL)
)
);
CREATE TABLE "notification_deliveries" (
"id" TEXT NOT NULL,
"notificationRecipientId" TEXT NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"status" "NotificationDeliveryStatus" NOT NULL DEFAULT 'PENDING',
"attemptCount" INTEGER NOT NULL DEFAULT 0,
"lastAttemptAt" TIMESTAMP(3),
"nextAttemptAt" TIMESTAMP(3),
"provider" TEXT,
"providerMessageId" TEXT,
"failureCode" TEXT,
"failureReason" TEXT,
"preferenceDecision" TEXT,
"sentAt" TIMESTAMP(3),
"deliveredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_deliveries_pkey" PRIMARY KEY ("id"),
CONSTRAINT "notification_deliveries_notificationRecipientId_fkey" FOREIGN KEY ("notificationRecipientId") REFERENCES "notification_recipients"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE "notification_outbox" (
"id" TEXT NOT NULL,
"notificationEventId" TEXT NOT NULL,
"status" "NotificationOutboxStatus" NOT NULL DEFAULT 'PENDING',
"payload" JSONB NOT NULL,
"publishedAt" TIMESTAMP(3),
"failureReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_outbox_pkey" PRIMARY KEY ("id"),
CONSTRAINT "notification_outbox_notificationEventId_fkey" FOREIGN KEY ("notificationEventId") REFERENCES "notification_events"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE "company_notification_preferences" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"notificationType" "NotificationType" NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "company_notification_preferences_pkey" PRIMARY KEY ("id"),
CONSTRAINT "company_notification_preferences_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "notification_events_companyId_idempotencyKey_key" ON "notification_events"("companyId", "idempotencyKey");
CREATE INDEX "notification_events_companyId_type_createdAt_idx" ON "notification_events"("companyId", "type", "createdAt");
CREATE INDEX "notification_recipients_employeeId_readAt_createdAt_idx" ON "notification_recipients"("employeeId", "readAt", "createdAt");
CREATE INDEX "notification_recipients_renterId_readAt_createdAt_idx" ON "notification_recipients"("renterId", "readAt", "createdAt");
CREATE INDEX "notification_recipients_notificationEventId_idx" ON "notification_recipients"("notificationEventId");
CREATE UNIQUE INDEX "notification_deliveries_notificationRecipientId_channel_key" ON "notification_deliveries"("notificationRecipientId", "channel");
CREATE INDEX "notification_deliveries_channel_status_nextAttemptAt_idx" ON "notification_deliveries"("channel", "status", "nextAttemptAt");
CREATE INDEX "notification_outbox_status_createdAt_idx" ON "notification_outbox"("status", "createdAt");
CREATE UNIQUE INDEX "company_notification_preferences_companyId_notificationType_channel_key" ON "company_notification_preferences"("companyId", "notificationType", "channel");
@@ -0,0 +1,94 @@
WITH notification_menu AS (
INSERT INTO "menu_items" (
"id",
"systemKey",
"label",
"itemType",
"routeOrUrl",
"icon",
"displayOrder",
"openInNewTab",
"isRequired",
"isActive",
"createdAt",
"updatedAt",
"createdBy",
"updatedBy"
)
VALUES (
'menu_notifications',
'notifications',
'Notifications',
'INTERNAL_PAGE'::"MenuItemType",
'/notifications',
'Bell',
120,
false,
false,
true,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
'system',
'system'
)
ON CONFLICT ("systemKey") DO UPDATE
SET
"label" = EXCLUDED."label",
"itemType" = EXCLUDED."itemType",
"routeOrUrl" = EXCLUDED."routeOrUrl",
"icon" = EXCLUDED."icon",
"isActive" = true,
"updatedBy" = 'system',
"updatedAt" = CURRENT_TIMESTAMP
RETURNING "id"
),
notification_plan_assignments ("plan", "displayOrder") AS (
VALUES
('STARTER'::"Plan", 120),
('GROWTH'::"Plan", 120),
('PRO'::"Plan", 120)
)
INSERT INTO "subscription_menu_items" (
"id",
"plan",
"menuItemId",
"displayOrder",
"isActive",
"createdAt",
"updatedAt"
)
SELECT
'menu_sub_' || lower(npa."plan"::text) || '_notifications',
npa."plan",
notification_menu."id",
npa."displayOrder",
true,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM notification_plan_assignments npa
CROSS JOIN notification_menu
ON CONFLICT ("plan", "menuItemId") DO UPDATE
SET
"displayOrder" = EXCLUDED."displayOrder",
"isActive" = true,
"updatedAt" = CURRENT_TIMESTAMP;
WITH notification_menu AS (
SELECT "id" FROM "menu_items" WHERE "systemKey" = 'notifications'
),
notification_roles ("role") AS (
VALUES
('OWNER'::"EmployeeRole"),
('MANAGER'::"EmployeeRole"),
('AGENT'::"EmployeeRole")
)
INSERT INTO "menu_item_role_visibility" ("id", "menuItemId", "role", "createdAt", "updatedAt")
SELECT
'menu_role_' || lower(notification_roles."role"::text) || '_notifications',
notification_menu."id",
notification_roles."role",
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM notification_roles
CROSS JOIN notification_menu
ON CONFLICT ("menuItemId", "role") DO NOTHING;
@@ -0,0 +1,27 @@
WITH notification_feature ("plan", "sortOrder") AS (
VALUES
('STARTER'::"Plan", 50),
('GROWTH'::"Plan", 150),
('PRO'::"Plan", 150)
)
INSERT INTO "plan_features" (
"id",
"plan",
"label",
"sortOrder",
"createdAt",
"updatedAt"
)
SELECT
'plan_feature_' || lower(notification_feature."plan"::text) || '_notification_management',
notification_feature."plan",
'NOTIFICATION_MANAGEMENT',
notification_feature."sortOrder",
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM notification_feature
ON CONFLICT ("id") DO UPDATE
SET
"label" = EXCLUDED."label",
"sortOrder" = EXCLUDED."sortOrder",
"updatedAt" = CURRENT_TIMESTAMP;
+118
View File
@@ -488,6 +488,27 @@ enum NotificationStatus {
READ
}
enum NotificationRecipientType {
EMPLOYEE
RENTER
}
enum NotificationDeliveryStatus {
PENDING
QUEUED
SENT
DELIVERED
FAILED
SKIPPED
DEAD_LETTER
}
enum NotificationOutboxStatus {
PENDING
PUBLISHED
FAILED
}
enum CalendarBlockType {
MANUAL
MAINTENANCE
@@ -523,6 +544,8 @@ model Company {
insurancePolicies InsurancePolicy[]
pricingRules PricingRule[]
notifications Notification[] @relation("CompanyNotifications")
notificationEvents NotificationEvent[]
notificationDefaults CompanyNotificationPreference[]
complaints Complaint[]
companyMenuItems CompanyMenuItem[]
apiKeys CompanyApiKey[]
@@ -1013,6 +1036,7 @@ model Employee {
isActive Boolean @default(true)
notifications Notification[] @relation("EmployeeNotifications")
notificationRecipients NotificationRecipient[]
notificationPreferences NotificationPreference[]
recordedRentalPayments RentalPayment[] @relation("RecordedRentalPayments")
@@ -1208,6 +1232,7 @@ model Renter {
savedCompanies RenterSavedCompany[]
reviews Review[]
notifications Notification[] @relation("RenterNotifications")
notificationRecipients NotificationRecipient[]
notificationPreferences NotificationPreference[]
createdAt DateTime @default(now())
@@ -1509,6 +1534,87 @@ model Notification {
@@map("notifications")
}
model NotificationEvent {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
type NotificationType
templateKey String?
locale String @default("en")
title String
body String
data Json?
sourceType String
sourceId String
idempotencyKey String
recipients NotificationRecipient[]
outboxEntries NotificationOutbox[]
createdAt DateTime @default(now())
@@unique([companyId, idempotencyKey])
@@index([companyId, type, createdAt])
@@map("notification_events")
}
model NotificationRecipient {
id String @id @default(cuid())
notificationEventId String
notificationEvent NotificationEvent @relation(fields: [notificationEventId], references: [id], onDelete: Cascade)
recipientType NotificationRecipientType
employeeId String?
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: Cascade)
renterId String?
renter Renter? @relation(fields: [renterId], references: [id], onDelete: Cascade)
readAt DateTime?
archivedAt DateTime?
deliveries NotificationDelivery[]
createdAt DateTime @default(now())
@@index([employeeId, readAt, createdAt])
@@index([renterId, readAt, createdAt])
@@index([notificationEventId])
@@map("notification_recipients")
}
model NotificationDelivery {
id String @id @default(cuid())
notificationRecipientId String
notificationRecipient NotificationRecipient @relation(fields: [notificationRecipientId], references: [id], onDelete: Cascade)
channel NotificationChannel
status NotificationDeliveryStatus @default(PENDING)
attemptCount Int @default(0)
lastAttemptAt DateTime?
nextAttemptAt DateTime?
provider String?
providerMessageId String?
failureCode String?
failureReason String?
preferenceDecision String?
sentAt DateTime?
deliveredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([notificationRecipientId, channel])
@@index([channel, status, nextAttemptAt])
@@map("notification_deliveries")
}
model NotificationOutbox {
id String @id @default(cuid())
notificationEventId String
notificationEvent NotificationEvent @relation(fields: [notificationEventId], references: [id], onDelete: Cascade)
status NotificationOutboxStatus @default(PENDING)
payload Json
publishedAt DateTime?
failureReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, createdAt])
@@map("notification_outbox")
}
model NotificationTemplate {
id String @id @default(cuid())
templateKey String
@@ -1544,6 +1650,18 @@ model NotificationPreference {
@@map("notification_preferences")
}
model CompanyNotificationPreference {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
notificationType NotificationType
channel NotificationChannel
enabled Boolean @default(true)
@@unique([companyId, notificationType, channel])
@@map("company_notification_preferences")
}
// ═══════════════════════════════════════════════════════════════
// MAINTENANCE
// ═══════════════════════════════════════════════════════════════
+2
View File
@@ -27,6 +27,8 @@ export type NotificationType =
| 'REVIEW_REQUEST'
export type NotificationChannel = 'EMAIL' | 'SMS' | 'WHATSAPP' | 'IN_APP' | 'PUSH'
export type NotificationDeliveryStatus = 'PENDING' | 'QUEUED' | 'SENT' | 'DELIVERED' | 'FAILED' | 'SKIPPED' | 'DEAD_LETTER'
export type NotificationRecipientType = 'EMPLOYEE' | 'RENTER'
export interface Company {
id: string
+4
View File
@@ -9,6 +9,8 @@ export type NotificationType =
| 'BOOKING_CANCELLED'
| 'PAYMENT_RECEIVED'
| 'PAYMENT_FAILED'
| 'DOCUMENTS_REQUIRED'
| 'PAYMENT_REQUIRED'
| 'SUBSCRIPTION_TRIAL_ENDING'
| 'SUBSCRIPTION_SUSPENDED'
| 'VEHICLE_MAINTENANCE_DUE'
@@ -25,6 +27,8 @@ export type NotificationType =
| 'REVIEW_REQUEST'
export type NotificationChannel = 'EMAIL' | 'SMS' | 'WHATSAPP' | 'IN_APP' | 'PUSH'
export type NotificationDeliveryStatus = 'PENDING' | 'QUEUED' | 'SENT' | 'DELIVERED' | 'FAILED' | 'SKIPPED' | 'DEAD_LETTER'
export type NotificationRecipientType = 'EMPLOYEE' | 'RENTER'
export interface Company {
id: string
+14
View File
@@ -41,6 +41,7 @@ export const PLAN_FEATURES: Record<string, string[]> = {
'1 user account',
'Basic analytics',
'Carplace listing',
'Notification management',
],
GROWTH: [
'Up to 50 vehicles',
@@ -58,6 +59,19 @@ export const PLAN_FEATURES: Record<string, string[]> = {
],
}
export const SUBSCRIPTION_CAPABILITIES = {
NOTIFICATION_MANAGEMENT: 'NOTIFICATION_MANAGEMENT',
} as const
export type SubscriptionCapability =
(typeof SUBSCRIPTION_CAPABILITIES)[keyof typeof SUBSCRIPTION_CAPABILITIES]
export const PLAN_CAPABILITIES: Record<string, SubscriptionCapability[]> = {
STARTER: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
GROWTH: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
PRO: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
}
export type Locale = 'en' | 'fr' | 'ar'
export type SupportedCurrency = 'MAD'