notification implemented

This commit is contained in:
root
2026-05-25 13:12:06 -04:00
parent 33b6bb55f0
commit c8bde121fc
31 changed files with 1491 additions and 1291 deletions
+19 -2
View File
@@ -1,15 +1,32 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '') const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
const apiUrl = new URL(apiOrigin) 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 // 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 // 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 // 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). // 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 = { const nextConfig = {
basePath: '/admin', basePath: ADMIN_BASE_PATH,
...(assetPrefix ? { assetPrefix } : {}), ...(assetPrefix ? { assetPrefix } : {}),
images: { images: {
remotePatterns: [ remotePatterns: [
+15 -6
View File
@@ -1,16 +1,25 @@
'use client' 'use client'
import { useEffect } from 'react' 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() { export default function AuthRedirectPage() {
const router = useRouter()
useEffect(() => { useEffect(() => {
const hash = window.location.hash const hash = window.location.hash
const params = new URLSearchParams(hash.replace(/^#/, '')) const params = new URLSearchParams(hash.replace(/^#/, ''))
const token = params.get('token') const token = params.get('token')
const next = params.get('next') || '/dashboard' const next = resolveAdminNextPath(params.get('next'))
if (token) { if (token) {
localStorage.setItem('admin_token', token) localStorage.setItem('admin_token', token)
@@ -18,8 +27,8 @@ export default function AuthRedirectPage() {
window.history.replaceState(null, '', window.location.pathname) window.history.replaceState(null, '', window.location.pathname)
} }
router.replace(next) window.location.replace(next)
}, [router]) }, [])
return ( return (
<div className="flex min-h-screen items-center justify-center bg-zinc-950"> <div className="flex min-h-screen items-center justify-center bg-zinc-950">
+1
View File
@@ -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/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/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/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/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/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' }, { 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', adminUsers: 'Admin Users',
billing: 'Billing', billing: 'Billing',
pricing: 'Pricing', pricing: 'Pricing',
notifications: 'Notifications',
}, },
logout: 'Logout', logout: 'Logout',
language: 'Language', language: 'Language',
@@ -50,6 +51,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
adminUsers: 'Utilisateurs admin', adminUsers: 'Utilisateurs admin',
billing: 'Facturation', billing: 'Facturation',
pricing: 'Tarification', pricing: 'Tarification',
notifications: 'Notifications',
}, },
logout: 'Déconnexion', logout: 'Déconnexion',
language: 'Langue', language: 'Langue',
@@ -71,6 +73,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
adminUsers: 'مستخدمو الإدارة', adminUsers: 'مستخدمو الإدارة',
billing: 'الفوترة', billing: 'الفوترة',
pricing: 'الأسعار', pricing: 'الأسعار',
notifications: 'الإشعارات',
}, },
logout: 'تسجيل الخروج', logout: 'تسجيل الخروج',
language: 'اللغة', language: 'اللغة',
+13 -10
View File
@@ -2,11 +2,11 @@ import http from 'http'
import { Server as SocketIOServer } from 'socket.io' import { Server as SocketIOServer } from 'socket.io'
import cron from 'node-cron' import cron from 'node-cron'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import { z } from 'zod'
import { redis } from './lib/redis' import { redis } from './lib/redis'
import { prisma } from './lib/prisma' import { prisma } from './lib/prisma'
import { assertStorageConfiguration } from './lib/storage' import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app' import { createApp, corsOrigins } from './app'
import { sendNotification } from './services/notificationService'
import { import {
runTrialExpirationJob, runTrialExpirationJob,
runPaymentPendingTimeoutJob, runPaymentPendingTimeoutJob,
@@ -24,11 +24,6 @@ const io = new SocketIOServer(server, {
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] }, 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 // Authenticate socket connections via JWT before joining user rooms
io.use((socket, next) => { io.use((socket, next) => {
const token = socket.handshake.auth?.token as string | undefined const token = socket.handshake.auth?.token as string | undefined
@@ -57,9 +52,8 @@ subscriber.psubscribe('notifications:*', (err) => {
subscriber.on('pmessage', (_pattern, channel, message) => { subscriber.on('pmessage', (_pattern, channel, message) => {
try { try {
const parsed = JSON.parse(message) const parsed = JSON.parse(message)
const validated = redisMessageSchema.parse(parsed)
const userId = channel.replace('notifications:', '') const userId = channel.replace('notifications:', '')
io.to(`user:${userId}`).emit('notification', validated) io.to(`user:${userId}`).emit('notification', parsed)
} catch (err) { } catch (err) {
console.error('[Redis] Invalid notification message:', err) console.error('[Redis] Invalid notification message:', err)
} }
@@ -117,8 +111,17 @@ cron.schedule('0 9 * * *', async () => {
for (const sub of subscriptions) { for (const sub of subscriptions) {
const owner = sub.company.employees[0] const owner = sub.company.employees[0]
if (owner) { if (owner) {
await prisma.notification.create({ await sendNotification({
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' }, type: 'SUBSCRIPTION_TRIAL_ENDING',
companyId: sub.companyId,
employeeId: owner.id,
channels: ['IN_APP'],
templateKey: 'subscription.trial_ending',
templateVariables: {
trialEndDate: sub.trialEndAt ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
},
}).catch((err) => {
console.error('[Notifications] Failed to create trial ending reminder:', err?.message ?? String(err))
}) })
} }
} }
+25
View File
@@ -488,3 +488,28 @@ export function updatePromotion(id: string, data: Partial<{
export function deletePromotion(id: string) { export function deletePromotion(id: string) {
return (prisma as any).pricingPromotion.delete({ where: { id } }) return (prisma as any).pricingPromotion.delete({ where: { id } })
} }
export async function listNotificationsPage(query: {
channel?: string
status?: string
companyId?: string
page: number
pageSize: number
}) {
const where: any = {}
if (query.channel) where.channel = query.channel
if (query.status) where.status = query.status
if (query.companyId) where.companyId = query.companyId
const [data, total] = await Promise.all([
prisma.notification.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
include: { company: { select: { name: true } } },
}),
prisma.notification.count({ where }),
])
return { data, total }
}
+9 -1
View File
@@ -7,7 +7,7 @@ import * as subService from '../subscriptions/subscription.service'
import { presentAdminUser } from './admin.presenter' import { presentAdminUser } from './admin.presenter'
import { import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema, loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, notificationsQuerySchema,
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema, invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
createAdminSchema, adminRoleSchema, adminPermissionsSchema, createAdminSchema, adminRoleSchema, adminPermissionsSchema,
homepageUpdateSchema, idParamSchema, companyIdParamSchema, billingAccountIdParamSchema, invoiceIdParamSchema, homepageUpdateSchema, idParamSchema, companyIdParamSchema, billingAccountIdParamSchema, invoiceIdParamSchema,
@@ -158,6 +158,14 @@ router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req
} catch (err) { next(err) } } 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 ──────────────────────────────────────────────── // ─── Audit logs ────────────────────────────────────────────────
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { 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), 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({ export const billingQuerySchema = z.object({
q: z.string().optional(), q: z.string().optional(),
status: z.string().optional(), status: z.string().optional(),
@@ -193,6 +193,11 @@ export function getPlatformMetrics() {
return repo.getPlatformMetricCounts() 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 }) { export async function getAuditLogs(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listAuditLogsPage(query) const { data, total } = await repo.listAuditLogsPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize) return presenter.presentPaginated(data, total, query.page, query.pageSize)
@@ -70,6 +70,7 @@ export async function createCompanySignup(db: DbClient, input: CompanySignupInpu
publicAddress: input.streetAddress, publicAddress: input.streetAddress,
publicCity: input.city, publicCity: input.city,
publicCountry: input.country, publicCountry: input.country,
defaultLocale: input.preferredLanguage,
defaultCurrency: input.currency, defaultCurrency: input.currency,
}, },
}) })
@@ -2,7 +2,11 @@ import bcrypt from 'bcryptjs'
import { AppError } from '../../http/errors' import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { sendNotification } from '../../services/notificationService' 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 { presentCompanySignup } from './auth.presenter'
import * as repo from './auth.company.repo' import * as repo from './auth.company.repo'
import { companySignupSchema } from './auth.company.schemas' import { companySignupSchema } from './auth.company.schemas'
@@ -66,24 +70,24 @@ export async function signup(body: CompanySignupInput) {
trialEndAt, trialEndAt,
})) }))
const lang = body.preferredLanguage as Lang const lang = coerceNotificationLocale(body.preferredLanguage)
const emailResult = await sendNotification({ const emailResult = await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING', type: 'ACCOUNT_CREATED',
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),
companyId: result.company.id, companyId: result.company.id,
employeeId: result.employee.id, employeeId: result.employee.id,
email: body.email, email: body.email,
channels: ['EMAIL', 'IN_APP'], channels: ['EMAIL', 'IN_APP'],
locale: lang, 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(() => []) }).catch(() => [])
const emailDelivery = (emailResult as Array<{ channel?: string }>).find((entry) => entry.channel === 'EMAIL') 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 ───────────────────────────────────── // ─── Renter notifications ─────────────────────────────────────
export function findRenter(renterId: string) { export function findRenter(renterId: string) {
@@ -6,7 +6,7 @@ import { requireRenterAuth } from '../../middleware/requireRenterAuth'
import { parseBody, parseQuery, parseParams } from '../../http/validate' import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok } from '../../http/respond' import { ok } from '../../http/respond'
import * as service from './notification.service' import * as service from './notification.service'
import { preferencesSchema, idParamSchema, unreadQuerySchema } from './notification.schemas' import { preferencesSchema, idParamSchema, unreadQuerySchema, historyQuerySchema } from './notification.schemas'
const router = Router() const router = Router()
@@ -56,6 +56,13 @@ router.patch('/company/preferences', ...companyAuth, async (req, res, next) => {
} catch (err) { next(err) } } 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 ───────────────────────────────────── // ─── Renter notifications ─────────────────────────────────────
router.get('/renter', requireRenterAuth, async (req, res, next) => { router.get('/renter', requireRenterAuth, async (req, res, next) => {
@@ -15,3 +15,9 @@ export const idParamSchema = z.object({
export const unreadQuerySchema = z.object({ export const unreadQuerySchema = z.object({
unread: z.string().optional(), 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' import * as repo from './notification.repo'
export const listCompany = (companyId: string, unread?: string) => repo.findCompany(companyId, unread) 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 countUnread = (companyId: string) => repo.countUnread(companyId)
export const markRead = (id: string, companyId: string) => repo.markRead(id, companyId) export const markRead = (id: string, companyId: string) => repo.markRead(id, companyId)
export const markAllRead = (companyId: string) => repo.markAllRead(companyId) export const markAllRead = (companyId: string) => repo.markAllRead(companyId)
@@ -3,7 +3,8 @@ import { prisma } from '../../lib/prisma'
import { AppError } from '../../http/errors' import { AppError } from '../../http/errors'
import { validateLicense } from '../../services/licenseValidationService' import { validateLicense } from '../../services/licenseValidationService'
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService' 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 { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
import * as repo from './reservation.repo' 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 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.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({ await sendNotification({
type: 'BOOKING_CONFIRMED', type: 'BOOKING_CONFIRMED',
title: bookingConfirmedNotif.title(lang),
body: bookingConfirmedNotif.body(lang),
companyId, companyId,
renterId: reservation.renterId ?? undefined,
email: customer.email, email: customer.email,
channels: ['EMAIL', 'IN_APP'], 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) }).catch(() => null)
return updated return updated
@@ -168,7 +168,8 @@ describe('confirmReservation', () => {
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any) vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'CONFIRMED' } 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.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) const result = await confirmReservation(RES_ID, COMPANY)
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest'
import {
renderLocalizedEmailHtml,
resolveNotificationLocale,
resolveNotificationTemplate,
} from './notificationLocalizationService'
function createDbStub(overrides: Record<string, any> = {}) {
return {
employee: {
findUnique: vi.fn().mockResolvedValue(null),
},
renter: {
findUnique: vi.fn().mockResolvedValue(null),
},
brandSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
billingAccount: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi.fn().mockResolvedValue(null),
},
notificationTemplate: {
findFirst: vi.fn().mockResolvedValue(null),
},
...overrides,
}
}
describe('notificationLocalizationService', () => {
it('resolves locale in recipient -> workspace -> billing -> default order', async () => {
const db = createDbStub({
employee: {
findUnique: vi.fn().mockResolvedValue({ preferredLanguage: 'ar' }),
},
brandSettings: {
findUnique: vi.fn().mockResolvedValue({ defaultLocale: 'fr' }),
},
billingAccount: {
findUnique: vi.fn().mockResolvedValue(null),
findFirst: vi.fn().mockResolvedValue({ preferredLanguage: 'en' }),
},
})
const locale = await resolveNotificationLocale({
companyId: 'company_1',
employeeId: 'employee_1',
}, db as any)
expect(locale).toBe('ar')
})
it('falls back to English when a localized template is missing', async () => {
const db = createDbStub({
notificationTemplate: {
findFirst: vi
.fn()
.mockImplementation(({ where }: any) => {
if (where.locale === 'en') {
return Promise.resolve({
templateKey: 'booking.confirmed',
channel: 'EMAIL',
locale: 'en',
subject: 'Booking confirmed - {{companyName}}',
body: 'Hello {{firstName}}',
requiredVariables: ['companyName', 'firstName'],
optionalVariables: [],
version: 1,
})
}
return Promise.resolve(null)
}),
},
})
const template = await resolveNotificationTemplate({
templateKey: 'booking.confirmed',
channel: 'EMAIL',
locale: 'ar',
variables: {
firstName: 'Aya',
companyName: 'Atlas Cars',
},
}, db as any)
expect(template.locale).toBe('en')
expect(template.usedFallback).toBe(true)
expect(template.subject).toBe('Booking confirmed - Atlas Cars')
expect(template.body).toBe('Hello Aya')
})
it('renders Arabic emails with rtl metadata', () => {
const html = renderLocalizedEmailHtml('مرحبا\n\nتم التأكيد', 'ar')
expect(html).toContain('<html lang="ar" dir="rtl">')
expect(html).toContain('text-align:right')
})
})
@@ -0,0 +1,440 @@
import type { NotificationChannel } from '@rentaldrivego/database'
import { prisma } from '../lib/prisma'
export const SUPPORTED_NOTIFICATION_LOCALES = ['en', 'fr', 'ar'] as const
export type NotificationLocale = (typeof SUPPORTED_NOTIFICATION_LOCALES)[number]
export const DEFAULT_NOTIFICATION_LOCALE: NotificationLocale = 'en'
const localeConfig: Record<NotificationLocale, { intl: string; direction: 'ltr' | 'rtl' }> = {
en: { intl: 'en-US', direction: 'ltr' },
fr: { intl: 'fr-FR', direction: 'ltr' },
ar: { intl: 'ar-MA', direction: 'rtl' },
}
const subscriptionStatusLabels: Record<NotificationLocale, Record<string, string>> = {
en: {
trialing: 'Trial active',
active: 'Active',
payment_pending: 'Payment pending',
past_due: 'Payment overdue',
suspended: 'Suspended',
cancelled: 'Canceled',
canceled: 'Canceled',
expired: 'Expired',
},
fr: {
trialing: 'Essai actif',
active: 'Actif',
payment_pending: 'Paiement en attente',
past_due: 'Paiement en retard',
suspended: 'Suspendu',
cancelled: 'Annule',
canceled: 'Annule',
expired: 'Expire',
},
ar: {
trialing: 'الفترة التجريبية نشطة',
active: 'نشط',
payment_pending: 'الدفع قيد الانتظار',
past_due: 'الدفع متأخر',
suspended: 'معلّق',
cancelled: 'ملغى',
canceled: 'ملغى',
expired: 'منتهي',
},
}
const invoiceStatusLabels: Record<NotificationLocale, Record<string, string>> = {
en: {
draft: 'Preparing',
open: 'Due',
payment_pending: 'Payment processing',
paid: 'Paid',
partially_paid: 'Partially paid',
past_due: 'Past due',
void: 'Canceled',
uncollectible: 'Contact support',
refunded: 'Refunded',
partially_refunded: 'Partially refunded',
},
fr: {
draft: 'En preparation',
open: 'A payer',
payment_pending: 'Paiement en cours',
paid: 'Payee',
partially_paid: 'Partiellement payee',
past_due: 'En retard',
void: 'Annulee',
uncollectible: 'Contacter le support',
refunded: 'Remboursee',
partially_refunded: 'Partiellement remboursee',
},
ar: {
draft: 'قيد الإعداد',
open: 'مستحقة الدفع',
payment_pending: 'الدفع قيد المعالجة',
paid: 'مدفوعة',
partially_paid: 'مدفوعة جزئياً',
past_due: 'متأخرة',
void: 'ملغاة',
uncollectible: 'تواصل مع الدعم',
refunded: 'مستردة',
partially_refunded: 'مستردة جزئياً',
},
}
const billingPeriodLabels: Record<NotificationLocale, Record<string, string>> = {
en: {
monthly: 'monthly',
annual: 'annual',
},
fr: {
monthly: 'mensuel',
annual: 'annuel',
},
ar: {
monthly: 'شهري',
annual: 'سنوي',
},
}
const planLabels: Record<NotificationLocale, Record<string, string>> = {
en: {
starter: 'Starter',
growth: 'Growth',
pro: 'Pro',
},
fr: {
starter: 'Starter',
growth: 'Growth',
pro: 'Pro',
},
ar: {
starter: 'Starter',
growth: 'Growth',
pro: 'Pro',
},
}
type TemplateDateValue = {
type: 'date'
value: Date | string | number
options?: Intl.DateTimeFormatOptions
}
type TemplateCurrencyValue = {
type: 'currency'
amountMinor: number
currency: string
}
type TemplateSubscriptionStatusValue = {
type: 'subscription_status'
status: string
}
type TemplateInvoiceStatusValue = {
type: 'invoice_status'
status: string
}
export type NotificationTemplateValue =
| string
| number
| boolean
| Date
| null
| undefined
| TemplateDateValue
| TemplateCurrencyValue
| TemplateSubscriptionStatusValue
| TemplateInvoiceStatusValue
export type NotificationTemplateVariables = Record<string, NotificationTemplateValue>
type NotificationLocalizationDb = Pick<
typeof prisma,
'brandSettings' | 'billingAccount' | 'employee' | 'notificationTemplate' | 'renter'
>
type NotificationTemplateRecord = {
templateKey: string
channel: NotificationChannel
locale: string
subject: string | null
body: string
requiredVariables: unknown
optionalVariables: unknown
version: number
}
export function coerceNotificationLocale(locale?: string | null): NotificationLocale {
if (locale && SUPPORTED_NOTIFICATION_LOCALES.includes(locale as NotificationLocale)) {
return locale as NotificationLocale
}
return DEFAULT_NOTIFICATION_LOCALE
}
export function getNotificationTextDirection(locale: NotificationLocale) {
return localeConfig[locale].direction
}
export function formatLocalizedDate(
value: Date | string | number,
locale: NotificationLocale,
options?: Intl.DateTimeFormatOptions,
) {
const date = value instanceof Date ? value : new Date(value)
return new Intl.DateTimeFormat(localeConfig[locale].intl, options ?? {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
export function formatLocalizedCurrency(amountMinor: number, currency: string, locale: NotificationLocale) {
return new Intl.NumberFormat(localeConfig[locale].intl, {
style: 'currency',
currency,
}).format(amountMinor / 100)
}
export function localizeSubscriptionStatus(status: string, locale: NotificationLocale) {
const normalized = status.toLowerCase()
return subscriptionStatusLabels[locale][normalized] ?? status
}
export function localizeInvoiceStatus(status: string, locale: NotificationLocale) {
const normalized = status.toLowerCase()
return invoiceStatusLabels[locale][normalized] ?? status
}
export function localizeBillingPeriod(period: string, locale: NotificationLocale) {
const normalized = period.toLowerCase()
return billingPeriodLabels[locale][normalized] ?? period
}
export function localizePlanName(plan: string, locale: NotificationLocale) {
const normalized = plan.toLowerCase()
return planLabels[locale][normalized] ?? plan
}
function isTemplateDateValue(value: NotificationTemplateValue): value is TemplateDateValue {
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'date'
}
function isTemplateCurrencyValue(value: NotificationTemplateValue): value is TemplateCurrencyValue {
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'currency'
}
function isTemplateSubscriptionStatusValue(value: NotificationTemplateValue): value is TemplateSubscriptionStatusValue {
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'subscription_status'
}
function isTemplateInvoiceStatusValue(value: NotificationTemplateValue): value is TemplateInvoiceStatusValue {
return typeof value === 'object' && value !== null && 'type' in value && value.type === 'invoice_status'
}
function formatTemplateValue(value: NotificationTemplateValue, locale: NotificationLocale): string {
if (value instanceof Date) return formatLocalizedDate(value, locale)
if (isTemplateDateValue(value)) return formatLocalizedDate(value.value, locale, value.options)
if (isTemplateCurrencyValue(value)) return formatLocalizedCurrency(value.amountMinor, value.currency, locale)
if (isTemplateSubscriptionStatusValue(value)) return localizeSubscriptionStatus(value.status, locale)
if (isTemplateInvoiceStatusValue(value)) return localizeInvoiceStatus(value.status, locale)
if (value === null || value === undefined) return ''
return String(value)
}
function normalizeTemplateVariableList(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === 'string')
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
async function findTemplate(
db: NotificationLocalizationDb,
templateKey: string,
channel: NotificationChannel,
locale: NotificationLocale,
): Promise<NotificationTemplateRecord | null> {
return db.notificationTemplate.findFirst({
where: { templateKey, channel, locale, isActive: true },
orderBy: { version: 'desc' },
select: {
templateKey: true,
channel: true,
locale: true,
subject: true,
body: true,
requiredVariables: true,
optionalVariables: true,
version: true,
},
})
}
export async function resolveNotificationLocale(input: {
companyId?: string | null
employeeId?: string | null
renterId?: string | null
billingAccountId?: string | null
locale?: string | null
}, db: NotificationLocalizationDb = prisma): Promise<NotificationLocale> {
if (input.locale) return coerceNotificationLocale(input.locale)
const employeePromise = input.employeeId
? db.employee.findUnique({
where: { id: input.employeeId },
select: { preferredLanguage: true },
})
: Promise.resolve(null)
const renterPromise = input.renterId
? db.renter.findUnique({
where: { id: input.renterId },
select: { preferredLocale: true },
})
: Promise.resolve(null)
const workspacePromise = input.companyId
? db.brandSettings.findUnique({
where: { companyId: input.companyId },
select: { defaultLocale: true },
})
: Promise.resolve(null)
const billingPromise = input.billingAccountId
? db.billingAccount.findUnique({
where: { id: input.billingAccountId },
select: { preferredLanguage: true },
})
: input.companyId
? db.billingAccount.findFirst({
where: { companyId: input.companyId, isPrimary: true },
select: { preferredLanguage: true },
orderBy: { createdAt: 'asc' },
})
: Promise.resolve(null)
const [employee, renter, workspace, billingAccount] = await Promise.all([
employeePromise,
renterPromise,
workspacePromise,
billingPromise,
])
return coerceNotificationLocale(
employee?.preferredLanguage ??
renter?.preferredLocale ??
workspace?.defaultLocale ??
billingAccount?.preferredLanguage ??
DEFAULT_NOTIFICATION_LOCALE,
)
}
export async function resolveNotificationTemplate(input: {
templateKey: string
channel: NotificationChannel
locale: NotificationLocale
variables?: NotificationTemplateVariables
}, db: NotificationLocalizationDb = prisma) {
const requested = await findTemplate(db, input.templateKey, input.channel, input.locale)
if (requested) {
return {
locale: coerceNotificationLocale(requested.locale),
subject: renderNotificationText(
requested.subject,
input.variables,
normalizeTemplateVariableList(requested.requiredVariables),
input.locale,
),
body: renderNotificationText(
requested.body,
input.variables,
normalizeTemplateVariableList(requested.requiredVariables),
input.locale,
),
templateKey: requested.templateKey,
usedFallback: false,
}
}
if (input.locale !== DEFAULT_NOTIFICATION_LOCALE) {
console.warn(
`[Notifications] Missing ${input.locale} template for ${input.templateKey}/${input.channel}; falling back to ${DEFAULT_NOTIFICATION_LOCALE}.`,
)
}
const fallback = await findTemplate(db, input.templateKey, input.channel, DEFAULT_NOTIFICATION_LOCALE)
if (!fallback) {
throw new Error(
`Missing notification template for ${input.templateKey}/${input.channel} in ${input.locale} and ${DEFAULT_NOTIFICATION_LOCALE}`,
)
}
return {
locale: DEFAULT_NOTIFICATION_LOCALE,
subject: renderNotificationText(
fallback.subject,
input.variables,
normalizeTemplateVariableList(fallback.requiredVariables),
DEFAULT_NOTIFICATION_LOCALE,
),
body: renderNotificationText(
fallback.body,
input.variables,
normalizeTemplateVariableList(fallback.requiredVariables),
DEFAULT_NOTIFICATION_LOCALE,
),
templateKey: fallback.templateKey,
usedFallback: true,
}
}
export function renderNotificationText(
template: string | null,
variables: NotificationTemplateVariables = {},
requiredVariables: string[] = [],
locale: NotificationLocale,
) {
if (!template) return null
for (const variableName of requiredVariables) {
if (variables[variableName] === undefined || variables[variableName] === null) {
throw new Error(`Missing notification template variable: ${variableName}`)
}
}
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, variableName: string) =>
formatTemplateValue(variables[variableName], locale),
)
}
export function renderLocalizedEmailHtml(body: string, locale: NotificationLocale) {
const direction = getNotificationTextDirection(locale)
const textAlign = direction === 'rtl' ? 'right' : 'left'
const paragraphs = body
.split(/\n{2,}/)
.map((paragraph) => `<p style="margin:0 0 16px;text-align:${textAlign};">${escapeHtml(paragraph).replace(/\n/g, '<br />')}</p>`)
.join('')
return [
`<html lang="${locale}" dir="${direction}">`,
`<body style="font-family:sans-serif;max-width:560px;margin:0 auto;padding:32px;color:#1c1917;direction:${direction};text-align:${textAlign};">`,
paragraphs,
'</body>',
'</html>',
].join('')
}
+44 -26
View File
@@ -4,6 +4,12 @@ import admin from 'firebase-admin'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { redis } from '../lib/redis' import { redis } from '../lib/redis'
import { NotificationType, NotificationChannel } from '@rentaldrivego/database' import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
import {
renderLocalizedEmailHtml,
resolveNotificationLocale,
resolveNotificationTemplate,
} from './notificationLocalizationService'
import type { NotificationTemplateVariables } from './notificationLocalizationService'
const resendApiKey = const resendApiKey =
process.env.RESEND_API_KEY && process.env.RESEND_API_KEY &&
@@ -100,33 +106,20 @@ if (hasFirebaseConfig && !admin.apps.length) {
interface SendNotificationOptions { interface SendNotificationOptions {
type: NotificationType type: NotificationType
title: string title?: string
body: string body?: string
data?: Record<string, unknown> data?: Record<string, unknown>
companyId?: string companyId?: string
employeeId?: string employeeId?: string
renterId?: string renterId?: string
billingAccountId?: string
email?: string email?: string
phone?: string phone?: string
fcmToken?: string fcmToken?: string
channels: NotificationChannel[] channels: NotificationChannel[]
locale?: string locale?: string
} templateKey?: string
templateVariables?: NotificationTemplateVariables
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function renderEmailHtml(body: string) {
return body
.split(/\n{2,}/)
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, '<br />')}</p>`)
.join('')
} }
function resolveSmtpReplyTo() { function resolveSmtpReplyTo() {
@@ -210,16 +203,41 @@ async function sendEmailWithProviders(opts: {
export async function sendNotification(opts: SendNotificationOptions) { export async function sendNotification(opts: SendNotificationOptions) {
const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = [] 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) { for (const channel of opts.channels) {
try { 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({ const notification = await prisma.notification.create({
data: { data: {
type: opts.type, type: opts.type,
title: opts.title, title,
body: opts.body, body,
data: (opts.data ?? {}) as any, data: (opts.data ?? {}) as any,
channel, channel,
templateKey: rendered?.templateKey ?? opts.templateKey ?? null,
locale: rendered?.locale ?? resolvedLocale,
status: 'PENDING', status: 'PENDING',
companyId: opts.companyId ?? null, companyId: opts.companyId ?? null,
employeeId: opts.employeeId ?? null, employeeId: opts.employeeId ?? null,
@@ -233,9 +251,9 @@ export async function sendNotification(opts: SendNotificationOptions) {
if (channel === 'EMAIL' && opts.email) { if (channel === 'EMAIL' && opts.email) {
const emailResult = await sendEmailWithProviders({ const emailResult = await sendEmailWithProviders({
to: opts.email, to: opts.email,
subject: opts.title, subject: title,
html: renderEmailHtml(opts.body), html: renderLocalizedEmailHtml(body, rendered?.locale ?? resolvedLocale),
text: opts.body, text: body,
}) })
providerMessageId = emailResult.providerMessageId providerMessageId = emailResult.providerMessageId
success = true success = true
@@ -244,7 +262,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
if (channel === 'SMS' && opts.phone) { if (channel === 'SMS' && opts.phone) {
if (!twilioClient) throw new Error('Twilio is not configured') if (!twilioClient) throw new Error('Twilio is not configured')
const msg = await twilioClient.messages.create({ const msg = await twilioClient.messages.create({
body: opts.body, body,
from: process.env.TWILIO_PHONE_NUMBER!, from: process.env.TWILIO_PHONE_NUMBER!,
to: opts.phone, to: opts.phone,
}) })
@@ -255,7 +273,7 @@ export async function sendNotification(opts: SendNotificationOptions) {
if (channel === 'WHATSAPP' && opts.phone) { if (channel === 'WHATSAPP' && opts.phone) {
if (!twilioClient) throw new Error('Twilio is not configured') if (!twilioClient) throw new Error('Twilio is not configured')
const msg = await twilioClient.messages.create({ const msg = await twilioClient.messages.create({
body: opts.body, body,
from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`, from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`,
to: `whatsapp:${opts.phone}`, 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') if (!admin.apps.length) throw new Error('Firebase is not configured')
const response = await admin.messaging().send({ const response = await admin.messaging().send({
token: opts.fcmToken, token: opts.fcmToken,
notification: { title: opts.title, body: opts.body }, notification: { title, body },
data: Object.fromEntries( data: Object.fromEntries(
Object.entries(opts.data ?? {}).map(([k, v]) => [k, String(v)]) Object.entries(opts.data ?? {}).map(([k, v]) => [k, String(v)])
), ),
+51 -3
View File
@@ -2,6 +2,7 @@ import crypto from 'crypto'
import { EmployeeRole } from '@rentaldrivego/database' import { EmployeeRole } from '@rentaldrivego/database'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { sendTransactionalEmail } from './notificationService' import { sendTransactionalEmail } from './notificationService'
import { coerceNotificationLocale } from './notificationLocalizationService'
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7 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' 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 { return {
subject: `You have been invited to ${companyName}`, subject: `You have been invited to ${companyName}`,
html: ` 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' }) 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 rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000) const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000)
const locale = coerceNotificationLocale(company.brand?.defaultLocale)
const employee = await prisma.employee.create({ const employee = await prisma.employee.create({
data: { data: {
@@ -91,6 +132,7 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
email: payload.email, email: payload.email,
role: payload.role, role: payload.role,
isActive: true, isActive: true,
preferredLanguage: locale,
passwordResetToken: rawToken, passwordResetToken: rawToken,
passwordResetExpiresAt: expiresAt, 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', process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
) )
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}` 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({ await sendTransactionalEmail({
to: payload.email, to: payload.email,
@@ -9,8 +9,13 @@ type NotificationItem = {
type: string type: string
title: string title: string
body: string body: string
channel: string
status: string
sentAt: string | null
readAt: string | null readAt: string | null
createdAt: string createdAt: string
providerMessageId: string | null
locale: string
} }
type PreferenceItem = { type PreferenceItem = {
@@ -30,19 +35,42 @@ const COMPANY_EVENTS = [
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH'] 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() { export default function DashboardNotificationsPage() {
const { language } = useDashboardI18n() const { language } = useDashboardI18n()
const [notifications, setNotifications] = useState<NotificationItem[]>([]) const [notifications, setNotifications] = useState<NotificationItem[]>([])
const [history, setHistory] = useState<NotificationItem[]>([])
const [preferences, setPreferences] = useState<Record<string, boolean>>({}) 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 [loading, setLoading] = useState(true)
const [historyLoading, setHistoryLoading] = useState(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [filterChannel, setFilterChannel] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const copy = { const copy = {
en: { en: {
title: 'Notifications', 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', inbox: 'Inbox',
history: 'History',
preferences: 'Preferences', preferences: 'Preferences',
markAllRead: 'Mark all as read', markAllRead: 'Mark all as read',
loading: 'Loading notifications…', loading: 'Loading notifications…',
@@ -50,24 +78,35 @@ export default function DashboardNotificationsPage() {
read: 'Read', read: 'Read',
markRead: 'Mark as read', markRead: 'Mark as read',
event: 'Event', event: 'Event',
channel: 'Channel',
status: 'Status',
sentAt: 'Sent at',
date: 'Date',
allChannels: 'All channels',
allStatuses: 'All statuses',
savePreferences: 'Save preferences', savePreferences: 'Save preferences',
saving: 'Saving…', saving: 'Saving…',
failedLoad: 'Failed to load notifications', failedLoad: 'Failed to load notifications',
failedSave: 'Failed to save preferences', failedSave: 'Failed to save preferences',
noHistory: 'No notification history found.',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record<string, string>, 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: { events: {
NEW_RESERVATION: 'New reservation', NEW_RESERVATION: 'New reservation',
RESERVATION_CANCELLED: 'Reservation cancelled', RESERVATION_CANCELLED: 'Reservation cancelled',
PAYMENT_RECEIVED: 'Payment received', PAYMENT_RECEIVED: 'Payment received',
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending', SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
MAINTENANCE_DUE: 'Maintenance à prévoir', MAINTENANCE_DUE: 'Maintenance due',
OFFER_EXPIRING: 'Offer expiring', OFFER_EXPIRING: 'Offer expiring',
BOOKING_CONFIRMED: 'Booking confirmed',
VEHICLE_MAINTENANCE_DUE: 'Vehicle maintenance due',
} as Record<string, string>, } as Record<string, string>,
}, },
fr: { fr: {
title: 'Notifications', title: 'Notifications',
subtitle: 'Suivez les alertes de lapplication 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', inbox: 'Boîte de réception',
history: 'Historique',
preferences: 'Préférences', preferences: 'Préférences',
markAllRead: 'Tout marquer comme lu', markAllRead: 'Tout marquer comme lu',
loading: 'Chargement des notifications', loading: 'Chargement des notifications',
@@ -75,24 +114,35 @@ export default function DashboardNotificationsPage() {
read: 'Lu', read: 'Lu',
markRead: 'Marquer comme lu', markRead: 'Marquer comme lu',
event: 'Événement', 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', savePreferences: 'Enregistrer les préférences',
saving: 'Enregistrement', saving: 'Enregistrement',
failedLoad: 'Échec du chargement des notifications', failedLoad: 'Échec du chargement des notifications',
failedSave: 'Échec de lenregistrement des préférences', failedSave: 'Échec de l'enregistrement des préférences',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans lapplication', PUSH: 'Push' } as Record<string, string>, 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: { events: {
NEW_RESERVATION: 'Nouvelle réservation', NEW_RESERVATION: 'Nouvelle réservation',
RESERVATION_CANCELLED: 'Réservation annulée', RESERVATION_CANCELLED: 'Réservation annulée',
PAYMENT_RECEIVED: 'Paiement reçu', PAYMENT_RECEIVED: 'Paiement reçu',
SUBSCRIPTION_TRIAL_ENDING: 'Fin dessai proche', SUBSCRIPTION_TRIAL_ENDING: 'Fin d'essai proche',
MAINTENANCE_DUE: 'Maintenance due', MAINTENANCE_DUE: 'Maintenance due',
OFFER_EXPIRING: 'Offre expirante', OFFER_EXPIRING: 'Offre expirante',
BOOKING_CONFIRMED: 'Réservation confirmée',
VEHICLE_MAINTENANCE_DUE: 'Maintenance véhicule due',
} as Record<string, string>, } as Record<string, string>,
}, },
ar: { ar: {
title: 'الإشعارات', title: 'الإشعارات',
subtitle: 'تابع تنبيهات التطبيق وتحكم في تفضيلات الإرسال لحساب فريقك.', subtitle: 'تابع التنبيهات وراجع سجل الإرسال وتحكم في التفضيلات لحساب فريقك.',
inbox: 'صندوق الوارد', inbox: 'صندوق الوارد',
history: 'السجل',
preferences: 'التفضيلات', preferences: 'التفضيلات',
markAllRead: 'تحديد الكل كمقروء', markAllRead: 'تحديد الكل كمقروء',
loading: 'جارٍ تحميل الإشعارات…', loading: 'جارٍ تحميل الإشعارات…',
@@ -100,11 +150,19 @@ export default function DashboardNotificationsPage() {
read: 'مقروء', read: 'مقروء',
markRead: 'تحديد كمقروء', markRead: 'تحديد كمقروء',
event: 'الحدث', event: 'الحدث',
channel: 'القناة',
status: 'الحالة',
sentAt: 'وقت الإرسال',
date: 'التاريخ',
allChannels: 'جميع القنوات',
allStatuses: 'جميع الحالات',
savePreferences: 'حفظ التفضيلات', savePreferences: 'حفظ التفضيلات',
saving: 'جارٍ الحفظ…', saving: 'جارٍ الحفظ…',
failedLoad: 'فشل تحميل الإشعارات', failedLoad: 'فشل تحميل الإشعارات',
failedSave: 'فشل حفظ التفضيلات', failedSave: 'فشل حفظ التفضيلات',
noHistory: 'لا يوجد سجل إشعارات.',
channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>, channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>,
statuses: { PENDING: 'قيد الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', READ: 'مقروء' } as Record<string, string>,
events: { events: {
NEW_RESERVATION: 'حجز جديد', NEW_RESERVATION: 'حجز جديد',
RESERVATION_CANCELLED: 'إلغاء حجز', RESERVATION_CANCELLED: 'إلغاء حجز',
@@ -112,6 +170,8 @@ export default function DashboardNotificationsPage() {
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة', SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
MAINTENANCE_DUE: 'صيانة مستحقة', MAINTENANCE_DUE: 'صيانة مستحقة',
OFFER_EXPIRING: 'عرض على وشك الانتهاء', OFFER_EXPIRING: 'عرض على وشك الانتهاء',
BOOKING_CONFIRMED: 'تأكيد الحجز',
VEHICLE_MAINTENANCE_DUE: 'صيانة المركبة مستحقة',
} as Record<string, string>, } as Record<string, string>,
}, },
}[language] }[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(() => { useEffect(() => {
load() load()
}, []) }, [])
useEffect(() => {
if (activeTab === 'history') {
loadHistory()
}
}, [activeTab, filterChannel, filterStatus])
async function markRead(id: string) { async function markRead(id: string) {
await apiFetch(`/notifications/company/${id}/read`, { method: 'POST' }) await apiFetch(`/notifications/company/${id}/read`, { method: 'POST' })
setNotifications((current) => setNotifications((current) =>
@@ -184,12 +268,16 @@ export default function DashboardNotificationsPage() {
})) }))
} }
function labelEvent(type: string) {
return copy.events[type] ?? type.replaceAll('_', ' ')
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold text-slate-900">{copy.title}</h1> <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">{copy.subtitle}</p> <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{copy.subtitle}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@@ -199,6 +287,13 @@ export default function DashboardNotificationsPage() {
> >
{copy.inbox} {copy.inbox}
</button> </button>
<button
type="button"
onClick={() => setActiveTab('history')}
className={activeTab === 'history' ? 'btn-primary' : 'btn-secondary'}
>
{copy.history}
</button>
<button <button
type="button" type="button"
onClick={() => setActiveTab('preferences')} onClick={() => setActiveTab('preferences')}
@@ -209,8 +304,9 @@ export default function DashboardNotificationsPage() {
</div> </div>
</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' ? ( {activeTab === 'inbox' ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-end"> <div className="flex justify-end">
@@ -225,34 +321,118 @@ export default function DashboardNotificationsPage() {
<article key={notification.id} className="card p-6"> <article key={notification.id} className="card p-6">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500"> <p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{copy.events[notification.type] ?? notification.type.replaceAll('_', ' ')} {labelEvent(notification.type)}
</p> </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> </div>
{notification.readAt ? ( {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"> <button type="button" onClick={() => markRead(notification.id)} className="btn-secondary">
{copy.markRead} {copy.markRead}
</button> </button>
)} )}
</div> </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> <p className="mt-4 text-xs text-slate-400">{new Date(notification.createdAt).toLocaleString()}</p>
</article> </article>
)) ))
: null} : null}
</div> </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="card overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="bg-slate-50"> <thead className="bg-slate-50 dark:bg-[#0d1b38]">
<tr> <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) => ( {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('_', ' ')} {copy.channels[channel] ?? channel.replace('_', ' ')}
</th> </th>
))} ))}
@@ -260,8 +440,8 @@ export default function DashboardNotificationsPage() {
</thead> </thead>
<tbody> <tbody>
{COMPANY_EVENTS.map((eventName) => ( {COMPANY_EVENTS.map((eventName) => (
<tr key={eventName} className="border-t border-slate-100"> <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">{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}</td> <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) => ( {COMPANY_CHANNELS.map((channel) => (
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center"> <td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
<input <input
@@ -277,13 +457,13 @@ export default function DashboardNotificationsPage() {
</tbody> </tbody>
</table> </table>
</div> </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"> <button type="button" onClick={savePreferences} disabled={saving} className="btn-primary">
{saving ? copy.saving : copy.savePreferences} {saving ? copy.saving : copy.savePreferences}
</button> </button>
</div> </div>
</div> </div>
)} ) : null}
</div> </div>
) )
} }
@@ -8,7 +8,7 @@ import { useDashboardI18n } from '@/components/I18nProvider'
import PublicShell from '@/components/layout/PublicShell' import PublicShell from '@/components/layout/PublicShell'
import { adminUrl, marketplaceUrl } from '@/lib/urls' import { adminUrl, marketplaceUrl } from '@/lib/urls'
import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api' 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' const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
@@ -209,7 +209,6 @@ function LocalSignInForm({
unexpectedError: string unexpectedError: string
} }
}) { }) {
const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { setLanguage } = useDashboardI18n() const { setLanguage } = useDashboardI18n()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@@ -221,7 +220,6 @@ function LocalSignInForm({
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const adminNext = searchParams.get('next') || '/dashboard' const adminNext = searchParams.get('next') || '/dashboard'
const employeeRedirect = searchParams.get('redirect') || '/dashboard' const employeeRedirect = searchParams.get('redirect') || '/dashboard'
const employeeAppRedirect = toDashboardAppPath(employeeRedirect)
function redirectAdmin(token: string) { function redirectAdmin(token: string) {
const hash = new URLSearchParams({ token, next: adminNext }).toString() const hash = new URLSearchParams({ token, next: adminNext }).toString()
@@ -243,6 +241,7 @@ function LocalSignInForm({
if (empRes.ok && empJson?.data?.token) { if (empRes.ok && empJson?.data?.token) {
const token = empJson.data.token const token = empJson.data.token
const targetPath = toPublicDashboardPath(employeeRedirect)
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token) localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
if (empJson?.data?.employee) { if (empJson?.data?.employee) {
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(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` document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed')) window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-login', path: toPublicDashboardPath(employeeRedirect) }) notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath })
router.push(employeeAppRedirect) // 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 return
} }
@@ -3,6 +3,7 @@
import { Bell } from 'lucide-react' import { Bell } from 'lucide-react'
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { io } from 'socket.io-client'
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api' import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider' import { useDashboardI18n } from '@/components/I18nProvider'
import { toDashboardAppPath } from '@/lib/dashboardPaths' import { toDashboardAppPath } from '@/lib/dashboardPaths'
@@ -34,6 +35,11 @@ export default function TopBar() {
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, []) useEffect(() => { setMounted(true) }, [])
function getEmployeeToken() {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
}
const title = (() => { const title = (() => {
if (!mounted) return dict.titles['/'] if (!mounted) return dict.titles['/']
if (dict.titles[appPath]) return dict.titles[appPath] if (dict.titles[appPath]) return dict.titles[appPath]
@@ -42,6 +48,11 @@ export default function TopBar() {
return dict.titles['/'] return dict.titles['/']
})() })()
async function refreshUnreadCount() { async function refreshUnreadCount() {
if (!getEmployeeToken()) {
setUnreadCount(0)
return
}
try { try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count') const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
setUnreadCount(data.unread) setUnreadCount(data.unread)
@@ -60,8 +71,29 @@ export default function TopBar() {
return () => window.removeEventListener('notifications:updated', onNotificationsUpdated) 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(() => { useEffect(() => {
if (!showNotifs) return if (!showNotifs) return
if (!getEmployeeToken()) {
setNotifications([])
setLoadingNotifs(false)
return
}
let cancelled = false let cancelled = false
setLoadingNotifs(true) setLoadingNotifs(true)
apiFetch<Array<{ apiFetch<Array<{
+11
View File
@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../car_rental_app"
},
{
"path": "."
}
],
"settings": {}
}
-558
View File
@@ -1,558 +0,0 @@
# Contrat de Location de Véhicule
**Contrat n° :** [●]
**Date de signature :** [●]
**Lieu de signature :** [Ville, Maroc]
---
## 1. Parties au contrat
Entre les soussignés :
### Le Loueur / Agence
**Nom commercial :** [●]
**Raison sociale :** [●]
**Forme juridique :** [●]
**RC n° :** [●]
**ICE n° :** [●]
**IF n° :** [●]
**Adresse :** [●]
**Téléphone :** [●]
**Email :** [●]
**Représenté par :** [Nom, prénom, fonction]
Ci-après désigné **« le Loueur »**,
Et :
### Le Locataire / Conducteur principal
**Nom :** [●]
**Prénom :** [●]
**Date de naissance :** [●]
**Nationalité :** [●]
**CIN / Passeport n° :** [●]
**Adresse complète :** [●]
**Téléphone :** [●]
**Email :** [●]
Ci-après désigné **« le Locataire »**.
Le Loueur et le Locataire sont ensemble désignés **« les Parties »**.
---
## 2. Permis de conduire
Le Locataire déclare être titulaire dun permis de conduire valide :
**Numéro du permis :** [●]
**Catégorie :** [B / autre]
**Date de délivrance :** [●]
**Date dexpiration :** [●]
**Pays de délivrance :** [●]
**Permis international, le cas échéant :** [Oui / Non, n° ●]
Le Locataire garantit que son permis est valide pendant toute la durée de location et quil nest frappé daucune suspension, annulation ou restriction incompatible avec la conduite du véhicule loué.
---
## 3. Conducteur(s) autorisé(s)
Le véhicule ne peut être conduit que par le Locataire et, le cas échéant, par les conducteurs additionnels expressément mentionnés ci-dessous :
### Conducteur additionnel 1
**Nom et prénom :** [●]
**CIN / Passeport :** [●]
**Permis n° :** [●]
**Catégorie :** [●]
**Date de délivrance :** [●]
**Téléphone :** [●]
Tout conducteur non déclaré est interdit. En cas daccident, vol, infraction ou dommage causé par un conducteur non autorisé, le Locataire assume lentière responsabilité des conséquences financières, civiles, pénales et assurantielles.
---
## 4. Véhicule loué
Le Loueur met à disposition du Locataire le véhicule suivant :
**Marque :** [●]
**Modèle :** [●]
**Année :** [●]
**Couleur :** [●]
**Immatriculation :** [●]
**Numéro de châssis :** [●]
**Kilométrage au départ :** [●] km
**Niveau de carburant au départ :** [●]
**Carte grise :** [Oui / Non]
**Assurance :** [Oui / Non]
**Visite technique :** [Oui / Non / Non applicable]
Un état des lieux contradictoire au départ et au retour est annexé au présent contrat et en fait partie intégrante.
---
## 5. Durée de location
La location commence le :
**Date :** [●]
**Heure :** [●]
**Lieu de prise en charge :** [●]
La location prend fin le :
**Date :** [●]
**Heure :** [●]
**Lieu de restitution :** [●]
Toute prolongation doit être acceptée préalablement par écrit par le Loueur. À défaut, le véhicule sera considéré comme conservé sans autorisation, et le Loueur pourra facturer les jours supplémentaires, pénalités de retard et frais éventuels.
---
## 6. Prix de location et modalités de paiement
Le prix de location est fixé comme suit :
| Désignation | Montant |
|---|---:|
| Tarif journalier HT | [●] MAD |
| Nombre de jours | [●] |
| Sous-total HT | [●] MAD |
| TVA applicable | [●] % |
| Montant TVA | [●] MAD |
| Total TTC | [●] MAD |
| Conducteur additionnel | [●] MAD |
| Livraison / récupération | [●] MAD |
| Siège enfant | [●] MAD |
| GPS / accessoires | [●] MAD |
| Autres frais | [●] MAD |
**Montant total à payer TTC : [●] MAD**
**Mode de paiement :** [Espèces / carte bancaire / virement / chèque / autre]
**Date de paiement :** [●]
**Référence de paiement :** [●]
Le Locataire reconnaît avoir reçu une information claire sur le prix total TTC avant la signature du contrat.
---
## 7. Dépôt de garantie / caution
Le Locataire verse au Loueur un dépôt de garantie de :
**Montant : [●] MAD**
**Forme de la caution :** [Préautorisation bancaire / espèces / chèque / autre]
**Date de constitution :** [●]
**Conditions de libération :** [●]
Le dépôt de garantie garantit notamment :
- les dommages non couverts par lassurance ;
- la franchise dassurance ;
- les kilomètres supplémentaires ;
- le carburant manquant ;
- les frais de nettoyage exceptionnel ;
- les contraventions, amendes, péages ou frais administratifs ;
- les retards de restitution ;
- la perte de documents, clés, accessoires ou équipements.
La caution sera restituée après vérification de l’état du véhicule, du kilométrage, du carburant, des éventuelles infractions et des sommes restant dues.
---
## 8. Assurance
Le véhicule est assuré auprès de :
**Compagnie dassurance :** [●]
**Numéro de police :** [●]
**Type de couverture :** [Responsabilité civile / Tous risques / autre]
**Franchise applicable :** [●] MAD
**Assistance :** [Oui / Non]
**Numéro dassistance :** [●]
Le Locataire reconnaît avoir été informé des garanties, exclusions et franchises applicables.
Sont notamment exclus de la couverture, sauf stipulation contraire de lassureur :
- conduite par une personne non autorisée ;
- conduite sans permis valide ;
- conduite sous lemprise dalcool, stupéfiants ou substances interdites ;
- usage du véhicule hors des voies autorisées ;
- participation à des courses, essais ou compétitions ;
- négligence grave ;
- fausse déclaration ;
- absence de déclaration daccident dans les délais ;
- vol avec clés laissées dans le véhicule ;
- transport illicite de personnes ou de marchandises.
---
## 9. Kilométrage
**Kilométrage inclus :** [●] km par jour / [●] km pour toute la durée de location.
**Kilométrage au départ :** [●] km.
**Kilométrage au retour :** [●] km.
Tout kilomètre supplémentaire sera facturé au tarif de :
**[●] MAD TTC par kilomètre supplémentaire.**
Le kilométrage fait foi sur la base du compteur du véhicule, constaté dans l’état des lieux de départ et de retour.
---
## 10. Utilisation du véhicule
Le Locataire sengage à utiliser le véhicule avec prudence, conformément à sa destination normale et à la législation marocaine.
Il est interdit notamment :
- de sous-louer le véhicule ;
- de transporter des marchandises dangereuses ou illicites ;
- dutiliser le véhicule pour des activités illégales ;
- de participer à des compétitions ou essais sportifs ;
- de tracter un autre véhicule sans autorisation écrite ;
- de circuler hors du territoire marocain sans autorisation écrite du Loueur ;
- de modifier le véhicule ou ses équipements ;
- de fumer dans le véhicule si lagence linterdit ;
- de transporter un nombre de passagers supérieur à celui autorisé.
Le Locataire est responsable des infractions au Code de la route commises pendant la période de location.
---
## 11. Restitution du véhicule
Le Locataire doit restituer le véhicule à la date, à lheure et au lieu prévus au contrat, dans l’état où il lui a été remis, sous réserve de lusure normale.
Le véhicule doit être restitué avec :
- le même niveau de carburant quau départ ;
- les papiers du véhicule ;
- les clés ;
- les accessoires et équipements remis ;
- un état de propreté normal ;
- aucun dommage nouveau non déclaré.
En cas de retard, le Loueur pourra facturer :
- [●] MAD par heure de retard ; ou
- une journée supplémentaire au tarif contractuel au-delà de [●] heures de retard.
En cas de carburant manquant, les frais seront facturés selon le coût réel augmenté de frais de service de [●] MAD.
En cas de salissure excessive, le Loueur pourra facturer des frais de nettoyage de [●] MAD à [●] MAD selon l’état du véhicule.
---
## 12. État des lieux départ et retour
Les Parties établissent un état des lieux au départ et au retour. Celui-ci peut être accompagné de photographies datées du véhicule, du compteur, du carburant, de la carrosserie, de lintérieur, des pneus, des vitres et des équipements.
Les éléments à contrôler comprennent notamment :
- Kilométrage compteur ;
- Niveau de carburant ;
- Carrosserie ;
- Pare-chocs ;
- Rétroviseurs ;
- Phares et feux ;
- Pare-brise et vitres ;
- Pneus et jantes ;
- Intérieur et sièges ;
- Tableau de bord ;
- Roue de secours ;
- Outillage ;
- Documents du véhicule ;
- Clés et accessoires.
Tout dommage constaté au retour et non mentionné dans l’état des lieux de départ pourra être facturé au Locataire, sous réserve des garanties dassurance applicables.
---
## 13. Accident, panne, vol ou sinistre
En cas daccident, panne, vol, tentative de vol, incendie ou tout autre sinistre, le Locataire doit immédiatement :
- informer le Loueur par téléphone et par écrit ;
- prévenir les autorités compétentes si nécessaire ;
- établir un constat amiable en cas daccident ;
- obtenir un procès-verbal ou document officiel en cas de vol, blessure, délit ou dommage important ;
- ne pas abandonner le véhicule sans autorisation du Loueur ;
- ne pas reconnaître de responsabilité sans accord préalable du Loueur ou de lassureur.
Le Locataire doit transmettre au Loueur tous les documents utiles dans un délai maximum de **48 heures** à compter du sinistre.
En cas de non-respect de cette procédure, le Locataire pourra être tenu responsable des conséquences financières, notamment si lassureur refuse sa garantie.
---
## 14. Responsabilité du Locataire
Le Locataire est responsable :
- des dommages causés au véhicule pendant la période de location ;
- des dommages causés aux tiers dans les limites prévues par la loi et lassurance ;
- des amendes, contraventions, frais de fourrière, péages et frais administratifs ;
- des frais liés à une mauvaise utilisation du véhicule ;
- des pertes de clés, documents ou accessoires ;
- des dommages non couverts par lassurance ;
- de la franchise prévue au contrat dassurance.
Le Locataire reste responsable jusqu’à la restitution effective du véhicule au Loueur et la signature de l’état des lieux de retour.
---
## 15. Infractions, amendes et frais administratifs
Toutes les infractions commises pendant la durée de location sont à la charge du Locataire.
Le Loueur pourra communiquer lidentité du Locataire aux autorités compétentes et facturer au Locataire :
- le montant des amendes ;
- les frais de dossier ;
- les frais de fourrière ;
- les frais de notification ;
- tout coût lié au traitement administratif de linfraction.
**Frais administratifs par infraction : [●] MAD TTC.**
---
## 16. Annulation, non-présentation et résiliation
En cas dannulation par le Locataire :
- plus de [●] heures avant le départ : [conditions de remboursement] ;
- moins de [●] heures avant le départ : [conditions] ;
- non-présentation : [conditions].
Le Loueur peut résilier immédiatement le contrat, sans indemnité pour le Locataire, en cas de :
- fausse déclaration ;
- permis invalide ;
- défaut de paiement ;
- utilisation interdite du véhicule ;
- conduite dangereuse ;
- non-respect grave du présent contrat ;
- suspicion légitime de fraude ou dusage illicite.
Dans ce cas, le Locataire doit restituer immédiatement le véhicule.
---
## 17. Données personnelles
Le Locataire autorise le Loueur à collecter et conserver les données nécessaires à lexécution du présent contrat, notamment son identité, ses coordonnées, son permis de conduire, les informations de paiement, les documents liés au véhicule, les états des lieux et les données relatives aux éventuels incidents.
Ces données sont utilisées pour :
- lexécution du contrat ;
- la facturation ;
- la gestion des sinistres ;
- la gestion des infractions ;
- les obligations comptables, fiscales et légales ;
- la défense des droits du Loueur en cas de litige.
Le Loueur sengage à protéger les données personnelles du Locataire, à limiter leur accès aux personnes habilitées et à les conserver uniquement pendant la durée nécessaire aux finalités prévues et aux obligations légales applicables.
---
## 18. Documents annexés
Les documents suivants sont annexés au présent contrat :
- Copie CIN ou passeport du Locataire ;
- Copie du permis de conduire ;
- Copie permis international, le cas échéant ;
- État des lieux de départ ;
- État des lieux de retour ;
- Photos du véhicule au départ ;
- Photos du véhicule au retour ;
- Copie de la carte grise ;
- Copie de lattestation dassurance ;
- Reçu de paiement ;
- Justificatif de dépôt de garantie ;
- Conditions générales de location, le cas échéant.
Les annexes font partie intégrante du présent contrat.
---
## 19. Loi applicable et juridiction compétente
Le présent contrat est soumis au droit marocain.
En cas de litige relatif à sa validité, son interprétation, son exécution ou sa résiliation, les Parties sefforceront de trouver une solution amiable.
À défaut daccord amiable, le litige sera porté devant le tribunal compétent du ressort de :
**[Ville du siège de lagence / tribunal compétent]**
---
## 20. Déclaration finale
Le Locataire déclare :
- avoir lu et compris le présent contrat ;
- avoir reçu toutes les informations utiles avant signature ;
- avoir vérifié l’état du véhicule au départ ;
- être titulaire dun permis valide ;
- sengager à respecter les conditions du présent contrat ;
- accepter les prix, caution, franchises, pénalités et frais indiqués.
**Fait à :** [●]
**Le :** [●]
**En deux exemplaires originaux.**
Chaque page du présent contrat doit être paraphée par les deux Parties.
### Signature du Loueur
**Nom :** [●]
**Signature et cachet :**
<br><br>
### Signature du Locataire
**Nom :** [●]
**Signature :**
<br><br>
---
# Annexe 1 — État des lieux de départ
**Contrat n° :** [●]
**Date :** [●]
**Heure :** [●]
**Lieu :** [●]
**Véhicule :** [Marque / Modèle]
**Immatriculation :** [●]
**Kilométrage départ :** [●] km
**Carburant départ :** [●]
| Élément | État au départ | Observations |
|---|---|---|
| Carrosserie avant | Bon / Rayé / Endommagé | [●] |
| Carrosserie arrière | Bon / Rayé / Endommagé | [●] |
| Côté droit | Bon / Rayé / Endommagé | [●] |
| Côté gauche | Bon / Rayé / Endommagé | [●] |
| Pare-brise | Bon / Impact / Fissure | [●] |
| Vitres | Bon / Endommagé | [●] |
| Pneus | Bon / Usé / Endommagé | [●] |
| Jantes | Bon / Rayé / Endommagé | [●] |
| Intérieur | Bon / Taché / Endommagé | [●] |
| Sièges | Bon / Taché / Déchiré | [●] |
| Tableau de bord | Bon / Endommagé | [●] |
| Climatisation | Fonctionne / Ne fonctionne pas | [●] |
| Feux | Fonctionnent / Défaut | [●] |
| Roue de secours | Présente / Absente | [●] |
| Outillage | Présent / Absent | [●] |
| Carte grise | Présente / Absente | [●] |
| Assurance | Présente / Absente | [●] |
| Clés | [Nombre] | [●] |
**Photos prises au départ :** Oui / Non
**Nombre de photos :** [●]
**Signature du Loueur :**
<br><br>
**Signature du Locataire :**
<br><br>
---
# Annexe 2 — État des lieux de retour
**Contrat n° :** [●]
**Date :** [●]
**Heure :** [●]
**Lieu :** [●]
**Kilométrage retour :** [●] km
**Kilométrage parcouru :** [●] km
**Kilométrage inclus :** [●] km
**Kilométrage supplémentaire :** [●] km
**Montant km supplémentaires :** [●] MAD
**Carburant retour :** [●]
**Carburant manquant :** Oui / Non
**Montant carburant :** [●] MAD
| Élément | État au retour | Nouveau dommage ? | Observations |
|---|---|---|---|
| Carrosserie avant | Bon / Rayé / Endommagé | Oui / Non | [●] |
| Carrosserie arrière | Bon / Rayé / Endommagé | Oui / Non | [●] |
| Côté droit | Bon / Rayé / Endommagé | Oui / Non | [●] |
| Côté gauche | Bon / Rayé / Endommagé | Oui / Non | [●] |
| Pare-brise | Bon / Impact / Fissure | Oui / Non | [●] |
| Vitres | Bon / Endommagé | Oui / Non | [●] |
| Pneus | Bon / Usé / Endommagé | Oui / Non | [●] |
| Jantes | Bon / Rayé / Endommagé | Oui / Non | [●] |
| Intérieur | Bon / Taché / Endommagé | Oui / Non | [●] |
| Sièges | Bon / Taché / Déchiré | Oui / Non | [●] |
| Documents | Complets / Manquants | Oui / Non | [●] |
| Clés | Restituées / Manquantes | Oui / Non | [●] |
## Frais constatés au retour
| Frais | Montant |
|---|---:|
| Dommages | [●] MAD |
| Franchise assurance | [●] MAD |
| Kilométrage supplémentaire | [●] MAD |
| Carburant | [●] MAD |
| Nettoyage | [●] MAD |
| Retard | [●] MAD |
| Autres frais | [●] MAD |
**Total à retenir sur la caution : [●] MAD**
**Solde de caution à restituer : [●] MAD**
**Photos prises au retour :** Oui / Non
**Nombre de photos :** [●]
**Signature du Loueur :**
<br><br>
**Signature du Locataire :**
<br><br>
---
# Checklist pratique avant signature
- Ne laisser aucun champ `[●]` vide dans la version signée.
- Faire parapher chaque page par les deux Parties.
- Joindre la copie CIN ou passeport du Locataire.
- Joindre la copie du permis de conduire.
- Photographier le compteur et le niveau de carburant au départ et au retour.
- Photographier toutes les faces du véhicule au départ et au retour.
- Écrire clairement le montant de la caution.
- Mentionner la compagnie dassurance, le numéro de police et la franchise.
- Conserver une preuve de paiement.
- Conserver les états des lieux signés.
---
# Note importante
Ce modèle est un document pratique destiné à structurer une location de voiture au Maroc. Il doit être adapté à lactivité réelle du Loueur, à ses conditions dassurance, à son statut fiscal et aux règles applicables. Pour un usage commercial régulier, une validation par un juriste ou avocat marocain est recommandée.
-634
View File
@@ -1,634 +0,0 @@
# Contrat de Location de Véhicule
**Contrat n° :** [●]
**Date de signature :** [●]
**Lieu de signature :** [Ville, Maroc]
---
## 1. Parties au contrat
Entre les soussignés :
### Le Loueur / Agence
**Nom commercial :** [●]
**Raison sociale :** [●]
**Forme juridique :** [●]
**RC n° :** [●]
**ICE n° :** [●]
**IF n° :** [●]
**Agrément / Autorisation dexercice n° :** [●]
**Date de délivrance de lagrément :** [●]
**Autorité délivrante :** [●]
**Adresse :** [●]
**Téléphone :** [●]
**Email :** [●]
**Représenté par :** [Nom, prénom, fonction]
Ci-après désigné **« le Loueur »**,
Et :
### Le Locataire / Conducteur principal
**Nom :** [●]
**Prénom :** [●]
**Date de naissance :** [●]
**Nationalité :** [●]
**CIN / Passeport n° :** [●]
**Adresse complète :** [●]
**Téléphone :** [●]
**Email :** [●]
Ci-après désigné **« le Locataire »**.
Le Loueur et le Locataire sont ensemble désignés **« les Parties »**.
---
## 2. Responsable légal et conformité réglementaire du Loueur
Le Loueur déclare exercer lactivité de location de véhicules sans chauffeur conformément à la réglementation marocaine applicable.
La personne responsable de lactivité est :
**Nom et prénom :** [●]
**Qualité :** [Dirigeant / Actionnaire / Salarié]
**Pièce didentité n° :** [●]
**Qualification / diplôme / expérience :** [●]
**Téléphone :** [●]
**Email :** [●]
Cette personne est responsable du suivi des contrats, de lentretien des véhicules, de leur conformité aux normes de sécurité routière et du respect des obligations administratives applicables.
Le Loueur déclare notamment :
- disposer des autorisations administratives requises pour lexercice de lactivité ;
- être régulièrement immatriculé auprès des administrations compétentes ;
- disposer dun siège social ou local professionnel déclaré ;
- respecter les obligations sociales, fiscales et administratives applicables ;
- exploiter une flotte conforme aux seuils et conditions réglementaires applicables ;
- maintenir les véhicules loués en état conforme aux normes de sécurité routière.
---
## 3. Permis de conduire
Le Locataire déclare être titulaire dun permis de conduire valide :
**Numéro du permis :** [●]
**Catégorie :** [B / autre]
**Date de délivrance :** [●]
**Date dexpiration :** [●]
**Pays de délivrance :** [●]
**Permis international, le cas échéant :** [Oui / Non, n° ●]
Le Locataire garantit que son permis est valide pendant toute la durée de location et quil nest frappé daucune suspension, annulation ou restriction incompatible avec la conduite du véhicule loué.
---
## 4. Conducteur(s) autorisé(s)
Le véhicule ne peut être conduit que par le Locataire et, le cas échéant, par les conducteurs additionnels expressément mentionnés ci-dessous :
### Conducteur additionnel 1
**Nom et prénom :** [●]
**CIN / Passeport :** [●]
**Permis n° :** [●]
**Catégorie :** [●]
**Date de délivrance :** [●]
**Téléphone :** [●]
Tout conducteur non déclaré est interdit. En cas daccident, vol, infraction ou dommage causé par un conducteur non autorisé, le Locataire assume lentière responsabilité des conséquences financières, civiles, pénales et assurantielles.
---
## 5. Véhicule loué
Le Loueur met à disposition du Locataire le véhicule suivant :
**Marque :** [●]
**Modèle :** [●]
**Année :** [●]
**Couleur :** [●]
**Immatriculation :** [●]
**Numéro de châssis :** [●]
**Propriétaire du véhicule :** [Loueur / Autre agence / Société de financement / Autre]
**Justificatif dexploitation si le véhicule appartient à un tiers :** [Contrat / Autorisation / Mandat / Autre]
**Type de motorisation :** [Thermique / Hybride / Électrique]
**Date de première mise en circulation :** [●]
**Âge du véhicule à la date de location :** [●]
**Véhicule conforme aux limites réglementaires dexploitation :** [Oui / Non]
**Kilométrage au départ :** [●] km
**Niveau de carburant au départ :** [●]
**Carte grise :** [Oui / Non]
**Assurance :** [Oui / Non]
**Visite technique :** [Oui / Non / Non applicable]
Un état des lieux contradictoire au départ et au retour est annexé au présent contrat et en fait partie intégrante.
Le Loueur déclare que le véhicule mis à disposition est conforme aux conditions réglementaires dexploitation applicables à sa catégorie, notamment en matière d’âge, dimmatriculation, dassurance, dentretien et de sécurité routière.
---
## 6. Entretien et conformité du véhicule
Le Loueur déclare que le véhicule est régulièrement entretenu, assuré, immatriculé et conforme aux normes de sécurité routière applicables.
Le Locataire reconnaît avoir reçu un véhicule en état apparent de fonctionnement, sous réserve des observations mentionnées dans l’état des lieux de départ.
En cas danomalie mécanique, voyant dalerte, bruit anormal, crevaison, panne ou incident susceptible daffecter la sécurité, le Locataire doit cesser lutilisation du véhicule dès que les conditions de sécurité le permettent et informer immédiatement le Loueur.
---
## 7. Durée de location
La location commence le :
**Date :** [●]
**Heure :** [●]
**Lieu de prise en charge :** [●]
La location prend fin le :
**Date :** [●]
**Heure :** [●]
**Lieu de restitution :** [●]
Toute prolongation doit être acceptée préalablement par écrit par le Loueur. À défaut, le véhicule sera considéré comme conservé sans autorisation, et le Loueur pourra facturer les jours supplémentaires, pénalités de retard et frais éventuels.
---
## 8. Prix de location et modalités de paiement
Le prix de location est fixé comme suit :
| Désignation | Montant |
|---|---:|
| Tarif journalier HT | [●] MAD |
| Nombre de jours | [●] |
| Sous-total HT | [●] MAD |
| TVA applicable | [●] % |
| Montant TVA | [●] MAD |
| Total TTC | [●] MAD |
| Conducteur additionnel | [●] MAD |
| Livraison / récupération | [●] MAD |
| Siège enfant | [●] MAD |
| GPS / accessoires | [●] MAD |
| Autres frais | [●] MAD |
**Montant total à payer TTC : [●] MAD**
**Mode de paiement :** [Espèces / carte bancaire / virement / chèque / autre]
**Date de paiement :** [●]
**Référence de paiement :** [●]
Le Locataire reconnaît avoir reçu une information claire sur le prix total TTC avant la signature du contrat.
---
## 9. Dépôt de garantie / caution
Le Locataire verse au Loueur un dépôt de garantie de :
**Montant : [●] MAD**
**Forme de la caution :** [Préautorisation bancaire / espèces / chèque / autre]
**Date de constitution :** [●]
**Conditions de libération :** [●]
Le dépôt de garantie garantit notamment :
- les dommages non couverts par lassurance ;
- la franchise dassurance ;
- les kilomètres supplémentaires ;
- le carburant manquant ;
- les frais de nettoyage exceptionnel ;
- les contraventions, amendes, péages ou frais administratifs ;
- les retards de restitution ;
- la perte de documents, clés, accessoires ou équipements.
La caution sera restituée après vérification de l’état du véhicule, du kilométrage, du carburant, des éventuelles infractions et des sommes restant dues.
---
## 10. Assurance
Le véhicule est assuré auprès de :
**Compagnie dassurance :** [●]
**Numéro de police :** [●]
**Type de couverture :** [Responsabilité civile / Tous risques / autre]
**Franchise applicable :** [●] MAD
**Assistance :** [Oui / Non]
**Numéro dassistance :** [●]
Le Locataire reconnaît avoir été informé des garanties, exclusions et franchises applicables.
Sont notamment exclus de la couverture, sauf stipulation contraire de lassureur :
- conduite par une personne non autorisée ;
- conduite sans permis valide ;
- conduite sous lemprise dalcool, stupéfiants ou substances interdites ;
- usage du véhicule hors des voies autorisées ;
- participation à des courses, essais ou compétitions ;
- négligence grave ;
- fausse déclaration ;
- absence de déclaration daccident dans les délais ;
- vol avec clés laissées dans le véhicule ;
- transport illicite de personnes ou de marchandises.
---
## 11. Kilométrage
**Kilométrage inclus :** [●] km par jour / [●] km pour toute la durée de location.
**Kilométrage au départ :** [●] km.
**Kilométrage au retour :** [●] km.
Tout kilomètre supplémentaire sera facturé au tarif de :
**[●] MAD TTC par kilomètre supplémentaire.**
Le kilométrage fait foi sur la base du compteur du véhicule, constaté dans l’état des lieux de départ et de retour.
---
## 12. Utilisation du véhicule
Le Locataire sengage à utiliser le véhicule avec prudence, conformément à sa destination normale et à la législation marocaine.
Il est interdit notamment :
- de sous-louer le véhicule ;
- de transporter des marchandises dangereuses ou illicites ;
- dutiliser le véhicule pour des activités illégales ;
- de participer à des compétitions ou essais sportifs ;
- de tracter un autre véhicule sans autorisation écrite ;
- de circuler hors du territoire marocain sans autorisation écrite du Loueur ;
- de modifier le véhicule ou ses équipements ;
- de fumer dans le véhicule si lagence linterdit ;
- de transporter un nombre de passagers supérieur à celui autorisé.
Le Locataire est responsable des infractions au Code de la route commises pendant la période de location.
---
## 13. Restitution du véhicule
Le Locataire doit restituer le véhicule à la date, à lheure et au lieu prévus au contrat, dans l’état où il lui a été remis, sous réserve de lusure normale.
Le véhicule doit être restitué avec :
- le même niveau de carburant quau départ ;
- les papiers du véhicule ;
- les clés ;
- les accessoires et équipements remis ;
- un état de propreté normal ;
- aucun dommage nouveau non déclaré.
En cas de retard, le Loueur pourra facturer :
- [●] MAD par heure de retard ; ou
- une journée supplémentaire au tarif contractuel au-delà de [●] heures de retard.
En cas de carburant manquant, les frais seront facturés selon le coût réel augmenté de frais de service de [●] MAD.
En cas de salissure excessive, le Loueur pourra facturer des frais de nettoyage de [●] MAD à [●] MAD selon l’état du véhicule.
---
## 14. État des lieux départ et retour
Les Parties établissent un état des lieux au départ et au retour. Celui-ci peut être accompagné de photographies datées du véhicule, du compteur, du carburant, de la carrosserie, de lintérieur, des pneus, des vitres et des équipements.
Les éléments à contrôler comprennent notamment :
- Kilométrage compteur ;
- Niveau de carburant ;
- Carrosserie ;
- Pare-chocs ;
- Rétroviseurs ;
- Phares et feux ;
- Pare-brise et vitres ;
- Pneus et jantes ;
- Intérieur et sièges ;
- Tableau de bord ;
- Roue de secours ;
- Outillage ;
- Documents du véhicule ;
- Clés et accessoires.
Tout dommage constaté au retour et non mentionné dans l’état des lieux de départ pourra être facturé au Locataire, sous réserve des garanties dassurance applicables.
---
## 15. Accident, panne, vol ou sinistre
En cas daccident, panne, vol, tentative de vol, incendie ou tout autre sinistre, le Locataire doit immédiatement :
- informer le Loueur par téléphone et par écrit ;
- prévenir les autorités compétentes si nécessaire ;
- établir un constat amiable en cas daccident ;
- obtenir un procès-verbal ou document officiel en cas de vol, blessure, délit ou dommage important ;
- ne pas abandonner le véhicule sans autorisation du Loueur ;
- ne pas reconnaître de responsabilité sans accord préalable du Loueur ou de lassureur.
Le Locataire doit transmettre au Loueur tous les documents utiles dans un délai maximum de **48 heures** à compter du sinistre.
En cas de non-respect de cette procédure, le Locataire pourra être tenu responsable des conséquences financières, notamment si lassureur refuse sa garantie.
---
## 16. Responsabilité du Locataire
Le Locataire est responsable :
- des dommages causés au véhicule pendant la période de location ;
- des dommages causés aux tiers dans les limites prévues par la loi et lassurance ;
- des amendes, contraventions, frais de fourrière, péages et frais administratifs ;
- des frais liés à une mauvaise utilisation du véhicule ;
- des pertes de clés, documents ou accessoires ;
- des dommages non couverts par lassurance ;
- de la franchise prévue au contrat dassurance.
Le Locataire reste responsable jusqu’à la restitution effective du véhicule au Loueur et la signature de l’état des lieux de retour.
---
## 17. Infractions, amendes et frais administratifs
Toutes les infractions commises pendant la durée de location sont à la charge du Locataire.
Le Loueur pourra communiquer lidentité du Locataire aux autorités compétentes et facturer au Locataire :
- le montant des amendes ;
- les frais de dossier ;
- les frais de fourrière ;
- les frais de notification ;
- tout coût lié au traitement administratif de linfraction.
**Frais administratifs par infraction : [●] MAD TTC.**
---
## 18. Annulation, non-présentation et résiliation
En cas dannulation par le Locataire :
- plus de [●] heures avant le départ : [conditions de remboursement] ;
- moins de [●] heures avant le départ : [conditions] ;
- non-présentation : [conditions].
Le Loueur peut résilier immédiatement le contrat, sans indemnité pour le Locataire, en cas de :
- fausse déclaration ;
- permis invalide ;
- défaut de paiement ;
- utilisation interdite du véhicule ;
- conduite dangereuse ;
- non-respect grave du présent contrat ;
- suspicion légitime de fraude ou dusage illicite.
Dans ce cas, le Locataire doit restituer immédiatement le véhicule.
---
## 19. Indisponibilité administrative, réglementaire ou technique
En cas dindisponibilité du véhicule ou dimpossibilité dexécuter la location pour une raison administrative, réglementaire, technique ou de sécurité, le Loueur pourra proposer au Locataire un véhicule équivalent, sous réserve de disponibilité.
À défaut de véhicule équivalent accepté par le Locataire, les sommes payées au titre de la période non exécutée seront remboursées, sans préjudice des droits légalement reconnus aux Parties.
---
## 20. Données personnelles
Le Locataire autorise le Loueur à collecter et conserver les données nécessaires à lexécution du présent contrat, notamment son identité, ses coordonnées, son permis de conduire, les informations de paiement, les documents liés au véhicule, les états des lieux et les données relatives aux éventuels incidents.
Ces données sont utilisées pour :
- lexécution du contrat ;
- la facturation ;
- la gestion des sinistres ;
- la gestion des infractions ;
- les obligations comptables, fiscales et légales ;
- la défense des droits du Loueur en cas de litige.
Le Loueur sengage à protéger les données personnelles du Locataire, à limiter leur accès aux personnes habilitées et à les conserver uniquement pendant la durée nécessaire aux finalités prévues et aux obligations légales applicables.
---
## 21. Documents annexés
Les documents suivants sont annexés au présent contrat :
- Copie CIN ou passeport du Locataire ;
- Copie du permis de conduire ;
- Copie permis international, le cas échéant ;
- État des lieux de départ ;
- État des lieux de retour ;
- Photos du véhicule au départ ;
- Photos du véhicule au retour ;
- Copie de la carte grise ;
- Copie de lattestation dassurance ;
- Justificatif autorisant lexploitation du véhicule par le Loueur, si le véhicule nappartient pas directement au Loueur ;
- Reçu de paiement ;
- Justificatif de dépôt de garantie ;
- Conditions générales de location, le cas échéant.
Les annexes font partie intégrante du présent contrat.
---
## 22. Loi applicable et juridiction compétente
Le présent contrat est soumis au droit marocain.
En cas de litige relatif à sa validité, son interprétation, son exécution ou sa résiliation, les Parties sefforceront de trouver une solution amiable.
À défaut daccord amiable, le litige sera porté devant le tribunal compétent du ressort de :
**[Ville du siège de lagence / tribunal compétent]**
---
## 23. Déclaration finale
Le Locataire déclare :
- avoir lu et compris le présent contrat ;
- avoir reçu toutes les informations utiles avant signature ;
- avoir vérifié l’état du véhicule au départ ;
- être titulaire dun permis valide ;
- sengager à respecter les conditions du présent contrat ;
- accepter les prix, caution, franchises, pénalités et frais indiqués.
**Fait à :** [●]
**Le :** [●]
**En deux exemplaires originaux.**
Chaque page du présent contrat doit être paraphée par les deux Parties.
### Signature du Loueur
**Nom :** [●]
**Signature et cachet :**
<br><br>
### Signature du Locataire
**Nom :** [●]
**Signature :**
<br><br>
---
# Annexe 1 — État des lieux de départ
**Contrat n° :** [●]
**Date :** [●]
**Heure :** [●]
**Lieu :** [●]
**Véhicule :** [Marque / Modèle]
**Immatriculation :** [●]
**Kilométrage départ :** [●] km
**Carburant départ :** [●]
| Élément | État au départ | Observations |
|---|---|---|
| Carrosserie avant | Bon / Rayé / Endommagé | [●] |
| Carrosserie arrière | Bon / Rayé / Endommagé | [●] |
| Côté droit | Bon / Rayé / Endommagé | [●] |
| Côté gauche | Bon / Rayé / Endommagé | [●] |
| Pare-brise | Bon / Impact / Fissure | [●] |
| Vitres | Bon / Endommagé | [●] |
| Pneus | Bon / Usé / Endommagé | [●] |
| Jantes | Bon / Rayé / Endommagé | [●] |
| Intérieur | Bon / Taché / Endommagé | [●] |
| Sièges | Bon / Taché / Déchiré | [●] |
| Tableau de bord | Bon / Endommagé | [●] |
| Climatisation | Fonctionne / Ne fonctionne pas | [●] |
| Feux | Fonctionnent / Défaut | [●] |
| Roue de secours | Présente / Absente | [●] |
| Outillage | Présent / Absent | [●] |
| Carte grise | Présente / Absente | [●] |
| Assurance | Présente / Absente | [●] |
| Clés | [Nombre] | [●] |
**Photos prises au départ :** Oui / Non
**Nombre de photos :** [●]
**Signature du Loueur :**
<br><br>
**Signature du Locataire :**
<br><br>
---
# Annexe 2 — État des lieux de retour
**Contrat n° :** [●]
**Date :** [●]
**Heure :** [●]
**Lieu :** [●]
**Kilométrage retour :** [●] km
**Kilométrage parcouru :** [●] km
**Kilométrage inclus :** [●] km
**Kilométrage supplémentaire :** [●] km
**Montant km supplémentaires :** [●] MAD
**Carburant retour :** [●]
**Carburant manquant :** Oui / Non
**Montant carburant :** [●] MAD
| Élément | État au retour | Nouveau dommage ? | Observations |
|---|---|---|---|
| Carrosserie avant | Bon / Rayé / Endommagé | Oui / Non | [●] |
| Carrosserie arrière | Bon / Rayé / Endommagé | Oui / Non | [●] |
| Côté droit | Bon / Rayé / Endommagé | Oui / Non | [●] |
| Côté gauche | Bon / Rayé / Endommagé | Oui / Non | [●] |
| Pare-brise | Bon / Impact / Fissure | Oui / Non | [●] |
| Vitres | Bon / Endommagé | Oui / Non | [●] |
| Pneus | Bon / Usé / Endommagé | Oui / Non | [●] |
| Jantes | Bon / Rayé / Endommagé | Oui / Non | [●] |
| Intérieur | Bon / Taché / Endommagé | Oui / Non | [●] |
| Sièges | Bon / Taché / Déchiré | Oui / Non | [●] |
| Documents | Complets / Manquants | Oui / Non | [●] |
| Clés | Restituées / Manquantes | Oui / Non | [●] |
## Frais constatés au retour
| Frais | Montant |
|---|---:|
| Dommages | [●] MAD |
| Franchise assurance | [●] MAD |
| Kilométrage supplémentaire | [●] MAD |
| Carburant | [●] MAD |
| Nettoyage | [●] MAD |
| Retard | [●] MAD |
| Autres frais | [●] MAD |
**Total à retenir sur la caution : [●] MAD**
**Solde de caution à restituer : [●] MAD**
**Photos prises au retour :** Oui / Non
**Nombre de photos :** [●]
**Signature du Loueur :**
<br><br>
**Signature du Locataire :**
<br><br>
---
# Checklist pratique avant signature
- Ne laisser aucun champ `[●]` vide dans la version signée.
- Faire parapher chaque page par les deux Parties.
- Joindre la copie CIN ou passeport du Locataire.
- Joindre la copie du permis de conduire.
- Photographier le compteur et le niveau de carburant au départ et au retour.
- Photographier toutes les faces du véhicule au départ et au retour.
- Écrire clairement le montant de la caution.
- Mentionner la compagnie dassurance, le numéro de police et la franchise.
- Conserver une preuve de paiement.
- Conserver les états des lieux signés.
---
# Note importante
Ce modèle est un document pratique destiné à structurer une location de voiture au Maroc. Il doit être adapté à lactivité réelle du Loueur, à ses conditions dassurance, à son statut fiscal et aux règles applicables. Pour un usage commercial régulier, une validation par un juriste ou avocat marocain est recommandée.
---
# Checklist interne de conformité du Loueur
Cette checklist est destinée au Loueur et ne remplace pas les documents administratifs obligatoires.
- Agrément / autorisation dexercice disponible.
- Responsable réglementaire désigné.
- Casier judiciaire conforme, si exigé.
- Société inscrite à la CNSS, si applicable.
- Siège social ou local professionnel justifié.
- Capital social conforme au seuil applicable.
- Flotte conforme au seuil réglementaire applicable.
- Âge des véhicules conforme selon motorisation.
- Documents de propriété ou dexploitation des véhicules disponibles.
- Assurance et visite technique valides pour chaque véhicule.
- Procédure de déclaration en cas de suspension ou cessation dactivité.
@@ -0,0 +1,210 @@
ALTER TYPE "NotificationType" ADD VALUE IF NOT EXISTS 'ACCOUNT_CREATED';
ALTER TABLE "billing_accounts"
ADD COLUMN "preferredLanguage" TEXT NOT NULL DEFAULT 'en';
ALTER TABLE "notifications"
ADD COLUMN "templateKey" TEXT,
ADD COLUMN "locale" TEXT NOT NULL DEFAULT 'en';
CREATE TABLE "notification_templates" (
"id" TEXT NOT NULL,
"templateKey" TEXT NOT NULL,
"category" TEXT NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"locale" TEXT NOT NULL,
"subject" TEXT,
"body" TEXT NOT NULL,
"requiredVariables" JSONB,
"optionalVariables" JSONB,
"version" INTEGER NOT NULL DEFAULT 1,
"isActive" BOOLEAN NOT NULL DEFAULT TRUE,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_templates_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "notification_templates_templateKey_channel_locale_version_key"
ON "notification_templates"("templateKey", "channel", "locale", "version");
CREATE INDEX "notification_templates_templateKey_channel_locale_isActive_idx"
ON "notification_templates"("templateKey", "channel", "locale", "isActive");
INSERT INTO "notification_templates" (
"id",
"templateKey",
"category",
"channel",
"locale",
"subject",
"body",
"requiredVariables",
"optionalVariables"
)
VALUES
(
'notif_tpl_account_created_email_en',
'account.created',
'account',
'EMAIL',
'en',
'Your workspace is ready - RentalDriveGo',
E'Hi {{firstName}},\n\nYour RentalDriveGo workspace for {{companyName}} has been created successfully.\nPlan: {{planName}} ({{billingPeriodLabel}})\nCurrency: {{currency}}\nPrimary payment provider: {{paymentProvider}}\nFree trial ends on {{trialEndDate}}.\n\nYour workspace is ready. Sign in with the email and password you chose during signup.\n\nRentalDriveGo',
'["firstName","companyName","planName","billingPeriodLabel","currency","paymentProvider","trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_account_created_email_fr',
'account.created',
'account',
'EMAIL',
'fr',
'Votre espace de travail est pret - RentalDriveGo',
E'Bonjour {{firstName}},\n\nVotre espace de travail RentalDriveGo pour {{companyName}} a ete cree avec succes.\nForfait : {{planName}} ({{billingPeriodLabel}})\nDevise : {{currency}}\nFournisseur de paiement principal : {{paymentProvider}}\nLa periode d''essai gratuit se termine le {{trialEndDate}}.\n\nVotre espace de travail est pret. Connectez-vous avec l''e-mail et le mot de passe choisis lors de l''inscription.\n\nRentalDriveGo',
'["firstName","companyName","planName","billingPeriodLabel","currency","paymentProvider","trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_account_created_email_ar',
'account.created',
'account',
'EMAIL',
'ar',
'مساحة عملك جاهزة - RentalDriveGo',
E'مرحباً {{firstName}}،\n\nتم إنشاء مساحة عمل RentalDriveGo الخاصة بـ {{companyName}} بنجاح.\nالخطة: {{planName}} ({{billingPeriodLabel}})\nالعملة: {{currency}}\nمزود الدفع الرئيسي: {{paymentProvider}}\nتنتهي الفترة التجريبية المجانية في {{trialEndDate}}.\n\nمساحة عملك جاهزة. سجّل الدخول باستخدام البريد الإلكتروني وكلمة المرور التي اخترتهما عند التسجيل.\n\nRentalDriveGo',
'["firstName","companyName","planName","billingPeriodLabel","currency","paymentProvider","trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_account_created_in_app_en',
'account.created',
'account',
'IN_APP',
'en',
'Workspace ready',
E'Your RentalDriveGo workspace for {{companyName}} is ready. Your free trial ends on {{trialEndDate}}.',
'["companyName","trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_account_created_in_app_fr',
'account.created',
'account',
'IN_APP',
'fr',
'Espace pret',
E'Votre espace de travail RentalDriveGo pour {{companyName}} est pret. Votre essai gratuit se termine le {{trialEndDate}}.',
'["companyName","trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_account_created_in_app_ar',
'account.created',
'account',
'IN_APP',
'ar',
'مساحة العمل جاهزة',
E'مساحة عمل RentalDriveGo الخاصة بـ {{companyName}} جاهزة. تنتهي الفترة التجريبية المجانية في {{trialEndDate}}.',
'["companyName","trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_booking_confirmed_email_en',
'booking.confirmed',
'reservation',
'EMAIL',
'en',
'Booking confirmed - {{companyName}}',
E'Hi {{firstName}},\n\nYour booking with {{companyName}} has been confirmed.\nPickup: {{startDate}}\nReturn: {{endDate}}\nVehicle: {{vehicleName}}\n\nThank you for choosing RentalDriveGo.',
'["firstName","companyName","startDate","endDate","vehicleName"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_booking_confirmed_email_fr',
'booking.confirmed',
'reservation',
'EMAIL',
'fr',
'Reservation confirmee - {{companyName}}',
E'Bonjour {{firstName}},\n\nVotre reservation chez {{companyName}} a ete confirmee.\nPrise en charge : {{startDate}}\nRetour : {{endDate}}\nVehicule : {{vehicleName}}\n\nMerci d''avoir choisi RentalDriveGo.',
'["firstName","companyName","startDate","endDate","vehicleName"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_booking_confirmed_email_ar',
'booking.confirmed',
'reservation',
'EMAIL',
'ar',
'تم تأكيد الحجز - {{companyName}}',
E'مرحباً {{firstName}}،\n\nتم تأكيد حجزك مع {{companyName}}.\nالاستلام: {{startDate}}\nالإرجاع: {{endDate}}\nالمركبة: {{vehicleName}}\n\nشكراً لاختيار RentalDriveGo.',
'["firstName","companyName","startDate","endDate","vehicleName"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_booking_confirmed_in_app_en',
'booking.confirmed',
'reservation',
'IN_APP',
'en',
'Booking confirmed',
E'Your booking with {{companyName}} has been confirmed.',
'["companyName"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_booking_confirmed_in_app_fr',
'booking.confirmed',
'reservation',
'IN_APP',
'fr',
'Reservation confirmee',
E'Votre reservation chez {{companyName}} a ete confirmee.',
'["companyName"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_booking_confirmed_in_app_ar',
'booking.confirmed',
'reservation',
'IN_APP',
'ar',
'تم تأكيد الحجز',
E'تم تأكيد حجزك مع {{companyName}}.',
'["companyName"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_subscription_trial_ending_in_app_en',
'subscription.trial_ending',
'subscription',
'IN_APP',
'en',
'Your trial ends soon',
E'Your free trial ends on {{trialEndDate}}. Add a payment method to keep access.',
'["trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_subscription_trial_ending_in_app_fr',
'subscription.trial_ending',
'subscription',
'IN_APP',
'fr',
'Votre essai se termine bientot',
E'Votre essai gratuit se termine le {{trialEndDate}}. Ajoutez un moyen de paiement pour conserver l''acces.',
'["trialEndDate"]'::jsonb,
'[]'::jsonb
),
(
'notif_tpl_subscription_trial_ending_in_app_ar',
'subscription.trial_ending',
'subscription',
'IN_APP',
'ar',
'ستنتهي الفترة التجريبية قريباً',
E'تنتهي فترتك التجريبية المجانية في {{trialEndDate}}. أضف وسيلة دفع للحفاظ على الوصول.',
'["trialEndDate"]'::jsonb,
'[]'::jsonb
);
+24
View File
@@ -377,6 +377,7 @@ enum AdminAction {
} }
enum NotificationType { enum NotificationType {
ACCOUNT_CREATED
NEW_BOOKING NEW_BOOKING
BOOKING_CANCELLED BOOKING_CANCELLED
PAYMENT_RECEIVED PAYMENT_RECEIVED
@@ -540,6 +541,7 @@ model BillingAccount {
isPrimary Boolean @default(true) isPrimary Boolean @default(true)
legalName String legalName String
billingEmail String billingEmail String
preferredLanguage String @default("en")
billingAddress Json? billingAddress Json?
taxId String? taxId String?
taxExempt Boolean @default(false) taxExempt Boolean @default(false)
@@ -1218,6 +1220,8 @@ model Notification {
body String body String
data Json? data Json?
channel NotificationChannel channel NotificationChannel
templateKey String?
locale String @default("en")
status NotificationStatus @default(PENDING) status NotificationStatus @default(PENDING)
sentAt DateTime? sentAt DateTime?
failReason String? failReason String?
@@ -1231,6 +1235,26 @@ model Notification {
@@map("notifications") @@map("notifications")
} }
model NotificationTemplate {
id String @id @default(cuid())
templateKey String
category String
channel NotificationChannel
locale String
subject String?
body String
requiredVariables Json?
optionalVariables Json?
version Int @default(1)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([templateKey, channel, locale, version])
@@index([templateKey, channel, locale, isActive])
@@map("notification_templates")
}
model NotificationPreference { model NotificationPreference {
id String @id @default(cuid()) id String @id @default(cuid())
employeeId String? employeeId String?
+1
View File
@@ -9,6 +9,7 @@ export type AdminRole = 'SUPER_ADMIN' | 'ADMIN' | 'SUPPORT' | 'FINANCE' | 'VIEWE
export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT' export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
export type LicenseStatus = 'PENDING' | 'VALID' | 'EXPIRING' | 'APPROVED' | 'DENIED' | 'EXPIRED' export type LicenseStatus = 'PENDING' | 'VALID' | 'EXPIRING' | 'APPROVED' | 'DENIED' | 'EXPIRED'
export type NotificationType = export type NotificationType =
| 'ACCOUNT_CREATED'
| 'NEW_BOOKING' | 'NEW_BOOKING'
| 'BOOKING_CANCELLED' | 'BOOKING_CANCELLED'
| 'PAYMENT_RECEIVED' | 'PAYMENT_RECEIVED'