notification implemented
This commit is contained in:
@@ -1,15 +1,32 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
||||
const apiUrl = new URL(apiOrigin)
|
||||
const ADMIN_BASE_PATH = '/admin'
|
||||
|
||||
function ensureAdminAssetPrefix(rawValue) {
|
||||
if (!rawValue) return undefined
|
||||
|
||||
try {
|
||||
const url = new URL(rawValue)
|
||||
const pathname = url.pathname.replace(/\/$/, '')
|
||||
if (!pathname.endsWith(ADMIN_BASE_PATH)) {
|
||||
url.pathname = `${pathname}${ADMIN_BASE_PATH}`
|
||||
}
|
||||
return url.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
const trimmed = rawValue.replace(/\/$/, '')
|
||||
return trimmed.endsWith(ADMIN_BASE_PATH) ? trimmed : `${trimmed}${ADMIN_BASE_PATH}`
|
||||
}
|
||||
}
|
||||
|
||||
// In Docker dev the admin app runs on port 3002 while the marketplace proxy
|
||||
// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits
|
||||
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
|
||||
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
|
||||
const assetPrefix = process.env.ADMIN_ASSET_PREFIX || undefined
|
||||
const assetPrefix = ensureAdminAssetPrefix(process.env.ADMIN_ASSET_PREFIX)
|
||||
|
||||
const nextConfig = {
|
||||
basePath: '/admin',
|
||||
basePath: ADMIN_BASE_PATH,
|
||||
...(assetPrefix ? { assetPrefix } : {}),
|
||||
images: {
|
||||
remotePatterns: [
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
function resolveAdminNextPath(next: string | null) {
|
||||
const fallback = '/admin/dashboard'
|
||||
if (!next) return fallback
|
||||
|
||||
if (/^https?:\/\//i.test(next)) return next
|
||||
if (next.startsWith('/admin/')) return next
|
||||
if (next === '/admin') return '/admin/dashboard'
|
||||
if (next.startsWith('/')) return `/admin${next}`
|
||||
|
||||
return `/admin/${next.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
export default function AuthRedirectPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash
|
||||
const params = new URLSearchParams(hash.replace(/^#/, ''))
|
||||
const token = params.get('token')
|
||||
const next = params.get('next') || '/dashboard'
|
||||
const next = resolveAdminNextPath(params.get('next'))
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem('admin_token', token)
|
||||
@@ -18,8 +27,8 @@ export default function AuthRedirectPage() {
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
}
|
||||
|
||||
router.replace(next)
|
||||
}, [router])
|
||||
window.location.replace(next)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-950">
|
||||
|
||||
@@ -25,6 +25,7 @@ const navLinks = [
|
||||
{ href: '/dashboard/site-config', key: 'siteConfig', icon: 'M4.5 12a7.5 7.5 0 1015 0 7.5 7.5 0 00-15 0zm7.5-4.5v4.5l3 3' },
|
||||
{ href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
{ href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||
{ href: '/dashboard/notifications', key: 'notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
|
||||
{ href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
|
||||
{ href: '/dashboard/billing', key: 'billing', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
|
||||
{ href: '/dashboard/pricing', key: 'pricing', icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface NotificationItem {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
body: string
|
||||
channel: string
|
||||
status: string
|
||||
locale: string
|
||||
sentAt: string | null
|
||||
createdAt: string
|
||||
company: { name: string } | null
|
||||
companyId: string | null
|
||||
renterId: string | null
|
||||
}
|
||||
|
||||
interface Paginated {
|
||||
data: NotificationItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
||||
const STATUSES = ['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ']
|
||||
|
||||
const CHANNEL_BADGE: Record<string, string> = {
|
||||
EMAIL: 'text-sky-400 bg-sky-950/40',
|
||||
SMS: 'text-emerald-400 bg-emerald-950/40',
|
||||
WHATSAPP: 'text-teal-400 bg-teal-950/40',
|
||||
IN_APP: 'text-violet-400 bg-violet-950/40',
|
||||
PUSH: 'text-orange-400 bg-orange-950/40',
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
PENDING: 'text-yellow-400 bg-yellow-950/40',
|
||||
SENT: 'text-emerald-400 bg-emerald-950/40',
|
||||
DELIVERED: 'text-emerald-400 bg-emerald-950/40',
|
||||
FAILED: 'text-red-400 bg-red-950/40',
|
||||
READ: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
export default function AdminNotificationsPage() {
|
||||
const [result, setResult] = useState<Paginated | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterChannel, setFilterChannel] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [filterCompany, setFilterCompany] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
function load(p: number) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const token = localStorage.getItem('admin_token') ?? ''
|
||||
const params = new URLSearchParams({ page: String(p), pageSize: '50' })
|
||||
if (filterChannel) params.set('channel', filterChannel)
|
||||
if (filterStatus) params.set('status', filterStatus)
|
||||
if (filterCompany) params.set('companyId', filterCompany)
|
||||
|
||||
fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => setResult(json.data ?? null))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
load(1)
|
||||
}, [filterChannel, filterStatus, filterCompany])
|
||||
|
||||
function goToPage(p: number) {
|
||||
setPage(p)
|
||||
load(p)
|
||||
}
|
||||
|
||||
const totalPages = result ? Math.ceil(result.total / result.pageSize) : 0
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">Platform</p>
|
||||
<h1 className="mt-1 text-3xl font-black">Notifications</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Full audit log of every notification sent across all companies.
|
||||
</p>
|
||||
</div>
|
||||
{result && (
|
||||
<span className="rounded-full bg-zinc-800 px-3 py-1 text-sm text-zinc-300">
|
||||
{result.total.toLocaleString()} total
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={filterChannel}
|
||||
onChange={(e) => setFilterChannel(e.target.value)}
|
||||
className="rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
<option value="">All channels</option>
|
||||
{CHANNELS.map((ch) => <option key={ch} value={ch}>{ch}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Date</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Company</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Event</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Channel</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Title</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Status</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Sent at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
) : !result || result.data.length === 0 ? (
|
||||
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
|
||||
) : result.data.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300">
|
||||
{item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs font-medium text-zinc-300">
|
||||
{item.type.replaceAll('_', ' ')}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${CHANNEL_BADGE[item.channel] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{item.channel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 max-w-[220px]">
|
||||
<p className="truncate text-sm text-zinc-200">{item.title}</p>
|
||||
<p className="truncate text-xs text-zinc-500">{item.body}</p>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[item.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
||||
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-zinc-800 px-5 py-3">
|
||||
<span className="text-xs text-zinc-500">
|
||||
Page {page} of {totalPages} — {result?.total.toLocaleString()} records
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => goToPage(page - 1)}
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => goToPage(page + 1)}
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
adminUsers: 'Admin Users',
|
||||
billing: 'Billing',
|
||||
pricing: 'Pricing',
|
||||
notifications: 'Notifications',
|
||||
},
|
||||
logout: 'Logout',
|
||||
language: 'Language',
|
||||
@@ -50,6 +51,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
adminUsers: 'Utilisateurs admin',
|
||||
billing: 'Facturation',
|
||||
pricing: 'Tarification',
|
||||
notifications: 'Notifications',
|
||||
},
|
||||
logout: 'Déconnexion',
|
||||
language: 'Langue',
|
||||
@@ -71,6 +73,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
adminUsers: 'مستخدمو الإدارة',
|
||||
billing: 'الفوترة',
|
||||
pricing: 'الأسعار',
|
||||
notifications: 'الإشعارات',
|
||||
},
|
||||
logout: 'تسجيل الخروج',
|
||||
language: 'اللغة',
|
||||
|
||||
+13
-10
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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, '&')
|
||||
.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,
|
||||
|
||||
@@ -9,8 +9,13 @@ type NotificationItem = {
|
||||
type: string
|
||||
title: string
|
||||
body: string
|
||||
channel: string
|
||||
status: string
|
||||
sentAt: string | null
|
||||
readAt: string | null
|
||||
createdAt: string
|
||||
providerMessageId: string | null
|
||||
locale: string
|
||||
}
|
||||
|
||||
type PreferenceItem = {
|
||||
@@ -30,19 +35,42 @@ const COMPANY_EVENTS = [
|
||||
|
||||
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
||||
|
||||
const CHANNEL_BADGE: Record<string, string> = {
|
||||
EMAIL: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
SMS: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
WHATSAPP: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
||||
IN_APP: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
PUSH: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
PENDING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||
SENT: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
DELIVERED: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
FAILED: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||
READ: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
|
||||
}
|
||||
|
||||
export default function DashboardNotificationsPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([])
|
||||
const [history, setHistory] = useState<NotificationItem[]>([])
|
||||
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
|
||||
const [activeTab, setActiveTab] = useState<'inbox' | 'preferences'>('inbox')
|
||||
const [activeTab, setActiveTab] = useState<'inbox' | 'history' | 'preferences'>('inbox')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterChannel, setFilterChannel] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Notifications',
|
||||
subtitle: 'Track in-app alerts and control delivery preferences for your team account.',
|
||||
subtitle: 'Track in-app alerts, audit delivery history, and control preferences for your team account.',
|
||||
inbox: 'Inbox',
|
||||
history: 'History',
|
||||
preferences: 'Preferences',
|
||||
markAllRead: 'Mark all as read',
|
||||
loading: 'Loading notifications…',
|
||||
@@ -50,24 +78,35 @@ export default function DashboardNotificationsPage() {
|
||||
read: 'Read',
|
||||
markRead: 'Mark as read',
|
||||
event: 'Event',
|
||||
channel: 'Channel',
|
||||
status: 'Status',
|
||||
sentAt: 'Sent at',
|
||||
date: 'Date',
|
||||
allChannels: 'All channels',
|
||||
allStatuses: 'All statuses',
|
||||
savePreferences: 'Save preferences',
|
||||
saving: 'Saving…',
|
||||
failedLoad: 'Failed to load notifications',
|
||||
failedSave: 'Failed to save preferences',
|
||||
noHistory: 'No notification history found.',
|
||||
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record<string, string>,
|
||||
statuses: { PENDING: 'Pending', SENT: 'Sent', DELIVERED: 'Delivered', FAILED: 'Failed', READ: 'Read' } as Record<string, string>,
|
||||
events: {
|
||||
NEW_RESERVATION: 'New reservation',
|
||||
RESERVATION_CANCELLED: 'Reservation cancelled',
|
||||
PAYMENT_RECEIVED: 'Payment received',
|
||||
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
|
||||
MAINTENANCE_DUE: 'Maintenance à prévoir',
|
||||
MAINTENANCE_DUE: 'Maintenance due',
|
||||
OFFER_EXPIRING: 'Offer expiring',
|
||||
BOOKING_CONFIRMED: 'Booking confirmed',
|
||||
VEHICLE_MAINTENANCE_DUE: 'Vehicle maintenance due',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
fr: {
|
||||
title: 'Notifications',
|
||||
subtitle: 'Suivez les alertes de l’application et gérez les préférences de diffusion de votre équipe.',
|
||||
subtitle: 'Suivez les alertes, auditez l'historique de diffusion et gérez les préférences de votre équipe.',
|
||||
inbox: 'Boîte de réception',
|
||||
history: 'Historique',
|
||||
preferences: 'Préférences',
|
||||
markAllRead: 'Tout marquer comme lu',
|
||||
loading: 'Chargement des notifications…',
|
||||
@@ -75,24 +114,35 @@ export default function DashboardNotificationsPage() {
|
||||
read: 'Lu',
|
||||
markRead: 'Marquer comme lu',
|
||||
event: 'Événement',
|
||||
channel: 'Canal',
|
||||
status: 'Statut',
|
||||
sentAt: 'Envoyé le',
|
||||
date: 'Date',
|
||||
allChannels: 'Tous les canaux',
|
||||
allStatuses: 'Tous les statuts',
|
||||
savePreferences: 'Enregistrer les préférences',
|
||||
saving: 'Enregistrement…',
|
||||
failedLoad: 'Échec du chargement des notifications',
|
||||
failedSave: 'Échec de l’enregistrement des préférences',
|
||||
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l’application', PUSH: 'Push' } as Record<string, string>,
|
||||
failedSave: 'Échec de l'enregistrement des préférences',
|
||||
noHistory: 'Aucun historique de notification trouvé.',
|
||||
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l'application', PUSH: 'Push' } as Record<string, string>,
|
||||
statuses: { PENDING: 'En attente', SENT: 'Envoyé', DELIVERED: 'Délivré', FAILED: 'Échoué', READ: 'Lu' } as Record<string, string>,
|
||||
events: {
|
||||
NEW_RESERVATION: 'Nouvelle réservation',
|
||||
RESERVATION_CANCELLED: 'Réservation annulée',
|
||||
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',
|
||||
BOOKING_CONFIRMED: 'Réservation confirmée',
|
||||
VEHICLE_MAINTENANCE_DUE: 'Maintenance véhicule due',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
ar: {
|
||||
title: 'الإشعارات',
|
||||
subtitle: 'تابع تنبيهات التطبيق وتحكم في تفضيلات الإرسال لحساب فريقك.',
|
||||
subtitle: 'تابع التنبيهات وراجع سجل الإرسال وتحكم في التفضيلات لحساب فريقك.',
|
||||
inbox: 'صندوق الوارد',
|
||||
history: 'السجل',
|
||||
preferences: 'التفضيلات',
|
||||
markAllRead: 'تحديد الكل كمقروء',
|
||||
loading: 'جارٍ تحميل الإشعارات…',
|
||||
@@ -100,11 +150,19 @@ export default function DashboardNotificationsPage() {
|
||||
read: 'مقروء',
|
||||
markRead: 'تحديد كمقروء',
|
||||
event: 'الحدث',
|
||||
channel: 'القناة',
|
||||
status: 'الحالة',
|
||||
sentAt: 'وقت الإرسال',
|
||||
date: 'التاريخ',
|
||||
allChannels: 'جميع القنوات',
|
||||
allStatuses: 'جميع الحالات',
|
||||
savePreferences: 'حفظ التفضيلات',
|
||||
saving: 'جارٍ الحفظ…',
|
||||
failedLoad: 'فشل تحميل الإشعارات',
|
||||
failedSave: 'فشل حفظ التفضيلات',
|
||||
noHistory: 'لا يوجد سجل إشعارات.',
|
||||
channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>,
|
||||
statuses: { PENDING: 'قيد الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', READ: 'مقروء' } as Record<string, string>,
|
||||
events: {
|
||||
NEW_RESERVATION: 'حجز جديد',
|
||||
RESERVATION_CANCELLED: 'إلغاء حجز',
|
||||
@@ -112,6 +170,8 @@ export default function DashboardNotificationsPage() {
|
||||
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
|
||||
MAINTENANCE_DUE: 'صيانة مستحقة',
|
||||
OFFER_EXPIRING: 'عرض على وشك الانتهاء',
|
||||
BOOKING_CONFIRMED: 'تأكيد الحجز',
|
||||
VEHICLE_MAINTENANCE_DUE: 'صيانة المركبة مستحقة',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
}[language]
|
||||
@@ -136,10 +196,34 @@ export default function DashboardNotificationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
setHistoryLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filterChannel) params.set('channel', filterChannel)
|
||||
if (filterStatus) params.set('status', filterStatus)
|
||||
const qs = params.toString()
|
||||
const data = await apiFetch<NotificationItem[]>(`/notifications/history${qs ? `?${qs}` : ''}`)
|
||||
setHistory(data)
|
||||
setHistoryLoaded(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? copy.failedLoad)
|
||||
} finally {
|
||||
setHistoryLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'history') {
|
||||
loadHistory()
|
||||
}
|
||||
}, [activeTab, filterChannel, filterStatus])
|
||||
|
||||
async function markRead(id: string) {
|
||||
await apiFetch(`/notifications/company/${id}/read`, { method: 'POST' })
|
||||
setNotifications((current) =>
|
||||
@@ -184,12 +268,16 @@ export default function DashboardNotificationsPage() {
|
||||
}))
|
||||
}
|
||||
|
||||
function labelEvent(type: string) {
|
||||
return copy.events[type] ?? type.replaceAll('_', ' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-slate-900">{copy.title}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
|
||||
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{copy.title}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{copy.subtitle}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -199,6 +287,13 @@ export default function DashboardNotificationsPage() {
|
||||
>
|
||||
{copy.inbox}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={activeTab === 'history' ? 'btn-primary' : 'btn-secondary'}
|
||||
>
|
||||
{copy.history}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('preferences')}
|
||||
@@ -209,8 +304,9 @@ export default function DashboardNotificationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="card p-4 text-sm text-red-600">{error}</div> : null}
|
||||
{error ? <div className="card p-4 text-sm text-red-600 dark:text-red-400">{error}</div> : null}
|
||||
|
||||
{/* ── Inbox ── */}
|
||||
{activeTab === 'inbox' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
@@ -225,34 +321,118 @@ export default function DashboardNotificationsPage() {
|
||||
<article key={notification.id} className="card p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
{copy.events[notification.type] ?? notification.type.replaceAll('_', ' ')}
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
||||
{labelEvent(notification.type)}
|
||||
</p>
|
||||
<h2 className="mt-2 text-lg font-semibold text-slate-900">{notification.title}</h2>
|
||||
<h2 className="mt-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{notification.title}</h2>
|
||||
</div>
|
||||
{notification.readAt ? (
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">{copy.read}</span>
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-400">{copy.read}</span>
|
||||
) : (
|
||||
<button type="button" onClick={() => markRead(notification.id)} className="btn-secondary">
|
||||
{copy.markRead}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-600">{notification.body}</p>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-600 dark:text-slate-300">{notification.body}</p>
|
||||
<p className="mt-4 text-xs text-slate-400">{new Date(notification.createdAt).toLocaleString()}</p>
|
||||
</article>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
) : (
|
||||
) : null}
|
||||
|
||||
{/* ── History ── */}
|
||||
{activeTab === 'history' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={filterChannel}
|
||||
onChange={(e) => setFilterChannel(e.target.value)}
|
||||
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-200"
|
||||
>
|
||||
<option value="">{copy.allChannels}</option>
|
||||
{COMPANY_CHANNELS.map((ch) => (
|
||||
<option key={ch} value={ch}>{copy.channels[ch] ?? ch}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-200"
|
||||
>
|
||||
<option value="">{copy.allStatuses}</option>
|
||||
{Object.keys(copy.statuses).map((s) => (
|
||||
<option key={s} value={s}>{copy.statuses[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{historyLoading ? (
|
||||
<div className="card p-6 text-sm text-slate-500">{copy.loading}</div>
|
||||
) : historyLoaded && history.length === 0 ? (
|
||||
<div className="card p-6 text-sm text-slate-500">{copy.noHistory}</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50 dark:bg-[#0d1b38]">
|
||||
<tr>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.date}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.event}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.channel}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.title ?? 'Title'}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.status}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.sentAt}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-blue-900/50">
|
||||
{history.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-slate-50/60 dark:hover:bg-[#0d1b38]/60">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-xs font-medium text-slate-700 dark:text-slate-300">
|
||||
{labelEvent(item.type)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${CHANNEL_BADGE[item.channel] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{copy.channels[item.channel] ?? item.channel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-[260px] px-4 py-3">
|
||||
<p className="truncate text-sm font-medium text-slate-800 dark:text-slate-200">{item.title}</p>
|
||||
<p className="truncate text-xs text-slate-400 dark:text-slate-500">{item.body}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${STATUS_BADGE[item.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{copy.statuses[item.status] ?? item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Preferences ── */}
|
||||
{activeTab === 'preferences' ? (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<thead className="bg-slate-50 dark:bg-[#0d1b38]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-600">{copy.event}</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-600 dark:text-slate-300">{copy.event}</th>
|
||||
{COMPANY_CHANNELS.map((channel) => (
|
||||
<th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600">
|
||||
<th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600 dark:text-slate-300">
|
||||
{copy.channels[channel] ?? channel.replace('_', ' ')}
|
||||
</th>
|
||||
))}
|
||||
@@ -260,8 +440,8 @@ export default function DashboardNotificationsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMPANY_EVENTS.map((eventName) => (
|
||||
<tr key={eventName} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-medium text-slate-900">{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}</td>
|
||||
<tr key={eventName} className="border-t border-slate-100 dark:border-blue-900/50">
|
||||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-200">{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}</td>
|
||||
{COMPANY_CHANNELS.map((channel) => (
|
||||
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
|
||||
<input
|
||||
@@ -277,13 +457,13 @@ export default function DashboardNotificationsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-100 p-4">
|
||||
<div className="flex justify-end border-t border-slate-100 dark:border-blue-900/50 p-4">
|
||||
<button type="button" onClick={savePreferences} disabled={saving} className="btn-primary">
|
||||
{saving ? copy.saving : copy.savePreferences}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { adminUrl, marketplaceUrl } from '@/lib/urls'
|
||||
import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||
import { toDashboardAppPath, toPublicDashboardPath } from '@/lib/dashboardPaths'
|
||||
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
|
||||
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
|
||||
@@ -209,7 +209,6 @@ function LocalSignInForm({
|
||||
unexpectedError: string
|
||||
}
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { setLanguage } = useDashboardI18n()
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -221,7 +220,6 @@ function LocalSignInForm({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const adminNext = searchParams.get('next') || '/dashboard'
|
||||
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
|
||||
const employeeAppRedirect = toDashboardAppPath(employeeRedirect)
|
||||
|
||||
function redirectAdmin(token: string) {
|
||||
const hash = new URLSearchParams({ token, next: adminNext }).toString()
|
||||
@@ -243,6 +241,7 @@ function LocalSignInForm({
|
||||
|
||||
if (empRes.ok && empJson?.data?.token) {
|
||||
const token = empJson.data.token
|
||||
const targetPath = toPublicDashboardPath(employeeRedirect)
|
||||
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
|
||||
if (empJson?.data?.employee) {
|
||||
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
|
||||
@@ -254,8 +253,10 @@ function LocalSignInForm({
|
||||
}
|
||||
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
|
||||
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
||||
notifyParent({ type: 'rentaldrivego:employee-login', path: toPublicDashboardPath(employeeRedirect) })
|
||||
router.push(employeeAppRedirect)
|
||||
notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath })
|
||||
// Use a document navigation here so the authenticated dashboard bootstraps
|
||||
// from a fresh request after the token cookie and localStorage are set.
|
||||
window.location.replace(targetPath)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Bell } from 'lucide-react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { toDashboardAppPath } from '@/lib/dashboardPaths'
|
||||
@@ -34,6 +35,11 @@ export default function TopBar() {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
function getEmployeeToken() {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
}
|
||||
|
||||
const title = (() => {
|
||||
if (!mounted) return dict.titles['/']
|
||||
if (dict.titles[appPath]) return dict.titles[appPath]
|
||||
@@ -42,6 +48,11 @@ export default function TopBar() {
|
||||
return dict.titles['/']
|
||||
})()
|
||||
async function refreshUnreadCount() {
|
||||
if (!getEmployeeToken()) {
|
||||
setUnreadCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
|
||||
setUnreadCount(data.unread)
|
||||
@@ -60,8 +71,29 @@ export default function TopBar() {
|
||||
return () => window.removeEventListener('notifications:updated', onNotificationsUpdated)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
if (!token) return
|
||||
|
||||
const socketUrl = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1').replace('/api/v1', '')
|
||||
const socket = io(socketUrl, { auth: { token }, transports: ['websocket'] })
|
||||
|
||||
socket.on('notification', () => {
|
||||
setUnreadCount((prev) => prev + 1)
|
||||
window.dispatchEvent(new Event('notifications:updated'))
|
||||
})
|
||||
|
||||
return () => { socket.disconnect() }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNotifs) return
|
||||
if (!getEmployeeToken()) {
|
||||
setNotifications([])
|
||||
setLoadingNotifs(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setLoadingNotifs(true)
|
||||
apiFetch<Array<{
|
||||
|
||||
Reference in New Issue
Block a user