diff --git a/apps/api/package.json b/apps/api/package.json index 164e361..ad3cb3f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 83477d9..8d47dba 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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}`, }) } }) diff --git a/apps/api/src/modules/notifications/notification.repo.edge.test.ts b/apps/api/src/modules/notifications/notification.repo.edge.test.ts index 0175cee..47da3d1 100644 --- a/apps/api/src/modules/notifications/notification.repo.edge.test.ts +++ b/apps/api/src/modules/notifications/notification.repo.edge.test.ts @@ -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 }, diff --git a/apps/api/src/modules/notifications/notification.repo.ts b/apps/api/src/modules/notifications/notification.repo.ts index 66d24a6..830ff8d 100644 --- a/apps/api/src/modules/notifications/notification.repo.ts +++ b/apps/api/src/modules/notifications/notification.repo.ts @@ -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) { diff --git a/apps/api/src/modules/notifications/notification.routes.ts b/apps/api/src/modules/notifications/notification.routes.ts index 5b115ad..132a72a 100644 --- a/apps/api/src/modules/notifications/notification.routes.ts +++ b/apps/api/src/modules/notifications/notification.routes.ts @@ -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 })) diff --git a/apps/api/src/modules/notifications/notification.schemas.edge.test.ts b/apps/api/src/modules/notifications/notification.schemas.edge.test.ts index 84bbcce..b986bd3 100644 --- a/apps/api/src/modules/notifications/notification.schemas.edge.test.ts +++ b/apps/api/src/modules/notifications/notification.schemas.edge.test.ts @@ -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', () => { diff --git a/apps/api/src/modules/notifications/notification.schemas.ts b/apps/api/src/modules/notifications/notification.schemas.ts index 2135234..7061118 100644 --- a/apps/api/src/modules/notifications/notification.schemas.ts +++ b/apps/api/src/modules/notifications/notification.schemas.ts @@ -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(), }) diff --git a/apps/api/src/modules/notifications/notification.service.test.ts b/apps/api/src/modules/notifications/notification.service.test.ts index bcec027..9feb7f9 100644 --- a/apps/api/src/modules/notifications/notification.service.test.ts +++ b/apps/api/src/modules/notifications/notification.service.test.ts @@ -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 () => { diff --git a/apps/api/src/modules/notifications/notification.service.ts b/apps/api/src/modules/notifications/notification.service.ts index cae0e86..2aee4e3 100644 --- a/apps/api/src/modules/notifications/notification.service.ts +++ b/apps/api/src/modules/notifications/notification.service.ts @@ -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) diff --git a/apps/api/src/services/notificationService.test.ts b/apps/api/src/services/notificationService.test.ts index 0b7affe..aeb3bbf 100644 --- a/apps/api/src/services/notificationService.test.ts +++ b/apps/api/src/services/notificationService.test.ts @@ -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('./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() }) }) diff --git a/apps/api/src/services/notificationService.ts b/apps/api/src/services/notificationService.ts index 79bdd66..059c970 100644 --- a/apps/api/src/services/notificationService.ts +++ b/apps/api/src/services/notificationService.ts @@ -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 + locale?: string +} + +const IMPLEMENTED_CHANNELS = new Set(['IN_APP', 'EMAIL']) +const PRODUCT_DEFAULT_ENABLED_CHANNELS = new Set(['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: { diff --git a/apps/api/src/tests/api/operations-validation.api.test.ts b/apps/api/src/tests/api/operations-validation.api.test.ts index ee67ed9..97a692e 100644 --- a/apps/api/src/tests/api/operations-validation.api.test.ts +++ b/apps/api/src/tests/api/operations-validation.api.test.ts @@ -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 }, ]) }) diff --git a/apps/api/src/tests/api/operations.api.test.ts b/apps/api/src/tests/api/operations.api.test.ts index ac7d4be..6433b2e 100644 --- a/apps/api/src/tests/api/operations.api.test.ts +++ b/apps/api/src/tests/api/operations.api.test.ts @@ -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 () => { diff --git a/apps/api/src/tests/helpers/fixtures.ts b/apps/api/src/tests/helpers/fixtures.ts index 30c1885..84ebcfc 100644 --- a/apps/api/src/tests/helpers/fixtures.ts +++ b/apps/api/src/tests/helpers/fixtures.ts @@ -182,35 +182,78 @@ export async function createCompanyNotification( employeeId?: string, overrides: Record = {}, ) { - 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 = {}, ) { - 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') { diff --git a/apps/api/src/tests/integration/notifications.test.ts b/apps/api/src/tests/integration/notifications.test.ts index 61deb4a..57e0c16 100644 --- a/apps/api/src/tests/integration/notifications.test.ts +++ b/apps/api/src/tests/integration/notifications.test.ts @@ -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, }), ]), diff --git a/apps/api/src/tests/integration/operations.test.ts b/apps/api/src/tests/integration/operations.test.ts index f08060c..6a4173f 100644 --- a/apps/api/src/tests/integration/operations.test.ts +++ b/apps/api/src/tests/integration/operations.test.ts @@ -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) }) diff --git a/apps/dashboard/src/app/(dashboard)/notifications/page.tsx b/apps/dashboard/src/app/(dashboard)/notifications/page.tsx index a127be7..52d6d01 100644 --- a/apps/dashboard/src/app/(dashboard)/notifications/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/notifications/page.tsx @@ -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 = { EMAIL: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', @@ -45,9 +45,12 @@ const CHANNEL_BADGE: Record = { const STATUS_BADGE: Record = { 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, - statuses: { PENDING: 'Pending', SENT: 'Sent', DELIVERED: 'Delivered', FAILED: 'Failed', READ: 'Read' } as Record, + channels: { EMAIL: 'Email', IN_APP: 'In-app' } as Record, + statuses: { PENDING: 'Pending', QUEUED: 'Queued', SENT: 'Sent', DELIVERED: 'Delivered', FAILED: 'Failed', SKIPPED: 'Skipped', DEAD_LETTER: 'Dead letter', READ: 'Read' } as Record, 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, - statuses: { PENDING: 'En attente', SENT: 'Envoyé', DELIVERED: 'Délivré', FAILED: 'Échoué', READ: 'Lu' } as Record, + channels: { EMAIL: 'Email', IN_APP: 'Dans l\'application' } as Record, + statuses: { PENDING: 'En attente', QUEUED: 'En file', SENT: 'Envoyé', DELIVERED: 'Délivré', FAILED: 'Échoué', SKIPPED: 'Ignoré', DEAD_LETTER: 'Échec final', READ: 'Lu' } as Record, 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, - statuses: { PENDING: 'قيد الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', READ: 'مقروء' } as Record, + channels: { EMAIL: 'البريد', IN_APP: 'داخل التطبيق' } as Record, + statuses: { PENDING: 'قيد الانتظار', QUEUED: 'في قائمة الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', SKIPPED: 'تم التجاوز', DEAD_LETTER: 'فشل نهائي', READ: 'مقروء' } as Record, events: { - NEW_RESERVATION: 'حجز جديد', - RESERVATION_CANCELLED: 'إلغاء حجز', + NEW_BOOKING: 'حجز جديد', + BOOKING_CANCELLED: 'إلغاء حجز', PAYMENT_RECEIVED: 'تم استلام الدفع', SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة', - MAINTENANCE_DUE: 'صيانة مستحقة', OFFER_EXPIRING: 'عرض على وشك الانتهاء', BOOKING_CONFIRMED: 'تأكيد الحجز', VEHICLE_MAINTENANCE_DUE: 'صيانة المركبة مستحقة', diff --git a/apps/dashboard/src/components/layout/Sidebar.boundary.test.ts b/apps/dashboard/src/components/layout/Sidebar.boundary.test.ts index d410df8..1d9fd8d 100644 --- a/apps/dashboard/src/components/layout/Sidebar.boundary.test.ts +++ b/apps/dashboard/src/components/layout/Sidebar.boundary.test.ts @@ -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', ]) }) diff --git a/apps/dashboard/src/components/layout/Sidebar.tsx b/apps/dashboard/src/components/layout/Sidebar.tsx index e282c3e..910d5ad 100644 --- a/apps/dashboard/src/components/layout/Sidebar.tsx +++ b/apps/dashboard/src/components/layout/Sidebar.tsx @@ -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) { if (typeof window === 'undefined' || window.parent === window) return window.parent.postMessage(message, '*') @@ -172,6 +196,7 @@ export default function Sidebar() { const [menuItems, setMenuItems] = useState(null) const [menuAccessLevel, setMenuAccessLevel] = useState(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 ( {Icon ? : null} - {label} + {label} + {badge ? ( + + {badge} + + ) : null} ) }) @@ -454,7 +521,7 @@ export default function Sidebar() {
diff --git a/packages/database/prisma/migrations/20260722090000_durable_notification_model/migration.sql b/packages/database/prisma/migrations/20260722090000_durable_notification_model/migration.sql new file mode 100644 index 0000000..e9026be --- /dev/null +++ b/packages/database/prisma/migrations/20260722090000_durable_notification_model/migration.sql @@ -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"); diff --git a/packages/database/prisma/migrations/20260722091000_notifications_menu_starter_entitlement/migration.sql b/packages/database/prisma/migrations/20260722091000_notifications_menu_starter_entitlement/migration.sql new file mode 100644 index 0000000..61cee08 --- /dev/null +++ b/packages/database/prisma/migrations/20260722091000_notifications_menu_starter_entitlement/migration.sql @@ -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; diff --git a/packages/database/prisma/migrations/20260722092000_notification_management_plan_feature/migration.sql b/packages/database/prisma/migrations/20260722092000_notification_management_plan_feature/migration.sql new file mode 100644 index 0000000..0e22996 --- /dev/null +++ b/packages/database/prisma/migrations/20260722092000_notification_management_plan_feature/migration.sql @@ -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; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 8e2af8c..f7c6312 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -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 // ═══════════════════════════════════════════════════════════════ diff --git a/packages/database/src/index.d.ts b/packages/database/src/index.d.ts index 551da76..a161b2b 100644 --- a/packages/database/src/index.d.ts +++ b/packages/database/src/index.d.ts @@ -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 diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 9f91f3a..aa1a32a 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -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 diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 27b6d00..c4f98ae 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -41,6 +41,7 @@ export const PLAN_FEATURES: Record = { '1 user account', 'Basic analytics', 'Carplace listing', + 'Notification management', ], GROWTH: [ 'Up to 50 vehicles', @@ -58,6 +59,19 @@ export const PLAN_FEATURES: Record = { ], } +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 = { + 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'