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