notification implemented

This commit is contained in:
root
2026-05-25 13:12:06 -04:00
parent 33b6bb55f0
commit c8bde121fc
31 changed files with 1491 additions and 1291 deletions
+25
View File
@@ -488,3 +488,28 @@ export function updatePromotion(id: string, data: Partial<{
export function deletePromotion(id: string) {
return (prisma as any).pricingPromotion.delete({ where: { id } })
}
export async function listNotificationsPage(query: {
channel?: string
status?: string
companyId?: string
page: number
pageSize: number
}) {
const where: any = {}
if (query.channel) where.channel = query.channel
if (query.status) where.status = query.status
if (query.companyId) where.companyId = query.companyId
const [data, total] = await Promise.all([
prisma.notification.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
include: { company: { select: { name: true } } },
}),
prisma.notification.count({ where }),
])
return { data, total }
}
+9 -1
View File
@@ -7,7 +7,7 @@ import * as subService from '../subscriptions/subscription.service'
import { presentAdminUser } from './admin.presenter'
import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema,
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, notificationsQuerySchema,
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
homepageUpdateSchema, idParamSchema, companyIdParamSchema, billingAccountIdParamSchema, invoiceIdParamSchema,
@@ -158,6 +158,14 @@ router.get('/metrics', requireAdminAuth, requireAdminRole('FINANCE'), async (req
} catch (err) { next(err) }
})
// ─── Notifications ─────────────────────────────────────────────
router.get('/notifications', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
ok(res, await service.listNotifications(parseQuery(notificationsQuerySchema, req)))
} catch (err) { next(err) }
})
// ─── Audit logs ────────────────────────────────────────────────
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
@@ -44,6 +44,14 @@ export const auditLogQuerySchema = z.object({
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
export const notificationsQuerySchema = z.object({
channel: z.string().optional(),
status: z.string().optional(),
companyId: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
})
export const billingQuerySchema = z.object({
q: z.string().optional(),
status: z.string().optional(),
@@ -193,6 +193,11 @@ export function getPlatformMetrics() {
return repo.getPlatformMetricCounts()
}
export async function listNotifications(query: { channel?: string; status?: string; companyId?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listNotificationsPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export async function getAuditLogs(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listAuditLogsPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
@@ -70,6 +70,7 @@ export async function createCompanySignup(db: DbClient, input: CompanySignupInpu
publicAddress: input.streetAddress,
publicCity: input.city,
publicCountry: input.country,
defaultLocale: input.preferredLanguage,
defaultCurrency: input.currency,
},
})
@@ -2,7 +2,11 @@ import bcrypt from 'bcryptjs'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { sendNotification } from '../../services/notificationService'
import { signupEmail, type Lang } from '../../lib/emailTranslations'
import {
coerceNotificationLocale,
localizeBillingPeriod,
localizePlanName,
} from '../../services/notificationLocalizationService'
import { presentCompanySignup } from './auth.presenter'
import * as repo from './auth.company.repo'
import { companySignupSchema } from './auth.company.schemas'
@@ -66,24 +70,24 @@ export async function signup(body: CompanySignupInput) {
trialEndAt,
}))
const lang = body.preferredLanguage as Lang
const lang = coerceNotificationLocale(body.preferredLanguage)
const emailResult = await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
title: signupEmail.subject(lang),
body: signupEmail.text({
firstName: body.firstName,
companyName: body.companyName,
plan: body.plan,
billingPeriod: body.billingPeriod,
currency: body.currency,
paymentProvider: body.paymentProvider,
trialEnd: trialEndAt,
}, lang),
type: 'ACCOUNT_CREATED',
companyId: result.company.id,
employeeId: result.employee.id,
email: body.email,
channels: ['EMAIL', 'IN_APP'],
locale: lang,
templateKey: 'account.created',
templateVariables: {
firstName: body.firstName,
companyName: body.companyName,
planName: localizePlanName(body.plan, lang),
billingPeriodLabel: localizeBillingPeriod(body.billingPeriod, lang),
currency: body.currency,
paymentProvider: body.paymentProvider,
trialEndDate: trialEndAt,
},
}).catch(() => [])
const emailDelivery = (emailResult as Array<{ channel?: string }>).find((entry) => entry.channel === 'EMAIL')
@@ -35,6 +35,20 @@ export async function upsertEmployeePreferences(employeeId: string, prefs: Array
}
}
export function findCompanyHistory(
companyId: string,
opts?: { channel?: string; status?: string; limit?: number },
) {
const where: any = { companyId }
if (opts?.channel) where.channel = opts.channel
if (opts?.status) where.status = opts.status
return prisma.notification.findMany({
where,
orderBy: { createdAt: 'desc' },
take: opts?.limit ?? 200,
})
}
// ─── Renter notifications ─────────────────────────────────────
export function findRenter(renterId: string) {
@@ -6,7 +6,7 @@ import { requireRenterAuth } from '../../middleware/requireRenterAuth'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok } from '../../http/respond'
import * as service from './notification.service'
import { preferencesSchema, idParamSchema, unreadQuerySchema } from './notification.schemas'
import { preferencesSchema, idParamSchema, unreadQuerySchema, historyQuerySchema } from './notification.schemas'
const router = Router()
@@ -56,6 +56,13 @@ router.patch('/company/preferences', ...companyAuth, async (req, res, next) => {
} catch (err) { next(err) }
})
router.get('/history', ...companyAuth, async (req, res, next) => {
try {
const { channel, status, limit } = parseQuery(historyQuerySchema, req)
ok(res, await service.listCompanyHistory(req.companyId, { channel, status, limit }))
} catch (err) { next(err) }
})
// ─── Renter notifications ─────────────────────────────────────
router.get('/renter', requireRenterAuth, async (req, res, next) => {
@@ -15,3 +15,9 @@ export const idParamSchema = z.object({
export const unreadQuerySchema = z.object({
unread: z.string().optional(),
})
export const historyQuerySchema = z.object({
channel: z.string().optional(),
status: z.string().optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
})
@@ -1,6 +1,7 @@
import * as repo from './notification.repo'
export const listCompany = (companyId: string, unread?: string) => repo.findCompany(companyId, unread)
export const listCompanyHistory = (companyId: string, opts?: { channel?: string; status?: string; limit?: number }) => repo.findCompanyHistory(companyId, opts)
export const countUnread = (companyId: string) => repo.countUnread(companyId)
export const markRead = (id: string, companyId: string) => repo.markRead(id, companyId)
export const markAllRead = (companyId: string) => repo.markAllRead(companyId)
@@ -3,7 +3,8 @@ import { prisma } from '../../lib/prisma'
import { AppError } from '../../http/errors'
import { validateLicense } from '../../services/licenseValidationService'
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
import { bookingConfirmedNotif, reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
import { coerceNotificationLocale } from '../../services/notificationLocalizationService'
import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
import * as repo from './reservation.repo'
@@ -39,19 +40,27 @@ export async function confirmReservation(id: string, companyId: string) {
const updated = await repo.updateById(id, { status: 'CONFIRMED' })
const [customer, brand] = await Promise.all([
const [customer, company, vehicle] = await Promise.all([
repo.findCustomerById(reservation.customerId),
repo.findBrandLocale(companyId),
repo.findCompanyWithBrand(companyId),
repo.findVehicle(reservation.vehicleId, companyId),
])
const lang = (brand?.defaultLocale ?? 'fr') as Lang
const fallbackLocale = coerceNotificationLocale(company?.brand?.defaultLocale)
await sendNotification({
type: 'BOOKING_CONFIRMED',
title: bookingConfirmedNotif.title(lang),
body: bookingConfirmedNotif.body(lang),
companyId,
renterId: reservation.renterId ?? undefined,
email: customer.email,
channels: ['EMAIL', 'IN_APP'],
locale: lang,
locale: reservation.renterId ? undefined : fallbackLocale,
templateKey: 'booking.confirmed',
templateVariables: {
firstName: customer.firstName,
companyName: company?.brand?.displayName ?? company?.name ?? 'RentalDriveGo',
startDate: reservation.startDate,
endDate: reservation.endDate,
vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`,
},
}).catch(() => null)
return updated
@@ -168,7 +168,8 @@ describe('confirmReservation', () => {
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'CONFIRMED' } as any)
vi.mocked(repo.findCustomerById).mockResolvedValue({ email: 'ali@test.com', firstName: 'Ali' } as any)
vi.mocked(repo.findBrandLocale).mockResolvedValue({ defaultLocale: 'fr' } as any)
vi.mocked(repo.findCompanyWithBrand).mockResolvedValue({ name: 'TestCo', brand: { displayName: 'TestCo', defaultLocale: 'fr' } } as any)
vi.mocked(repo.findVehicle).mockResolvedValue({ year: 2022, make: 'Toyota', model: 'Camry' } as any)
const result = await confirmReservation(RES_ID, COMPANY)