fix notifications
Build & Push / Pipeline Tests (push) Failing after 1m8s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Failing after 52s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m7s
Build & Push / Pipeline Tests (push) Failing after 1m8s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Failing after 52s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m7s
This commit is contained in:
@@ -45,6 +45,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"resend": "^3.2.0",
|
"resend": "^3.2.0",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
|
"stripe": "^22.3.2",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"turbo": "2.10.0",
|
"turbo": "2.10.0",
|
||||||
"twilio": "^5.1.0",
|
"twilio": "^5.1.0",
|
||||||
|
|||||||
+6
-17
@@ -159,7 +159,6 @@ cron.schedule('0 9 * * *', async () => {
|
|||||||
cron.schedule('0 8 * * *', async () => {
|
cron.schedule('0 8 * * *', async () => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
|
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.
|
// 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
|
// 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)
|
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
|
||||||
if (!isOverdue && !isDueSoon) continue
|
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
|
// Build human-readable description
|
||||||
const dueParts: string[] = []
|
const dueParts: string[] = []
|
||||||
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
|
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
|
||||||
@@ -236,8 +224,8 @@ cron.schedule('0 8 * * *', async () => {
|
|||||||
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
|
: `${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.`
|
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)
|
||||||
data: {
|
await sendNotification({
|
||||||
type: 'VEHICLE_MAINTENANCE_DUE',
|
type: 'VEHICLE_MAINTENANCE_DUE',
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
@@ -253,9 +241,10 @@ cron.schedule('0 8 * * *', async () => {
|
|||||||
},
|
},
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
employeeId: recipient.id,
|
employeeId: recipient.id,
|
||||||
channel: 'IN_APP',
|
channels: ['IN_APP'],
|
||||||
status: 'DELIVERED',
|
sourceType: 'maintenance_log',
|
||||||
},
|
sourceId: log.id,
|
||||||
|
idempotencyKey: `maintenance:${log.id}:${recipient.id}:${reminderDate}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const prismaMock = vi.hoisted(() => ({
|
const prismaMock = vi.hoisted(() => ({
|
||||||
notification: {
|
notificationRecipient: {
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
count: vi.fn(),
|
count: vi.fn(),
|
||||||
updateMany: vi.fn(),
|
updateMany: vi.fn(),
|
||||||
},
|
},
|
||||||
|
notificationDelivery: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
notificationPreference: {
|
notificationPreference: {
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
upsert: vi.fn(),
|
upsert: vi.fn(),
|
||||||
@@ -17,25 +20,35 @@ vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
|
|||||||
import * as repo from './notification.repo'
|
import * as repo from './notification.repo'
|
||||||
|
|
||||||
describe('notification.repo query boundaries', () => {
|
describe('notification.repo query boundaries', () => {
|
||||||
it('scopes company in-app notification lists and applies unread filtering only when requested', async () => {
|
it('scopes company in-app notification lists to the authenticated employee recipient', async () => {
|
||||||
await repo.findCompany('company_1', 'true')
|
prismaMock.notificationRecipient.findMany.mockResolvedValue([])
|
||||||
|
await repo.findCompany('employee_1', 'true')
|
||||||
|
|
||||||
expect(prismaMock.notification.findMany).toHaveBeenCalledWith({
|
expect(prismaMock.notificationRecipient.findMany).toHaveBeenCalledWith({
|
||||||
where: { companyId: 'company_1', channel: 'IN_APP', readAt: null },
|
where: {
|
||||||
|
employeeId: 'employee_1',
|
||||||
|
archivedAt: null,
|
||||||
|
deliveries: { some: { channel: 'IN_APP' } },
|
||||||
|
readAt: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
notificationEvent: true,
|
||||||
|
deliveries: { where: { channel: 'IN_APP' } },
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 50,
|
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.useFakeTimers()
|
||||||
vi.setSystemTime(new Date('2026-06-09T13:00:00.000Z'))
|
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({
|
expect(prismaMock.notificationRecipient.updateMany).toHaveBeenCalledWith({
|
||||||
where: { id: 'notification_1', companyId: 'company_1' },
|
where: { id: 'recipient_1', employeeId: 'employee_1' },
|
||||||
data: { readAt: new Date('2026-06-09T13:00:00.000Z'), status: 'READ' },
|
data: { readAt: new Date('2026-06-09T13:00:00.000Z') },
|
||||||
})
|
})
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
@@ -43,17 +56,32 @@ describe('notification.repo query boundaries', () => {
|
|||||||
it('keeps renter bulk read updates constrained to renter in-app notifications', async () => {
|
it('keeps renter bulk read updates constrained to renter in-app notifications', async () => {
|
||||||
await repo.markAllRenterRead('renter_1')
|
await repo.markAllRenterRead('renter_1')
|
||||||
|
|
||||||
expect(prismaMock.notification.updateMany).toHaveBeenCalledWith({
|
expect(prismaMock.notificationRecipient.updateMany).toHaveBeenCalledWith({
|
||||||
where: { renterId: 'renter_1', channel: 'IN_APP', readAt: null },
|
where: {
|
||||||
data: { readAt: expect.any(Date), status: 'READ' },
|
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 () => {
|
it('defaults company notification history to a capped newest-first query', async () => {
|
||||||
|
prismaMock.notificationDelivery.findMany.mockResolvedValue([])
|
||||||
await repo.findCompanyHistory('company_1')
|
await repo.findCompanyHistory('company_1')
|
||||||
|
|
||||||
expect(prismaMock.notification.findMany).toHaveBeenCalledWith({
|
expect(prismaMock.notificationDelivery.findMany).toHaveBeenCalledWith({
|
||||||
where: { companyId: 'company_1' },
|
where: {
|
||||||
|
notificationRecipient: {
|
||||||
|
notificationEvent: { companyId: 'company_1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
notificationRecipient: {
|
||||||
|
include: { notificationEvent: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 200,
|
take: 200,
|
||||||
})
|
})
|
||||||
@@ -61,7 +89,7 @@ describe('notification.repo query boundaries', () => {
|
|||||||
|
|
||||||
it('upserts employee preferences by composite key without cross-employee mutation', async () => {
|
it('upserts employee preferences by composite key without cross-employee mutation', async () => {
|
||||||
await repo.upsertEmployeePreferences('employee_1', [
|
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 },
|
{ notificationType: 'PAYMENT_RECEIVED', channel: 'IN_APP', enabled: true },
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -70,13 +98,13 @@ describe('notification.repo query boundaries', () => {
|
|||||||
where: {
|
where: {
|
||||||
employeeId_notificationType_channel: {
|
employeeId_notificationType_channel: {
|
||||||
employeeId: 'employee_1',
|
employeeId: 'employee_1',
|
||||||
notificationType: 'RESERVATION_CREATED',
|
notificationType: 'NEW_BOOKING',
|
||||||
channel: 'EMAIL',
|
channel: 'EMAIL',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
employeeId: 'employee_1',
|
employeeId: 'employee_1',
|
||||||
notificationType: 'RESERVATION_CREATED',
|
notificationType: 'NEW_BOOKING',
|
||||||
channel: 'EMAIL',
|
channel: 'EMAIL',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
@@ -86,21 +114,21 @@ describe('notification.repo query boundaries', () => {
|
|||||||
|
|
||||||
it('upserts renter preferences by renter composite key', async () => {
|
it('upserts renter preferences by renter composite key', async () => {
|
||||||
await repo.upsertRenterPreferences('renter_1', [
|
await repo.upsertRenterPreferences('renter_1', [
|
||||||
{ notificationType: 'RESERVATION_CONFIRMED', channel: 'PUSH', enabled: true },
|
{ notificationType: 'BOOKING_CONFIRMED', channel: 'EMAIL', enabled: true },
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(prismaMock.notificationPreference.upsert).toHaveBeenCalledWith({
|
expect(prismaMock.notificationPreference.upsert).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
renterId_notificationType_channel: {
|
renterId_notificationType_channel: {
|
||||||
renterId: 'renter_1',
|
renterId: 'renter_1',
|
||||||
notificationType: 'RESERVATION_CONFIRMED',
|
notificationType: 'BOOKING_CONFIRMED',
|
||||||
channel: 'PUSH',
|
channel: 'EMAIL',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
renterId: 'renter_1',
|
renterId: 'renter_1',
|
||||||
notificationType: 'RESERVATION_CONFIRMED',
|
notificationType: 'BOOKING_CONFIRMED',
|
||||||
channel: 'PUSH',
|
channel: 'EMAIL',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
update: { enabled: true },
|
update: { enabled: true },
|
||||||
|
|||||||
@@ -3,22 +3,109 @@ import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
|
|||||||
|
|
||||||
// ─── Company notifications ────────────────────────────────────
|
// ─── Company notifications ────────────────────────────────────
|
||||||
|
|
||||||
export function findCompany(companyId: string, unread?: string) {
|
function presentRecipientNotification(recipient: any) {
|
||||||
const where: any = { companyId, channel: 'IN_APP' }
|
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
|
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) {
|
export function countUnread(employeeId: string) {
|
||||||
return prisma.notification.count({ where: { companyId, channel: 'IN_APP', readAt: null } })
|
return prisma.notificationRecipient.count({
|
||||||
|
where: {
|
||||||
|
employeeId,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
deliveries: { some: { channel: 'IN_APP' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markRead(id: string, companyId: string) {
|
export function markRead(id: string, employeeId: string) {
|
||||||
return prisma.notification.updateMany({ where: { id, companyId }, data: { readAt: new Date(), status: 'READ' } })
|
return prisma.notificationRecipient.updateMany({ where: { id, employeeId }, data: { readAt: new Date() } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markAllRead(companyId: string) {
|
export function markAllRead(employeeId: string) {
|
||||||
return prisma.notification.updateMany({ where: { companyId, readAt: null }, data: { readAt: new Date(), status: 'READ' } })
|
return prisma.notificationRecipient.updateMany({
|
||||||
|
where: {
|
||||||
|
employeeId,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
deliveries: { some: { channel: 'IN_APP' } },
|
||||||
|
},
|
||||||
|
data: { readAt: new Date() },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findEmployeePreferences(employeeId: string) {
|
export function findEmployeePreferences(employeeId: string) {
|
||||||
@@ -39,28 +126,58 @@ export function findCompanyHistory(
|
|||||||
companyId: string,
|
companyId: string,
|
||||||
opts?: { channel?: string; status?: string; limit?: number },
|
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?.channel) where.channel = opts.channel
|
||||||
if (opts?.status) where.status = opts.status
|
if (opts?.status) where.status = opts.status
|
||||||
return prisma.notification.findMany({
|
return prisma.notificationDelivery.findMany({
|
||||||
where,
|
where,
|
||||||
|
include: {
|
||||||
|
notificationRecipient: {
|
||||||
|
include: { notificationEvent: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: opts?.limit ?? 200,
|
take: opts?.limit ?? 200,
|
||||||
})
|
}).then((rows) => rows.map(presentDeliveryHistory))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Renter notifications ─────────────────────────────────────
|
// ─── Renter notifications ─────────────────────────────────────
|
||||||
|
|
||||||
export function findRenter(renterId: string) {
|
export async function findRenter(renterId: string) {
|
||||||
return prisma.notification.findMany({ where: { renterId, channel: 'IN_APP' }, orderBy: { createdAt: 'desc' }, take: 50 })
|
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) {
|
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) {
|
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) {
|
export function findRenterPreferences(renterId: string) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
|||||||
import { requireTenant } from '../../middleware/requireTenant'
|
import { requireTenant } from '../../middleware/requireTenant'
|
||||||
import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription'
|
import { requireSubscriptionRead, requireSubscriptionWrite } from '../../middleware/requireSubscription'
|
||||||
import { requireRenterAuth } from '../../middleware/requireRenterAuth'
|
import { requireRenterAuth } from '../../middleware/requireRenterAuth'
|
||||||
|
import { requireRole } from '../../middleware/requireRole'
|
||||||
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||||
import { ok } from '../../http/respond'
|
import { ok } from '../../http/respond'
|
||||||
import * as service from './notification.service'
|
import * as service from './notification.service'
|
||||||
@@ -18,27 +19,27 @@ const companyWriteAuth = [requireCompanyAuth, requireTenant, requireSubscription
|
|||||||
router.get('/company', ...companyReadAuth, async (req, res, next) => {
|
router.get('/company', ...companyReadAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { unread } = parseQuery(unreadQuerySchema, req)
|
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) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/unread-count', ...companyReadAuth, async (req, res, next) => {
|
router.get('/unread-count', ...companyReadAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
ok(res, { unread: await service.countUnread(req.companyId) })
|
ok(res, { unread: await service.countUnread(req.employee.id) })
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/company/:id/read', ...companyWriteAuth, async (req, res, next) => {
|
router.post('/company/:id/read', ...companyWriteAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = parseParams(idParamSchema, req)
|
const { id } = parseParams(idParamSchema, req)
|
||||||
await service.markRead(id, req.companyId)
|
await service.markRead(id, req.employee.id)
|
||||||
ok(res, { success: true })
|
ok(res, { success: true })
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/company/read-all', ...companyWriteAuth, async (req, res, next) => {
|
router.post('/company/read-all', ...companyWriteAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
await service.markAllRead(req.companyId)
|
await service.markAllRead(req.employee.id)
|
||||||
ok(res, { success: true })
|
ok(res, { success: true })
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
@@ -57,7 +58,7 @@ router.patch('/company/preferences', ...companyWriteAuth, async (req, res, next)
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/history', ...companyReadAuth, async (req, res, next) => {
|
router.get('/history', ...companyReadAuth, requireRole('MANAGER'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { channel, status, limit } = parseQuery(historyQuerySchema, req)
|
const { channel, status, limit } = parseQuery(historyQuerySchema, req)
|
||||||
ok(res, await service.listCompanyHistory(req.companyId, { channel, status, limit }))
|
ok(res, await service.listCompanyHistory(req.companyId, { channel, status, limit }))
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { historyQuerySchema, idParamSchema, preferencesSchema, unreadQuerySchema
|
|||||||
|
|
||||||
describe('notification schema contracts', () => {
|
describe('notification schema contracts', () => {
|
||||||
it('requires complete preference entries', () => {
|
it('requires complete preference entries', () => {
|
||||||
expect(preferencesSchema.parse([{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL', enabled: true }])).toEqual([
|
expect(preferencesSchema.parse([{ notificationType: 'NEW_BOOKING', channel: 'EMAIL', enabled: true }])).toEqual([
|
||||||
{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL', enabled: true },
|
{ 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', () => {
|
it('coerces history limits and caps bulk history requests', () => {
|
||||||
|
|||||||
@@ -1,8 +1,44 @@
|
|||||||
import { z } from 'zod'
|
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({
|
export const preferenceItemSchema = z.object({
|
||||||
notificationType: z.string(),
|
notificationType: notificationTypeSchema,
|
||||||
channel: z.string(),
|
channel: implementedNotificationChannelSchema,
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -17,7 +53,7 @@ export const unreadQuerySchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const historyQuerySchema = z.object({
|
export const historyQuerySchema = z.object({
|
||||||
channel: z.string().optional(),
|
channel: notificationChannelSchema.optional(),
|
||||||
status: z.string().optional(),
|
status: notificationDeliveryStatusSchema.optional(),
|
||||||
limit: z.coerce.number().int().min(1).max(500).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.findCompanyHistory).mockResolvedValue([{ id: 'h1' }] as never)
|
||||||
vi.mocked(repo.countUnread).mockResolvedValue(4 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.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.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 () => {
|
it('marks company notifications read only for the authenticated employee recipient', async () => {
|
||||||
await service.markRead('notification_1', 'company_1')
|
await service.markRead('notification_1', 'employee_1')
|
||||||
await service.markAllRead('company_1')
|
await service.markAllRead('employee_1')
|
||||||
|
|
||||||
expect(repo.markRead).toHaveBeenCalledWith('notification_1', 'company_1')
|
expect(repo.markRead).toHaveBeenCalledWith('notification_1', 'employee_1')
|
||||||
expect(repo.markAllRead).toHaveBeenCalledWith('company_1')
|
expect(repo.markAllRead).toHaveBeenCalledWith('employee_1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reads and writes employee preferences through the employee identity, not the company id', async () => {
|
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'
|
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 listCompanyHistory = (companyId: string, opts?: { channel?: string; status?: string; limit?: number }) => repo.findCompanyHistory(companyId, opts)
|
||||||
export const countUnread = (companyId: string) => repo.countUnread(companyId)
|
export const countUnread = (employeeId: string) => repo.countUnread(employeeId)
|
||||||
export const markRead = (id: string, companyId: string) => repo.markRead(id, companyId)
|
export const markRead = (id: string, employeeId: string) => repo.markRead(id, employeeId)
|
||||||
export const markAllRead = (companyId: string) => repo.markAllRead(companyId)
|
export const markAllRead = (employeeId: string) => repo.markAllRead(employeeId)
|
||||||
export const getPreferences = (employeeId: string) => repo.findEmployeePreferences(employeeId)
|
export const getPreferences = (employeeId: string) => repo.findEmployeePreferences(employeeId)
|
||||||
export const setPreferences = (employeeId: string, prefs: any[]) => repo.upsertEmployeePreferences(employeeId, prefs)
|
export const setPreferences = (employeeId: string, prefs: any[]) => repo.upsertEmployeePreferences(employeeId, prefs)
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,36 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
vi.mock('resend', () => ({ Resend: vi.fn() }))
|
const prismaMock = vi.hoisted(() => {
|
||||||
vi.mock('twilio', () => ({ default: vi.fn(() => ({ messages: { create: vi.fn() } })) }))
|
const tx = {
|
||||||
vi.mock('firebase-admin', () => ({
|
notificationEvent: {
|
||||||
default: {
|
create: vi.fn().mockResolvedValue({ id: 'event_1' }),
|
||||||
apps: [],
|
|
||||||
initializeApp: vi.fn(),
|
|
||||||
credential: { cert: vi.fn() },
|
|
||||||
messaging: vi.fn(() => ({ send: vi.fn() })),
|
|
||||||
},
|
},
|
||||||
}))
|
notificationRecipient: {
|
||||||
vi.mock('../lib/prisma', () => ({
|
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: {
|
prisma: {
|
||||||
notification: {
|
employee: { findFirst: vi.fn(), findMany: vi.fn() },
|
||||||
create: vi.fn(),
|
renter: { findUnique: vi.fn() },
|
||||||
update: vi.fn(),
|
notificationPreference: { findFirst: vi.fn() },
|
||||||
|
companyNotificationPreference: { findUnique: vi.fn() },
|
||||||
|
notificationEvent: { findUniqueOrThrow: vi.fn() },
|
||||||
|
$transaction: vi.fn((callback) => callback(tx)),
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
}))
|
})
|
||||||
vi.mock('../lib/redis', () => ({
|
|
||||||
redis: { publish: vi.fn(), on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
vi.mock('resend', () => ({ Resend: vi.fn() }))
|
||||||
}))
|
vi.mock('../lib/prisma', () => ({ prisma: prismaMock.prisma }))
|
||||||
vi.mock('./notificationLocalizationService', async () => {
|
vi.mock('./notificationLocalizationService', async () => {
|
||||||
const actual = await vi.importActual<typeof import('./notificationLocalizationService')>('./notificationLocalizationService')
|
const actual = await vi.importActual<typeof import('./notificationLocalizationService')>('./notificationLocalizationService')
|
||||||
return {
|
return {
|
||||||
@@ -31,104 +41,107 @@ vi.mock('./notificationLocalizationService', async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { redis } from '../lib/redis'
|
import { createNotification, sendNotification } from './notificationService'
|
||||||
import { sendNotification } from './notificationService'
|
|
||||||
import { resolveNotificationLocale, resolveNotificationTemplate } from './notificationLocalizationService'
|
import { resolveNotificationLocale, resolveNotificationTemplate } from './notificationLocalizationService'
|
||||||
|
|
||||||
describe('notificationService delivery boundaries', () => {
|
describe('notificationService command boundaries', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.mocked(prisma.notification.create).mockResolvedValue({ id: 'notification_1', title: 'Hello', body: 'Body' } as never)
|
vi.mocked(prisma.employee.findFirst).mockResolvedValue({
|
||||||
vi.mocked(prisma.notification.update).mockResolvedValue({ id: 'notification_1' } as never)
|
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(resolveNotificationLocale).mockResolvedValue('en')
|
||||||
vi.mocked(resolveNotificationTemplate).mockResolvedValue(null as never)
|
vi.mocked(resolveNotificationTemplate).mockResolvedValue(null as never)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('persists and publishes in-app notifications for employee recipients', async () => {
|
it('creates an event, explicit recipient, delivery rows, and an outbox row without inline provider calls', async () => {
|
||||||
const result = await sendNotification({
|
const result = await createNotification({
|
||||||
type: 'SYSTEM_ALERT' as never,
|
|
||||||
title: 'Maintenance window',
|
|
||||||
body: 'The dashboard will be unavailable.',
|
|
||||||
companyId: 'company_1',
|
companyId: 'company_1',
|
||||||
employeeId: 'employee_1',
|
type: 'NEW_BOOKING',
|
||||||
channels: ['IN_APP' as never],
|
audience: { type: 'EMPLOYEE', employeeId: 'employee_1' },
|
||||||
data: { severity: 'low' },
|
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(result.duplicate).toBe(false)
|
||||||
expect(resolveNotificationLocale).toHaveBeenCalledWith(expect.objectContaining({
|
expect(prisma.notificationEvent.findUniqueOrThrow).not.toHaveBeenCalled()
|
||||||
companyId: 'company_1',
|
expect(prismaMock.tx.notificationEvent.create).toHaveBeenCalledWith({
|
||||||
employeeId: 'employee_1',
|
|
||||||
}))
|
|
||||||
expect(prisma.notification.create).toHaveBeenCalledWith({
|
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
type: 'SYSTEM_ALERT',
|
|
||||||
title: 'Maintenance window',
|
|
||||||
body: 'The dashboard will be unavailable.',
|
|
||||||
channel: 'IN_APP',
|
|
||||||
status: 'PENDING',
|
|
||||||
companyId: 'company_1',
|
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',
|
employeeId: 'employee_1',
|
||||||
renterId: null,
|
renterId: null,
|
||||||
}),
|
},
|
||||||
})
|
})
|
||||||
expect(redis.publish).toHaveBeenCalledWith(
|
expect(prismaMock.tx.notificationDelivery.create).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({
|
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
title: 'Réservation confirmée',
|
notificationRecipientId: 'recipient_1',
|
||||||
body: 'Votre réservation est confirmée.',
|
channel: 'IN_APP',
|
||||||
templateKey: 'reservation.confirmed',
|
status: 'QUEUED',
|
||||||
locale: 'fr',
|
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({
|
const result = await sendNotification({
|
||||||
type: 'SYSTEM_ALERT' as never,
|
type: 'BOOKING_CONFIRMED',
|
||||||
title: 'Missing body',
|
title: 'Booking confirmed',
|
||||||
channels: ['IN_APP' as never],
|
body: 'Your booking is confirmed.',
|
||||||
|
companyId: 'company_1',
|
||||||
employeeId: 'employee_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(result).toEqual([
|
||||||
expect(prisma.notification.create).not.toHaveBeenCalled()
|
{ channel: 'PUSH', success: false, error: 'Delivery channel disabled by notification policy or preference.' },
|
||||||
expect(redis.publish).not.toHaveBeenCalled()
|
])
|
||||||
|
expect(prismaMock.tx.notificationDelivery.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
channel: 'PUSH',
|
||||||
|
status: 'SKIPPED',
|
||||||
|
preferenceDecision: 'SKIPPED_UNSUPPORTED_CHANNEL',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails before persistence when an explicit recipient is missing', async () => {
|
||||||
|
const result = await sendNotification({
|
||||||
|
type: 'NEW_BOOKING',
|
||||||
|
title: 'Missing recipient',
|
||||||
|
body: 'No target.',
|
||||||
|
companyId: 'company_1',
|
||||||
|
channels: ['IN_APP'],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual([{ channel: 'IN_APP', success: false, error: 'An explicit employee or renter recipient is required' }])
|
||||||
|
expect(prismaMock.tx.notificationEvent.create).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { Resend } from 'resend'
|
import { Resend } from 'resend'
|
||||||
import twilio from 'twilio'
|
|
||||||
import admin from 'firebase-admin'
|
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { redis } from '../lib/redis'
|
|
||||||
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
|
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
|
||||||
import {
|
import {
|
||||||
renderLocalizedEmailHtml,
|
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 {
|
interface SendNotificationOptions {
|
||||||
type: NotificationType
|
type: NotificationType
|
||||||
title?: string
|
title?: string
|
||||||
@@ -120,6 +88,143 @@ interface SendNotificationOptions {
|
|||||||
locale?: string
|
locale?: string
|
||||||
templateKey?: string
|
templateKey?: string
|
||||||
templateVariables?: NotificationTemplateVariables
|
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() {
|
function resolveSmtpReplyTo() {
|
||||||
@@ -202,23 +307,74 @@ async function sendEmailWithProviders(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function sendNotification(opts: SendNotificationOptions) {
|
export async function sendNotification(opts: SendNotificationOptions) {
|
||||||
const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = []
|
if (!opts.companyId) {
|
||||||
const resolvedLocale = await resolveNotificationLocale({
|
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,
|
companyId: opts.companyId,
|
||||||
employeeId: opts.employeeId,
|
type: opts.type,
|
||||||
renterId: opts.renterId,
|
audience,
|
||||||
billingAccountId: opts.billingAccountId,
|
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,
|
locale: opts.locale,
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const channel of opts.channels) {
|
return command.deliveries.map((delivery) => ({
|
||||||
try {
|
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,
|
||||||
|
locale: opts.locale,
|
||||||
|
})
|
||||||
|
const channels = uniqueChannels(opts.channels)
|
||||||
|
const renderChannel: NotificationChannel = channels.includes('IN_APP') ? 'IN_APP' : channels[0]!
|
||||||
const rendered = opts.templateKey
|
const rendered = opts.templateKey
|
||||||
? await resolveNotificationTemplate({
|
? await resolveNotificationTemplate({
|
||||||
templateKey: opts.templateKey,
|
templateKey: opts.templateKey,
|
||||||
channel,
|
channel: renderChannel,
|
||||||
locale: resolvedLocale,
|
locale: resolvedLocale,
|
||||||
variables: opts.templateVariables,
|
variables: opts.variables,
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
@@ -226,102 +382,94 @@ export async function sendNotification(opts: SendNotificationOptions) {
|
|||||||
const body = rendered?.body ?? opts.body
|
const body = rendered?.body ?? opts.body
|
||||||
|
|
||||||
if (!title || !body) {
|
if (!title || !body) {
|
||||||
throw new Error(`Notification content is incomplete for channel ${channel}`)
|
throw new Error('Notification content is incomplete')
|
||||||
}
|
}
|
||||||
|
|
||||||
const notification = await prisma.notification.create({
|
const recipients = await resolveAudienceRecipients(opts.companyId, opts.audience)
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
throw new Error('No eligible notification recipients were resolved')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx: any) => {
|
||||||
|
const createdEvent = await tx.notificationEvent.create({
|
||||||
data: {
|
data: {
|
||||||
|
companyId: opts.companyId,
|
||||||
type: opts.type,
|
type: opts.type,
|
||||||
|
templateKey: rendered?.templateKey ?? opts.templateKey ?? null,
|
||||||
|
locale: rendered?.locale ?? resolvedLocale,
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
data: (opts.data ?? {}) as any,
|
data: (opts.data ?? {}) as any,
|
||||||
channel,
|
sourceType: opts.source.type,
|
||||||
templateKey: rendered?.templateKey ?? opts.templateKey ?? null,
|
sourceId: opts.source.id,
|
||||||
locale: rendered?.locale ?? resolvedLocale,
|
idempotencyKey: opts.idempotencyKey,
|
||||||
status: 'PENDING',
|
|
||||||
companyId: opts.companyId ?? null,
|
|
||||||
employeeId: opts.employeeId ?? null,
|
|
||||||
renterId: opts.renterId ?? null,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let providerMessageId: string | null = null
|
const deliveries: any[] = []
|
||||||
let success = false
|
for (const recipient of recipients) {
|
||||||
|
const createdRecipient = await tx.notificationRecipient.create({
|
||||||
if (channel === 'EMAIL' && opts.email) {
|
|
||||||
const emailResult = await sendEmailWithProviders({
|
|
||||||
to: opts.email,
|
|
||||||
subject: title,
|
|
||||||
html: renderLocalizedEmailHtml(body, rendered?.locale ?? resolvedLocale),
|
|
||||||
text: body,
|
|
||||||
})
|
|
||||||
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' })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
success = true
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.notification.update({
|
|
||||||
where: { id: notification.id },
|
|
||||||
data: {
|
data: {
|
||||||
status: success ? 'SENT' : 'FAILED',
|
notificationEventId: createdEvent.id,
|
||||||
sentAt: success ? new Date() : null,
|
recipientType: recipient.recipientType,
|
||||||
providerMessageId,
|
employeeId: recipient.employeeId,
|
||||||
|
renterId: recipient.renterId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
results.push({ channel, success })
|
for (const channel of channels) {
|
||||||
} catch (err: any) {
|
const preference = await resolvePreferenceDecision({
|
||||||
results.push({ channel, success: false, error: err.message })
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
await tx.notificationOutbox.create({
|
||||||
|
data: {
|
||||||
|
notificationEventId: createdEvent.id,
|
||||||
|
payload: {
|
||||||
|
notificationEventId: createdEvent.id,
|
||||||
|
type: opts.type,
|
||||||
|
channels,
|
||||||
|
source: opts.source,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendTransactionalEmail(opts: {
|
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 () => {
|
it('accepts valid company notification preferences and passes employee identity', async () => {
|
||||||
const res = await request(app).patch('/api/v1/notifications/company/preferences').send([
|
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(res.status).toBe(200)
|
||||||
expect(notificationService.setPreferences).toHaveBeenCalledWith('employee_1', [
|
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.status).toBe(200)
|
||||||
expect(res.body).toEqual({ data: { unread: 7 } })
|
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 () => {
|
it('GET /api/v1/notifications/history coerces limit and passes history filters to the service', async () => {
|
||||||
|
|||||||
@@ -182,35 +182,78 @@ export async function createCompanyNotification(
|
|||||||
employeeId?: string,
|
employeeId?: string,
|
||||||
overrides: Record<string, unknown> = {},
|
overrides: Record<string, unknown> = {},
|
||||||
) {
|
) {
|
||||||
return prisma.notification.create({
|
const notificationEvent = await prisma.notificationEvent.create({
|
||||||
data: {
|
data: {
|
||||||
companyId,
|
companyId,
|
||||||
employeeId,
|
type: (overrides.type ?? 'PAYMENT_RECEIVED') as any,
|
||||||
type: 'PAYMENT_RECEIVED',
|
title: (overrides.title ?? 'Payment received') as string,
|
||||||
title: 'Payment received',
|
body: (overrides.body ?? 'A payment was recorded.') as string,
|
||||||
body: 'A payment was recorded.',
|
data: (overrides.data ?? {}) as any,
|
||||||
channel: 'IN_APP',
|
sourceType: (overrides.sourceType ?? 'test') as string,
|
||||||
status: 'PENDING',
|
sourceId: (overrides.sourceId ?? `source-${uid()}`) as string,
|
||||||
...overrides,
|
idempotencyKey: (overrides.idempotencyKey ?? `test-${uid()}`) as string,
|
||||||
} as any,
|
} 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(
|
export async function createRenterNotification(
|
||||||
renterId: string,
|
renterId: string,
|
||||||
overrides: Record<string, unknown> = {},
|
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: {
|
data: {
|
||||||
renterId,
|
companyId,
|
||||||
type: 'BOOKING_CONFIRMED',
|
type: (overrides.type ?? 'BOOKING_CONFIRMED') as any,
|
||||||
title: 'Booking confirmed',
|
title: (overrides.title ?? 'Booking confirmed') as string,
|
||||||
body: 'Your booking is confirmed.',
|
body: (overrides.body ?? 'Your booking is confirmed.') as string,
|
||||||
channel: 'IN_APP',
|
data: (overrides.data ?? {}) as any,
|
||||||
status: 'PENDING',
|
sourceType: (overrides.sourceType ?? 'test') as string,
|
||||||
...overrides,
|
sourceId: (overrides.sourceId ?? `source-${uid()}`) as string,
|
||||||
|
idempotencyKey: (overrides.idempotencyKey ?? `test-${uid()}`) as string,
|
||||||
} as any,
|
} 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') {
|
export function signEmployeeToken(employeeId: string, _companyId: string, _role: string = 'OWNER') {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ describe('Notifications API', () => {
|
|||||||
|
|
||||||
it('updates renter notification preferences', async () => {
|
it('updates renter notification preferences', async () => {
|
||||||
const payload = [
|
const payload = [
|
||||||
{ notificationType: 'BOOKING_CONFIRMED', channel: 'PUSH', enabled: true },
|
{ notificationType: 'BOOKING_CONFIRMED', channel: 'EMAIL', enabled: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const patchRes = await request(app)
|
const patchRes = await request(app)
|
||||||
@@ -136,7 +136,7 @@ describe('Notifications API', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
renterId,
|
renterId,
|
||||||
notificationType: 'BOOKING_CONFIRMED',
|
notificationType: 'BOOKING_CONFIRMED',
|
||||||
channel: 'PUSH',
|
channel: 'EMAIL',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ describe('Operations API integration', () => {
|
|||||||
expect(marked.status).toBe(200)
|
expect(marked.status).toBe(200)
|
||||||
expect(marked.body.data).toEqual({ success: true })
|
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)
|
expect(stored.readAt).toBeInstanceOf(Date)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -25,15 +25,15 @@ type PreferenceItem = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COMPANY_EVENTS = [
|
const COMPANY_EVENTS = [
|
||||||
'NEW_RESERVATION',
|
'NEW_BOOKING',
|
||||||
'RESERVATION_CANCELLED',
|
'BOOKING_CANCELLED',
|
||||||
'PAYMENT_RECEIVED',
|
'PAYMENT_RECEIVED',
|
||||||
'SUBSCRIPTION_TRIAL_ENDING',
|
'SUBSCRIPTION_TRIAL_ENDING',
|
||||||
'MAINTENANCE_DUE',
|
'VEHICLE_MAINTENANCE_DUE',
|
||||||
'OFFER_EXPIRING',
|
'OFFER_EXPIRING',
|
||||||
]
|
]
|
||||||
|
|
||||||
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
const COMPANY_CHANNELS = ['EMAIL', 'IN_APP']
|
||||||
|
|
||||||
const CHANNEL_BADGE: Record<string, string> = {
|
const CHANNEL_BADGE: Record<string, string> = {
|
||||||
EMAIL: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
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> = {
|
const STATUS_BADGE: Record<string, string> = {
|
||||||
PENDING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
|
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',
|
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',
|
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',
|
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',
|
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',
|
failedLoad: 'Failed to load notifications',
|
||||||
failedSave: 'Failed to save preferences',
|
failedSave: 'Failed to save preferences',
|
||||||
noHistory: 'No notification history found.',
|
noHistory: 'No notification history found.',
|
||||||
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record<string, string>,
|
channels: { EMAIL: 'Email', IN_APP: 'In-app' } as Record<string, string>,
|
||||||
statuses: { PENDING: 'Pending', SENT: 'Sent', DELIVERED: 'Delivered', FAILED: 'Failed', READ: 'Read' } 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: {
|
events: {
|
||||||
NEW_RESERVATION: 'New reservation',
|
NEW_BOOKING: 'New booking',
|
||||||
RESERVATION_CANCELLED: 'Reservation cancelled',
|
BOOKING_CANCELLED: 'Booking cancelled',
|
||||||
PAYMENT_RECEIVED: 'Payment received',
|
PAYMENT_RECEIVED: 'Payment received',
|
||||||
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
|
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
|
||||||
MAINTENANCE_DUE: 'Maintenance due',
|
|
||||||
OFFER_EXPIRING: 'Offer expiring',
|
OFFER_EXPIRING: 'Offer expiring',
|
||||||
BOOKING_CONFIRMED: 'Booking confirmed',
|
BOOKING_CONFIRMED: 'Booking confirmed',
|
||||||
VEHICLE_MAINTENANCE_DUE: 'Vehicle maintenance due',
|
VEHICLE_MAINTENANCE_DUE: 'Vehicle maintenance due',
|
||||||
@@ -125,14 +127,13 @@ export default function DashboardNotificationsPage() {
|
|||||||
failedLoad: 'Échec du chargement des notifications',
|
failedLoad: 'Échec du chargement des notifications',
|
||||||
failedSave: 'Échec de l\'enregistrement des préférences',
|
failedSave: 'Échec de l\'enregistrement des préférences',
|
||||||
noHistory: 'Aucun historique de notification trouvé.',
|
noHistory: 'Aucun historique de notification trouvé.',
|
||||||
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l\'application', PUSH: 'Push' } as Record<string, string>,
|
channels: { EMAIL: 'Email', IN_APP: 'Dans l\'application' } as Record<string, string>,
|
||||||
statuses: { PENDING: 'En attente', SENT: 'Envoyé', DELIVERED: 'Délivré', FAILED: 'Échoué', READ: 'Lu' } 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: {
|
events: {
|
||||||
NEW_RESERVATION: 'Nouvelle réservation',
|
NEW_BOOKING: 'Nouvelle réservation',
|
||||||
RESERVATION_CANCELLED: 'Réservation annulée',
|
BOOKING_CANCELLED: 'Réservation annulée',
|
||||||
PAYMENT_RECEIVED: 'Paiement reçu',
|
PAYMENT_RECEIVED: 'Paiement reçu',
|
||||||
SUBSCRIPTION_TRIAL_ENDING: 'Fin d\'essai proche',
|
SUBSCRIPTION_TRIAL_ENDING: 'Fin d\'essai proche',
|
||||||
MAINTENANCE_DUE: 'Maintenance due',
|
|
||||||
OFFER_EXPIRING: 'Offre expirante',
|
OFFER_EXPIRING: 'Offre expirante',
|
||||||
BOOKING_CONFIRMED: 'Réservation confirmée',
|
BOOKING_CONFIRMED: 'Réservation confirmée',
|
||||||
VEHICLE_MAINTENANCE_DUE: 'Maintenance véhicule due',
|
VEHICLE_MAINTENANCE_DUE: 'Maintenance véhicule due',
|
||||||
@@ -161,14 +162,13 @@ export default function DashboardNotificationsPage() {
|
|||||||
failedLoad: 'فشل تحميل الإشعارات',
|
failedLoad: 'فشل تحميل الإشعارات',
|
||||||
failedSave: 'فشل حفظ التفضيلات',
|
failedSave: 'فشل حفظ التفضيلات',
|
||||||
noHistory: 'لا يوجد سجل إشعارات.',
|
noHistory: 'لا يوجد سجل إشعارات.',
|
||||||
channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>,
|
channels: { EMAIL: 'البريد', IN_APP: 'داخل التطبيق' } as Record<string, string>,
|
||||||
statuses: { PENDING: 'قيد الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', READ: 'مقروء' } as Record<string, string>,
|
statuses: { PENDING: 'قيد الانتظار', QUEUED: 'في قائمة الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', SKIPPED: 'تم التجاوز', DEAD_LETTER: 'فشل نهائي', READ: 'مقروء' } as Record<string, string>,
|
||||||
events: {
|
events: {
|
||||||
NEW_RESERVATION: 'حجز جديد',
|
NEW_BOOKING: 'حجز جديد',
|
||||||
RESERVATION_CANCELLED: 'إلغاء حجز',
|
BOOKING_CANCELLED: 'إلغاء حجز',
|
||||||
PAYMENT_RECEIVED: 'تم استلام الدفع',
|
PAYMENT_RECEIVED: 'تم استلام الدفع',
|
||||||
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
|
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
|
||||||
MAINTENANCE_DUE: 'صيانة مستحقة',
|
|
||||||
OFFER_EXPIRING: 'عرض على وشك الانتهاء',
|
OFFER_EXPIRING: 'عرض على وشك الانتهاء',
|
||||||
BOOKING_CONFIRMED: 'تأكيد الحجز',
|
BOOKING_CONFIRMED: 'تأكيد الحجز',
|
||||||
VEHICLE_MAINTENANCE_DUE: 'صيانة المركبة مستحقة',
|
VEHICLE_MAINTENANCE_DUE: 'صيانة المركبة مستحقة',
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { APPROVED_BASELINE_MENU_KEYS, getEmployeeLogoutRedirectUrl, hasRenderableMenuItems, shouldUseGeneratedMenu } from './Sidebar'
|
import { APPROVED_BASELINE_MENU_KEYS, getEmployeeLogoutRedirectUrl, hasRenderableMenuItems, shouldUseGeneratedMenu } from './Sidebar'
|
||||||
|
|
||||||
describe('Sidebar menu rendering helpers', () => {
|
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([
|
expect(APPROVED_BASELINE_MENU_KEYS).toEqual([
|
||||||
'dashboard',
|
'dashboard',
|
||||||
'reservations',
|
|
||||||
'fleet',
|
|
||||||
'customers',
|
|
||||||
'reports',
|
|
||||||
'billing',
|
'billing',
|
||||||
|
'customers',
|
||||||
|
'fleet',
|
||||||
|
'reports',
|
||||||
|
'reservations',
|
||||||
'settings',
|
'settings',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -101,7 +101,13 @@ const OWNER_SYSTEM_NAV_ITEMS = [
|
|||||||
{ href: '/subscription', key: 'subscription', icon: 'CreditCard', minRole: 'OWNER' },
|
{ href: '/subscription', key: 'subscription', icon: 'CreditCard', minRole: 'OWNER' },
|
||||||
] as const
|
] 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 = {
|
const ICON_MAP = {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -152,6 +158,24 @@ export function getEmployeeLogoutRedirectUrl() {
|
|||||||
return websiteUrl
|
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>) {
|
function notifyParent(message: Record<string, unknown>) {
|
||||||
if (typeof window === 'undefined' || window.parent === window) return
|
if (typeof window === 'undefined' || window.parent === window) return
|
||||||
window.parent.postMessage(message, '*')
|
window.parent.postMessage(message, '*')
|
||||||
@@ -172,6 +196,7 @@ export default function Sidebar() {
|
|||||||
const [menuItems, setMenuItems] = useState<GeneratedMenuItem[] | null>(null)
|
const [menuItems, setMenuItems] = useState<GeneratedMenuItem[] | null>(null)
|
||||||
const [menuAccessLevel, setMenuAccessLevel] = useState<SubscriptionAccessLevel | null>(null)
|
const [menuAccessLevel, setMenuAccessLevel] = useState<SubscriptionAccessLevel | null>(null)
|
||||||
const [menuLoadState, setMenuLoadState] = useState<'loading' | 'loaded' | 'failed'>('loading')
|
const [menuLoadState, setMenuLoadState] = useState<'loading' | 'loaded' | 'failed'>('loading')
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
@@ -276,6 +301,36 @@ export default function Sidebar() {
|
|||||||
// Close sidebar when navigating on mobile
|
// Close sidebar when navigating on mobile
|
||||||
useEffect(() => { setOpen(false) }, [pathname])
|
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]) => {
|
const isActive = (item: typeof NAV_ITEMS[number]) => {
|
||||||
if ('exact' in item && item.exact) return appPath === item.href
|
if ('exact' in item && item.exact) return appPath === item.href
|
||||||
return appPath.startsWith(item.href)
|
return appPath.startsWith(item.href)
|
||||||
@@ -328,6 +383,10 @@ export default function Sidebar() {
|
|||||||
...generatedMenuItems,
|
...generatedMenuItems,
|
||||||
...ownerSystemMenuItems.filter((item) => !generatedRoutes.has(toDashboardAppPath(item.routeOrUrl))),
|
...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 {
|
function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode {
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
@@ -360,6 +419,9 @@ export default function Sidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const active = mounted && isGeneratedItemActive(item)
|
const active = mounted && isGeneratedItemActive(item)
|
||||||
|
const badge = item.systemKey === 'notifications' && unreadCount > 0
|
||||||
|
? unreadCount > 99 ? '99+' : String(unreadCount)
|
||||||
|
: null
|
||||||
const className = [
|
const className = [
|
||||||
'relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200',
|
'relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200',
|
||||||
paddingClass,
|
paddingClass,
|
||||||
@@ -388,7 +450,12 @@ export default function Sidebar() {
|
|||||||
return (
|
return (
|
||||||
<Link key={item.id} href={href} className={className}>
|
<Link key={item.id} href={href} className={className}>
|
||||||
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
|
{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>
|
</Link>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -454,7 +521,7 @@ export default function Sidebar() {
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav className="flex-1 space-y-1.5 overflow-y-auto px-3 py-5">
|
<nav className="flex-1 space-y-1.5 overflow-y-auto px-3 py-5">
|
||||||
{renderGeneratedMenu(resolvedMenuItems)}
|
{renderGeneratedMenu(sortedResolvedMenuItems)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t border-blue-200/70 px-3 py-4 dark:border-blue-400/10">
|
<div className="border-t border-blue-200/70 px-3 py-4 dark:border-blue-400/10">
|
||||||
|
|||||||
+105
@@ -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");
|
||||||
+94
@@ -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;
|
||||||
+27
@@ -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;
|
||||||
@@ -488,6 +488,27 @@ enum NotificationStatus {
|
|||||||
READ
|
READ
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum NotificationRecipientType {
|
||||||
|
EMPLOYEE
|
||||||
|
RENTER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationDeliveryStatus {
|
||||||
|
PENDING
|
||||||
|
QUEUED
|
||||||
|
SENT
|
||||||
|
DELIVERED
|
||||||
|
FAILED
|
||||||
|
SKIPPED
|
||||||
|
DEAD_LETTER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationOutboxStatus {
|
||||||
|
PENDING
|
||||||
|
PUBLISHED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
enum CalendarBlockType {
|
enum CalendarBlockType {
|
||||||
MANUAL
|
MANUAL
|
||||||
MAINTENANCE
|
MAINTENANCE
|
||||||
@@ -523,6 +544,8 @@ model Company {
|
|||||||
insurancePolicies InsurancePolicy[]
|
insurancePolicies InsurancePolicy[]
|
||||||
pricingRules PricingRule[]
|
pricingRules PricingRule[]
|
||||||
notifications Notification[] @relation("CompanyNotifications")
|
notifications Notification[] @relation("CompanyNotifications")
|
||||||
|
notificationEvents NotificationEvent[]
|
||||||
|
notificationDefaults CompanyNotificationPreference[]
|
||||||
complaints Complaint[]
|
complaints Complaint[]
|
||||||
companyMenuItems CompanyMenuItem[]
|
companyMenuItems CompanyMenuItem[]
|
||||||
apiKeys CompanyApiKey[]
|
apiKeys CompanyApiKey[]
|
||||||
@@ -1013,6 +1036,7 @@ model Employee {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
notifications Notification[] @relation("EmployeeNotifications")
|
notifications Notification[] @relation("EmployeeNotifications")
|
||||||
|
notificationRecipients NotificationRecipient[]
|
||||||
notificationPreferences NotificationPreference[]
|
notificationPreferences NotificationPreference[]
|
||||||
recordedRentalPayments RentalPayment[] @relation("RecordedRentalPayments")
|
recordedRentalPayments RentalPayment[] @relation("RecordedRentalPayments")
|
||||||
|
|
||||||
@@ -1208,6 +1232,7 @@ model Renter {
|
|||||||
savedCompanies RenterSavedCompany[]
|
savedCompanies RenterSavedCompany[]
|
||||||
reviews Review[]
|
reviews Review[]
|
||||||
notifications Notification[] @relation("RenterNotifications")
|
notifications Notification[] @relation("RenterNotifications")
|
||||||
|
notificationRecipients NotificationRecipient[]
|
||||||
notificationPreferences NotificationPreference[]
|
notificationPreferences NotificationPreference[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -1509,6 +1534,87 @@ model Notification {
|
|||||||
@@map("notifications")
|
@@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 {
|
model NotificationTemplate {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
templateKey String
|
templateKey String
|
||||||
@@ -1544,6 +1650,18 @@ model NotificationPreference {
|
|||||||
@@map("notification_preferences")
|
@@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
|
// MAINTENANCE
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Vendored
+2
@@ -27,6 +27,8 @@ export type NotificationType =
|
|||||||
| 'REVIEW_REQUEST'
|
| 'REVIEW_REQUEST'
|
||||||
|
|
||||||
export type NotificationChannel = 'EMAIL' | 'SMS' | 'WHATSAPP' | 'IN_APP' | 'PUSH'
|
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 {
|
export interface Company {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type NotificationType =
|
|||||||
| 'BOOKING_CANCELLED'
|
| 'BOOKING_CANCELLED'
|
||||||
| 'PAYMENT_RECEIVED'
|
| 'PAYMENT_RECEIVED'
|
||||||
| 'PAYMENT_FAILED'
|
| 'PAYMENT_FAILED'
|
||||||
|
| 'DOCUMENTS_REQUIRED'
|
||||||
|
| 'PAYMENT_REQUIRED'
|
||||||
| 'SUBSCRIPTION_TRIAL_ENDING'
|
| 'SUBSCRIPTION_TRIAL_ENDING'
|
||||||
| 'SUBSCRIPTION_SUSPENDED'
|
| 'SUBSCRIPTION_SUSPENDED'
|
||||||
| 'VEHICLE_MAINTENANCE_DUE'
|
| 'VEHICLE_MAINTENANCE_DUE'
|
||||||
@@ -25,6 +27,8 @@ export type NotificationType =
|
|||||||
| 'REVIEW_REQUEST'
|
| 'REVIEW_REQUEST'
|
||||||
|
|
||||||
export type NotificationChannel = 'EMAIL' | 'SMS' | 'WHATSAPP' | 'IN_APP' | 'PUSH'
|
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 {
|
export interface Company {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const PLAN_FEATURES: Record<string, string[]> = {
|
|||||||
'1 user account',
|
'1 user account',
|
||||||
'Basic analytics',
|
'Basic analytics',
|
||||||
'Carplace listing',
|
'Carplace listing',
|
||||||
|
'Notification management',
|
||||||
],
|
],
|
||||||
GROWTH: [
|
GROWTH: [
|
||||||
'Up to 50 vehicles',
|
'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 Locale = 'en' | 'fr' | 'ar'
|
||||||
export type SupportedCurrency = 'MAD'
|
export type SupportedCurrency = 'MAD'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user