notification implemented
This commit is contained in:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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('')
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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)])
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user