diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index 0107fdd..0d29994 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -1,15 +1,32 @@ /** @type {import('next').NextConfig} */ const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '') const apiUrl = new URL(apiOrigin) +const ADMIN_BASE_PATH = '/admin' + +function ensureAdminAssetPrefix(rawValue) { + if (!rawValue) return undefined + + try { + const url = new URL(rawValue) + const pathname = url.pathname.replace(/\/$/, '') + if (!pathname.endsWith(ADMIN_BASE_PATH)) { + url.pathname = `${pathname}${ADMIN_BASE_PATH}` + } + return url.toString().replace(/\/$/, '') + } catch { + const trimmed = rawValue.replace(/\/$/, '') + return trimmed.endsWith(ADMIN_BASE_PATH) ? trimmed : `${trimmed}${ADMIN_BASE_PATH}` + } +} // In Docker dev the admin app runs on port 3002 while the marketplace proxy // serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits // absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from // port 3002, bypassing the proxy (which can't upgrade WebSocket connections). -const assetPrefix = process.env.ADMIN_ASSET_PREFIX || undefined +const assetPrefix = ensureAdminAssetPrefix(process.env.ADMIN_ASSET_PREFIX) const nextConfig = { - basePath: '/admin', + basePath: ADMIN_BASE_PATH, ...(assetPrefix ? { assetPrefix } : {}), images: { remotePatterns: [ diff --git a/apps/admin/src/app/auth-redirect/page.tsx b/apps/admin/src/app/auth-redirect/page.tsx index 43fb837..b1d9e7a 100644 --- a/apps/admin/src/app/auth-redirect/page.tsx +++ b/apps/admin/src/app/auth-redirect/page.tsx @@ -1,16 +1,25 @@ 'use client' import { useEffect } from 'react' -import { useRouter } from 'next/navigation' + +function resolveAdminNextPath(next: string | null) { + const fallback = '/admin/dashboard' + if (!next) return fallback + + if (/^https?:\/\//i.test(next)) return next + if (next.startsWith('/admin/')) return next + if (next === '/admin') return '/admin/dashboard' + if (next.startsWith('/')) return `/admin${next}` + + return `/admin/${next.replace(/^\/+/, '')}` +} export default function AuthRedirectPage() { - const router = useRouter() - useEffect(() => { const hash = window.location.hash const params = new URLSearchParams(hash.replace(/^#/, '')) const token = params.get('token') - const next = params.get('next') || '/dashboard' + const next = resolveAdminNextPath(params.get('next')) if (token) { localStorage.setItem('admin_token', token) @@ -18,8 +27,8 @@ export default function AuthRedirectPage() { window.history.replaceState(null, '', window.location.pathname) } - router.replace(next) - }, [router]) + window.location.replace(next) + }, []) return (
diff --git a/apps/admin/src/app/dashboard/layout.tsx b/apps/admin/src/app/dashboard/layout.tsx index c2beb96..0361edd 100644 --- a/apps/admin/src/app/dashboard/layout.tsx +++ b/apps/admin/src/app/dashboard/layout.tsx @@ -25,6 +25,7 @@ const navLinks = [ { href: '/dashboard/site-config', key: 'siteConfig', icon: 'M4.5 12a7.5 7.5 0 1015 0 7.5 7.5 0 00-15 0zm7.5-4.5v4.5l3 3' }, { href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' }, { href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' }, + { href: '/dashboard/notifications', key: 'notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' }, { href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' }, { href: '/dashboard/billing', key: 'billing', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' }, { href: '/dashboard/pricing', key: 'pricing', icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z' }, diff --git a/apps/admin/src/app/dashboard/notifications/page.tsx b/apps/admin/src/app/dashboard/notifications/page.tsx new file mode 100644 index 0000000..af34c41 --- /dev/null +++ b/apps/admin/src/app/dashboard/notifications/page.tsx @@ -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 = { + 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 = { + 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(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+
+

Platform

+

Notifications

+

+ Full audit log of every notification sent across all companies. +

+
+ {result && ( + + {result.total.toLocaleString()} total + + )} +
+ + {/* Filters */} +
+ + +
+ + {error &&
{error}
} + +
+
+ + + + + + + + + + + + + + {loading ? ( + + ) : !result || result.data.length === 0 ? ( + + ) : result.data.map((item) => ( + + + + + + + + + + ))} + +
DateCompanyEventChannelTitleStatusSent at
Loading…
No notifications found.
+ {new Date(item.createdAt).toLocaleString()} + + {item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')} + + {item.type.replaceAll('_', ' ')} + + + {item.channel} + + +

{item.title}

+

{item.body}

+
+ + {item.status} + + + {item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} — {result?.total.toLocaleString()} records + +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/admin/src/components/I18nProvider.tsx b/apps/admin/src/components/I18nProvider.tsx index d7f46d2..9c7d0b6 100644 --- a/apps/admin/src/components/I18nProvider.tsx +++ b/apps/admin/src/components/I18nProvider.tsx @@ -29,6 +29,7 @@ const dictionaries: Record = { adminUsers: 'Admin Users', billing: 'Billing', pricing: 'Pricing', + notifications: 'Notifications', }, logout: 'Logout', language: 'Language', @@ -50,6 +51,7 @@ const dictionaries: Record = { adminUsers: 'Utilisateurs admin', billing: 'Facturation', pricing: 'Tarification', + notifications: 'Notifications', }, logout: 'Déconnexion', language: 'Langue', @@ -71,6 +73,7 @@ const dictionaries: Record = { adminUsers: 'مستخدمو الإدارة', billing: 'الفوترة', pricing: 'الأسعار', + notifications: 'الإشعارات', }, logout: 'تسجيل الخروج', language: 'اللغة', diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5a54379..dbae323 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,11 +2,11 @@ import http from 'http' import { Server as SocketIOServer } from 'socket.io' import cron from 'node-cron' import jwt from 'jsonwebtoken' -import { z } from 'zod' import { redis } from './lib/redis' import { prisma } from './lib/prisma' import { assertStorageConfiguration } from './lib/storage' import { createApp, corsOrigins } from './app' +import { sendNotification } from './services/notificationService' import { runTrialExpirationJob, runPaymentPendingTimeoutJob, @@ -24,11 +24,6 @@ const io = new SocketIOServer(server, { cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] }, }) -const redisMessageSchema = z.object({ - type: z.string(), - payload: z.unknown(), -}) - // Authenticate socket connections via JWT before joining user rooms io.use((socket, next) => { const token = socket.handshake.auth?.token as string | undefined @@ -57,9 +52,8 @@ subscriber.psubscribe('notifications:*', (err) => { subscriber.on('pmessage', (_pattern, channel, message) => { try { const parsed = JSON.parse(message) - const validated = redisMessageSchema.parse(parsed) const userId = channel.replace('notifications:', '') - io.to(`user:${userId}`).emit('notification', validated) + io.to(`user:${userId}`).emit('notification', parsed) } catch (err) { console.error('[Redis] Invalid notification message:', err) } @@ -117,8 +111,17 @@ cron.schedule('0 9 * * *', async () => { for (const sub of subscriptions) { const owner = sub.company.employees[0] if (owner) { - await prisma.notification.create({ - data: { type: 'SUBSCRIPTION_TRIAL_ENDING', title: 'Your trial ends soon', body: 'Your 90-day free trial ends in 3 days. Add a payment method to keep access.', companyId: sub.companyId, employeeId: owner.id, channel: 'IN_APP', status: 'DELIVERED' }, + await sendNotification({ + type: 'SUBSCRIPTION_TRIAL_ENDING', + companyId: sub.companyId, + employeeId: owner.id, + channels: ['IN_APP'], + templateKey: 'subscription.trial_ending', + templateVariables: { + trialEndDate: sub.trialEndAt ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + }, + }).catch((err) => { + console.error('[Notifications] Failed to create trial ending reminder:', err?.message ?? String(err)) }) } } diff --git a/apps/api/src/modules/admin/admin.repo.ts b/apps/api/src/modules/admin/admin.repo.ts index 7b27b38..67d06ee 100644 --- a/apps/api/src/modules/admin/admin.repo.ts +++ b/apps/api/src/modules/admin/admin.repo.ts @@ -488,3 +488,28 @@ export function updatePromotion(id: string, data: Partial<{ export function deletePromotion(id: string) { return (prisma as any).pricingPromotion.delete({ where: { id } }) } + +export async function listNotificationsPage(query: { + channel?: string + status?: string + companyId?: string + page: number + pageSize: number +}) { + const where: any = {} + if (query.channel) where.channel = query.channel + if (query.status) where.status = query.status + if (query.companyId) where.companyId = query.companyId + + const [data, total] = await Promise.all([ + prisma.notification.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + include: { company: { select: { name: true } } }, + }), + prisma.notification.count({ where }), + ]) + return { data, total } +} diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index 69ddc31..55c9f28 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -7,7 +7,7 @@ import * as subService from '../subscriptions/subscription.service' import { presentAdminUser } from './admin.presenter' import { loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema, - companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, + companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, notificationsQuerySchema, invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema, createAdminSchema, adminRoleSchema, adminPermissionsSchema, homepageUpdateSchema, idParamSchema, companyIdParamSchema, billingAccountIdParamSchema, invoiceIdParamSchema, @@ -158,6 +158,14 @@ router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req } catch (err) { next(err) } }) +// ─── Notifications ───────────────────────────────────────────── + +router.get('/notifications', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { + try { + ok(res, await service.listNotifications(parseQuery(notificationsQuerySchema, req))) + } catch (err) { next(err) } +}) + // ─── Audit logs ──────────────────────────────────────────────── router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { diff --git a/apps/api/src/modules/admin/admin.schemas.ts b/apps/api/src/modules/admin/admin.schemas.ts index d80641b..e4d316e 100644 --- a/apps/api/src/modules/admin/admin.schemas.ts +++ b/apps/api/src/modules/admin/admin.schemas.ts @@ -44,6 +44,14 @@ export const auditLogQuerySchema = z.object({ pageSize: z.coerce.number().int().min(1).max(100).default(50), }) +export const notificationsQuerySchema = z.object({ + channel: z.string().optional(), + status: z.string().optional(), + companyId: z.string().optional(), + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(200).default(50), +}) + export const billingQuerySchema = z.object({ q: z.string().optional(), status: z.string().optional(), diff --git a/apps/api/src/modules/admin/admin.service.ts b/apps/api/src/modules/admin/admin.service.ts index fcf4bfb..88fb8a1 100644 --- a/apps/api/src/modules/admin/admin.service.ts +++ b/apps/api/src/modules/admin/admin.service.ts @@ -193,6 +193,11 @@ export function getPlatformMetrics() { return repo.getPlatformMetricCounts() } +export async function listNotifications(query: { channel?: string; status?: string; companyId?: string; page: number; pageSize: number }) { + const { data, total } = await repo.listNotificationsPage(query) + return presenter.presentPaginated(data, total, query.page, query.pageSize) +} + export async function getAuditLogs(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) { const { data, total } = await repo.listAuditLogsPage(query) return presenter.presentPaginated(data, total, query.page, query.pageSize) diff --git a/apps/api/src/modules/auth/auth.company.repo.ts b/apps/api/src/modules/auth/auth.company.repo.ts index af6e2da..557aab0 100644 --- a/apps/api/src/modules/auth/auth.company.repo.ts +++ b/apps/api/src/modules/auth/auth.company.repo.ts @@ -70,6 +70,7 @@ export async function createCompanySignup(db: DbClient, input: CompanySignupInpu publicAddress: input.streetAddress, publicCity: input.city, publicCountry: input.country, + defaultLocale: input.preferredLanguage, defaultCurrency: input.currency, }, }) diff --git a/apps/api/src/modules/auth/auth.company.service.ts b/apps/api/src/modules/auth/auth.company.service.ts index d01e8ec..90ece9f 100644 --- a/apps/api/src/modules/auth/auth.company.service.ts +++ b/apps/api/src/modules/auth/auth.company.service.ts @@ -2,7 +2,11 @@ import bcrypt from 'bcryptjs' import { AppError } from '../../http/errors' import { prisma } from '../../lib/prisma' import { sendNotification } from '../../services/notificationService' -import { signupEmail, type Lang } from '../../lib/emailTranslations' +import { + coerceNotificationLocale, + localizeBillingPeriod, + localizePlanName, +} from '../../services/notificationLocalizationService' import { presentCompanySignup } from './auth.presenter' import * as repo from './auth.company.repo' import { companySignupSchema } from './auth.company.schemas' @@ -66,24 +70,24 @@ export async function signup(body: CompanySignupInput) { trialEndAt, })) - const lang = body.preferredLanguage as Lang + const lang = coerceNotificationLocale(body.preferredLanguage) const emailResult = await sendNotification({ - type: 'SUBSCRIPTION_TRIAL_ENDING', - title: signupEmail.subject(lang), - body: signupEmail.text({ - firstName: body.firstName, - companyName: body.companyName, - plan: body.plan, - billingPeriod: body.billingPeriod, - currency: body.currency, - paymentProvider: body.paymentProvider, - trialEnd: trialEndAt, - }, lang), + type: 'ACCOUNT_CREATED', companyId: result.company.id, employeeId: result.employee.id, email: body.email, channels: ['EMAIL', 'IN_APP'], locale: lang, + templateKey: 'account.created', + templateVariables: { + firstName: body.firstName, + companyName: body.companyName, + planName: localizePlanName(body.plan, lang), + billingPeriodLabel: localizeBillingPeriod(body.billingPeriod, lang), + currency: body.currency, + paymentProvider: body.paymentProvider, + trialEndDate: trialEndAt, + }, }).catch(() => []) const emailDelivery = (emailResult as Array<{ channel?: string }>).find((entry) => entry.channel === 'EMAIL') diff --git a/apps/api/src/modules/notifications/notification.repo.ts b/apps/api/src/modules/notifications/notification.repo.ts index 014a34f..66d24a6 100644 --- a/apps/api/src/modules/notifications/notification.repo.ts +++ b/apps/api/src/modules/notifications/notification.repo.ts @@ -35,6 +35,20 @@ export async function upsertEmployeePreferences(employeeId: string, prefs: Array } } +export function findCompanyHistory( + companyId: string, + opts?: { channel?: string; status?: string; limit?: number }, +) { + const where: any = { companyId } + if (opts?.channel) where.channel = opts.channel + if (opts?.status) where.status = opts.status + return prisma.notification.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: opts?.limit ?? 200, + }) +} + // ─── Renter notifications ───────────────────────────────────── export function findRenter(renterId: string) { diff --git a/apps/api/src/modules/notifications/notification.routes.ts b/apps/api/src/modules/notifications/notification.routes.ts index b2f52f6..29300d1 100644 --- a/apps/api/src/modules/notifications/notification.routes.ts +++ b/apps/api/src/modules/notifications/notification.routes.ts @@ -6,7 +6,7 @@ import { requireRenterAuth } from '../../middleware/requireRenterAuth' import { parseBody, parseQuery, parseParams } from '../../http/validate' import { ok } from '../../http/respond' import * as service from './notification.service' -import { preferencesSchema, idParamSchema, unreadQuerySchema } from './notification.schemas' +import { preferencesSchema, idParamSchema, unreadQuerySchema, historyQuerySchema } from './notification.schemas' const router = Router() @@ -56,6 +56,13 @@ router.patch('/company/preferences', ...companyAuth, async (req, res, next) => { } catch (err) { next(err) } }) +router.get('/history', ...companyAuth, async (req, res, next) => { + try { + const { channel, status, limit } = parseQuery(historyQuerySchema, req) + ok(res, await service.listCompanyHistory(req.companyId, { channel, status, limit })) + } catch (err) { next(err) } +}) + // ─── Renter notifications ───────────────────────────────────── router.get('/renter', requireRenterAuth, async (req, res, next) => { diff --git a/apps/api/src/modules/notifications/notification.schemas.ts b/apps/api/src/modules/notifications/notification.schemas.ts index 675a0e4..2135234 100644 --- a/apps/api/src/modules/notifications/notification.schemas.ts +++ b/apps/api/src/modules/notifications/notification.schemas.ts @@ -15,3 +15,9 @@ export const idParamSchema = z.object({ export const unreadQuerySchema = z.object({ unread: z.string().optional(), }) + +export const historyQuerySchema = z.object({ + channel: z.string().optional(), + status: z.string().optional(), + limit: z.coerce.number().int().min(1).max(500).optional(), +}) diff --git a/apps/api/src/modules/notifications/notification.service.ts b/apps/api/src/modules/notifications/notification.service.ts index 106990e..cae0e86 100644 --- a/apps/api/src/modules/notifications/notification.service.ts +++ b/apps/api/src/modules/notifications/notification.service.ts @@ -1,6 +1,7 @@ import * as repo from './notification.repo' export const listCompany = (companyId: string, unread?: string) => repo.findCompany(companyId, unread) +export const listCompanyHistory = (companyId: string, opts?: { channel?: string; status?: string; limit?: number }) => repo.findCompanyHistory(companyId, opts) export const countUnread = (companyId: string) => repo.countUnread(companyId) export const markRead = (id: string, companyId: string) => repo.markRead(id, companyId) export const markAllRead = (companyId: string) => repo.markAllRead(companyId) diff --git a/apps/api/src/modules/reservations/reservation.lifecycle.service.ts b/apps/api/src/modules/reservations/reservation.lifecycle.service.ts index 1733744..aaf772d 100644 --- a/apps/api/src/modules/reservations/reservation.lifecycle.service.ts +++ b/apps/api/src/modules/reservations/reservation.lifecycle.service.ts @@ -3,7 +3,8 @@ import { prisma } from '../../lib/prisma' import { AppError } from '../../http/errors' import { validateLicense } from '../../services/licenseValidationService' import { sendNotification, sendTransactionalEmail } from '../../services/notificationService' -import { bookingConfirmedNotif, reviewRequestEmail, type Lang } from '../../lib/emailTranslations' +import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations' +import { coerceNotificationLocale } from '../../services/notificationLocalizationService' import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter' import * as repo from './reservation.repo' @@ -39,19 +40,27 @@ export async function confirmReservation(id: string, companyId: string) { const updated = await repo.updateById(id, { status: 'CONFIRMED' }) - const [customer, brand] = await Promise.all([ + const [customer, company, vehicle] = await Promise.all([ repo.findCustomerById(reservation.customerId), - repo.findBrandLocale(companyId), + repo.findCompanyWithBrand(companyId), + repo.findVehicle(reservation.vehicleId, companyId), ]) - const lang = (brand?.defaultLocale ?? 'fr') as Lang + const fallbackLocale = coerceNotificationLocale(company?.brand?.defaultLocale) await sendNotification({ type: 'BOOKING_CONFIRMED', - title: bookingConfirmedNotif.title(lang), - body: bookingConfirmedNotif.body(lang), companyId, + renterId: reservation.renterId ?? undefined, email: customer.email, channels: ['EMAIL', 'IN_APP'], - locale: lang, + locale: reservation.renterId ? undefined : fallbackLocale, + templateKey: 'booking.confirmed', + templateVariables: { + firstName: customer.firstName, + companyName: company?.brand?.displayName ?? company?.name ?? 'RentalDriveGo', + startDate: reservation.startDate, + endDate: reservation.endDate, + vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`, + }, }).catch(() => null) return updated diff --git a/apps/api/src/modules/reservations/reservation.test.ts b/apps/api/src/modules/reservations/reservation.test.ts index 418345a..6bd1004 100644 --- a/apps/api/src/modules/reservations/reservation.test.ts +++ b/apps/api/src/modules/reservations/reservation.test.ts @@ -168,7 +168,8 @@ describe('confirmReservation', () => { vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any) vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'CONFIRMED' } as any) vi.mocked(repo.findCustomerById).mockResolvedValue({ email: 'ali@test.com', firstName: 'Ali' } as any) - vi.mocked(repo.findBrandLocale).mockResolvedValue({ defaultLocale: 'fr' } as any) + vi.mocked(repo.findCompanyWithBrand).mockResolvedValue({ name: 'TestCo', brand: { displayName: 'TestCo', defaultLocale: 'fr' } } as any) + vi.mocked(repo.findVehicle).mockResolvedValue({ year: 2022, make: 'Toyota', model: 'Camry' } as any) const result = await confirmReservation(RES_ID, COMPANY) diff --git a/apps/api/src/services/notificationLocalizationService.test.ts b/apps/api/src/services/notificationLocalizationService.test.ts new file mode 100644 index 0000000..17075c6 --- /dev/null +++ b/apps/api/src/services/notificationLocalizationService.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest' +import { + renderLocalizedEmailHtml, + resolveNotificationLocale, + resolveNotificationTemplate, +} from './notificationLocalizationService' + +function createDbStub(overrides: Record = {}) { + 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('') + expect(html).toContain('text-align:right') + }) +}) diff --git a/apps/api/src/services/notificationLocalizationService.ts b/apps/api/src/services/notificationLocalizationService.ts new file mode 100644 index 0000000..8599df2 --- /dev/null +++ b/apps/api/src/services/notificationLocalizationService.ts @@ -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 = { + en: { intl: 'en-US', direction: 'ltr' }, + fr: { intl: 'fr-FR', direction: 'ltr' }, + ar: { intl: 'ar-MA', direction: 'rtl' }, +} + +const subscriptionStatusLabels: Record> = { + 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> = { + 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> = { + en: { + monthly: 'monthly', + annual: 'annual', + }, + fr: { + monthly: 'mensuel', + annual: 'annuel', + }, + ar: { + monthly: 'شهري', + annual: 'سنوي', + }, +} + +const planLabels: Record> = { + 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 + +type NotificationLocalizationDb = Pick< + typeof prisma, + 'brandSettings' | 'billingAccount' | 'employee' | 'notificationTemplate' | 'renter' +> + +type NotificationTemplateRecord = { + templateKey: string + channel: NotificationChannel + locale: string + subject: string | null + body: string + requiredVariables: unknown + optionalVariables: unknown + version: number +} + +export function coerceNotificationLocale(locale?: string | null): NotificationLocale { + if (locale && SUPPORTED_NOTIFICATION_LOCALES.includes(locale as NotificationLocale)) { + return locale as NotificationLocale + } + + return DEFAULT_NOTIFICATION_LOCALE +} + +export function getNotificationTextDirection(locale: NotificationLocale) { + return localeConfig[locale].direction +} + +export function formatLocalizedDate( + value: Date | string | number, + locale: NotificationLocale, + options?: Intl.DateTimeFormatOptions, +) { + const date = value instanceof Date ? value : new Date(value) + return new Intl.DateTimeFormat(localeConfig[locale].intl, options ?? { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(date) +} + +export function formatLocalizedCurrency(amountMinor: number, currency: string, locale: NotificationLocale) { + return new Intl.NumberFormat(localeConfig[locale].intl, { + style: 'currency', + currency, + }).format(amountMinor / 100) +} + +export function localizeSubscriptionStatus(status: string, locale: NotificationLocale) { + const normalized = status.toLowerCase() + return subscriptionStatusLabels[locale][normalized] ?? status +} + +export function localizeInvoiceStatus(status: string, locale: NotificationLocale) { + const normalized = status.toLowerCase() + return invoiceStatusLabels[locale][normalized] ?? status +} + +export function localizeBillingPeriod(period: string, locale: NotificationLocale) { + const normalized = period.toLowerCase() + return billingPeriodLabels[locale][normalized] ?? period +} + +export function localizePlanName(plan: string, locale: NotificationLocale) { + const normalized = plan.toLowerCase() + return planLabels[locale][normalized] ?? plan +} + +function isTemplateDateValue(value: NotificationTemplateValue): value is TemplateDateValue { + return typeof value === 'object' && value !== null && 'type' in value && value.type === 'date' +} + +function isTemplateCurrencyValue(value: NotificationTemplateValue): value is TemplateCurrencyValue { + return typeof value === 'object' && value !== null && 'type' in value && value.type === 'currency' +} + +function isTemplateSubscriptionStatusValue(value: NotificationTemplateValue): value is TemplateSubscriptionStatusValue { + return typeof value === 'object' && value !== null && 'type' in value && value.type === 'subscription_status' +} + +function isTemplateInvoiceStatusValue(value: NotificationTemplateValue): value is TemplateInvoiceStatusValue { + return typeof value === 'object' && value !== null && 'type' in value && value.type === 'invoice_status' +} + +function formatTemplateValue(value: NotificationTemplateValue, locale: NotificationLocale): string { + if (value instanceof Date) return formatLocalizedDate(value, locale) + if (isTemplateDateValue(value)) return formatLocalizedDate(value.value, locale, value.options) + if (isTemplateCurrencyValue(value)) return formatLocalizedCurrency(value.amountMinor, value.currency, locale) + if (isTemplateSubscriptionStatusValue(value)) return localizeSubscriptionStatus(value.status, locale) + if (isTemplateInvoiceStatusValue(value)) return localizeInvoiceStatus(value.status, locale) + if (value === null || value === undefined) return '' + return String(value) +} + +function normalizeTemplateVariableList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === 'string') +} + +function escapeHtml(value: string) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +async function findTemplate( + db: NotificationLocalizationDb, + templateKey: string, + channel: NotificationChannel, + locale: NotificationLocale, +): Promise { + 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 { + 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) => `

${escapeHtml(paragraph).replace(/\n/g, '
')}

`) + .join('') + + return [ + ``, + ``, + paragraphs, + '', + '', + ].join('') +} diff --git a/apps/api/src/services/notificationService.ts b/apps/api/src/services/notificationService.ts index bb1d54b..79bdd66 100644 --- a/apps/api/src/services/notificationService.ts +++ b/apps/api/src/services/notificationService.ts @@ -4,6 +4,12 @@ import admin from 'firebase-admin' import { prisma } from '../lib/prisma' import { redis } from '../lib/redis' import { NotificationType, NotificationChannel } from '@rentaldrivego/database' +import { + renderLocalizedEmailHtml, + resolveNotificationLocale, + resolveNotificationTemplate, +} from './notificationLocalizationService' +import type { NotificationTemplateVariables } from './notificationLocalizationService' const resendApiKey = process.env.RESEND_API_KEY && @@ -100,33 +106,20 @@ if (hasFirebaseConfig && !admin.apps.length) { interface SendNotificationOptions { type: NotificationType - title: string - body: string + title?: string + body?: string data?: Record companyId?: string employeeId?: string renterId?: string + billingAccountId?: string email?: string phone?: string fcmToken?: string channels: NotificationChannel[] locale?: string -} - -function escapeHtml(value: string) { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -function renderEmailHtml(body: string) { - return body - .split(/\n{2,}/) - .map((paragraph) => `

${escapeHtml(paragraph).replace(/\n/g, '
')}

`) - .join('') + templateKey?: string + templateVariables?: NotificationTemplateVariables } function resolveSmtpReplyTo() { @@ -210,16 +203,41 @@ async function sendEmailWithProviders(opts: { export async function sendNotification(opts: SendNotificationOptions) { const results: Array<{ channel: NotificationChannel; success: boolean; error?: string }> = [] + const resolvedLocale = await resolveNotificationLocale({ + companyId: opts.companyId, + employeeId: opts.employeeId, + renterId: opts.renterId, + billingAccountId: opts.billingAccountId, + locale: opts.locale, + }) for (const channel of opts.channels) { try { + const rendered = opts.templateKey + ? await resolveNotificationTemplate({ + templateKey: opts.templateKey, + channel, + locale: resolvedLocale, + variables: opts.templateVariables, + }) + : null + + const title = rendered?.subject ?? opts.title + const body = rendered?.body ?? opts.body + + if (!title || !body) { + throw new Error(`Notification content is incomplete for channel ${channel}`) + } + const notification = await prisma.notification.create({ data: { type: opts.type, - title: opts.title, - body: opts.body, + title, + body, data: (opts.data ?? {}) as any, channel, + templateKey: rendered?.templateKey ?? opts.templateKey ?? null, + locale: rendered?.locale ?? resolvedLocale, status: 'PENDING', companyId: opts.companyId ?? null, employeeId: opts.employeeId ?? null, @@ -233,9 +251,9 @@ export async function sendNotification(opts: SendNotificationOptions) { if (channel === 'EMAIL' && opts.email) { const emailResult = await sendEmailWithProviders({ to: opts.email, - subject: opts.title, - html: renderEmailHtml(opts.body), - text: opts.body, + subject: title, + html: renderLocalizedEmailHtml(body, rendered?.locale ?? resolvedLocale), + text: body, }) providerMessageId = emailResult.providerMessageId success = true @@ -244,7 +262,7 @@ export async function sendNotification(opts: SendNotificationOptions) { if (channel === 'SMS' && opts.phone) { if (!twilioClient) throw new Error('Twilio is not configured') const msg = await twilioClient.messages.create({ - body: opts.body, + body, from: process.env.TWILIO_PHONE_NUMBER!, to: opts.phone, }) @@ -255,7 +273,7 @@ export async function sendNotification(opts: SendNotificationOptions) { if (channel === 'WHATSAPP' && opts.phone) { if (!twilioClient) throw new Error('Twilio is not configured') const msg = await twilioClient.messages.create({ - body: opts.body, + body, from: `whatsapp:${process.env.TWILIO_WHATSAPP_NUMBER}`, to: `whatsapp:${opts.phone}`, }) @@ -267,7 +285,7 @@ export async function sendNotification(opts: SendNotificationOptions) { if (!admin.apps.length) throw new Error('Firebase is not configured') const response = await admin.messaging().send({ token: opts.fcmToken, - notification: { title: opts.title, body: opts.body }, + notification: { title, body }, data: Object.fromEntries( Object.entries(opts.data ?? {}).map(([k, v]) => [k, String(v)]) ), diff --git a/apps/api/src/services/teamService.ts b/apps/api/src/services/teamService.ts index 6b87a80..3264062 100644 --- a/apps/api/src/services/teamService.ts +++ b/apps/api/src/services/teamService.ts @@ -2,6 +2,7 @@ import crypto from 'crypto' import { EmployeeRole } from '@rentaldrivego/database' import { prisma } from '../lib/prisma' import { sendTransactionalEmail } from './notificationService' +import { coerceNotificationLocale } from './notificationLocalizationService' const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7 @@ -39,9 +40,45 @@ function ensureDashboardBasePath(baseUrl: string) { } } -function buildInviteEmail(resetUrl: string, companyName: string, firstName: string, role: EmployeeRole) { +function buildInviteEmail( + resetUrl: string, + companyName: string, + firstName: string, + role: EmployeeRole, + locale: ReturnType, +) { const greetingName = firstName.trim() || 'there' + if (locale === 'fr') { + return { + subject: `Vous avez ete invite chez ${companyName}`, + html: ` +

Bonjour ${greetingName},

+

Vous avez ete invite a rejoindre ${companyName} sur RentalDriveGo en tant que ${role.toLowerCase()}.

+

Definir votre mot de passe

+

Ce lien d'invitation expire dans 7 jours.

+

RentalDriveGo

+ `, + 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: ` +
+

مرحباً ${greetingName}،

+

تمت دعوتك للانضمام إلى ${companyName} على RentalDriveGo بصفتك ${role.toLowerCase()}.

+

عيّن كلمة المرور

+

تنتهي صلاحية رابط الدعوة هذا خلال 7 أيام.

+

RentalDriveGo

+
+ `, + text: `مرحباً ${greetingName}،\n\nتمت دعوتك للانضمام إلى ${companyName} على RentalDriveGo بصفتك ${role.toLowerCase()}.\n\nعيّن كلمة المرور هنا: ${resetUrl}\n\nتنتهي صلاحية رابط الدعوة هذا خلال 7 أيام.\n\nRentalDriveGo`, + } + } + return { subject: `You have been invited to ${companyName}`, html: ` @@ -78,9 +115,13 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo throw Object.assign(new Error('An employee with this email already exists in your team'), { statusCode: 409, code: 'employee_already_exists' }) } - const company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } }) + const company = await prisma.company.findUniqueOrThrow({ + where: { id: companyId }, + include: { brand: { select: { defaultLocale: true, displayName: true } } }, + }) const rawToken = crypto.randomBytes(32).toString('hex') const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000) + const locale = coerceNotificationLocale(company.brand?.defaultLocale) const employee = await prisma.employee.create({ data: { @@ -91,6 +132,7 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo email: payload.email, role: payload.role, isActive: true, + preferredLanguage: locale, passwordResetToken: rawToken, passwordResetExpiresAt: expiresAt, }, @@ -100,7 +142,13 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard', ) const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}` - const message = buildInviteEmail(resetUrl, company.name, payload.firstName, payload.role) + const message = buildInviteEmail( + resetUrl, + company.brand?.displayName ?? company.name, + payload.firstName, + payload.role, + locale, + ) await sendTransactionalEmail({ to: payload.email, diff --git a/apps/dashboard/src/app/(dashboard)/notifications/page.tsx b/apps/dashboard/src/app/(dashboard)/notifications/page.tsx index 288248b..b978bd4 100644 --- a/apps/dashboard/src/app/(dashboard)/notifications/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/notifications/page.tsx @@ -9,8 +9,13 @@ type NotificationItem = { type: string title: string body: string + channel: string + status: string + sentAt: string | null readAt: string | null createdAt: string + providerMessageId: string | null + locale: string } type PreferenceItem = { @@ -30,19 +35,42 @@ const COMPANY_EVENTS = [ const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH'] +const CHANNEL_BADGE: Record = { + 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 = { + PENDING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300', + SENT: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + DELIVERED: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + FAILED: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + READ: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400', +} + export default function DashboardNotificationsPage() { const { language } = useDashboardI18n() const [notifications, setNotifications] = useState([]) + const [history, setHistory] = useState([]) const [preferences, setPreferences] = useState>({}) - const [activeTab, setActiveTab] = useState<'inbox' | 'preferences'>('inbox') + const [activeTab, setActiveTab] = useState<'inbox' | 'history' | 'preferences'>('inbox') const [loading, setLoading] = useState(true) + const [historyLoading, setHistoryLoading] = useState(false) + const [historyLoaded, setHistoryLoaded] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) + const [filterChannel, setFilterChannel] = useState('') + const [filterStatus, setFilterStatus] = useState('') + const copy = { en: { title: 'Notifications', - subtitle: 'Track in-app alerts and control delivery preferences for your team account.', + subtitle: 'Track in-app alerts, audit delivery history, and control preferences for your team account.', inbox: 'Inbox', + history: 'History', preferences: 'Preferences', markAllRead: 'Mark all as read', loading: 'Loading notifications…', @@ -50,24 +78,35 @@ export default function DashboardNotificationsPage() { read: 'Read', markRead: 'Mark as read', event: 'Event', + channel: 'Channel', + status: 'Status', + sentAt: 'Sent at', + date: 'Date', + allChannels: 'All channels', + allStatuses: 'All statuses', savePreferences: 'Save preferences', saving: 'Saving…', failedLoad: 'Failed to load notifications', failedSave: 'Failed to save preferences', + noHistory: 'No notification history found.', channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record, + statuses: { PENDING: 'Pending', SENT: 'Sent', DELIVERED: 'Delivered', FAILED: 'Failed', READ: 'Read' } as Record, events: { NEW_RESERVATION: 'New reservation', RESERVATION_CANCELLED: 'Reservation cancelled', PAYMENT_RECEIVED: 'Payment received', SUBSCRIPTION_TRIAL_ENDING: 'Trial ending', - MAINTENANCE_DUE: 'Maintenance à prévoir', + MAINTENANCE_DUE: 'Maintenance due', OFFER_EXPIRING: 'Offer expiring', + BOOKING_CONFIRMED: 'Booking confirmed', + VEHICLE_MAINTENANCE_DUE: 'Vehicle maintenance due', } as Record, }, fr: { title: 'Notifications', - subtitle: 'Suivez les alertes de l’application et gérez les préférences de diffusion de votre équipe.', + subtitle: 'Suivez les alertes, auditez l'historique de diffusion et gérez les préférences de votre équipe.', inbox: 'Boîte de réception', + history: 'Historique', preferences: 'Préférences', markAllRead: 'Tout marquer comme lu', loading: 'Chargement des notifications…', @@ -75,24 +114,35 @@ export default function DashboardNotificationsPage() { read: 'Lu', markRead: 'Marquer comme lu', event: 'Événement', + channel: 'Canal', + status: 'Statut', + sentAt: 'Envoyé le', + date: 'Date', + allChannels: 'Tous les canaux', + allStatuses: 'Tous les statuts', savePreferences: 'Enregistrer les préférences', saving: 'Enregistrement…', failedLoad: 'Échec du chargement des notifications', - failedSave: 'Échec de l’enregistrement des préférences', - channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l’application', PUSH: 'Push' } as Record, + failedSave: 'Échec de l'enregistrement des préférences', + noHistory: 'Aucun historique de notification trouvé.', + channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l'application', PUSH: 'Push' } as Record, + statuses: { PENDING: 'En attente', SENT: 'Envoyé', DELIVERED: 'Délivré', FAILED: 'Échoué', READ: 'Lu' } as Record, events: { NEW_RESERVATION: 'Nouvelle réservation', RESERVATION_CANCELLED: 'Réservation annulée', PAYMENT_RECEIVED: 'Paiement reçu', - SUBSCRIPTION_TRIAL_ENDING: 'Fin d’essai proche', + SUBSCRIPTION_TRIAL_ENDING: 'Fin d'essai proche', MAINTENANCE_DUE: 'Maintenance due', OFFER_EXPIRING: 'Offre expirante', + BOOKING_CONFIRMED: 'Réservation confirmée', + VEHICLE_MAINTENANCE_DUE: 'Maintenance véhicule due', } as Record, }, ar: { title: 'الإشعارات', - subtitle: 'تابع تنبيهات التطبيق وتحكم في تفضيلات الإرسال لحساب فريقك.', + subtitle: 'تابع التنبيهات وراجع سجل الإرسال وتحكم في التفضيلات لحساب فريقك.', inbox: 'صندوق الوارد', + history: 'السجل', preferences: 'التفضيلات', markAllRead: 'تحديد الكل كمقروء', loading: 'جارٍ تحميل الإشعارات…', @@ -100,11 +150,19 @@ export default function DashboardNotificationsPage() { read: 'مقروء', markRead: 'تحديد كمقروء', event: 'الحدث', + channel: 'القناة', + status: 'الحالة', + sentAt: 'وقت الإرسال', + date: 'التاريخ', + allChannels: 'جميع القنوات', + allStatuses: 'جميع الحالات', savePreferences: 'حفظ التفضيلات', saving: 'جارٍ الحفظ…', failedLoad: 'فشل تحميل الإشعارات', failedSave: 'فشل حفظ التفضيلات', + noHistory: 'لا يوجد سجل إشعارات.', channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record, + statuses: { PENDING: 'قيد الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', READ: 'مقروء' } as Record, events: { NEW_RESERVATION: 'حجز جديد', RESERVATION_CANCELLED: 'إلغاء حجز', @@ -112,6 +170,8 @@ export default function DashboardNotificationsPage() { SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة', MAINTENANCE_DUE: 'صيانة مستحقة', OFFER_EXPIRING: 'عرض على وشك الانتهاء', + BOOKING_CONFIRMED: 'تأكيد الحجز', + VEHICLE_MAINTENANCE_DUE: 'صيانة المركبة مستحقة', } as Record, }, }[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(`/notifications/history${qs ? `?${qs}` : ''}`) + setHistory(data) + setHistoryLoaded(true) + } catch (err: any) { + setError(err.message ?? copy.failedLoad) + } finally { + setHistoryLoading(false) + } + } + useEffect(() => { load() }, []) + useEffect(() => { + if (activeTab === 'history') { + loadHistory() + } + }, [activeTab, filterChannel, filterStatus]) + async function markRead(id: string) { await apiFetch(`/notifications/company/${id}/read`, { method: 'POST' }) setNotifications((current) => @@ -184,12 +268,16 @@ export default function DashboardNotificationsPage() { })) } + function labelEvent(type: string) { + return copy.events[type] ?? type.replaceAll('_', ' ') + } + return (
-

{copy.title}

-

{copy.subtitle}

+

{copy.title}

+

{copy.subtitle}

+
- {error ?
{error}
: null} + {error ?
{error}
: null} + {/* ── Inbox ── */} {activeTab === 'inbox' ? (
@@ -225,34 +321,118 @@ export default function DashboardNotificationsPage() {
-

- {copy.events[notification.type] ?? notification.type.replaceAll('_', ' ')} +

+ {labelEvent(notification.type)}

-

{notification.title}

+

{notification.title}

{notification.readAt ? ( - {copy.read} + {copy.read} ) : ( )}
-

{notification.body}

+

{notification.body}

{new Date(notification.createdAt).toLocaleString()}

)) : null}
- ) : ( + ) : null} + + {/* ── History ── */} + {activeTab === 'history' ? ( +
+ {/* Filters */} +
+ + +
+ + {historyLoading ? ( +
{copy.loading}
+ ) : historyLoaded && history.length === 0 ? ( +
{copy.noHistory}
+ ) : ( +
+
+ + + + + + + + + + + + + {history.map((item) => ( + + + + + + + + + ))} + +
{copy.date}{copy.event}{copy.channel}{copy.title ?? 'Title'}{copy.status}{copy.sentAt}
+ {new Date(item.createdAt).toLocaleString()} + + {labelEvent(item.type)} + + + {copy.channels[item.channel] ?? item.channel} + + +

{item.title}

+

{item.body}

+
+ + {copy.statuses[item.status] ?? item.status} + + + {item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'} +
+
+
+ )} +
+ ) : null} + + {/* ── Preferences ── */} + {activeTab === 'preferences' ? (
- + - + {COMPANY_CHANNELS.map((channel) => ( - ))} @@ -260,8 +440,8 @@ export default function DashboardNotificationsPage() { {COMPANY_EVENTS.map((eventName) => ( - - + + {COMPANY_CHANNELS.map((channel) => (
{copy.event}{copy.event} + {copy.channels[channel] ?? channel.replace('_', ' ')}
{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}
{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}
-
+
- )} + ) : null}
) } diff --git a/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx b/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx index 319f09f..dfc30c4 100644 --- a/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx +++ b/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx @@ -8,7 +8,7 @@ import { useDashboardI18n } from '@/components/I18nProvider' import PublicShell from '@/components/layout/PublicShell' import { adminUrl, marketplaceUrl } from '@/lib/urls' import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api' -import { toDashboardAppPath, toPublicDashboardPath } from '@/lib/dashboardPaths' +import { toPublicDashboardPath } from '@/lib/dashboardPaths' const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png' @@ -209,7 +209,6 @@ function LocalSignInForm({ unexpectedError: string } }) { - const router = useRouter() const searchParams = useSearchParams() const { setLanguage } = useDashboardI18n() const [email, setEmail] = useState('') @@ -221,7 +220,6 @@ function LocalSignInForm({ const [error, setError] = useState(null) const adminNext = searchParams.get('next') || '/dashboard' const employeeRedirect = searchParams.get('redirect') || '/dashboard' - const employeeAppRedirect = toDashboardAppPath(employeeRedirect) function redirectAdmin(token: string) { const hash = new URLSearchParams({ token, next: adminNext }).toString() @@ -243,6 +241,7 @@ function LocalSignInForm({ if (empRes.ok && empJson?.data?.token) { const token = empJson.data.token + const targetPath = toPublicDashboardPath(employeeRedirect) localStorage.setItem(EMPLOYEE_TOKEN_KEY, token) if (empJson?.data?.employee) { localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee)) @@ -254,8 +253,10 @@ function LocalSignInForm({ } document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax` window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed')) - notifyParent({ type: 'rentaldrivego:employee-login', path: toPublicDashboardPath(employeeRedirect) }) - router.push(employeeAppRedirect) + notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath }) + // Use a document navigation here so the authenticated dashboard bootstraps + // from a fresh request after the token cookie and localStorage are set. + window.location.replace(targetPath) return } diff --git a/apps/dashboard/src/components/layout/TopBar.tsx b/apps/dashboard/src/components/layout/TopBar.tsx index b649bd1..9020cd6 100644 --- a/apps/dashboard/src/components/layout/TopBar.tsx +++ b/apps/dashboard/src/components/layout/TopBar.tsx @@ -3,6 +3,7 @@ import { Bell } from 'lucide-react' import { usePathname, useRouter } from 'next/navigation' import { useState, useEffect } from 'react' +import { io } from 'socket.io-client' import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api' import { useDashboardI18n } from '@/components/I18nProvider' import { toDashboardAppPath } from '@/lib/dashboardPaths' @@ -34,6 +35,11 @@ export default function TopBar() { const [mounted, setMounted] = useState(false) useEffect(() => { setMounted(true) }, []) + function getEmployeeToken() { + if (typeof window === 'undefined') return null + return window.localStorage.getItem(EMPLOYEE_TOKEN_KEY) + } + const title = (() => { if (!mounted) return dict.titles['/'] if (dict.titles[appPath]) return dict.titles[appPath] @@ -42,6 +48,11 @@ export default function TopBar() { return dict.titles['/'] })() async function refreshUnreadCount() { + if (!getEmployeeToken()) { + setUnreadCount(0) + return + } + try { const data = await apiFetch<{ unread: number }>('/notifications/unread-count') setUnreadCount(data.unread) @@ -60,8 +71,29 @@ export default function TopBar() { return () => window.removeEventListener('notifications:updated', onNotificationsUpdated) }, []) + useEffect(() => { + const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY) + if (!token) return + + const socketUrl = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1').replace('/api/v1', '') + const socket = io(socketUrl, { auth: { token }, transports: ['websocket'] }) + + socket.on('notification', () => { + setUnreadCount((prev) => prev + 1) + window.dispatchEvent(new Event('notifications:updated')) + }) + + return () => { socket.disconnect() } + }, []) + useEffect(() => { if (!showNotifs) return + if (!getEmployeeToken()) { + setNotifications([]) + setLoadingNotifs(false) + return + } + let cancelled = false setLoadingNotifs(true) apiFetch
- -### Signature du Locataire - -**Nom :** [●] -**Signature :** - -

- ---- - -# 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 :** - -

- -**Signature du Locataire :** - -

- ---- - -# 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 :** - -

- -**Signature du Locataire :** - -

- ---- - -# 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 d’assurance, 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é à l’activité réelle du Loueur, à ses conditions d’assurance, à 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. diff --git a/contrat_location_voiture_maroc_updated.md b/contrat_location_voiture_maroc_updated.md deleted file mode 100644 index 544f1de..0000000 --- a/contrat_location_voiture_maroc_updated.md +++ /dev/null @@ -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 d’exercice n° :** [●] -**Date de délivrance de l’agré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 l’activité de location de véhicules sans chauffeur conformément à la réglementation marocaine applicable. - -La personne responsable de l’activité est : - -**Nom et prénom :** [●] -**Qualité :** [Dirigeant / Actionnaire / Salarié] -**Pièce d’identité n° :** [●] -**Qualification / diplôme / expérience :** [●] -**Téléphone :** [●] -**Email :** [●] - -Cette personne est responsable du suivi des contrats, de l’entretien 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 l’exercice de l’activité ; -- être régulièrement immatriculé auprès des administrations compétentes ; -- disposer d’un 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 d’un permis de conduire valide : - -**Numéro du permis :** [●] -**Catégorie :** [B / autre] -**Date de délivrance :** [●] -**Date d’expiration :** [●] -**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 qu’il n’est frappé d’aucune 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 d’accident, vol, infraction ou dommage causé par un conducteur non autorisé, le Locataire assume l’entiè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 d’exploitation 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 d’exploitation :** [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 d’exploitation applicables à sa catégorie, notamment en matière d’âge, d’immatriculation, d’assurance, d’entretien 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 d’anomalie mécanique, voyant d’alerte, bruit anormal, crevaison, panne ou incident susceptible d’affecter la sécurité, le Locataire doit cesser l’utilisation 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 l’assurance ; -- la franchise d’assurance ; -- 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 d’assurance :** [●] -**Numéro de police :** [●] -**Type de couverture :** [Responsabilité civile / Tous risques / autre] -**Franchise applicable :** [●] MAD -**Assistance :** [Oui / Non] -**Numéro d’assistance :** [●] - -Le Locataire reconnaît avoir été informé des garanties, exclusions et franchises applicables. - -Sont notamment exclus de la couverture, sauf stipulation contraire de l’assureur : - -- conduite par une personne non autorisée ; -- conduite sans permis valide ; -- conduite sous l’emprise d’alcool, 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 d’accident 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 s’engage à 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 ; -- d’utiliser 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 l’agence l’interdit ; -- 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, à l’heure et au lieu prévus au contrat, dans l’état où il lui a été remis, sous réserve de l’usure normale. - -Le véhicule doit être restitué avec : - -- le même niveau de carburant qu’au 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 l’inté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 d’assurance applicables. - ---- - -## 15. Accident, panne, vol ou sinistre - -En cas d’accident, 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 d’accident ; -- 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 l’assureur. - -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 l’assureur 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 l’assurance ; -- 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 l’assurance ; -- de la franchise prévue au contrat d’assurance. - -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 l’identité 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 l’infraction. - -**Frais administratifs par infraction : [●] MAD TTC.** - ---- - -## 18. Annulation, non-présentation et résiliation - -En cas d’annulation 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 d’usage illicite. - -Dans ce cas, le Locataire doit restituer immédiatement le véhicule. - ---- - -## 19. Indisponibilité administrative, réglementaire ou technique - -En cas d’indisponibilité du véhicule ou d’impossibilité d’exé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 à l’exé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 : - -- l’exé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 s’engage à 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 l’attestation d’assurance ; -- Justificatif autorisant l’exploitation du véhicule par le Loueur, si le véhicule n’appartient 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 s’efforceront de trouver une solution amiable. - -À défaut d’accord amiable, le litige sera porté devant le tribunal compétent du ressort de : - -**[Ville du siège de l’agence / 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 d’un permis valide ; -- s’engager à 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 :** - -

- -### Signature du Locataire - -**Nom :** [●] -**Signature :** - -

- ---- - -# 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 :** - -

- -**Signature du Locataire :** - -

- ---- - -# 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 :** - -

- -**Signature du Locataire :** - -

- ---- - -# 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 d’assurance, 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é à l’activité réelle du Loueur, à ses conditions d’assurance, à 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 d’exercice 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 d’exploitation des véhicules disponibles. -- Assurance et visite technique valides pour chaque véhicule. -- Procédure de déclaration en cas de suspension ou cessation d’activité. - diff --git a/packages/database/prisma/migrations/20260525190000_notification_localization/migration.sql b/packages/database/prisma/migrations/20260525190000_notification_localization/migration.sql new file mode 100644 index 0000000..58608cb --- /dev/null +++ b/packages/database/prisma/migrations/20260525190000_notification_localization/migration.sql @@ -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 + ); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 3f99cb7..3e360e5 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -377,6 +377,7 @@ enum AdminAction { } enum NotificationType { + ACCOUNT_CREATED NEW_BOOKING BOOKING_CANCELLED PAYMENT_RECEIVED @@ -540,6 +541,7 @@ model BillingAccount { isPrimary Boolean @default(true) legalName String billingEmail String + preferredLanguage String @default("en") billingAddress Json? taxId String? taxExempt Boolean @default(false) @@ -1218,6 +1220,8 @@ model Notification { body String data Json? channel NotificationChannel + templateKey String? + locale String @default("en") status NotificationStatus @default(PENDING) sentAt DateTime? failReason String? @@ -1231,6 +1235,26 @@ model Notification { @@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 { id String @id @default(cuid()) employeeId String? diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index f24d8d8..81d60a4 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -9,6 +9,7 @@ export type AdminRole = 'SUPER_ADMIN' | 'ADMIN' | 'SUPPORT' | 'FINANCE' | 'VIEWE export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT' export type LicenseStatus = 'PENDING' | 'VALID' | 'EXPIRING' | 'APPROVED' | 'DENIED' | 'EXPIRED' export type NotificationType = + | 'ACCOUNT_CREATED' | 'NEW_BOOKING' | 'BOOKING_CANCELLED' | 'PAYMENT_RECEIVED'