notification implemented

This commit is contained in:
root
2026-05-25 13:12:06 -04:00
parent 33b6bb55f0
commit c8bde121fc
31 changed files with 1491 additions and 1291 deletions
+13 -10
View File
@@ -2,11 +2,11 @@ import http from 'http'
import { Server as SocketIOServer } from 'socket.io'
import cron from 'node-cron'
import jwt from 'jsonwebtoken'
import { z } from 'zod'
import { redis } from './lib/redis'
import { prisma } from './lib/prisma'
import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app'
import { sendNotification } from './services/notificationService'
import {
runTrialExpirationJob,
runPaymentPendingTimeoutJob,
@@ -24,11 +24,6 @@ const io = new SocketIOServer(server, {
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
})
const redisMessageSchema = z.object({
type: z.string(),
payload: z.unknown(),
})
// Authenticate socket connections via JWT before joining user rooms
io.use((socket, next) => {
const token = socket.handshake.auth?.token as string | undefined
@@ -57,9 +52,8 @@ subscriber.psubscribe('notifications:*', (err) => {
subscriber.on('pmessage', (_pattern, channel, message) => {
try {
const parsed = JSON.parse(message)
const validated = redisMessageSchema.parse(parsed)
const userId = channel.replace('notifications:', '')
io.to(`user:${userId}`).emit('notification', validated)
io.to(`user:${userId}`).emit('notification', parsed)
} catch (err) {
console.error('[Redis] Invalid notification message:', err)
}
@@ -117,8 +111,17 @@ cron.schedule('0 9 * * *', async () => {
for (const sub of subscriptions) {
const owner = sub.company.employees[0]
if (owner) {
await prisma.notification.create({
data: { type: 'SUBSCRIPTION_TRIAL_ENDING', title: 'Your trial ends soon', body: 'Your 90-day free trial ends in 3 days. Add a payment method to keep access.', companyId: sub.companyId, employeeId: owner.id, channel: 'IN_APP', status: 'DELIVERED' },
await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
companyId: sub.companyId,
employeeId: owner.id,
channels: ['IN_APP'],
templateKey: 'subscription.trial_ending',
templateVariables: {
trialEndDate: sub.trialEndAt ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
},
}).catch((err) => {
console.error('[Notifications] Failed to create trial ending reminder:', err?.message ?? String(err))
})
}
}
+25
View File
@@ -488,3 +488,28 @@ export function updatePromotion(id: string, data: Partial<{
export function deletePromotion(id: string) {
return (prisma as any).pricingPromotion.delete({ where: { id } })
}
export async function listNotificationsPage(query: {
channel?: string
status?: string
companyId?: string
page: number
pageSize: number
}) {
const where: any = {}
if (query.channel) where.channel = query.channel
if (query.status) where.status = query.status
if (query.companyId) where.companyId = query.companyId
const [data, total] = await Promise.all([
prisma.notification.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
include: { company: { select: { name: true } } },
}),
prisma.notification.count({ where }),
])
return { data, total }
}
+9 -1
View File
@@ -7,7 +7,7 @@ import * as subService from '../subscriptions/subscription.service'
import { presentAdminUser } from './admin.presenter'
import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema,
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, notificationsQuerySchema,
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
homepageUpdateSchema, idParamSchema, companyIdParamSchema, billingAccountIdParamSchema, invoiceIdParamSchema,
@@ -158,6 +158,14 @@ router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req
} catch (err) { next(err) }
})
// ─── Notifications ─────────────────────────────────────────────
router.get('/notifications', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
ok(res, await service.listNotifications(parseQuery(notificationsQuerySchema, req)))
} catch (err) { next(err) }
})
// ─── Audit logs ────────────────────────────────────────────────
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
@@ -44,6 +44,14 @@ export const auditLogQuerySchema = z.object({
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
export const notificationsQuerySchema = z.object({
channel: z.string().optional(),
status: z.string().optional(),
companyId: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
})
export const billingQuerySchema = z.object({
q: z.string().optional(),
status: z.string().optional(),
@@ -193,6 +193,11 @@ export function getPlatformMetrics() {
return repo.getPlatformMetricCounts()
}
export async function listNotifications(query: { channel?: string; status?: string; companyId?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listNotificationsPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export async function getAuditLogs(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listAuditLogsPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
@@ -70,6 +70,7 @@ export async function createCompanySignup(db: DbClient, input: CompanySignupInpu
publicAddress: input.streetAddress,
publicCity: input.city,
publicCountry: input.country,
defaultLocale: input.preferredLanguage,
defaultCurrency: input.currency,
},
})
@@ -2,7 +2,11 @@ import bcrypt from 'bcryptjs'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { sendNotification } from '../../services/notificationService'
import { signupEmail, type Lang } from '../../lib/emailTranslations'
import {
coerceNotificationLocale,
localizeBillingPeriod,
localizePlanName,
} from '../../services/notificationLocalizationService'
import { presentCompanySignup } from './auth.presenter'
import * as repo from './auth.company.repo'
import { companySignupSchema } from './auth.company.schemas'
@@ -66,24 +70,24 @@ export async function signup(body: CompanySignupInput) {
trialEndAt,
}))
const lang = body.preferredLanguage as Lang
const lang = coerceNotificationLocale(body.preferredLanguage)
const emailResult = await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
title: signupEmail.subject(lang),
body: signupEmail.text({
firstName: body.firstName,
companyName: body.companyName,
plan: body.plan,
billingPeriod: body.billingPeriod,
currency: body.currency,
paymentProvider: body.paymentProvider,
trialEnd: trialEndAt,
}, lang),
type: 'ACCOUNT_CREATED',
companyId: result.company.id,
employeeId: result.employee.id,
email: body.email,
channels: ['EMAIL', 'IN_APP'],
locale: lang,
templateKey: 'account.created',
templateVariables: {
firstName: body.firstName,
companyName: body.companyName,
planName: localizePlanName(body.plan, lang),
billingPeriodLabel: localizeBillingPeriod(body.billingPeriod, lang),
currency: body.currency,
paymentProvider: body.paymentProvider,
trialEndDate: trialEndAt,
},
}).catch(() => [])
const emailDelivery = (emailResult as Array<{ channel?: string }>).find((entry) => entry.channel === 'EMAIL')
@@ -35,6 +35,20 @@ export async function upsertEmployeePreferences(employeeId: string, prefs: Array
}
}
export function findCompanyHistory(
companyId: string,
opts?: { channel?: string; status?: string; limit?: number },
) {
const where: any = { companyId }
if (opts?.channel) where.channel = opts.channel
if (opts?.status) where.status = opts.status
return prisma.notification.findMany({
where,
orderBy: { createdAt: 'desc' },
take: opts?.limit ?? 200,
})
}
// ─── Renter notifications ─────────────────────────────────────
export function findRenter(renterId: string) {
@@ -6,7 +6,7 @@ import { requireRenterAuth } from '../../middleware/requireRenterAuth'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok } from '../../http/respond'
import * as service from './notification.service'
import { preferencesSchema, idParamSchema, unreadQuerySchema } from './notification.schemas'
import { preferencesSchema, idParamSchema, unreadQuerySchema, historyQuerySchema } from './notification.schemas'
const router = Router()
@@ -56,6 +56,13 @@ router.patch('/company/preferences', ...companyAuth, async (req, res, next) => {
} catch (err) { next(err) }
})
router.get('/history', ...companyAuth, async (req, res, next) => {
try {
const { channel, status, limit } = parseQuery(historyQuerySchema, req)
ok(res, await service.listCompanyHistory(req.companyId, { channel, status, limit }))
} catch (err) { next(err) }
})
// ─── Renter notifications ─────────────────────────────────────
router.get('/renter', requireRenterAuth, async (req, res, next) => {
@@ -15,3 +15,9 @@ export const idParamSchema = z.object({
export const unreadQuerySchema = z.object({
unread: z.string().optional(),
})
export const historyQuerySchema = z.object({
channel: z.string().optional(),
status: z.string().optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
})
@@ -1,6 +1,7 @@
import * as repo from './notification.repo'
export const listCompany = (companyId: string, unread?: string) => repo.findCompany(companyId, unread)
export const listCompanyHistory = (companyId: string, opts?: { channel?: string; status?: string; limit?: number }) => repo.findCompanyHistory(companyId, opts)
export const countUnread = (companyId: string) => repo.countUnread(companyId)
export const markRead = (id: string, companyId: string) => repo.markRead(id, companyId)
export const markAllRead = (companyId: string) => repo.markAllRead(companyId)
@@ -3,7 +3,8 @@ import { prisma } from '../../lib/prisma'
import { AppError } from '../../http/errors'
import { validateLicense } from '../../services/licenseValidationService'
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
import { bookingConfirmedNotif, reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
import { coerceNotificationLocale } from '../../services/notificationLocalizationService'
import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
import * as repo from './reservation.repo'
@@ -39,19 +40,27 @@ export async function confirmReservation(id: string, companyId: string) {
const updated = await repo.updateById(id, { status: 'CONFIRMED' })
const [customer, brand] = await Promise.all([
const [customer, company, vehicle] = await Promise.all([
repo.findCustomerById(reservation.customerId),
repo.findBrandLocale(companyId),
repo.findCompanyWithBrand(companyId),
repo.findVehicle(reservation.vehicleId, companyId),
])
const lang = (brand?.defaultLocale ?? 'fr') as Lang
const fallbackLocale = coerceNotificationLocale(company?.brand?.defaultLocale)
await sendNotification({
type: 'BOOKING_CONFIRMED',
title: bookingConfirmedNotif.title(lang),
body: bookingConfirmedNotif.body(lang),
companyId,
renterId: reservation.renterId ?? undefined,
email: customer.email,
channels: ['EMAIL', 'IN_APP'],
locale: lang,
locale: reservation.renterId ? undefined : fallbackLocale,
templateKey: 'booking.confirmed',
templateVariables: {
firstName: customer.firstName,
companyName: company?.brand?.displayName ?? company?.name ?? 'RentalDriveGo',
startDate: reservation.startDate,
endDate: reservation.endDate,
vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`,
},
}).catch(() => null)
return updated
@@ -168,7 +168,8 @@ describe('confirmReservation', () => {
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'CONFIRMED' } as any)
vi.mocked(repo.findCustomerById).mockResolvedValue({ email: 'ali@test.com', firstName: 'Ali' } as any)
vi.mocked(repo.findBrandLocale).mockResolvedValue({ defaultLocale: 'fr' } as any)
vi.mocked(repo.findCompanyWithBrand).mockResolvedValue({ name: 'TestCo', brand: { displayName: 'TestCo', defaultLocale: 'fr' } } as any)
vi.mocked(repo.findVehicle).mockResolvedValue({ year: 2022, make: 'Toyota', model: 'Camry' } as any)
const result = await confirmReservation(RES_ID, COMPANY)
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest'
import {
renderLocalizedEmailHtml,
resolveNotificationLocale,
resolveNotificationTemplate,
} from './notificationLocalizationService'
function createDbStub(overrides: Record<string, any> = {}) {
return {
employee: {
findUnique: vi.fn().mockResolvedValue(null),
},
renter: {
findUnique: vi.fn().mockResolvedValue(null),
},
brandSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
billingAccount: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi.fn().mockResolvedValue(null),
},
notificationTemplate: {
findFirst: vi.fn().mockResolvedValue(null),
},
...overrides,
}
}
describe('notificationLocalizationService', () => {
it('resolves locale in recipient -> workspace -> billing -> default order', async () => {
const db = createDbStub({
employee: {
findUnique: vi.fn().mockResolvedValue({ preferredLanguage: 'ar' }),
},
brandSettings: {
findUnique: vi.fn().mockResolvedValue({ defaultLocale: 'fr' }),
},
billingAccount: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi.fn().mockResolvedValue({ preferredLanguage: 'en' }),
},
})
const locale = await resolveNotificationLocale({
companyId: 'company_1',
employeeId: 'employee_1',
}, db as any)
expect(locale).toBe('ar')
})
it('falls back to English when a localized template is missing', async () => {
const db = createDbStub({
notificationTemplate: {
findFirst: vi
.fn()
.mockImplementation(({ where }: any) => {
if (where.locale === 'en') {
return Promise.resolve({
templateKey: 'booking.confirmed',
channel: 'EMAIL',
locale: 'en',
subject: 'Booking confirmed - {{companyName}}',
body: 'Hello {{firstName}}',
requiredVariables: ['companyName', 'firstName'],
optionalVariables: [],
version: 1,
})
}
return Promise.resolve(null)
}),
},
})
const template = await resolveNotificationTemplate({
templateKey: 'booking.confirmed',
channel: 'EMAIL',
locale: 'ar',
variables: {
firstName: 'Aya',
companyName: 'Atlas Cars',
},
}, db as any)
expect(template.locale).toBe('en')
expect(template.usedFallback).toBe(true)
expect(template.subject).toBe('Booking confirmed - Atlas Cars')
expect(template.body).toBe('Hello Aya')
})
it('renders Arabic emails with rtl metadata', () => {
const html = renderLocalizedEmailHtml('مرحبا\n\nتم التأكيد', 'ar')
expect(html).toContain('<html lang="ar" dir="rtl">')
expect(html).toContain('text-align:right')
})
})
@@ -0,0 +1,440 @@
import type { NotificationChannel } from '@rentaldrivego/database'
import { prisma } from '../lib/prisma'
export const SUPPORTED_NOTIFICATION_LOCALES = ['en', 'fr', 'ar'] as const
export type NotificationLocale = (typeof SUPPORTED_NOTIFICATION_LOCALES)[number]
export const DEFAULT_NOTIFICATION_LOCALE: NotificationLocale = 'en'
const localeConfig: Record<NotificationLocale, { intl: string; direction: 'ltr' | 'rtl' }> = {
en: { intl: 'en-US', direction: 'ltr' },
fr: { intl: 'fr-FR', direction: 'ltr' },
ar: { intl: 'ar-MA', direction: 'rtl' },
}
const subscriptionStatusLabels: Record<NotificationLocale, Record<string, string>> = {
en: {
trialing: 'Trial active',
active: 'Active',
payment_pending: 'Payment pending',
past_due: 'Payment overdue',
suspended: 'Suspended',
cancelled: 'Canceled',
canceled: 'Canceled',
expired: 'Expired',
},
fr: {
trialing: 'Essai actif',
active: 'Actif',
payment_pending: 'Paiement en attente',
past_due: 'Paiement en retard',
suspended: 'Suspendu',
cancelled: 'Annule',
canceled: 'Annule',
expired: 'Expire',
},
ar: {
trialing: 'الفترة التجريبية نشطة',
active: 'نشط',
payment_pending: 'الدفع قيد الانتظار',
past_due: 'الدفع متأخر',
suspended: 'معلّق',
cancelled: 'ملغى',
canceled: 'ملغى',
expired: 'منتهي',
},
}
const invoiceStatusLabels: Record<NotificationLocale, Record<string, string>> = {
en: {
draft: 'Preparing',
open: 'Due',
payment_pending: 'Payment processing',
paid: 'Paid',
partially_paid: 'Partially paid',
past_due: 'Past due',
void: 'Canceled',
uncollectible: 'Contact support',
refunded: 'Refunded',
partially_refunded: 'Partially refunded',
},
fr: {
draft: 'En preparation',
open: 'A payer',
payment_pending: 'Paiement en cours',
paid: 'Payee',
partially_paid: 'Partiellement payee',
past_due: 'En retard',
void: 'Annulee',
uncollectible: 'Contacter le support',
refunded: 'Remboursee',
partially_refunded: 'Partiellement remboursee',
},
ar: {
draft: 'قيد الإعداد',
open: 'مستحقة الدفع',
payment_pending: 'الدفع قيد المعالجة',
paid: 'مدفوعة',
partially_paid: 'مدفوعة جزئياً',
past_due: 'متأخرة',
void: 'ملغاة',
uncollectible: 'تواصل مع الدعم',
refunded: 'مستردة',
partially_refunded: 'مستردة جزئياً',
},
}
const billingPeriodLabels: Record<NotificationLocale, Record<string, string>> = {
en: {
monthly: 'monthly',
annual: 'annual',
},
fr: {
monthly: 'mensuel',
annual: 'annuel',
},
ar: {
monthly: 'شهري',
annual: 'سنوي',
},
}
const planLabels: Record<NotificationLocale, Record<string, string>> = {
en: {
starter: 'Starter',
growth: 'Growth',
pro: 'Pro',
},
fr: {
starter: 'Starter',
growth: 'Growth',
pro: 'Pro',
},
ar: {
starter: 'Starter',
growth: 'Growth',
pro: 'Pro',
},
}
type TemplateDateValue = {
type: 'date'
value: Date | string | number
options?: Intl.DateTimeFormatOptions
}
type TemplateCurrencyValue = {
type: 'currency'
amountMinor: number
currency: string
}
type TemplateSubscriptionStatusValue = {
type: 'subscription_status'
status: string
}
type TemplateInvoiceStatusValue = {
type: 'invoice_status'
status: string
}
export type NotificationTemplateValue =
| string
| number
| boolean
| Date
| null
| undefined
| TemplateDateValue
| TemplateCurrencyValue
| TemplateSubscriptionStatusValue
| TemplateInvoiceStatusValue
export type NotificationTemplateVariables = Record<string, NotificationTemplateValue>
type NotificationLocalizationDb = Pick<
typeof prisma,
'brandSettings' | 'billingAccount' | 'employee' | 'notificationTemplate' | 'renter'
>
type NotificationTemplateRecord = {
templateKey: string
channel: NotificationChannel
locale: string
subject: string | null
body: string
requiredVariables: unknown
optionalVariables: unknown
version: number
}
export function coerceNotificationLocale(locale?: string | null): NotificationLocale {
if (locale && SUPPORTED_NOTIFICATION_LOCALES.includes(locale as NotificationLocale)) {
return locale as NotificationLocale
}
return DEFAULT_NOTIFICATION_LOCALE
}
export function getNotificationTextDirection(locale: NotificationLocale) {
return localeConfig[locale].direction
}
export function formatLocalizedDate(
value: Date | string | number,
locale: NotificationLocale,
options?: Intl.DateTimeFormatOptions,
) {
const date = value instanceof Date ? value : new Date(value)
return new Intl.DateTimeFormat(localeConfig[locale].intl, options ?? {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
export function formatLocalizedCurrency(amountMinor: number, currency: string, locale: NotificationLocale) {
return new Intl.NumberFormat(localeConfig[locale].intl, {
style: 'currency',
currency,
}).format(amountMinor / 100)
}
export function localizeSubscriptionStatus(status: string, locale: NotificationLocale) {
const normalized = status.toLowerCase()
return subscriptionStatusLabels[locale][normalized] ?? status
}
export function localizeInvoiceStatus(status: string, locale: NotificationLocale) {
const normalized = status.toLowerCase()
return invoiceStatusLabels[locale][normalized] ?? status
}
export function localizeBillingPeriod(period: string, locale: NotificationLocale) {
const normalized = period.toLowerCase()
return billingPeriodLabels[locale][normalized] ?? period
}
export function localizePlanName(plan: string, locale: NotificationLocale) {
const normalized = plan.toLowerCase()
return planLabels[locale][normalized] ?? plan
}
function isTemplateDateValue(value: NotificationTemplateValue): value is TemplateDateValue {
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'date'
}
function isTemplateCurrencyValue(value: NotificationTemplateValue): value is TemplateCurrencyValue {
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'currency'
}
function isTemplateSubscriptionStatusValue(value: NotificationTemplateValue): value is TemplateSubscriptionStatusValue {
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'subscription_status'
}
function isTemplateInvoiceStatusValue(value: NotificationTemplateValue): value is TemplateInvoiceStatusValue {
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'invoice_status'
}
function formatTemplateValue(value: NotificationTemplateValue, locale: NotificationLocale): string {
if (value instanceof Date) return formatLocalizedDate(value, locale)
if (isTemplateDateValue(value)) return formatLocalizedDate(value.value, locale, value.options)
if (isTemplateCurrencyValue(value)) return formatLocalizedCurrency(value.amountMinor, value.currency, locale)
if (isTemplateSubscriptionStatusValue(value)) return localizeSubscriptionStatus(value.status, locale)
if (isTemplateInvoiceStatusValue(value)) return localizeInvoiceStatus(value.status, locale)
if (value === null || value === undefined) return ''
return String(value)
}
function normalizeTemplateVariableList(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === 'string')
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
async function findTemplate(
db: NotificationLocalizationDb,
templateKey: string,
channel: NotificationChannel,
locale: NotificationLocale,
): Promise<NotificationTemplateRecord | null> {
return db.notificationTemplate.findFirst({
where: { templateKey, channel, locale, isActive: true },
orderBy: { version: 'desc' },
select: {
templateKey: true,
channel: true,
locale: true,
subject: true,
body: true,
requiredVariables: true,
optionalVariables: true,
version: true,
},
})
}
export async function resolveNotificationLocale(input: {
companyId?: string | null
employeeId?: string | null
renterId?: string | null
billingAccountId?: string | null
locale?: string | null
}, db: NotificationLocalizationDb = prisma): Promise<NotificationLocale> {
if (input.locale) return coerceNotificationLocale(input.locale)
const employeePromise = input.employeeId
? db.employee.findUnique({
where: { id: input.employeeId },
select: { preferredLanguage: true },
})
: Promise.resolve(null)
const renterPromise = input.renterId
? db.renter.findUnique({
where: { id: input.renterId },
select: { preferredLocale: true },
})
: Promise.resolve(null)
const workspacePromise = input.companyId
? db.brandSettings.findUnique({
where: { companyId: input.companyId },
select: { defaultLocale: true },
})
: Promise.resolve(null)
const billingPromise = input.billingAccountId
? db.billingAccount.findUnique({
where: { id: input.billingAccountId },
select: { preferredLanguage: true },
})
: input.companyId
? db.billingAccount.findFirst({
where: { companyId: input.companyId, isPrimary: true },
select: { preferredLanguage: true },
orderBy: { createdAt: 'asc' },
})
: Promise.resolve(null)
const [employee, renter, workspace, billingAccount] = await Promise.all([
employeePromise,
renterPromise,
workspacePromise,
billingPromise,
])
return coerceNotificationLocale(
employee?.preferredLanguage ??
renter?.preferredLocale ??
workspace?.defaultLocale ??
billingAccount?.preferredLanguage ??
DEFAULT_NOTIFICATION_LOCALE,
)
}
export async function resolveNotificationTemplate(input: {
templateKey: string
channel: NotificationChannel
locale: NotificationLocale
variables?: NotificationTemplateVariables
}, db: NotificationLocalizationDb = prisma) {
const requested = await findTemplate(db, input.templateKey, input.channel, input.locale)
if (requested) {
return {
locale: coerceNotificationLocale(requested.locale),
subject: renderNotificationText(
requested.subject,
input.variables,
normalizeTemplateVariableList(requested.requiredVariables),
input.locale,
),
body: renderNotificationText(
requested.body,
input.variables,
normalizeTemplateVariableList(requested.requiredVariables),
input.locale,
),
templateKey: requested.templateKey,
usedFallback: false,
}
}
if (input.locale !== DEFAULT_NOTIFICATION_LOCALE) {
console.warn(
`[Notifications] Missing ${input.locale} template for ${input.templateKey}/${input.channel}; falling back to ${DEFAULT_NOTIFICATION_LOCALE}.`,
)
}
const fallback = await findTemplate(db, input.templateKey, input.channel, DEFAULT_NOTIFICATION_LOCALE)
if (!fallback) {
throw new Error(
`Missing notification template for ${input.templateKey}/${input.channel} in ${input.locale} and ${DEFAULT_NOTIFICATION_LOCALE}`,
)
}
return {
locale: DEFAULT_NOTIFICATION_LOCALE,
subject: renderNotificationText(
fallback.subject,
input.variables,
normalizeTemplateVariableList(fallback.requiredVariables),
DEFAULT_NOTIFICATION_LOCALE,
),
body: renderNotificationText(
fallback.body,
input.variables,
normalizeTemplateVariableList(fallback.requiredVariables),
DEFAULT_NOTIFICATION_LOCALE,
),
templateKey: fallback.templateKey,
usedFallback: true,
}
}
export function renderNotificationText(
template: string | null,
variables: NotificationTemplateVariables = {},
requiredVariables: string[] = [],
locale: NotificationLocale,
) {
if (!template) return null
for (const variableName of requiredVariables) {
if (variables[variableName] === undefined || variables[variableName] === null) {
throw new Error(`Missing notification template variable: ${variableName}`)
}
}
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, variableName: string) =>
formatTemplateValue(variables[variableName], locale),
)
}
export function renderLocalizedEmailHtml(body: string, locale: NotificationLocale) {
const direction = getNotificationTextDirection(locale)
const textAlign = direction === 'rtl' ? 'right' : 'left'
const paragraphs = body
.split(/\n{2,}/)
.map((paragraph) => `<p style="margin:0 0 16px;text-align:${textAlign};">${escapeHtml(paragraph).replace(/\n/g, '<br />')}</p>`)
.join('')
return [
`<html lang="${locale}" dir="${direction}">`,
`<body style="font-family:sans-serif;max-width:560px;margin:0 auto;padding:32px;color:#1c1917;direction:${direction};text-align:${textAlign};">`,
paragraphs,
'</body>',
'</html>',
].join('')
}
+44 -26
View File
@@ -4,6 +4,12 @@ import admin from 'firebase-admin'
import { prisma } from '../lib/prisma'
import { redis } from '../lib/redis'
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
import {
renderLocalizedEmailHtml,
resolveNotificationLocale,
resolveNotificationTemplate,
} from './notificationLocalizationService'
import type { NotificationTemplateVariables } from './notificationLocalizationService'
const resendApiKey =
process.env.RESEND_API_KEY &&
@@ -100,33 +106,20 @@ if (hasFirebaseConfig && !admin.apps.length) {
interface SendNotificationOptions {
type: NotificationType
title: string
body: string
title?: string
body?: string
data?: Record<string, unknown>
companyId?: string
employeeId?: string
renterId?: string
billingAccountId?: string
email?: string
phone?: string
fcmToken?: string
channels: NotificationChannel[]
locale?: string
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function renderEmailHtml(body: string) {
return body
.split(/\n{2,}/)
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, '<br />')}</p>`)
.join('')
templateKey?: string
templateVariables?: NotificationTemplateVariables
}
function resolveSmtpReplyTo() {
@@ -210,16 +203,41 @@ async function sendEmailWithProviders(opts: {
export async function sendNotification(opts: SendNotificationOptions) {
const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = []
const resolvedLocale = await resolveNotificationLocale({
companyId: opts.companyId,
employeeId: opts.employeeId,
renterId: opts.renterId,
billingAccountId: opts.billingAccountId,
locale: opts.locale,
})
for (const channel of opts.channels) {
try {
const rendered = opts.templateKey
? await resolveNotificationTemplate({
templateKey: opts.templateKey,
channel,
locale: resolvedLocale,
variables: opts.templateVariables,
})
: null
const title = rendered?.subject ?? opts.title
const body = rendered?.body ?? opts.body
if (!title || !body) {
throw new Error(`Notification content is incomplete for channel ${channel}`)
}
const notification = await prisma.notification.create({
data: {
type: opts.type,
title: opts.title,
body: opts.body,
title,
body,
data: (opts.data ?? {}) as any,
channel,
templateKey: rendered?.templateKey ?? opts.templateKey ?? null,
locale: rendered?.locale ?? resolvedLocale,
status: 'PENDING',
companyId: opts.companyId ?? null,
employeeId: opts.employeeId ?? null,
@@ -233,9 +251,9 @@ export async function sendNotification(opts: SendNotificationOptions) {
if (channel === 'EMAIL' && opts.email) {
const emailResult = await sendEmailWithProviders({
to: opts.email,
subject: opts.title,
html: renderEmailHtml(opts.body),
text: opts.body,
subject: title,
html: renderLocalizedEmailHtml(body, rendered?.locale ?? resolvedLocale),
text: body,
})
providerMessageId = emailResult.providerMessageId
success = true
@@ -244,7 +262,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
if (channel === 'SMS' && opts.phone) {
if (!twilioClient) throw new Error('Twilio is not configured')
const msg = await twilioClient.messages.create({
body: opts.body,
body,
from: process.env.TWILIO_PHONE_NUMBER!,
to: opts.phone,
})
@@ -255,7 +273,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
if (channel === 'WHATSAPP' && opts.phone) {
if (!twilioClient) throw new Error('Twilio is not configured')
const msg = await twilioClient.messages.create({
body: opts.body,
body,
from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`,
to: `whatsapp:${opts.phone}`,
})
@@ -267,7 +285,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
if (!admin.apps.length) throw new Error('Firebase is not configured')
const response = await admin.messaging().send({
token: opts.fcmToken,
notification: { title: opts.title, body: opts.body },
notification: { title, body },
data: Object.fromEntries(
Object.entries(opts.data ?? {}).map(([k, v]) => [k, String(v)])
),
+51 -3
View File
@@ -2,6 +2,7 @@ import crypto from 'crypto'
import { EmployeeRole } from '@rentaldrivego/database'
import { prisma } from '../lib/prisma'
import { sendTransactionalEmail } from './notificationService'
import { coerceNotificationLocale } from './notificationLocalizationService'
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7
@@ -39,9 +40,45 @@ function ensureDashboardBasePath(baseUrl: string) {
}
}
function buildInviteEmail(resetUrl: string, companyName: string, firstName: string, role: EmployeeRole) {
function buildInviteEmail(
resetUrl: string,
companyName: string,
firstName: string,
role: EmployeeRole,
locale: ReturnType<typeof coerceNotificationLocale>,
) {
const greetingName = firstName.trim() || 'there'
if (locale === 'fr') {
return {
subject: `Vous avez ete invite chez ${companyName}`,
html: `
<p>Bonjour ${greetingName},</p>
<p>Vous avez ete invite a rejoindre ${companyName} sur RentalDriveGo en tant que ${role.toLowerCase()}.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Definir votre mot de passe</a></p>
<p>Ce lien d'invitation expire dans 7 jours.</p>
<p>RentalDriveGo</p>
`,
text: `Bonjour ${greetingName},\n\nVous avez ete invite a rejoindre ${companyName} sur RentalDriveGo en tant que ${role.toLowerCase()}.\n\nDefinissez votre mot de passe ici : ${resetUrl}\n\nCe lien d'invitation expire dans 7 jours.\n\nRentalDriveGo`,
}
}
if (locale === 'ar') {
return {
subject: `تمت دعوتك إلى ${companyName}`,
html: `
<div dir="rtl" style="text-align:right">
<p>مرحباً ${greetingName}،</p>
<p>تمت دعوتك للانضمام إلى ${companyName} على RentalDriveGo بصفتك ${role.toLowerCase()}.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">عيّن كلمة المرور</a></p>
<p>تنتهي صلاحية رابط الدعوة هذا خلال 7 أيام.</p>
<p>RentalDriveGo</p>
</div>
`,
text: `مرحباً ${greetingName}،\n\nتمت دعوتك للانضمام إلى ${companyName} على RentalDriveGo بصفتك ${role.toLowerCase()}.\n\nعيّن كلمة المرور هنا: ${resetUrl}\n\nتنتهي صلاحية رابط الدعوة هذا خلال 7 أيام.\n\nRentalDriveGo`,
}
}
return {
subject: `You have been invited to ${companyName}`,
html: `
@@ -78,9 +115,13 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
throw Object.assign(new Error('An employee with this email already exists in your team'), { statusCode: 409, code: 'employee_already_exists' })
}
const company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } })
const company = await prisma.company.findUniqueOrThrow({
where: { id: companyId },
include: { brand: { select: { defaultLocale: true, displayName: true } } },
})
const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000)
const locale = coerceNotificationLocale(company.brand?.defaultLocale)
const employee = await prisma.employee.create({
data: {
@@ -91,6 +132,7 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
email: payload.email,
role: payload.role,
isActive: true,
preferredLanguage: locale,
passwordResetToken: rawToken,
passwordResetExpiresAt: expiresAt,
},
@@ -100,7 +142,13 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
)
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
const message = buildInviteEmail(resetUrl, company.name, payload.firstName, payload.role)
const message = buildInviteEmail(
resetUrl,
company.brand?.displayName ?? company.name,
payload.firstName,
payload.role,
locale,
)
await sendTransactionalEmail({
to: payload.email,