fix production issues
Build & Push / Pipeline Tests (push) Failing after 59s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 51s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Push / Pipeline Tests (push) Failing after 59s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 51s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
This commit is contained in:
+67
-2
@@ -9,6 +9,7 @@ import { authLimiter, apiLimiter, publicLimiter, adminLimiter, webhookLimiter }
|
||||
import { requireTrustedOriginForCookieMutations } from './middleware/csrf'
|
||||
import { sanitizeForwardedHeaders } from './middleware/forwardedHeaders'
|
||||
import { requestIdMiddleware } from './middleware/requestId'
|
||||
import { metricsMiddleware, renderPrometheusText, setGauge } from './lib/opsMetrics'
|
||||
|
||||
// ─── Module routes ────────────────────────────────────────────
|
||||
import webhookRouter from './modules/webhooks/webhook.routes'
|
||||
@@ -132,7 +133,9 @@ export const corsOptions: CorsOptions = {
|
||||
}
|
||||
|
||||
const routeDocs = [
|
||||
{ method: 'GET', path: '/health', description: 'Health check' },
|
||||
{ method: 'GET', path: '/health', description: 'Liveness health check' },
|
||||
{ method: 'GET', path: '/ready', description: 'Readiness probe (database, redis, storage)' },
|
||||
{ method: 'GET', path: '/metrics', description: 'Prometheus-style ops metrics' },
|
||||
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
|
||||
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
|
||||
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
|
||||
@@ -167,6 +170,7 @@ export function createApp() {
|
||||
|
||||
app.use(sanitizeForwardedHeaders)
|
||||
app.use(requestIdMiddleware)
|
||||
app.use(metricsMiddleware)
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.headers['x-middleware-subrequest']) {
|
||||
@@ -242,7 +246,21 @@ export function createApp() {
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
frameguard: { action: 'deny' },
|
||||
}))
|
||||
if (process.env.NODE_ENV !== 'test') app.use(morgan('combined'))
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.use(morgan((tokens, req, res) => {
|
||||
const requestId = (req as any).requestId ?? '-'
|
||||
return JSON.stringify({
|
||||
level: 'info',
|
||||
msg: 'http_request',
|
||||
requestId,
|
||||
method: tokens.method(req, res),
|
||||
url: tokens.url(req, res),
|
||||
status: Number(tokens.status(req, res)),
|
||||
durationMs: Number(tokens['response-time'](req, res)),
|
||||
contentLength: tokens.res(req, res, 'content-length'),
|
||||
})
|
||||
}))
|
||||
}
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
// ─── API Routes ─────────────────────────────────────────────
|
||||
@@ -292,6 +310,53 @@ export function createApp() {
|
||||
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
|
||||
})
|
||||
|
||||
app.get('/metrics', async (_req, res) => {
|
||||
try {
|
||||
const { prisma } = await import('./lib/prisma')
|
||||
const [pending, published] = await Promise.all([
|
||||
prisma.notificationOutbox.count({ where: { status: 'PENDING' } }),
|
||||
prisma.notificationOutbox.count({ where: { status: 'PUBLISHED' } }),
|
||||
])
|
||||
setGauge('notification_outbox_pending', pending)
|
||||
setGauge('notification_outbox_completed', published)
|
||||
} catch {
|
||||
/* leave previous gauges */
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8')
|
||||
res.status(200).send(renderPrometheusText())
|
||||
})
|
||||
|
||||
app.get('/ready', async (_req, res) => {
|
||||
const { prisma } = await import('./lib/prisma')
|
||||
const { redis } = await import('./lib/redis')
|
||||
const { checkStorageReady } = await import('./lib/storage')
|
||||
const checks: Record<string, 'ok' | 'error'> = { database: 'error', redis: 'error', storage: 'error' }
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
checks.database = 'ok'
|
||||
} catch {
|
||||
checks.database = 'error'
|
||||
}
|
||||
try {
|
||||
const pong = await redis.ping()
|
||||
checks.redis = pong === 'PONG' ? 'ok' : 'error'
|
||||
} catch {
|
||||
checks.redis = 'error'
|
||||
}
|
||||
try {
|
||||
await checkStorageReady()
|
||||
checks.storage = 'ok'
|
||||
} catch {
|
||||
checks.storage = 'error'
|
||||
}
|
||||
const ready = Object.values(checks).every((v) => v === 'ok')
|
||||
res.status(ready ? 200 : 503).json({
|
||||
status: ready ? 'ready' : 'not_ready',
|
||||
checks,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
app.get(`${v1}/docs`, (_req, res) => {
|
||||
if (!publicDocsEnabled) return docsDisabled(_req, res)
|
||||
res.json({
|
||||
|
||||
+51
-177
@@ -1,30 +1,22 @@
|
||||
import http from 'http'
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
import type { Socket } from 'socket.io'
|
||||
import cron from 'node-cron'
|
||||
import { redis } from './lib/redis'
|
||||
import { prisma } from './lib/prisma'
|
||||
import { assertStorageConfiguration } from './lib/storage'
|
||||
import { createApp, corsOrigins } from './app'
|
||||
import { verifyAnyActorToken } from './security/tokens'
|
||||
import { getSessionCookieName } from './security/sessionCookies'
|
||||
import { processNotificationOutbox, sendNotification } from './services/notificationService'
|
||||
import {
|
||||
runTrialExpirationJob,
|
||||
runPeriodEndCancellationJob,
|
||||
} from './modules/subscriptions/subscription.service'
|
||||
import { runCollectionsWorker } from './modules/subscriptions/subscription.collections.service'
|
||||
import { startOutboxWorker, startScheduledJobs } from './workers/jobs'
|
||||
|
||||
const app = createApp()
|
||||
const app = createApp()
|
||||
const server = http.createServer(app)
|
||||
assertStorageConfiguration()
|
||||
|
||||
// ─── Socket.io ────────────────────────────────────────────────
|
||||
const io = new SocketIOServer(server, {
|
||||
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
|
||||
})
|
||||
|
||||
|
||||
function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null {
|
||||
if (!cookieHeader) return null
|
||||
|
||||
@@ -49,10 +41,9 @@ function getSocketSessionToken(socket: Socket): string | undefined {
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticate socket connections via JWT before joining user rooms
|
||||
io.use((socket, next) => {
|
||||
const token = getSocketSessionToken(socket)
|
||||
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
|
||||
if (!token) return next()
|
||||
try {
|
||||
const payload = verifyAnyActorToken(token)
|
||||
;(socket as any).authenticatedUserId = payload.sub
|
||||
@@ -64,12 +55,9 @@ io.use((socket, next) => {
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const userId = (socket as any).authenticatedUserId as string | undefined
|
||||
if (userId) {
|
||||
socket.join(`user:${userId}`)
|
||||
}
|
||||
if (userId) socket.join(`user:${userId}`)
|
||||
})
|
||||
|
||||
// Redis pub/sub → broadcast to connected clients
|
||||
const subscriber = redis.duplicate()
|
||||
subscriber.psubscribe('notifications:*', (err) => {
|
||||
if (err) console.error('[Redis] Subscribe error:', err)
|
||||
@@ -84,168 +72,14 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Scheduled jobs ───────────────────────────────────────────
|
||||
// Embedded jobs only when explicitly enabled (single-process local/dev).
|
||||
// Production should run `npm run worker` / Compose `api-worker` instead.
|
||||
if (process.env.ENABLE_EMBEDDED_JOBS === 'true') {
|
||||
console.warn('[API] ENABLE_EMBEDDED_JOBS=true — running outbox/cron inside the API process')
|
||||
startOutboxWorker()
|
||||
startScheduledJobs()
|
||||
}
|
||||
|
||||
// Daily: flag expiring/expired licenses
|
||||
cron.schedule('0 8 * * *', async () => {
|
||||
const customers = await prisma.customer.findMany({ where: { licenseExpiry: { not: null } } })
|
||||
for (const c of customers) {
|
||||
if (!c.licenseExpiry) continue
|
||||
const daysLeft = Math.ceil((c.licenseExpiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
const expired = c.licenseExpiry <= new Date()
|
||||
const expiring = !expired && daysLeft < 90
|
||||
if (expired !== c.licenseExpired || expiring !== c.licenseExpiringSoon) {
|
||||
await prisma.customer.update({ where: { id: c.id }, data: { licenseExpired: expired, licenseExpiringSoon: expiring, licenseValidationStatus: expired ? 'EXPIRED' : expiring ? 'EXPIRING' : 'VALID' } })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Hourly: expire trials that ended without payment
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
const n = await runTrialExpirationJob()
|
||||
if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`)
|
||||
})
|
||||
|
||||
// Daily: explicit period-end cancellations. Subscription collections use the
|
||||
// timezone-aware 30-day grace worker below, so the old fixed 7-day chain is
|
||||
// intentionally not scheduled.
|
||||
cron.schedule('0 1 * * *', async () => {
|
||||
const nPeriod = await runPeriodEndCancellationJob()
|
||||
if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`)
|
||||
})
|
||||
|
||||
cron.schedule('*/15 * * * *', async () => {
|
||||
const n = await runCollectionsWorker()
|
||||
if (n > 0) console.log(`[subscription] collections: ${n} cases processed`)
|
||||
})
|
||||
|
||||
cron.schedule('* * * * *', async () => {
|
||||
const n = await processNotificationOutbox()
|
||||
if (n > 0) console.log(`[notifications] outbox: ${n} events completed`)
|
||||
})
|
||||
|
||||
// Daily: send trial-ending reminders (3 days before trial end)
|
||||
cron.schedule('0 9 * * *', async () => {
|
||||
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
|
||||
const subscriptions = await prisma.subscription.findMany({
|
||||
where: { status: 'TRIALING', trialEndAt: { lte: soon, gte: new Date() } },
|
||||
include: { company: { include: { employees: { where: { role: 'OWNER' } } } } },
|
||||
})
|
||||
for (const sub of subscriptions) {
|
||||
const owner = sub.company.employees[0]
|
||||
if (owner) {
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Daily: notify companies about upcoming and overdue vehicle maintenance (date- and odometer-based).
|
||||
// Repeats every day until the owner logs a new service entry that pushes the due date/mileage into the future.
|
||||
cron.schedule('0 8 * * *', async () => {
|
||||
const now = new Date()
|
||||
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
// Fetch all candidate logs (date-due or has an odometer target), ordered newest-first per vehicle+type.
|
||||
// We keep only the LATEST log per vehicle+type so that once the owner logs a new service the
|
||||
// old overdue log is superseded and notifications stop automatically.
|
||||
const allCandidates = await prisma.maintenanceLog.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ nextDueAt: { lte: in30Days } },
|
||||
{ nextDueMileage: { not: null } },
|
||||
],
|
||||
},
|
||||
include: { vehicle: { include: { company: { include: { employees: { where: { role: { in: ['OWNER', 'MANAGER'] }, isActive: true }, take: 1 } } } } } },
|
||||
orderBy: { performedAt: 'desc' },
|
||||
})
|
||||
|
||||
// Keep only the most-recent log per vehicle+type combination
|
||||
const latestByKey = new Map<string, typeof allCandidates[number]>()
|
||||
for (const log of allCandidates) {
|
||||
const key = `${log.vehicleId}:${log.type}`
|
||||
if (!latestByKey.has(key)) latestByKey.set(key, log)
|
||||
}
|
||||
|
||||
for (const log of latestByKey.values()) {
|
||||
const vehicle = log.vehicle
|
||||
const company = vehicle.company
|
||||
const recipient = company.employees[0]
|
||||
if (!recipient) continue
|
||||
|
||||
// Determine date-based urgency
|
||||
let isOverdueByDate = false
|
||||
let daysLeft: number | null = null
|
||||
let dueSoonByDate = false
|
||||
if (log.nextDueAt) {
|
||||
daysLeft = Math.ceil((log.nextDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
isOverdueByDate = log.nextDueAt <= now
|
||||
dueSoonByDate = !isOverdueByDate && daysLeft <= 30
|
||||
}
|
||||
|
||||
// Determine odometer-based urgency
|
||||
let isOverdueByOdometer = false
|
||||
let kmLeft: number | null = null
|
||||
let dueSoonByOdometer = false
|
||||
if (log.nextDueMileage != null && vehicle.mileage != null) {
|
||||
kmLeft = log.nextDueMileage - vehicle.mileage
|
||||
isOverdueByOdometer = kmLeft <= 0
|
||||
dueSoonByOdometer = !isOverdueByOdometer && kmLeft <= 500
|
||||
}
|
||||
|
||||
// Skip if the latest log is no longer due (owner has updated it)
|
||||
const isOverdue = isOverdueByDate || isOverdueByOdometer
|
||||
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
|
||||
if (!isOverdue && !isDueSoon) continue
|
||||
|
||||
// Build human-readable description
|
||||
const dueParts: string[] = []
|
||||
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
|
||||
else if (dueSoonByDate && daysLeft != null) dueParts.push(`due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`)
|
||||
if (isOverdueByOdometer) dueParts.push(`overdue by odometer (${Math.abs(kmLeft!).toLocaleString()} km ago)`)
|
||||
else if (dueSoonByOdometer && kmLeft != null) dueParts.push(`${kmLeft.toLocaleString()} km remaining`)
|
||||
|
||||
const title = isOverdue
|
||||
? `Overdue: ${log.type} — ${vehicle.make} ${vehicle.model}`
|
||||
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
|
||||
const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`
|
||||
|
||||
const reminderDate = now.toISOString().slice(0, 10)
|
||||
await sendNotification({
|
||||
type: 'VEHICLE_MAINTENANCE_DUE',
|
||||
title,
|
||||
body,
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
maintenanceLogId: log.id,
|
||||
maintenanceType: log.type,
|
||||
isOverdue,
|
||||
daysLeft,
|
||||
kmLeft,
|
||||
isOverdueByDate,
|
||||
isOverdueByOdometer,
|
||||
},
|
||||
companyId: company.id,
|
||||
employeeId: recipient.id,
|
||||
channels: ['IN_APP'],
|
||||
sourceType: 'maintenance_log',
|
||||
sourceId: log.id,
|
||||
idempotencyKey: `maintenance:${log.id}:${recipient.id}:${reminderDate}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────────
|
||||
const PORT = Number(process.env.API_PORT ?? 4000)
|
||||
const HOST = process.env.API_HOST ?? '0.0.0.0'
|
||||
|
||||
@@ -253,4 +87,44 @@ server.listen(PORT, HOST, () => {
|
||||
console.log(`[API] Server running on ${HOST}:${PORT}`)
|
||||
})
|
||||
|
||||
let shuttingDown = false
|
||||
async function shutdown(signal: string) {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
console.log(`[API] ${signal} received, draining`)
|
||||
|
||||
server.close((err) => {
|
||||
if (err) console.error('[API] HTTP close error:', err.message)
|
||||
})
|
||||
|
||||
try {
|
||||
io.close()
|
||||
} catch (err: any) {
|
||||
console.error('[API] Socket.IO close error:', err?.message ?? err)
|
||||
}
|
||||
|
||||
try {
|
||||
await subscriber.quit()
|
||||
} catch {
|
||||
subscriber.disconnect()
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.quit()
|
||||
} catch {
|
||||
redis.disconnect()
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$disconnect()
|
||||
} catch (err: any) {
|
||||
console.error('[API] Prisma disconnect error:', err?.message ?? err)
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'))
|
||||
|
||||
export { app, io }
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { getIdempotentResult, setIdempotentResult } from './idempotencyStore'
|
||||
|
||||
describe('idempotencyStore (memory)', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.IDEMPOTENCY_STORE = 'memory'
|
||||
})
|
||||
|
||||
it('returns miss then hit for the same fingerprint', async () => {
|
||||
const key = `k-${Date.now()}`
|
||||
await expect(getIdempotentResult('carplace', key, 'fp1')).resolves.toEqual({ kind: 'miss' })
|
||||
await setIdempotentResult('carplace', key, 'fp1', { id: 'reservation_1' })
|
||||
await expect(getIdempotentResult('carplace', key, 'fp1')).resolves.toEqual({
|
||||
kind: 'hit',
|
||||
result: { id: 'reservation_1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('detects fingerprint conflicts', async () => {
|
||||
const key = `conflict-${Date.now()}`
|
||||
await setIdempotentResult('carplace', key, 'fp1', { id: 'a' })
|
||||
await expect(getIdempotentResult('carplace', key, 'fp2')).resolves.toEqual({ kind: 'conflict' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { redis } from '../lib/redis'
|
||||
|
||||
const MEMORY = new Map<string, { expiresAt: number; fingerprint: string; result: unknown }>()
|
||||
const DEFAULT_TTL_SECONDS = 15 * 60
|
||||
|
||||
function useMemory() {
|
||||
return process.env.IDEMPOTENCY_STORE === 'memory' || process.env.NODE_ENV === 'test'
|
||||
}
|
||||
|
||||
export type IdempotencyHit =
|
||||
| { kind: 'miss' }
|
||||
| { kind: 'hit'; result: unknown }
|
||||
| { kind: 'conflict' }
|
||||
|
||||
export async function getIdempotentResult(
|
||||
scope: string,
|
||||
key: string,
|
||||
fingerprint: string,
|
||||
): Promise<IdempotencyHit> {
|
||||
const redisKey = `idempotency:${scope}:${key}`
|
||||
|
||||
if (useMemory()) {
|
||||
const cached = MEMORY.get(redisKey)
|
||||
if (!cached || cached.expiresAt <= Date.now()) return { kind: 'miss' }
|
||||
if (cached.fingerprint !== fingerprint) return { kind: 'conflict' }
|
||||
return { kind: 'hit', result: cached.result }
|
||||
}
|
||||
|
||||
const raw = await redis.get(redisKey)
|
||||
if (!raw) return { kind: 'miss' }
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { fingerprint: string; result: unknown }
|
||||
if (parsed.fingerprint !== fingerprint) return { kind: 'conflict' }
|
||||
return { kind: 'hit', result: parsed.result }
|
||||
} catch {
|
||||
return { kind: 'miss' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function setIdempotentResult(
|
||||
scope: string,
|
||||
key: string,
|
||||
fingerprint: string,
|
||||
result: unknown,
|
||||
ttlSeconds = DEFAULT_TTL_SECONDS,
|
||||
): Promise<void> {
|
||||
const redisKey = `idempotency:${scope}:${key}`
|
||||
const payload = JSON.stringify({ fingerprint, result })
|
||||
|
||||
if (useMemory()) {
|
||||
MEMORY.set(redisKey, { expiresAt: Date.now() + ttlSeconds * 1000, fingerprint, result })
|
||||
return
|
||||
}
|
||||
|
||||
await redis.set(redisKey, payload, 'EX', ttlSeconds)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Optional S3-compatible object storage (MinIO / AWS S3).
|
||||
* Activated when FILE_STORAGE_DRIVER=s3.
|
||||
*
|
||||
* Uses the AWS SDK v3 if installed; otherwise falls back to a clear startup error.
|
||||
* Add dependency: `@aws-sdk/client-s3`
|
||||
*/
|
||||
|
||||
type S3ClientLike = {
|
||||
send: (command: unknown) => Promise<unknown>
|
||||
}
|
||||
|
||||
let clientPromise: Promise<S3ClientLike> | null = null
|
||||
|
||||
function required(name: string) {
|
||||
const value = process.env[name]
|
||||
if (!value) throw new Error(`${name} is required for S3 storage`)
|
||||
return value
|
||||
}
|
||||
|
||||
async function getClient(): Promise<S3ClientLike> {
|
||||
if (!clientPromise) {
|
||||
clientPromise = (async () => {
|
||||
try {
|
||||
// Dynamic import keeps local-only installs working without the SDK.
|
||||
const sdk = await import('@aws-sdk/client-s3')
|
||||
return new sdk.S3Client({
|
||||
region: process.env.S3_REGION ?? 'us-east-1',
|
||||
endpoint: process.env.S3_ENDPOINT || undefined,
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== 'false',
|
||||
credentials: {
|
||||
accessKeyId: required('S3_ACCESS_KEY_ID'),
|
||||
secretAccessKey: required('S3_SECRET_ACCESS_KEY'),
|
||||
},
|
||||
}) as S3ClientLike
|
||||
} catch (err: any) {
|
||||
throw new Error(
|
||||
`FILE_STORAGE_DRIVER=s3 requires @aws-sdk/client-s3. Install it in apps/api. (${err?.message ?? err})`,
|
||||
)
|
||||
}
|
||||
})()
|
||||
}
|
||||
return clientPromise
|
||||
}
|
||||
|
||||
export async function headBucket() {
|
||||
const sdk = await import('@aws-sdk/client-s3')
|
||||
const client = await getClient()
|
||||
await client.send(new sdk.HeadBucketCommand({ Bucket: required('S3_BUCKET') }))
|
||||
}
|
||||
|
||||
export async function putObject(key: string, body: Buffer, contentType = 'application/octet-stream') {
|
||||
const sdk = await import('@aws-sdk/client-s3')
|
||||
const client = await getClient()
|
||||
await client.send(
|
||||
new sdk.PutObjectCommand({
|
||||
Bucket: required('S3_BUCKET'),
|
||||
Key: key.replace(/^\/+/, ''),
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function getObjectBuffer(key: string): Promise<Buffer> {
|
||||
const sdk = await import('@aws-sdk/client-s3')
|
||||
const client = await getClient()
|
||||
const result: any = await client.send(
|
||||
new sdk.GetObjectCommand({
|
||||
Bucket: required('S3_BUCKET'),
|
||||
Key: key.replace(/^\/+/, ''),
|
||||
}),
|
||||
)
|
||||
const stream = result.Body
|
||||
if (!stream) throw new Error('Empty S3 object body')
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
}
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
export async function deleteObject(key: string) {
|
||||
const sdk = await import('@aws-sdk/client-s3')
|
||||
const client = await getClient()
|
||||
await client.send(
|
||||
new sdk.DeleteObjectCommand({
|
||||
Bucket: required('S3_BUCKET'),
|
||||
Key: key.replace(/^\/+/, ''),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function publicObjectUrl(key: string) {
|
||||
const base = (process.env.S3_PUBLIC_BASE_URL || process.env.API_URL || 'http://localhost:4000').replace(/\/$/, '')
|
||||
if (process.env.S3_PUBLIC_BASE_URL) return `${base}/${key.replace(/^\/+/, '')}`
|
||||
return `${base}/storage/${key.replace(/^\/+/, '')}`
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
type CounterKey = string
|
||||
|
||||
const counters = new Map<CounterKey, number>()
|
||||
const latencyMs: number[] = []
|
||||
const MAX_LATENCY_SAMPLES = 2_000
|
||||
|
||||
function bump(key: CounterKey, by = 1) {
|
||||
counters.set(key, (counters.get(key) ?? 0) + by)
|
||||
}
|
||||
|
||||
export function observeHttpRequest(method: string, route: string, statusCode: number, durationMs: number) {
|
||||
const normalizedRoute = route || 'unknown'
|
||||
bump(`http_requests_total{method="${method}",route="${normalizedRoute}",status="${statusCode}"}`)
|
||||
latencyMs.push(durationMs)
|
||||
if (latencyMs.length > MAX_LATENCY_SAMPLES) latencyMs.splice(0, latencyMs.length - MAX_LATENCY_SAMPLES)
|
||||
}
|
||||
|
||||
export function observeOutboxProcessed(count: number) {
|
||||
if (count > 0) bump('notification_outbox_processed_total', count)
|
||||
}
|
||||
|
||||
export function setGauge(name: string, value: number) {
|
||||
counters.set(`gauge:${name}`, value)
|
||||
}
|
||||
|
||||
export function getGauge(name: string): number {
|
||||
return counters.get(`gauge:${name}`) ?? 0
|
||||
}
|
||||
|
||||
function percentile(sorted: number[], p: number) {
|
||||
if (sorted.length === 0) return 0
|
||||
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))
|
||||
return sorted[idx]!
|
||||
}
|
||||
|
||||
export function renderPrometheusText(): string {
|
||||
const lines: string[] = [
|
||||
'# HELP http_requests_total Total HTTP requests handled by the API',
|
||||
'# TYPE http_requests_total counter',
|
||||
]
|
||||
|
||||
for (const [key, value] of counters) {
|
||||
if (key.startsWith('gauge:')) continue
|
||||
if (key.startsWith('http_requests_total')) {
|
||||
lines.push(`${key} ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of counters) {
|
||||
if (key.startsWith('notification_outbox_processed_total')) {
|
||||
lines.push('# HELP notification_outbox_processed_total Notification outbox events completed')
|
||||
lines.push('# TYPE notification_outbox_processed_total counter')
|
||||
lines.push(`notification_outbox_processed_total ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...latencyMs].sort((a, b) => a - b)
|
||||
lines.push('# HELP http_request_duration_ms HTTP request duration percentiles (recent window)')
|
||||
lines.push('# TYPE http_request_duration_ms gauge')
|
||||
lines.push(`http_request_duration_ms{quantile="0.5"} ${percentile(sorted, 50)}`)
|
||||
lines.push(`http_request_duration_ms{quantile="0.95"} ${percentile(sorted, 95)}`)
|
||||
lines.push(`http_request_duration_ms{quantile="0.99"} ${percentile(sorted, 99)}`)
|
||||
|
||||
lines.push('# HELP notification_outbox_pending Notification outbox rows pending dispatch')
|
||||
lines.push('# TYPE notification_outbox_pending gauge')
|
||||
lines.push(`notification_outbox_pending ${getGauge('notification_outbox_pending')}`)
|
||||
|
||||
lines.push('# HELP notification_outbox_completed Notification outbox rows with status PUBLISHED (DB gauge)')
|
||||
lines.push('# TYPE notification_outbox_completed gauge')
|
||||
lines.push(`notification_outbox_completed ${getGauge('notification_outbox_completed')}`)
|
||||
|
||||
lines.push('# HELP process_uptime_seconds Process uptime')
|
||||
lines.push('# TYPE process_uptime_seconds gauge')
|
||||
lines.push(`process_uptime_seconds ${process.uptime()}`)
|
||||
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
export function metricsMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
const started = Date.now()
|
||||
res.on('finish', () => {
|
||||
const route = (req.route?.path ? `${req.baseUrl}${req.route.path}` : req.path) || 'unknown'
|
||||
observeHttpRequest(req.method, route, res.statusCode, Date.now() - started)
|
||||
})
|
||||
next()
|
||||
}
|
||||
|
||||
/** Reset in-memory series (tests only). */
|
||||
export function resetMetricsForTests() {
|
||||
counters.clear()
|
||||
latencyMs.length = 0
|
||||
}
|
||||
@@ -38,11 +38,11 @@ function isWithinPath(targetPath: string, parentPath: string): boolean {
|
||||
export function assertStorageConfiguration(): string {
|
||||
const storageRoot = getStorageRoot()
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT) {
|
||||
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT && getStorageDriver() === 'local') {
|
||||
throw new Error('FILE_STORAGE_ROOT must be set in production so uploads are stored on the mounted volume.')
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (process.env.NODE_ENV === 'production' && getStorageDriver() === 'local') {
|
||||
const forbiddenRoots = [APP_PACKAGE_ROOT, APP_SOURCE_ROOT, APP_DIST_ROOT]
|
||||
const invalidRoot = forbiddenRoots.find((root) => isWithinPath(storageRoot, root))
|
||||
if (invalidRoot) {
|
||||
@@ -60,9 +60,30 @@ export function assertStorageConfiguration(): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (getStorageDriver() === 's3') {
|
||||
for (const key of ['S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY'] as const) {
|
||||
if (!process.env[key]) throw new Error(`${key} is required when FILE_STORAGE_DRIVER=s3`)
|
||||
}
|
||||
}
|
||||
|
||||
return storageRoot
|
||||
}
|
||||
|
||||
export function getStorageDriver(): 'local' | 's3' {
|
||||
return process.env.FILE_STORAGE_DRIVER === 's3' ? 's3' : 'local'
|
||||
}
|
||||
|
||||
export async function checkStorageReady(): Promise<void> {
|
||||
if (getStorageDriver() === 's3') {
|
||||
const { headBucket } = await import('./objectStorage')
|
||||
await headBucket()
|
||||
return
|
||||
}
|
||||
const root = assertStorageConfiguration()
|
||||
fs.mkdirSync(path.join(root, 'public'), { recursive: true })
|
||||
fs.mkdirSync(path.join(root, 'private'), { recursive: true })
|
||||
}
|
||||
|
||||
function ensureStorageRoot(visibility: StorageVisibility): string {
|
||||
assertStorageConfiguration()
|
||||
const root = visibility === 'public' ? getPublicStorageRoot() : getPrivateStorageRoot()
|
||||
@@ -111,19 +132,24 @@ export async function uploadImage(
|
||||
publicId?: string,
|
||||
visibility: StorageVisibility = inferVisibility(folder),
|
||||
): Promise<string> {
|
||||
const safePublicId = (publicId ?? '').replace(/[^a-zA-Z0-9_-]/g, '') || crypto.randomBytes(16).toString('hex')
|
||||
const filename = `${safePublicId}.jpg`
|
||||
|
||||
if (getStorageDriver() === 's3') {
|
||||
const objectKey = path.posix.join(visibility, folder.replace(/\\/g, '/'), filename)
|
||||
const { putObject } = await import('./objectStorage')
|
||||
await putObject(objectKey, buffer, 'image/jpeg')
|
||||
// Keep the historical public URL shape so existing clients and resolveStoredFilePath continue to work via API proxy.
|
||||
return `${getApiBase()}/storage/${folder}/${filename}`
|
||||
}
|
||||
|
||||
const storageRoot = ensureStorageRoot(visibility)
|
||||
const folderPath = path.join(storageRoot, folder)
|
||||
if (!isWithinPath(folderPath, storageRoot)) {
|
||||
throw new Error('Upload path escapes storage root')
|
||||
}
|
||||
fs.mkdirSync(folderPath, { recursive: true })
|
||||
|
||||
const filename = publicId
|
||||
? `${publicId}.jpg`
|
||||
: `${crypto.randomBytes(16).toString('hex')}.jpg`
|
||||
|
||||
fs.writeFileSync(path.join(folderPath, filename), buffer)
|
||||
|
||||
return `${getApiBase()}/storage/${folder}/${filename}`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
|
||||
import type { Request } from 'express'
|
||||
import { verifyAnyActorToken } from '../security/tokens'
|
||||
import { getSessionCookieName } from '../security/sessionCookies'
|
||||
|
||||
import { sharedRateLimitStore } from './redisRateLimitStore'
|
||||
|
||||
const SESSION_COOKIE_NAMES = [
|
||||
getSessionCookieName('admin'),
|
||||
@@ -50,15 +50,18 @@ function getAuthenticatedActorKey(req: Request): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
|
||||
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
|
||||
const skipPreflightRequest = (req: Request) => req.method === 'OPTIONS'
|
||||
|
||||
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing.
|
||||
// Successful requests (e.g. GET /me profile reads) are skipped so only failed
|
||||
// attempts count toward the cap.
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
function withStore(options: Parameters<typeof rateLimit>[0]) {
|
||||
return rateLimit({
|
||||
...options,
|
||||
store: sharedRateLimitStore,
|
||||
})
|
||||
}
|
||||
|
||||
export const authLimiter = withStore({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 20,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
@@ -68,9 +71,8 @@ export const authLimiter = rateLimit({
|
||||
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Standard limiter for general authenticated API endpoints
|
||||
export const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
export const apiLimiter = withStore({
|
||||
windowMs: 60 * 1000,
|
||||
max: 300,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
@@ -85,8 +87,7 @@ export const apiLimiter = rateLimit({
|
||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Limiter for public carplace and site endpoints (no auth)
|
||||
export const publicLimiter = rateLimit({
|
||||
export const publicLimiter = withStore({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: 'draft-7',
|
||||
@@ -96,9 +97,7 @@ export const publicLimiter = rateLimit({
|
||||
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Dedicated limiter for public payment/subscription webhooks. Provider retries
|
||||
// still fit under this limit, but spray-and-pray signature attempts do not.
|
||||
export const webhookLimiter = rateLimit({
|
||||
export const webhookLimiter = withStore({
|
||||
windowMs: 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: 'draft-7',
|
||||
@@ -108,8 +107,7 @@ export const webhookLimiter = rateLimit({
|
||||
message: { error: 'too_many_requests', message: 'Webhook rate limit exceeded', statusCode: 429 },
|
||||
})
|
||||
|
||||
// Tight limiter for admin endpoints
|
||||
export const adminLimiter = rateLimit({
|
||||
export const adminLimiter = withStore({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 100,
|
||||
standardHeaders: 'draft-7',
|
||||
@@ -122,10 +120,7 @@ export const adminLimiter = rateLimit({
|
||||
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
|
||||
})
|
||||
|
||||
|
||||
// Applied after authentication so limits can include actor identity rather than
|
||||
// pretending every employee behind the same NAT is the same organism.
|
||||
export const actorLimiter = rateLimit({
|
||||
export const actorLimiter = withStore({
|
||||
windowMs: 60 * 1000,
|
||||
max: 240,
|
||||
standardHeaders: 'draft-7',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Store, Options, ClientRateLimitInfo, IncrementResponse } from 'express-rate-limit'
|
||||
import { redis } from '../lib/redis'
|
||||
|
||||
/**
|
||||
* Redis-backed store for express-rate-limit.
|
||||
* Uses memory fallback only when explicitly requested (tests) via RATE_LIMIT_STORE=memory.
|
||||
*/
|
||||
export class RedisRateLimitStore implements Store {
|
||||
prefix: string
|
||||
windowMs = 60_000
|
||||
#local = new Map<string, { totalHits: number; resetTime: Date }>()
|
||||
|
||||
constructor(prefix = 'rl:') {
|
||||
this.prefix = prefix
|
||||
}
|
||||
|
||||
init(options: Options): void {
|
||||
this.windowMs = options.windowMs
|
||||
}
|
||||
|
||||
private useMemory() {
|
||||
return process.env.RATE_LIMIT_STORE === 'memory' || process.env.NODE_ENV === 'test'
|
||||
}
|
||||
|
||||
async get(key: string): Promise<ClientRateLimitInfo | undefined> {
|
||||
if (this.useMemory()) {
|
||||
const hit = this.#local.get(key)
|
||||
if (!hit) return undefined
|
||||
return { totalHits: hit.totalHits, resetTime: hit.resetTime }
|
||||
}
|
||||
|
||||
const redisKey = `${this.prefix}${key}`
|
||||
const [count, ttl] = await Promise.all([redis.get(redisKey), redis.pttl(redisKey)])
|
||||
if (count == null) return undefined
|
||||
const resetTime = ttl > 0 ? new Date(Date.now() + ttl) : new Date(Date.now() + this.windowMs)
|
||||
return { totalHits: Number(count), resetTime }
|
||||
}
|
||||
|
||||
async increment(key: string): Promise<IncrementResponse> {
|
||||
if (this.useMemory()) {
|
||||
const now = Date.now()
|
||||
const existing = this.#local.get(key)
|
||||
if (!existing || existing.resetTime.getTime() <= now) {
|
||||
const resetTime = new Date(now + this.windowMs)
|
||||
this.#local.set(key, { totalHits: 1, resetTime })
|
||||
return { totalHits: 1, resetTime }
|
||||
}
|
||||
existing.totalHits += 1
|
||||
return { totalHits: existing.totalHits, resetTime: existing.resetTime }
|
||||
}
|
||||
|
||||
const redisKey = `${this.prefix}${key}`
|
||||
const totalHits = await redis.incr(redisKey)
|
||||
if (totalHits === 1) await redis.pexpire(redisKey, this.windowMs)
|
||||
const ttl = await redis.pttl(redisKey)
|
||||
const resetTime = new Date(Date.now() + (ttl > 0 ? ttl : this.windowMs))
|
||||
return { totalHits, resetTime }
|
||||
}
|
||||
|
||||
async decrement(key: string): Promise<void> {
|
||||
if (this.useMemory()) {
|
||||
const existing = this.#local.get(key)
|
||||
if (existing && existing.totalHits > 0) existing.totalHits -= 1
|
||||
return
|
||||
}
|
||||
const redisKey = `${this.prefix}${key}`
|
||||
const value = await redis.decr(redisKey)
|
||||
if (value < 0) await redis.set(redisKey, '0', 'KEEPTTL')
|
||||
}
|
||||
|
||||
async resetKey(key: string): Promise<void> {
|
||||
if (this.useMemory()) {
|
||||
this.#local.delete(key)
|
||||
return
|
||||
}
|
||||
await redis.del(`${this.prefix}${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const sharedRateLimitStore = new RedisRateLimitStore('rl:api:')
|
||||
@@ -165,7 +165,21 @@ describe('requireAdminRole middleware', () => {
|
||||
})
|
||||
|
||||
describe('requireFreshAdmin2FA middleware', () => {
|
||||
it('allows a 2FA-verified admin session until the session ends', () => {
|
||||
it('allows a recently 2FA-verified admin session', () => {
|
||||
const req = {
|
||||
admin: { id: 'admin_1', totpEnabled: true },
|
||||
adminAuthLast2faAt: Date.now() - 5 * 60 * 1000,
|
||||
} as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
requireFreshAdmin2FA(req, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks enrolled admins whose 2FA proof is stale', () => {
|
||||
const req = {
|
||||
admin: { id: 'admin_1', totpEnabled: true },
|
||||
adminAuthLast2faAt: Date.now() - 24 * 60 * 60 * 1000,
|
||||
@@ -175,8 +189,13 @@ describe('requireFreshAdmin2FA middleware', () => {
|
||||
|
||||
requireFreshAdmin2FA(req, res, next)
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'fresh_2fa_required',
|
||||
message: 'Admin 2FA verification has expired; verify again to continue',
|
||||
statusCode: 403,
|
||||
})
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks enrolled admins whose session has no 2FA verification proof', () => {
|
||||
|
||||
@@ -86,6 +86,15 @@ export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunc
|
||||
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification is required for this session')
|
||||
}
|
||||
|
||||
const maxAgeMs = Number(process.env.ADMIN_FRESH_2FA_MAX_AGE_MS ?? 30 * 60 * 1000)
|
||||
if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
|
||||
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA freshness policy is misconfigured')
|
||||
}
|
||||
|
||||
if (Date.now() - req.adminAuthLast2faAt > maxAgeMs) {
|
||||
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification has expired; verify again to continue')
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,26 @@ describe('admin.presenter', () => {
|
||||
role: 'SUPER_ADMIN',
|
||||
passwordHash: 'hash',
|
||||
totpSecret: 'secret',
|
||||
passwordResetToken: 'reset-token',
|
||||
passwordResetExpiresAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
emailVerificationToken: 'verify-token',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ id: 'admin_1', email: 'admin@example.com', role: 'SUPER_ADMIN' })
|
||||
})
|
||||
|
||||
it('wraps sessions without leaking credentials', () => {
|
||||
expect(presentAdminSession({ id: 'admin_1', passwordHash: 'hash', totpSecret: 'secret' }, 'jwt-token')).toEqual({
|
||||
expect(
|
||||
presentAdminSession(
|
||||
{
|
||||
id: 'admin_1',
|
||||
passwordHash: 'hash',
|
||||
totpSecret: 'secret',
|
||||
passwordResetToken: 'reset-token',
|
||||
},
|
||||
'jwt-token',
|
||||
),
|
||||
).toEqual({
|
||||
token: 'jwt-token',
|
||||
admin: { id: 'admin_1' },
|
||||
})
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
const ADMIN_SECRET_FIELDS = [
|
||||
'passwordHash',
|
||||
'totpSecret',
|
||||
'passwordResetToken',
|
||||
'passwordResetExpiresAt',
|
||||
'emailVerificationToken',
|
||||
] as const
|
||||
|
||||
export function presentAdminUser<T extends Record<string, any>>(admin: T) {
|
||||
const { passwordHash, totpSecret, ...safe } = admin
|
||||
const safe = { ...admin }
|
||||
for (const field of ADMIN_SECRET_FIELDS) {
|
||||
delete (safe as Record<string, unknown>)[field]
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
|
||||
@@ -63,10 +63,7 @@ describe('admin.repo edge queries', () => {
|
||||
|
||||
expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ passwordResetToken: hashPublicAccessToken('reset-token') },
|
||||
{ passwordResetToken: 'reset-token' },
|
||||
],
|
||||
passwordResetToken: hashPublicAccessToken('reset-token'),
|
||||
passwordResetExpiresAt: { gt: new Date('2026-06-01T00:00:00.000Z') },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -92,7 +92,7 @@ export function findAdminByResetToken(token: string) {
|
||||
const tokenHash = hashPublicAccessToken(token)
|
||||
return prisma.adminUser.findFirst({
|
||||
where: {
|
||||
OR: [{ passwordResetToken: tokenHash }, { passwordResetToken: token }],
|
||||
passwordResetToken: tokenHash,
|
||||
passwordResetExpiresAt: { gt: new Date() },
|
||||
},
|
||||
})
|
||||
@@ -163,6 +163,13 @@ export async function applyCompanyUpdate(
|
||||
return prisma.$transaction(async (tx: any) => {
|
||||
if (body.company) {
|
||||
const companyData = { ...body.company }
|
||||
if (typeof companyData.slug === 'string') {
|
||||
companyData.slug = companyData.slug
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 50) || 'company'
|
||||
}
|
||||
if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
|
||||
const baseAddress = current.address && typeof current.address === 'object' && !Array.isArray(current.address)
|
||||
? current.address as Record<string, unknown>
|
||||
|
||||
@@ -122,7 +122,13 @@ const nullableDate = z.union([z.string().datetime(), z.string().regex(/^\d{4}-
|
||||
|
||||
export const adminCompanyUpdateSchema = z.object({
|
||||
company: z.object({
|
||||
name: z.string().min(1).optional(), slug: z.string().min(1).optional(),
|
||||
name: z.string().min(1).optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug must be lowercase alphanumeric with optional hyphens')
|
||||
.max(50)
|
||||
.optional(),
|
||||
email: z.string().email().optional(), phone: nullableString,
|
||||
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
|
||||
subscriptionPaymentRef: nullableString,
|
||||
|
||||
@@ -4,6 +4,7 @@ const prismaMock = vi.hoisted(() => ({
|
||||
employee: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
@@ -24,26 +25,32 @@ describe('auth.employee.repo query boundaries', () => {
|
||||
})
|
||||
|
||||
it('looks up employee login emails case-insensitively and includes company context', async () => {
|
||||
prismaMock.employee.findMany.mockResolvedValue([])
|
||||
|
||||
await repo.findEmployeeWithCompanyByEmail('Agent@Example.TEST')
|
||||
|
||||
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
|
||||
expect(prismaMock.employee.findMany).toHaveBeenCalledWith({
|
||||
where: { email: { equals: 'Agent@Example.TEST', mode: 'insensitive' } },
|
||||
include: { company: true },
|
||||
take: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('only sends forgot-password emails to active employees', async () => {
|
||||
prismaMock.employee.findMany.mockResolvedValue([])
|
||||
|
||||
await repo.findActiveEmployeeByEmail('agent@example.test')
|
||||
|
||||
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
|
||||
expect(prismaMock.employee.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
email: { equals: 'agent@example.test', mode: 'insensitive' },
|
||||
isActive: true,
|
||||
},
|
||||
take: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('requires unexpired reset tokens for stored-token password reset lookup', async () => {
|
||||
it('requires unexpired hashed reset tokens for stored-token password reset lookup', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
|
||||
|
||||
@@ -51,10 +58,7 @@ describe('auth.employee.repo query boundaries', () => {
|
||||
|
||||
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ passwordResetToken: hashPublicAccessToken('reset_123') },
|
||||
{ passwordResetToken: 'reset_123' },
|
||||
],
|
||||
passwordResetToken: hashPublicAccessToken('reset_123'),
|
||||
passwordResetExpiresAt: { gt: new Date('2026-06-09T12:00:00.000Z') },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
employee: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -14,12 +14,13 @@ import { findActiveEmployeeByEmail, findEmployeeWithCompanyByEmail } from './aut
|
||||
describe('auth.employee.repo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(prisma.employee.findMany).mockResolvedValue([] as never)
|
||||
})
|
||||
|
||||
it('looks up employee login email case-insensitively', async () => {
|
||||
await findEmployeeWithCompanyByEmail('Owner@Example.com')
|
||||
|
||||
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
|
||||
expect(prisma.employee.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
email: {
|
||||
equals: 'Owner@Example.com',
|
||||
@@ -27,13 +28,14 @@ describe('auth.employee.repo', () => {
|
||||
},
|
||||
},
|
||||
include: { company: true },
|
||||
take: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('looks up active employee reset email case-insensitively', async () => {
|
||||
await findActiveEmployeeByEmail('Owner@Example.com')
|
||||
|
||||
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
|
||||
expect(prisma.employee.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
email: {
|
||||
equals: 'Owner@Example.com',
|
||||
@@ -41,6 +43,15 @@ describe('auth.employee.repo', () => {
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
take: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when multiple employees share an email', async () => {
|
||||
vi.mocked(prisma.employee.findMany).mockResolvedValue([{ id: 'a' }, { id: 'b' }] as never)
|
||||
await expect(findEmployeeWithCompanyByEmail('shared@example.com')).rejects.toMatchObject({
|
||||
code: 'ambiguous_employee_email',
|
||||
statusCode: 409,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,8 +14,8 @@ export function findEmployeeById(id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function findEmployeeWithCompanyByEmail(email: string) {
|
||||
return prisma.employee.findFirst({
|
||||
export async function findEmployeeWithCompanyByEmail(email: string) {
|
||||
const matches = await prisma.employee.findMany({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
@@ -23,11 +23,21 @@ export function findEmployeeWithCompanyByEmail(email: string) {
|
||||
},
|
||||
},
|
||||
include: { company: true },
|
||||
take: 2,
|
||||
})
|
||||
|
||||
if (matches.length > 1) {
|
||||
throw Object.assign(new Error('Multiple employee accounts match this email. Sign in with your company context or contact support.'), {
|
||||
statusCode: 409,
|
||||
code: 'ambiguous_employee_email',
|
||||
})
|
||||
}
|
||||
|
||||
return matches[0] ?? null
|
||||
}
|
||||
|
||||
export function findActiveEmployeeByEmail(email: string) {
|
||||
return prisma.employee.findFirst({
|
||||
export async function findActiveEmployeeByEmail(email: string) {
|
||||
const matches = await prisma.employee.findMany({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
@@ -35,7 +45,17 @@ export function findActiveEmployeeByEmail(email: string) {
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
take: 2,
|
||||
})
|
||||
|
||||
if (matches.length > 1) {
|
||||
throw Object.assign(new Error('Multiple employee accounts match this email. Contact support.'), {
|
||||
statusCode: 409,
|
||||
code: 'ambiguous_employee_email',
|
||||
})
|
||||
}
|
||||
|
||||
return matches[0] ?? null
|
||||
}
|
||||
|
||||
export function setPasswordResetToken(id: string, passwordResetToken: string, passwordResetExpiresAt: Date) {
|
||||
@@ -56,7 +76,7 @@ export function findEmployeeByResetToken(token: string) {
|
||||
const tokenHash = hashPublicAccessToken(token)
|
||||
return prisma.employee.findFirst({
|
||||
where: {
|
||||
OR: [{ passwordResetToken: tokenHash }, { passwordResetToken: token }],
|
||||
passwordResetToken: tokenHash,
|
||||
passwordResetExpiresAt: { gt: new Date() },
|
||||
},
|
||||
})
|
||||
@@ -83,7 +103,7 @@ export function setEmailVerificationToken(id: string, tokenHash: string) {
|
||||
export function findEmployeeByVerificationToken(token: string) {
|
||||
const tokenHash = hashPublicAccessToken(token)
|
||||
return prisma.employee.findFirst({
|
||||
where: { OR: [{ emailVerificationToken: tokenHash }, { emailVerificationToken: token }] },
|
||||
where: { emailVerificationToken: tokenHash },
|
||||
include: { company: true },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
|
||||
import { parseBody, parseParams, parseQuery } from '../../http/validate'
|
||||
import { created, ok } from '../../http/respond'
|
||||
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
|
||||
import { getIdempotentResult, setIdempotentResult } from '../../lib/idempotencyStore'
|
||||
import * as service from './carplace.service'
|
||||
import {
|
||||
carplaceQuoteSchema,
|
||||
@@ -23,13 +24,6 @@ import {
|
||||
const router = Router()
|
||||
router.use(optionalRenterAuth)
|
||||
|
||||
const idempotencyCache = new Map<string, { expiresAt: number; fingerprint: string; result: unknown }>()
|
||||
const IDEMPOTENCY_TTL_MS = 15 * 60 * 1000
|
||||
|
||||
function cleanupIdempotencyCache(now = Date.now()) {
|
||||
for (const [key, value] of idempotencyCache) if (value.expiresAt <= now) idempotencyCache.delete(key)
|
||||
}
|
||||
|
||||
router.get('/home', async (_req, res, next) => {
|
||||
try {
|
||||
const [cities, offers, companies, search] = await Promise.all([
|
||||
@@ -81,19 +75,18 @@ router.post('/quotes', async (req, res, next) => {
|
||||
|
||||
router.post('/reservations', async (req, res, next) => {
|
||||
try {
|
||||
cleanupIdempotencyCache()
|
||||
const body = parseBody(carplaceReservationSchema, req)
|
||||
const key = body.idempotencyKey ?? req.header('Idempotency-Key')?.trim()
|
||||
const fingerprint = createHash('sha256').update(JSON.stringify(body)).digest('hex')
|
||||
if (key) {
|
||||
const cached = idempotencyCache.get(key)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
if (cached.fingerprint !== fingerprint) return res.status(409).json({ error: 'idempotency_conflict', message: 'This idempotency key was already used with a different request', statusCode: 409 })
|
||||
return ok(res, cached.result)
|
||||
const cached = await getIdempotentResult('carplace:reservations', key, fingerprint)
|
||||
if (cached.kind === 'conflict') {
|
||||
return res.status(409).json({ error: 'idempotency_conflict', message: 'This idempotency key was already used with a different request', statusCode: 409 })
|
||||
}
|
||||
if (cached.kind === 'hit') return ok(res, cached.result)
|
||||
}
|
||||
const result = await service.createCarplaceReservation(body)
|
||||
if (key) idempotencyCache.set(key, { expiresAt: Date.now() + IDEMPOTENCY_TTL_MS, fingerprint, result })
|
||||
if (key) await setIdempotentResult('carplace:reservations', key, fingerprint, result)
|
||||
created(res, result)
|
||||
} catch (error) {
|
||||
if (isDatabaseUnavailableError(error)) return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
|
||||
|
||||
@@ -75,11 +75,13 @@ describe('payment.service', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-08T10:15:00.000Z'))
|
||||
delete process.env.API_URL
|
||||
process.env.DASHBOARD_URL = 'https://app.example'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
delete process.env.API_URL
|
||||
delete process.env.DASHBOARD_URL
|
||||
})
|
||||
|
||||
it('creates an AmanPay deposit checkout using reservation, customer, and webhook details', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as paypal from '../../services/paypalService'
|
||||
import * as stripe from '../../services/stripeService'
|
||||
import * as repo from './payment.repo'
|
||||
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
|
||||
import { assertAllowedPaymentRedirect } from '../../security/paymentRedirects'
|
||||
|
||||
export function listByCompany(companyId: string) {
|
||||
return repo.findByCompany(companyId)
|
||||
@@ -120,6 +121,9 @@ export async function initCharge(reservationId: string, companyId: string, body:
|
||||
throw new ConflictError(body.type === 'DEPOSIT' ? 'Security deposit is already fully collected' : 'Reservation is already fully paid')
|
||||
}
|
||||
|
||||
assertAllowedPaymentRedirect(body.successUrl)
|
||||
assertAllowedPaymentRedirect(body.failureUrl)
|
||||
|
||||
const amount = balanceDue
|
||||
const description = `${body.type === 'DEPOSIT' ? 'Deposit' : 'Rental'}: ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||
const orderId = `${reservationId}-${body.type}-${Date.now()}`
|
||||
|
||||
@@ -36,4 +36,20 @@ describe('serializeReservationForDashboard', () => {
|
||||
|
||||
expect(result.customer?.licenseImageUrl).toBe('http://localhost:3000/dashboard/api/v1/customers/customer-1/license-image')
|
||||
})
|
||||
|
||||
it('omits reviewToken from serialized dashboard payloads (S11)', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation-2',
|
||||
status: 'COMPLETED',
|
||||
source: 'DASHBOARD',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
paymentStatus: 'PAID',
|
||||
extras: {},
|
||||
reviewToken: 'secret-review-token',
|
||||
customer: null,
|
||||
} as any)
|
||||
|
||||
expect((result as any).reviewToken).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -159,8 +159,11 @@ export function serializeReservationForDashboard<T extends {
|
||||
}
|
||||
: reservation.customer
|
||||
|
||||
// S11: never expose reviewToken on API responses (capability URL secret)
|
||||
const { reviewToken: _reviewToken, ...safeReservation } = reservation as T & { reviewToken?: unknown }
|
||||
|
||||
return {
|
||||
...reservation,
|
||||
...safeReservation,
|
||||
...(customer !== undefined ? { customer } : {}),
|
||||
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
|
||||
spareWheel: typeof extras.spareWheel === 'boolean' ? extras.spareWheel : false,
|
||||
|
||||
@@ -4,6 +4,13 @@ import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import * as repo from './review.repo'
|
||||
import * as reservationRepo from '../reservations/reservation.repo'
|
||||
|
||||
/** Strip capability secrets (reviewToken) from API-facing review payloads. */
|
||||
function presentReview<T extends { reservation?: { reviewToken?: string | null } | null }>(review: T) {
|
||||
if (!review.reservation) return review
|
||||
const { reviewToken: _t, ...reservation } = review.reservation
|
||||
return { ...review, reservation }
|
||||
}
|
||||
|
||||
export async function listReviews(
|
||||
companyId: string,
|
||||
query: { rating?: number; page: number; pageSize: number },
|
||||
@@ -14,7 +21,7 @@ export async function listReviews(
|
||||
|
||||
const [reviews, total] = await repo.findMany(companyId, where, (page - 1) * pageSize, pageSize)
|
||||
return {
|
||||
data: reviews,
|
||||
data: reviews.map(presentReview),
|
||||
meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
|
||||
}
|
||||
}
|
||||
@@ -22,17 +29,17 @@ export async function listReviews(
|
||||
export async function getReview(id: string, companyId: string) {
|
||||
const review = await repo.findById(id, companyId)
|
||||
if (!review) throw new NotFoundError('Review not found')
|
||||
return review
|
||||
return presentReview(review)
|
||||
}
|
||||
|
||||
export async function replyToReview(id: string, companyId: string, companyReply: string) {
|
||||
const review = await repo.findById(id, companyId)
|
||||
if (!review) throw new NotFoundError('Review not found')
|
||||
|
||||
return repo.updateById(id, {
|
||||
return presentReview(await repo.updateById(id, {
|
||||
companyReply,
|
||||
companyRepliedAt: new Date(),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getStats(companyId: string) {
|
||||
|
||||
@@ -156,9 +156,19 @@ export async function createReservationPublicAccess(reservationId: string, token
|
||||
})
|
||||
}
|
||||
|
||||
export async function findReservationPublicAccess(reservationId: string, tokenHash: string) {
|
||||
export async function findReservationPublicAccess(
|
||||
reservationId: string,
|
||||
tokenHash: string,
|
||||
options: { requireUnused?: boolean } = {},
|
||||
) {
|
||||
return (prisma as any).reservationPublicAccess.findFirst({
|
||||
where: { reservationId, tokenHash, revokedAt: null, expiresAt: { gt: new Date() } },
|
||||
where: {
|
||||
reservationId,
|
||||
tokenHash,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
...(options.requireUnused ? { usedAt: null } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -168,3 +178,17 @@ export async function markReservationPublicAccessUsed(id: string) {
|
||||
data: { usedAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
/** Atomically burn a public access row. Returns false if already used/revoked/expired. */
|
||||
export async function consumeReservationPublicAccess(id: string): Promise<boolean> {
|
||||
const result = await (prisma as any).reservationPublicAccess.updateMany({
|
||||
where: {
|
||||
id,
|
||||
usedAt: null,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
data: { usedAt: new Date() },
|
||||
})
|
||||
return result.count === 1
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock('./site.repo', () => ({
|
||||
createReservationPublicAccess: vi.fn(),
|
||||
findReservationPublicAccess: vi.fn(),
|
||||
markReservationPublicAccessUsed: vi.fn(),
|
||||
consumeReservationPublicAccess: vi.fn(),
|
||||
createRentalPayment: vi.fn(),
|
||||
findPaymentByPaypalOrderId: vi.fn(),
|
||||
capturePaypalPayment: vi.fn(),
|
||||
@@ -88,6 +89,7 @@ describe('site.service public booking/payment boundaries', () => {
|
||||
vi.mocked(repo.createReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.findReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.markReservationPublicAccessUsed).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.consumeReservationPublicAccess).mockResolvedValue(true as never)
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(company as never)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null } as never)
|
||||
vi.mocked(applyPricingRules).mockResolvedValue({ applied: [{ code: 'WEEKEND' }], total: 90 } as never)
|
||||
|
||||
@@ -10,40 +10,15 @@ import * as paypal from '../../services/paypalService'
|
||||
import * as repo from './site.repo'
|
||||
import { presentBrand, presentPublicBooking } from './site.presenter'
|
||||
import { generatePublicAccessToken, hashPublicAccessToken } from '../../security/publicAccessTokens'
|
||||
import { assertAllowedPaymentRedirect } from '../../security/paymentRedirects'
|
||||
|
||||
function assertAllowedPaymentRedirect(urlValue: string, company: any) {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(urlValue)
|
||||
} catch {
|
||||
throw new AppError('Invalid payment redirect URL', 400, 'invalid_redirect_url')
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
|
||||
throw new AppError('Payment redirect URLs must use HTTPS', 400, 'invalid_redirect_url')
|
||||
}
|
||||
|
||||
const allowedHosts = new Set<string>()
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
allowedHosts.add('localhost:3000')
|
||||
allowedHosts.add('localhost:4000')
|
||||
allowedHosts.add('127.0.0.1:3000')
|
||||
allowedHosts.add('127.0.0.1:4000')
|
||||
}
|
||||
for (const value of [process.env.CARPLACE_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_CARPLACE_URL, process.env.NEXT_PUBLIC_DASHBOARD_URL]) {
|
||||
if (!value) continue
|
||||
try { allowedHosts.add(new URL(value).host) } catch {}
|
||||
}
|
||||
|
||||
function assertCompanyPaymentRedirect(urlValue: string, company: any) {
|
||||
const brand = company.brand as any
|
||||
if (brand?.customDomain && brand?.customDomainVerified) allowedHosts.add(brand.customDomain)
|
||||
if (brand?.subdomain && process.env.PUBLIC_SITE_BASE_DOMAIN) {
|
||||
allowedHosts.add(`${brand.subdomain}.${process.env.PUBLIC_SITE_BASE_DOMAIN}`)
|
||||
}
|
||||
|
||||
if (!allowedHosts.has(parsed.host)) {
|
||||
throw new AppError('Payment redirect host is not allowed', 400, 'invalid_redirect_url')
|
||||
}
|
||||
assertAllowedPaymentRedirect(urlValue, {
|
||||
customDomain: brand?.customDomain,
|
||||
customDomainVerified: brand?.customDomainVerified,
|
||||
subdomain: brand?.subdomain,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -243,16 +218,28 @@ export async function createBooking(slug: string, body: {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPublicBookingAccess(reservationId: string, token: string | undefined) {
|
||||
async function assertPublicBookingAccess(
|
||||
reservationId: string,
|
||||
token: string | undefined,
|
||||
options: { consume?: boolean } = {},
|
||||
) {
|
||||
if (!token) throw new AppError('Booking access token is required', 403, 'booking_token_required')
|
||||
const access = await repo.findReservationPublicAccess(reservationId, hashPublicAccessToken(token))
|
||||
const access = await repo.findReservationPublicAccess(
|
||||
reservationId,
|
||||
hashPublicAccessToken(token),
|
||||
{ requireUnused: Boolean(options.consume) },
|
||||
)
|
||||
if (!access) throw new AppError('Booking not found', 404, 'not_found')
|
||||
await repo.markReservationPublicAccessUsed(access.id)
|
||||
if (options.consume) {
|
||||
const burned = await repo.consumeReservationPublicAccess(access.id)
|
||||
if (!burned) throw new AppError('Booking not found', 404, 'not_found')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBooking(slug: string, reservationId: string, accessToken?: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
await assertPublicBookingAccess(reservationId, accessToken)
|
||||
// Reads do not burn the token (S12); payment init consumes it once.
|
||||
await assertPublicBookingAccess(reservationId, accessToken, { consume: false })
|
||||
return presentPublicBooking(await repo.findBooking(reservationId, company.id))
|
||||
}
|
||||
|
||||
@@ -261,7 +248,7 @@ export async function initPayment(slug: string, reservationId: string, body: {
|
||||
}) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
assertPublicBookingCompanyAllowed(company)
|
||||
await assertPublicBookingAccess(reservationId, body.accessToken)
|
||||
await assertPublicBookingAccess(reservationId, body.accessToken, { consume: true })
|
||||
const reservation = await repo.findReservationForPayment(reservationId, company.id)
|
||||
|
||||
if (reservation.paymentStatus === 'PAID') {
|
||||
@@ -279,8 +266,8 @@ export async function initPayment(slug: string, reservationId: string, body: {
|
||||
throw new AppError('This reservation requires license review before payment can be processed', 409, 'license_review_required')
|
||||
}
|
||||
|
||||
assertAllowedPaymentRedirect(body.successUrl, company)
|
||||
assertAllowedPaymentRedirect(body.failureUrl, company)
|
||||
assertCompanyPaymentRedirect(body.successUrl, company)
|
||||
assertCompanyPaymentRedirect(body.failureUrl, company)
|
||||
|
||||
const currency = body.currency ?? 'MAD'
|
||||
const amount = reservation.totalAmount
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock('./site.repo', () => ({
|
||||
createReservationPublicAccess: vi.fn(),
|
||||
findReservationPublicAccess: vi.fn(),
|
||||
markReservationPublicAccessUsed: vi.fn(),
|
||||
consumeReservationPublicAccess: vi.fn(),
|
||||
createRentalPayment: vi.fn(),
|
||||
findPaymentByPaypalOrderId: vi.fn(),
|
||||
capturePaypalPayment: vi.fn(),
|
||||
@@ -110,6 +111,7 @@ beforeEach(() => {
|
||||
vi.mocked(repo.createReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.findReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.markReservationPublicAccessUsed).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.consumeReservationPublicAccess).mockResolvedValue(true as never)
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -296,5 +298,13 @@ describe('initPayment — payment guard paths', () => {
|
||||
const result = await initPayment(SLUG, 'r-1', payBody)
|
||||
expect(result.checkoutUrl).toBe('https://paypal.com/approve')
|
||||
expect(repo.createRentalPayment).toHaveBeenCalledOnce()
|
||||
expect(repo.consumeReservationPublicAccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects payment when public access token was already consumed (S12)', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
|
||||
vi.mocked(repo.findReservationPublicAccess).mockResolvedValue(null as never)
|
||||
|
||||
await expect(initPayment(SLUG, 'r-1', payBody)).rejects.toMatchObject({ error: 'not_found' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,11 +82,13 @@ describe('subscription.service operational edges', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z'))
|
||||
process.env.API_URL = 'https://api.example.test'
|
||||
process.env.DASHBOARD_URL = 'https://app.example.test'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
delete process.env.API_URL
|
||||
delete process.env.DASHBOARD_URL
|
||||
})
|
||||
|
||||
it('builds plans from pricing config rows when platform overrides exist', async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as stripe from '../../services/stripeService'
|
||||
import * as repo from './subscription.repo'
|
||||
import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy'
|
||||
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
|
||||
import { assertAllowedPaymentRedirect } from '../../security/paymentRedirects'
|
||||
import {
|
||||
createCanonicalStripeCheckoutInvoice,
|
||||
finalizeCanonicalOnlinePayment,
|
||||
@@ -279,6 +280,9 @@ export async function checkout(companyId: string, body: {
|
||||
const company = await prisma.company.findUniqueOrThrow({ where: { id: companyId } })
|
||||
const subscription = await repo.findOrCreateSubscription(companyId, body.plan, body.billingPeriod, body.currency)
|
||||
|
||||
assertAllowedPaymentRedirect(body.successUrl)
|
||||
assertAllowedPaymentRedirect(body.failureUrl)
|
||||
|
||||
const orderId = `sub-${companyId}-${Date.now()}`
|
||||
const description = `${body.plan} plan — ${body.billingPeriod}`
|
||||
if (!stripe.isConfigured()) throw new ValidationError('Stripe is not configured on this platform')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PLAN_FEATURES } from '@rentaldrivego/types'
|
||||
import { PLAN_FEATURES, PLAN_ENTITLEMENTS, getVehicleLimit } from '@rentaldrivego/types'
|
||||
import { uploadImage } from '../../lib/storage'
|
||||
import { AppError, NotFoundError, ValidationError } from '../../http/errors'
|
||||
import { presentVehicle, presentVehicleList } from './vehicle.presenter'
|
||||
@@ -351,12 +351,6 @@ const VEHICLE_CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV',
|
||||
const VEHICLE_TRANSMISSIONS = ['AUTOMATIC', 'MANUAL'] as const
|
||||
const VEHICLE_FUEL_TYPES = ['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const
|
||||
const ACTIVE_FLEET_STATUSES = new Set(VEHICLE_STATUSES.filter((status) => status !== 'OUT_OF_SERVICE'))
|
||||
const FALLBACK_VEHICLE_LIMITS: Record<string, number | null> = {
|
||||
STARTER: 25,
|
||||
GROWTH: 75,
|
||||
PRO: 150,
|
||||
ENTERPRISE: null,
|
||||
}
|
||||
|
||||
function listTextVariants(value: string) {
|
||||
const lower = value.toLowerCase()
|
||||
@@ -390,10 +384,14 @@ async function getVehicleLimitForCompany(companyId: string) {
|
||||
const persistedLimit = parseVehicleLimit(persistedFeatures.map((feature: any) => feature.label))
|
||||
if (persistedLimit !== undefined) return persistedLimit
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(PLAN_ENTITLEMENTS, plan)) {
|
||||
return getVehicleLimit(plan)
|
||||
}
|
||||
|
||||
const fallbackLimit = parseVehicleLimit(PLAN_FEATURES[plan] ?? [])
|
||||
if (fallbackLimit !== undefined) return fallbackLimit
|
||||
|
||||
return FALLBACK_VEHICLE_LIMITS[plan] ?? null
|
||||
return null
|
||||
}
|
||||
|
||||
async function assertCanAddActiveFleetVehicle(companyId: string) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { AppError } from '../http/errors'
|
||||
import { assertAllowedPaymentRedirect } from './paymentRedirects'
|
||||
|
||||
describe('assertAllowedPaymentRedirect', () => {
|
||||
const previous = {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
DASHBOARD_URL: process.env.DASHBOARD_URL,
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = previous.NODE_ENV
|
||||
process.env.DASHBOARD_URL = previous.DASHBOARD_URL
|
||||
})
|
||||
|
||||
it('allows configured first-party hosts', () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
process.env.DASHBOARD_URL = 'https://app.rentaldrivego.test'
|
||||
expect(() => assertAllowedPaymentRedirect('https://app.rentaldrivego.test/billing/ok')).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects unknown hosts', () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
process.env.DASHBOARD_URL = 'https://app.rentaldrivego.test'
|
||||
expect(() => assertAllowedPaymentRedirect('https://evil.example/phish')).toThrow(AppError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { AppError } from '../http/errors'
|
||||
|
||||
/**
|
||||
* Allowlist payment provider return URLs to first-party hosts only.
|
||||
* Prevents authenticated checkout from being used as an open-redirect / phishing vector.
|
||||
*/
|
||||
export function assertAllowedPaymentRedirect(
|
||||
urlValue: string,
|
||||
options: {
|
||||
customDomain?: string | null
|
||||
customDomainVerified?: boolean | null
|
||||
subdomain?: string | null
|
||||
} = {},
|
||||
) {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(urlValue)
|
||||
} catch {
|
||||
throw new AppError('Invalid payment redirect URL', 400, 'invalid_redirect_url')
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
|
||||
throw new AppError('Payment redirect URLs must use HTTPS', 400, 'invalid_redirect_url')
|
||||
}
|
||||
|
||||
const allowedHosts = new Set<string>()
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
for (const host of [
|
||||
'localhost:3000',
|
||||
'localhost:3001',
|
||||
'localhost:3002',
|
||||
'localhost:3004',
|
||||
'localhost:4000',
|
||||
'127.0.0.1:3000',
|
||||
'127.0.0.1:3001',
|
||||
'127.0.0.1:3002',
|
||||
'127.0.0.1:3004',
|
||||
'127.0.0.1:4000',
|
||||
]) {
|
||||
allowedHosts.add(host)
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of [
|
||||
process.env.CARPLACE_URL,
|
||||
process.env.DASHBOARD_URL,
|
||||
process.env.HOMEPAGE_URL,
|
||||
process.env.ADMIN_URL,
|
||||
process.env.API_URL,
|
||||
process.env.NEXT_PUBLIC_CARPLACE_URL,
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL,
|
||||
process.env.NEXT_PUBLIC_HOMEPAGE_URL,
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL,
|
||||
process.env.SITE_ORIGIN,
|
||||
]) {
|
||||
if (!value) continue
|
||||
try {
|
||||
allowedHosts.add(new URL(value).host)
|
||||
} catch {
|
||||
/* ignore invalid env */
|
||||
}
|
||||
}
|
||||
|
||||
if (options.customDomain && options.customDomainVerified) {
|
||||
allowedHosts.add(options.customDomain)
|
||||
}
|
||||
if (options.subdomain && process.env.PUBLIC_SITE_BASE_DOMAIN) {
|
||||
allowedHosts.add(`${options.subdomain}.${process.env.PUBLIC_SITE_BASE_DOMAIN}`)
|
||||
}
|
||||
|
||||
if (!allowedHosts.has(parsed.host)) {
|
||||
throw new AppError('Payment redirect host is not allowed', 400, 'invalid_redirect_url')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Allow only same-app relative paths for post-login redirects.
|
||||
* Rejects protocol-relative URLs (//evil), absolute URLs, and backslash tricks.
|
||||
*/
|
||||
export function resolveSafeAppRedirect(candidate: string | null | undefined, fallback: string): string {
|
||||
const value = (candidate ?? '').trim()
|
||||
if (!value) return fallback
|
||||
if (!value.startsWith('/')) return fallback
|
||||
if (value.startsWith('//') || value.startsWith('/\\')) return fallback
|
||||
if (value.includes('://')) return fallback
|
||||
if (/[\r\n\\]/.test(value)) return fallback
|
||||
return value
|
||||
}
|
||||
@@ -1,136 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ContainerFeatureDisabledError, buildComposeYaml, listCompanyContainers, provisionCompanyContainer } from './containerService'
|
||||
|
||||
const execFileMock = vi.fn()
|
||||
const fsState = new Map<string, string>()
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: (file: string, args: string[], cb: (error: any, stdout: string, stderr: string) => void) => {
|
||||
const result = execFileMock(file, args) as { stdout?: string; stderr?: string } | undefined
|
||||
cb(null, result?.stdout ?? '', result?.stderr ?? '')
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
access: vi.fn(async (file: string) => {
|
||||
if (!fsState.has(file)) throw Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
}),
|
||||
readFile: vi.fn(async (file: string) => fsState.get(file) ?? '{}'),
|
||||
writeFile: vi.fn(async (file: string, contents: string) => { fsState.set(file, contents) }),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/prisma', () => ({
|
||||
prisma: {
|
||||
companyContainer: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import {
|
||||
createCompanyContainer,
|
||||
getContainerLogs,
|
||||
restartCompanyContainer,
|
||||
startCompanyContainer,
|
||||
stopCompanyContainer,
|
||||
syncContainerStatuses,
|
||||
} from './containerService'
|
||||
|
||||
describe('containerService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fsState.clear()
|
||||
fsState.set('/opt/companies/docker-compose.companies.yml', 'services: {}')
|
||||
fsState.set('/opt/companies/companies-services.json', '{}')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('allocates the first configured port, writes compose service metadata, starts the service, and stores docker id', async () => {
|
||||
vi.mocked(prisma.companyContainer.findFirst).mockResolvedValue(null as never)
|
||||
vi.mocked(prisma.companyContainer.create).mockResolvedValue({ id: 'container_row_1' } as never)
|
||||
|
||||
execFileMock.mockImplementation((file: string, args: string[]) => {
|
||||
// The child_process mock calls back with empty stdout. This spy records the command shape.
|
||||
return { file, args }
|
||||
})
|
||||
|
||||
await createCompanyContainer({ id: 'company_1', slug: 'atlas-cars', name: 'Atlas Cars' })
|
||||
|
||||
expect(prisma.companyContainer.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
containerName: 'rdg-company-atlas-cars',
|
||||
status: 'CREATING',
|
||||
port: 5100,
|
||||
}),
|
||||
})
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
'/opt/companies/companies-services.json',
|
||||
expect.stringContaining('"atlas-cars"'),
|
||||
'utf8',
|
||||
)
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
'/opt/companies/docker-compose.companies.yml',
|
||||
expect.stringContaining('container_name: "rdg-company-atlas-cars"'),
|
||||
'utf8',
|
||||
)
|
||||
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['version'])
|
||||
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'up', '-d', '--no-recreate', 'company-atlas-cars'])
|
||||
expect(prisma.companyContainer.update).toHaveBeenCalledWith({
|
||||
where: { id: 'container_row_1' },
|
||||
data: { dockerId: null, status: 'RUNNING' },
|
||||
describe('containerService (disabled)', () => {
|
||||
it('fails closed for all orchestration entry points', async () => {
|
||||
await expect(listCompanyContainers()).rejects.toBeInstanceOf(ContainerFeatureDisabledError)
|
||||
await expect(provisionCompanyContainer('company_1')).rejects.toMatchObject({
|
||||
code: 'container_feature_disabled',
|
||||
statusCode: 501,
|
||||
})
|
||||
expect(() => buildComposeYaml([])).toThrow(ContainerFeatureDisabledError)
|
||||
})
|
||||
|
||||
it('starts, stops, and restarts an existing company service with status transitions', async () => {
|
||||
vi.mocked(prisma.companyContainer.findUniqueOrThrow).mockResolvedValue({
|
||||
companyId: 'company_1',
|
||||
company: { slug: 'atlas-cars' },
|
||||
} as never)
|
||||
|
||||
await startCompanyContainer('company_1')
|
||||
await stopCompanyContainer('company_1')
|
||||
await restartCompanyContainer('company_1')
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'up', '-d', '--no-recreate', 'company-atlas-cars'])
|
||||
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'stop', 'company-atlas-cars'])
|
||||
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'restart', 'company-atlas-cars'])
|
||||
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'RESTARTING', errorMessage: null } })
|
||||
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'RUNNING', errorMessage: null } })
|
||||
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'STOPPED', errorMessage: null } })
|
||||
})
|
||||
|
||||
it('returns an empty log string when docker log retrieval fails', async () => {
|
||||
vi.mocked(prisma.companyContainer.findUniqueOrThrow).mockResolvedValue({ company: { slug: 'atlas-cars' } } as never)
|
||||
execFileMock.mockImplementationOnce(() => { throw new Error('docker unavailable') })
|
||||
|
||||
await expect(getContainerLogs('company_1', 50)).resolves.toBe('')
|
||||
})
|
||||
|
||||
it('loads container records and leaves stored statuses unchanged when docker status sync cannot complete', async () => {
|
||||
vi.mocked(prisma.companyContainer.findMany).mockResolvedValue([
|
||||
{ id: 'row_1', status: 'STOPPED', company: { slug: 'atlas-cars' } },
|
||||
{ id: 'row_2', status: 'RUNNING', company: { slug: 'sahara-rentals' } },
|
||||
] as never)
|
||||
|
||||
execFileMock.mockImplementation(() => { throw new Error('Docker daemon is unavailable') })
|
||||
|
||||
await expect(syncContainerStatuses()).resolves.toBeUndefined()
|
||||
|
||||
expect(prisma.companyContainer.findMany).toHaveBeenCalledWith({
|
||||
where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
expect(prisma.companyContainer.update).not.toHaveBeenCalled()
|
||||
})})
|
||||
})
|
||||
|
||||
@@ -1,469 +1,58 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { prisma as _prisma } from '../lib/prisma'
|
||||
/**
|
||||
* Per-tenant Docker container orchestration is OUT OF PRODUCTION SCOPE.
|
||||
* This module is intentionally disabled so the business API never talks to
|
||||
* a Docker socket or generates Compose YAML from tenant input.
|
||||
*
|
||||
* See docs/ADR-001-disable-per-tenant-containers.md
|
||||
*/
|
||||
|
||||
const db = _prisma as any
|
||||
export class ContainerFeatureDisabledError extends Error {
|
||||
statusCode = 501
|
||||
code = 'container_feature_disabled'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
let composeCommand: 'docker-compose' | 'docker' | null = null
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const COMPOSE_DIR = process.env.COMPANIES_COMPOSE_DIR || '/opt/companies'
|
||||
const COMPOSE_FILE = path.join(COMPOSE_DIR, 'docker-compose.companies.yml')
|
||||
const SERVICES_FILE = path.join(COMPOSE_DIR, 'companies-services.json') // our source of truth
|
||||
|
||||
const DASHBOARD_IMAGE = process.env.DASHBOARD_CONTAINER_IMAGE || 'rentaldrivego/dashboard:latest'
|
||||
const PORT_RANGE_START = parseInt(process.env.CONTAINER_PORT_START || '5100', 10)
|
||||
const PORT_RANGE_END = parseInt(process.env.CONTAINER_PORT_END || '5999', 10)
|
||||
const API_INTERNAL_URL = process.env.API_INTERNAL_URL || 'http://api:4000'
|
||||
const CONTAINER_NETWORK = process.env.CONTAINER_NETWORK || 'rentaldrivego_default'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ServiceDef {
|
||||
companyId: string
|
||||
slug: string
|
||||
image: string
|
||||
port: number
|
||||
}
|
||||
|
||||
type ServicesMap = Record<string, ServiceDef> // key = service name e.g. "company-slug"
|
||||
|
||||
type ServiceStatus = 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'ERROR'
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function serviceName(slug: string) {
|
||||
return `company-${slug}`
|
||||
}
|
||||
|
||||
function containerName(slug: string) {
|
||||
return `rdg-company-${slug}`
|
||||
}
|
||||
|
||||
async function ensureDir(): Promise<void> {
|
||||
await fs.mkdir(COMPOSE_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
async function readServices(): Promise<ServicesMap> {
|
||||
try {
|
||||
const raw = await fs.readFile(SERVICES_FILE, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return {}
|
||||
constructor(message = 'Per-tenant container management is disabled and out of production scope') {
|
||||
super(message)
|
||||
this.name = 'ContainerFeatureDisabledError'
|
||||
}
|
||||
}
|
||||
|
||||
async function writeServices(services: ServicesMap): Promise<void> {
|
||||
await ensureDir()
|
||||
await fs.writeFile(SERVICES_FILE, JSON.stringify(services, null, 2), 'utf8')
|
||||
await fs.writeFile(COMPOSE_FILE, buildComposeYaml(services), 'utf8')
|
||||
function disabled(): never {
|
||||
throw new ContainerFeatureDisabledError()
|
||||
}
|
||||
|
||||
async function composeFilesExist(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(COMPOSE_FILE)
|
||||
await fs.access(SERVICES_FILE)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
export async function provisionCompanyContainer(_companyId: string): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
async function rebuildServicesFromDatabase(): Promise<ServicesMap> {
|
||||
const records = await db.companyContainer.findMany({
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
return Object.fromEntries(
|
||||
records.map((record: any) => [
|
||||
serviceName(record.company.slug),
|
||||
{
|
||||
companyId: record.companyId,
|
||||
slug: record.company.slug,
|
||||
image: record.image,
|
||||
port: record.port,
|
||||
},
|
||||
]),
|
||||
)
|
||||
export async function startCompanyContainer(_companyId: string): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
async function ensureComposeFiles(): Promise<void> {
|
||||
if (await composeFilesExist()) return
|
||||
|
||||
const services = await rebuildServicesFromDatabase()
|
||||
await writeServices(services)
|
||||
export async function stopCompanyContainer(_companyId: string): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
function buildComposeYaml(services: ServicesMap): string {
|
||||
const lines: string[] = ['services:']
|
||||
|
||||
for (const [name, svc] of Object.entries(services)) {
|
||||
lines.push(` ${name}:`)
|
||||
lines.push(` image: "${svc.image}"`)
|
||||
lines.push(` container_name: "${containerName(svc.slug)}"`)
|
||||
lines.push(` restart: unless-stopped`)
|
||||
lines.push(` environment:`)
|
||||
lines.push(` COMPANY_ID: "${svc.companyId}"`)
|
||||
lines.push(` COMPANY_SLUG: "${svc.slug}"`)
|
||||
lines.push(` API_URL: "${API_INTERNAL_URL}"`)
|
||||
lines.push(` NEXT_PUBLIC_API_URL: "${API_INTERNAL_URL}"`)
|
||||
lines.push(` PORT: "3000"`)
|
||||
lines.push(` NODE_ENV: "production"`)
|
||||
lines.push(` ports:`)
|
||||
lines.push(` - "${svc.port}:3000"`)
|
||||
lines.push(` networks:`)
|
||||
lines.push(` - ${CONTAINER_NETWORK}`)
|
||||
lines.push(` labels:`)
|
||||
lines.push(` rdg.managed: "true"`)
|
||||
lines.push(` rdg.company.id: "${svc.companyId}"`)
|
||||
lines.push(` rdg.company.slug: "${svc.slug}"`)
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
lines.push('networks:')
|
||||
lines.push(` ${CONTAINER_NETWORK}:`)
|
||||
lines.push(` external: true`)
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
export async function restartCompanyContainer(_companyId: string): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
async function resolveComposeCommand(): Promise<'docker-compose' | 'docker'> {
|
||||
if (composeCommand) return composeCommand
|
||||
|
||||
try {
|
||||
await execFileAsync('docker-compose', ['version'])
|
||||
composeCommand = 'docker-compose'
|
||||
return composeCommand
|
||||
} catch {
|
||||
try {
|
||||
await execFileAsync('docker', ['compose', 'version'])
|
||||
composeCommand = 'docker'
|
||||
return composeCommand
|
||||
} catch (err) {
|
||||
throw mapDockerError(err)
|
||||
}
|
||||
}
|
||||
export async function removeCompanyContainer(_companyId: string): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
async function compose(...args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
try {
|
||||
await ensureComposeFiles()
|
||||
const command = await resolveComposeCommand()
|
||||
if (command === 'docker-compose') {
|
||||
return await execFileAsync('docker-compose', ['-f', COMPOSE_FILE, ...args])
|
||||
}
|
||||
return await execFileAsync('docker', ['compose', '-f', COMPOSE_FILE, ...args])
|
||||
} catch (err) {
|
||||
throw mapDockerError(err)
|
||||
}
|
||||
export async function getCompanyContainerLogs(_companyId: string, _tail = 100): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
function dockerUnavailableError(message: string, details?: string) {
|
||||
const err = Object.assign(new Error(message), {
|
||||
statusCode: 503,
|
||||
code: 'docker_unavailable',
|
||||
})
|
||||
|
||||
if (details) {
|
||||
;(err as Error & { details?: string }).details = details
|
||||
}
|
||||
|
||||
return err
|
||||
export async function listCompanyContainers(): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
function mapDockerError(err: unknown) {
|
||||
if (err && typeof err === 'object') {
|
||||
const error = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string }
|
||||
const stderr = error.stderr?.trim()
|
||||
|
||||
if (error.code === 'ENOENT' && error.path === 'docker') {
|
||||
return dockerUnavailableError(
|
||||
'Docker CLI is not available to the API service.',
|
||||
'Install Docker in the API container or run the API on a host with Docker available in PATH.',
|
||||
)
|
||||
}
|
||||
|
||||
if (error.code === 'ENOENT' && error.path === 'docker-compose') {
|
||||
return dockerUnavailableError(
|
||||
'Docker Compose is not available to the API service.',
|
||||
'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.',
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
stderr?.includes("docker: 'compose' is not a docker command.") ||
|
||||
stderr?.includes("unknown shorthand flag: 'f' in -f")
|
||||
) {
|
||||
return dockerUnavailableError(
|
||||
'Docker Compose is not available to the API service.',
|
||||
'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.',
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
stderr?.includes('Cannot connect to the Docker daemon') ||
|
||||
stderr?.includes('permission denied while trying to connect to the Docker daemon socket') ||
|
||||
stderr?.includes('error during connect')
|
||||
) {
|
||||
return dockerUnavailableError(
|
||||
'Docker daemon is not reachable from the API service.',
|
||||
'Mount `/var/run/docker.sock` into the API container and ensure the Docker daemon is running.',
|
||||
)
|
||||
}
|
||||
|
||||
// Catch-all: docker ran but exited non-zero — surface stderr as a readable 502
|
||||
if (typeof error.code === 'number' && error.code !== 0) {
|
||||
const detail = stderr || error.stdout?.trim() || 'docker compose exited with a non-zero status'
|
||||
return Object.assign(new Error(detail), { statusCode: 502, code: 'docker_error' })
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
export async function provisionAllPending(): Promise<never> {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
async function setContainerError(companyId: string, message: string): Promise<void> {
|
||||
await db.companyContainer.update({
|
||||
where: { companyId },
|
||||
data: { status: 'ERROR', errorMessage: message },
|
||||
}).catch(() => null)
|
||||
}
|
||||
|
||||
async function runContainerAction(
|
||||
companyId: string,
|
||||
slug: string,
|
||||
command: 'start' | 'stop' | 'restart',
|
||||
successStatus: Exclude<ServiceStatus, 'ERROR'>,
|
||||
preStatus?: Exclude<ServiceStatus, 'ERROR'>,
|
||||
): Promise<void> {
|
||||
if (preStatus) {
|
||||
await db.companyContainer.update({ where: { companyId }, data: { status: preStatus, errorMessage: null } })
|
||||
}
|
||||
|
||||
try {
|
||||
// Use `up -d --no-recreate` for start so it creates the container if it doesn't exist yet
|
||||
const args = command === 'start'
|
||||
? ['up', '-d', '--no-recreate', serviceName(slug)]
|
||||
: [command, serviceName(slug)]
|
||||
await compose(...args)
|
||||
await db.companyContainer.update({
|
||||
where: { companyId },
|
||||
data: { status: successStatus, errorMessage: null },
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : `Failed to ${command} container`
|
||||
await setContainerError(companyId, message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function allocatePort(): Promise<number> {
|
||||
const last = await db.companyContainer.findFirst({
|
||||
orderBy: { port: 'desc' },
|
||||
select: { port: true },
|
||||
})
|
||||
const next = last ? last.port + 1 : PORT_RANGE_START
|
||||
if (next > PORT_RANGE_END) throw new Error('No available ports in container port range')
|
||||
return next
|
||||
}
|
||||
|
||||
async function getDockerServiceId(slug: string): Promise<string | null> {
|
||||
try {
|
||||
const name = serviceName(slug)
|
||||
const { stdout } = await compose('ps', '--format', 'json', name)
|
||||
const line = stdout.trim().split('\n')[0]
|
||||
if (!line) return null
|
||||
const info = JSON.parse(line)
|
||||
return info.ID ?? info.Id ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Maps docker compose State strings to our DB enum
|
||||
function mapDockerState(state: string): ServiceStatus {
|
||||
switch (state.toLowerCase()) {
|
||||
case 'running': return 'RUNNING'
|
||||
case 'restarting': return 'RESTARTING'
|
||||
case 'exited':
|
||||
case 'created':
|
||||
case 'paused': return 'STOPPED'
|
||||
default: return 'ERROR'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createCompanyContainer(company: {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
}): Promise<void> {
|
||||
const name = serviceName(company.slug)
|
||||
const port = await allocatePort()
|
||||
|
||||
const record = await db.companyContainer.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
containerName: containerName(company.slug),
|
||||
status: 'CREATING',
|
||||
port,
|
||||
image: DASHBOARD_IMAGE,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const services = await readServices()
|
||||
services[name] = { companyId: company.id, slug: company.slug, image: DASHBOARD_IMAGE, port }
|
||||
await writeServices(services)
|
||||
|
||||
await compose('up', '-d', '--no-recreate', name)
|
||||
|
||||
const dockerId = await getDockerServiceId(company.slug)
|
||||
await db.companyContainer.update({
|
||||
where: { id: record.id },
|
||||
data: { dockerId, status: 'RUNNING' },
|
||||
})
|
||||
} catch (err) {
|
||||
await db.companyContainer.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
status: 'ERROR',
|
||||
errorMessage: err instanceof Error ? err.message : 'Unknown error during service creation',
|
||||
},
|
||||
})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function startCompanyContainer(companyId: string): Promise<void> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
await runContainerAction(companyId, record.company.slug, 'start', 'RUNNING')
|
||||
}
|
||||
|
||||
export async function stopCompanyContainer(companyId: string): Promise<void> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
await runContainerAction(companyId, record.company.slug, 'stop', 'STOPPED')
|
||||
}
|
||||
|
||||
export async function restartCompanyContainer(companyId: string): Promise<void> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
await runContainerAction(companyId, record.company.slug, 'restart', 'RUNNING', 'RESTARTING')
|
||||
}
|
||||
|
||||
export async function removeCompanyContainer(companyId: string): Promise<void> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
const name = serviceName(record.company.slug)
|
||||
await db.companyContainer.update({ where: { companyId }, data: { status: 'REMOVING' } })
|
||||
|
||||
try {
|
||||
await compose('stop', name)
|
||||
} catch { /* already stopped */ }
|
||||
|
||||
try {
|
||||
await compose('rm', '-f', name)
|
||||
} catch { /* already removed */ }
|
||||
|
||||
const services = await readServices()
|
||||
delete services[name]
|
||||
await writeServices(services)
|
||||
|
||||
await db.companyContainer.delete({ where: { companyId } })
|
||||
}
|
||||
|
||||
export async function redeployCompanyContainer(company: {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
}): Promise<void> {
|
||||
const name = serviceName(company.slug)
|
||||
|
||||
const existing = await db.companyContainer.findUnique({ where: { companyId: company.id } })
|
||||
if (existing) {
|
||||
try { await compose('stop', name) } catch { /* ok */ }
|
||||
try { await compose('rm', '-f', name) } catch { /* ok */ }
|
||||
await db.companyContainer.delete({ where: { companyId: company.id } })
|
||||
}
|
||||
|
||||
// Remove from compose file too, then recreate
|
||||
const services = await readServices()
|
||||
delete services[name]
|
||||
await writeServices(services)
|
||||
|
||||
await createCompanyContainer(company)
|
||||
}
|
||||
|
||||
export async function getContainerLogs(companyId: string, tail = 150): Promise<string> {
|
||||
const record = await db.companyContainer.findUniqueOrThrow({
|
||||
where: { companyId },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
try {
|
||||
const { stdout } = await compose(
|
||||
'logs',
|
||||
'--no-log-prefix',
|
||||
`--tail=${tail}`,
|
||||
serviceName(record.company.slug),
|
||||
)
|
||||
return stdout
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncContainerStatuses(): Promise<void> {
|
||||
const records = await db.companyContainer.findMany({
|
||||
where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } },
|
||||
include: { company: { select: { slug: true } } },
|
||||
})
|
||||
|
||||
try {
|
||||
// Get all compose service states in one shot
|
||||
const { stdout } = await compose('ps', '--format', 'json')
|
||||
const rows = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try { return JSON.parse(line) } catch { return null }
|
||||
})
|
||||
.filter(Boolean) as Array<{ Service: string; State: string }>
|
||||
|
||||
const stateByService = Object.fromEntries(rows.map((r) => [r.Service, r.State]))
|
||||
|
||||
await Promise.allSettled(
|
||||
records.map(async (rec: any) => {
|
||||
const name = serviceName(rec.company.slug)
|
||||
const dockerState = stateByService[name]
|
||||
const status = dockerState ? mapDockerState(dockerState) : 'STOPPED'
|
||||
|
||||
if (rec.status !== status) {
|
||||
await db.companyContainer.update({ where: { id: rec.id }, data: { status } })
|
||||
}
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
// Docker daemon unreachable — leave statuses as-is
|
||||
}
|
||||
/** @deprecated Kept only so accidental imports fail closed. */
|
||||
export function buildComposeYaml(_services: unknown): never {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Resend } from 'resend'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { redis } from '../lib/redis'
|
||||
import { NotificationType, NotificationChannel } from '@rentaldrivego/database'
|
||||
import {
|
||||
renderLocalizedEmailHtml,
|
||||
@@ -708,18 +709,55 @@ async function deliveryEmail(recipient: any) {
|
||||
?? null
|
||||
}
|
||||
|
||||
export async function processNotificationOutbox(limit = 50) {
|
||||
export async function processNotificationOutbox(limit = 50, workerId = `worker:${process.pid}`) {
|
||||
const leaseExpiredBefore = new Date(Date.now() - 2 * 60 * 1000)
|
||||
const now = new Date()
|
||||
const candidates = await prisma.notificationOutbox.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
AND: [
|
||||
{ OR: [{ availableAt: null }, { availableAt: { lte: now } }] },
|
||||
{ OR: [{ lockedAt: null }, { lockedAt: { lt: leaseExpiredBefore } }] },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: Math.max(1, Math.min(limit, 200)),
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const claimedIds: string[] = []
|
||||
for (const candidate of candidates) {
|
||||
const claimed = await prisma.notificationOutbox.updateMany({
|
||||
where: {
|
||||
id: candidate.id,
|
||||
status: 'PENDING',
|
||||
AND: [
|
||||
{ OR: [{ availableAt: null }, { availableAt: { lte: now } }] },
|
||||
{ OR: [{ lockedAt: null }, { lockedAt: { lt: leaseExpiredBefore } }] },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
lockedAt: now,
|
||||
lockedBy: workerId,
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
})
|
||||
if (claimed.count === 1) claimedIds.push(candidate.id)
|
||||
}
|
||||
|
||||
if (claimedIds.length === 0) return 0
|
||||
|
||||
const entries = await prisma.notificationOutbox.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
where: { id: { in: claimedIds } },
|
||||
include: {
|
||||
notificationEvent: {
|
||||
include: {
|
||||
recipients: {
|
||||
include: {
|
||||
employee: { select: { email: true } },
|
||||
renter: { select: { email: true } },
|
||||
employee: { select: { id: true, email: true } },
|
||||
renter: { select: { id: true, email: true } },
|
||||
billingContact: { select: { email: true, isActive: true, verifiedAt: true } },
|
||||
adminUser: { select: { email: true, isActive: true } },
|
||||
adminUser: { select: { id: true, email: true, isActive: true } },
|
||||
deliveries: true,
|
||||
},
|
||||
},
|
||||
@@ -727,7 +765,6 @@ export async function processNotificationOutbox(limit = 50) {
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: Math.max(1, Math.min(limit, 200)),
|
||||
})
|
||||
|
||||
let processed = 0
|
||||
@@ -740,7 +777,10 @@ export async function processNotificationOutbox(limit = 50) {
|
||||
where: { notificationRecipient: { notificationEventId: event.id }, status: { in: ['PENDING', 'QUEUED', 'FAILED'] } },
|
||||
data: { status: 'SKIPPED', failureCode: 'COLLECTIONS_CASE_CLOSED', failureReason: 'Suppressed because the collections case is closed.' },
|
||||
})
|
||||
await prisma.notificationOutbox.update({ where: { id: entry.id }, data: { status: 'PUBLISHED', publishedAt: new Date() } })
|
||||
await prisma.notificationOutbox.update({
|
||||
where: { id: entry.id },
|
||||
data: { status: 'PUBLISHED', publishedAt: new Date(), lockedAt: null, lockedBy: null },
|
||||
})
|
||||
processed += 1
|
||||
continue
|
||||
}
|
||||
@@ -756,6 +796,20 @@ export async function processNotificationOutbox(limit = 50) {
|
||||
where: { id: delivery.id },
|
||||
data: { status: 'SENT', sentAt: new Date(), attemptCount: { increment: 1 }, lastAttemptAt: new Date() },
|
||||
})
|
||||
const userId = recipient.employeeId ?? recipient.renterId ?? recipient.adminUserId
|
||||
if (userId) {
|
||||
await redis.publish(
|
||||
`notifications:${userId}`,
|
||||
JSON.stringify({
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
title: event.title,
|
||||
body: event.body,
|
||||
data: event.data,
|
||||
createdAt: event.createdAt,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} else if (delivery.channel === 'EMAIL') {
|
||||
const to = await deliveryEmail(recipient)
|
||||
const externalContactInvalid = recipient.billingContact && (!recipient.billingContact.isActive || !recipient.billingContact.verifiedAt)
|
||||
@@ -810,8 +864,21 @@ export async function processNotificationOutbox(limit = 50) {
|
||||
},
|
||||
})
|
||||
if (remaining === 0) {
|
||||
await prisma.notificationOutbox.update({ where: { id: entry.id }, data: { status: 'PUBLISHED', publishedAt: new Date() } })
|
||||
await prisma.notificationOutbox.update({
|
||||
where: { id: entry.id },
|
||||
data: { status: 'PUBLISHED', publishedAt: new Date(), lockedAt: null, lockedBy: null },
|
||||
})
|
||||
processed += 1
|
||||
} else {
|
||||
await prisma.notificationOutbox.update({
|
||||
where: { id: entry.id },
|
||||
data: {
|
||||
lockedAt: null,
|
||||
lockedBy: null,
|
||||
availableAt: new Date(Date.now() + 30_000),
|
||||
failureReason: 'Pending deliveries remain',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return processed
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../lib/prisma', () => ({
|
||||
employee: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
company: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
@@ -19,7 +20,8 @@ vi.mock('./notificationService', () => ({
|
||||
import crypto from 'crypto'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendTransactionalEmail } from './notificationService'
|
||||
import { inviteEmployee } from './teamService'
|
||||
import { hashPublicAccessToken } from '../security/publicAccessTokens'
|
||||
import { inviteEmployee, listEmployees } from './teamService'
|
||||
|
||||
describe('teamService inviteEmployee', () => {
|
||||
const originalDashboardUrl = process.env.DASHBOARD_URL
|
||||
@@ -37,11 +39,19 @@ describe('teamService inviteEmployee', () => {
|
||||
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({ name: 'Atlas Cars' } as any)
|
||||
vi.mocked(prisma.employee.create).mockResolvedValue({
|
||||
id: 'emp_1',
|
||||
clerkUserId: 'local_member_uuid-123',
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.com',
|
||||
phone: null,
|
||||
role: 'AGENT',
|
||||
isActive: true,
|
||||
preferredLanguage: 'en',
|
||||
emailVerified: null,
|
||||
passwordHash: null,
|
||||
passwordResetToken: 'hashed',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
} as any)
|
||||
})
|
||||
|
||||
@@ -51,20 +61,56 @@ describe('teamService inviteEmployee', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('builds invite reset links under the dashboard base path', async () => {
|
||||
await inviteEmployee('company_1', 'owner_1', {
|
||||
it('stores hashed invite tokens and never returns secrets in the API payload', async () => {
|
||||
const rawToken = Buffer.from('token-123').toString('hex')
|
||||
const result = await inviteEmployee('company_1', 'owner_1', {
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.com',
|
||||
email: 'Aya@Example.com',
|
||||
role: 'AGENT',
|
||||
})
|
||||
|
||||
expect(prisma.employee.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
email: 'aya@example.com',
|
||||
passwordResetToken: hashPublicAccessToken(rawToken),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(result.employee).not.toHaveProperty('passwordHash')
|
||||
expect(result.employee).not.toHaveProperty('passwordResetToken')
|
||||
expect(result.employee.invitationStatus).toBe('pending')
|
||||
expect(sendTransactionalEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: 'aya@example.com',
|
||||
html: expect.stringContaining('http://localhost:3000/dashboard/reset-password?token=746f6b656e2d313233'),
|
||||
text: expect.stringContaining('http://localhost:3000/dashboard/reset-password?token=746f6b656e2d313233'),
|
||||
html: expect.stringContaining(`http://localhost:3000/dashboard/reset-password?token=${rawToken}`),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('lists team members without password hashes or reset tokens', async () => {
|
||||
vi.mocked(prisma.employee.findMany).mockResolvedValue([
|
||||
{
|
||||
id: 'emp_1',
|
||||
clerkUserId: 'c1',
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.com',
|
||||
phone: null,
|
||||
role: 'AGENT',
|
||||
isActive: true,
|
||||
preferredLanguage: 'en',
|
||||
emailVerified: null,
|
||||
passwordHash: '$2a$12$secret',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
] as any)
|
||||
|
||||
const members = await listEmployees('company_1')
|
||||
expect(members[0]).not.toHaveProperty('passwordHash')
|
||||
expect(members[0]).not.toHaveProperty('passwordResetToken')
|
||||
expect(members[0]?.invitationStatus).toBe('accepted')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EmployeeRole } from '@rentaldrivego/database'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendTransactionalEmail } from './notificationService'
|
||||
import { coerceNotificationLocale } from './notificationLocalizationService'
|
||||
import { hashPublicAccessToken } from '../security/publicAccessTokens'
|
||||
|
||||
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7
|
||||
|
||||
@@ -22,12 +23,47 @@ export interface TeamMember {
|
||||
phone: string | null
|
||||
role: EmployeeRole
|
||||
isActive: boolean
|
||||
preferredLanguage?: string
|
||||
emailVerified?: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
lastActiveAt?: Date | null
|
||||
invitationStatus?: 'accepted' | 'pending' | 'revoked'
|
||||
}
|
||||
|
||||
function presentTeamMember(employee: {
|
||||
id: string
|
||||
clerkUserId: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string | null
|
||||
role: EmployeeRole
|
||||
isActive: boolean
|
||||
preferredLanguage?: string
|
||||
emailVerified?: Date | null
|
||||
passwordHash?: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}): TeamMember {
|
||||
return {
|
||||
id: employee.id,
|
||||
clerkUserId: employee.clerkUserId,
|
||||
firstName: employee.firstName,
|
||||
lastName: employee.lastName,
|
||||
email: employee.email,
|
||||
phone: employee.phone,
|
||||
role: employee.role,
|
||||
isActive: employee.isActive,
|
||||
preferredLanguage: employee.preferredLanguage,
|
||||
emailVerified: employee.emailVerified ?? null,
|
||||
createdAt: employee.createdAt,
|
||||
updatedAt: employee.updatedAt,
|
||||
lastActiveAt: null,
|
||||
invitationStatus: employee.passwordHash ? 'accepted' : 'pending',
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDashboardBasePath(baseUrl: string) {
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
@@ -96,13 +132,24 @@ export async function listEmployees(companyId: string): Promise<TeamMember[]> {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { companyId },
|
||||
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
clerkUserId: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
preferredLanguage: true,
|
||||
emailVerified: true,
|
||||
passwordHash: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return employees.map((employee: any) => ({
|
||||
...employee,
|
||||
lastActiveAt: null,
|
||||
invitationStatus: employee.passwordHash ? 'accepted' : 'pending',
|
||||
}))
|
||||
return employees.map(presentTeamMember)
|
||||
}
|
||||
|
||||
export async function inviteEmployee(companyId: string, inviterId: string, payload: InvitePayload) {
|
||||
@@ -110,7 +157,8 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
|
||||
throw Object.assign(new Error('Cannot invite a member with the OWNER role'), { statusCode: 400, code: 'invalid_role' })
|
||||
}
|
||||
|
||||
const existing = await prisma.employee.findFirst({ where: { companyId, email: payload.email } })
|
||||
const email = payload.email.trim().toLowerCase()
|
||||
const existing = await prisma.employee.findFirst({ where: { companyId, email: { equals: email, mode: 'insensitive' } } })
|
||||
if (existing) {
|
||||
throw Object.assign(new Error('An employee with this email already exists in your team'), { statusCode: 409, code: 'employee_already_exists' })
|
||||
}
|
||||
@@ -120,6 +168,7 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
|
||||
include: { brand: { select: { defaultLocale: true, displayName: true } } },
|
||||
})
|
||||
const rawToken = crypto.randomBytes(32).toString('hex')
|
||||
const tokenHash = hashPublicAccessToken(rawToken)
|
||||
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000)
|
||||
const locale = coerceNotificationLocale(company.brand?.defaultLocale)
|
||||
|
||||
@@ -129,11 +178,11 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
|
||||
clerkUserId: `local_member_${crypto.randomUUID()}`,
|
||||
firstName: payload.firstName,
|
||||
lastName: payload.lastName,
|
||||
email: payload.email,
|
||||
email,
|
||||
role: payload.role,
|
||||
isActive: true,
|
||||
preferredLanguage: locale,
|
||||
passwordResetToken: rawToken,
|
||||
passwordResetToken: tokenHash,
|
||||
passwordResetExpiresAt: expiresAt,
|
||||
},
|
||||
})
|
||||
@@ -151,18 +200,14 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
|
||||
)
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: payload.email,
|
||||
to: email,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
text: message.text,
|
||||
})
|
||||
|
||||
return {
|
||||
employee: {
|
||||
...employee,
|
||||
lastActiveAt: null,
|
||||
invitationStatus: 'pending' as const,
|
||||
},
|
||||
employee: presentTeamMember(employee),
|
||||
invitationId: employee.id,
|
||||
invitedBy: inviterId,
|
||||
}
|
||||
@@ -176,7 +221,8 @@ export async function updateEmployeeRole(companyId: string, requesterId: string,
|
||||
if (payload.role === 'OWNER') throw Object.assign(new Error('Cannot assign the OWNER role via this endpoint'), { statusCode: 400 })
|
||||
if (target.id === requesterId) throw Object.assign(new Error('You cannot change your own role'), { statusCode: 400 })
|
||||
|
||||
return prisma.employee.update({ where: { id: employeeId }, data: { role: payload.role } })
|
||||
const updated = await prisma.employee.update({ where: { id: employeeId }, data: { role: payload.role } })
|
||||
return presentTeamMember(updated)
|
||||
}
|
||||
|
||||
export async function deactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
|
||||
@@ -184,14 +230,16 @@ export async function deactivateEmployee(companyId: string, requesterRole: Emplo
|
||||
const target = await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
|
||||
if (target.role === 'OWNER') throw Object.assign(new Error('Cannot deactivate the account owner'), { statusCode: 400 })
|
||||
|
||||
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: false } })
|
||||
const updated = await prisma.employee.update({ where: { id: employeeId }, data: { isActive: false } })
|
||||
return presentTeamMember(updated)
|
||||
}
|
||||
|
||||
export async function reactivateEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
|
||||
if (requesterRole !== 'OWNER') throw Object.assign(new Error('Only the account owner can reactivate team members'), { statusCode: 403 })
|
||||
await prisma.employee.findFirstOrThrow({ where: { id: employeeId, companyId } })
|
||||
|
||||
return prisma.employee.update({ where: { id: employeeId }, data: { isActive: true } })
|
||||
const updated = await prisma.employee.update({ where: { id: employeeId }, data: { isActive: true } })
|
||||
return presentTeamMember(updated)
|
||||
}
|
||||
|
||||
export async function removeEmployee(companyId: string, requesterRole: EmployeeRole, employeeId: string) {
|
||||
|
||||
@@ -185,7 +185,7 @@ export const openApiDocument: JsonObject = {
|
||||
'/health': {
|
||||
get: {
|
||||
tags: ['Health'],
|
||||
summary: 'Health check',
|
||||
summary: 'Liveness health check',
|
||||
security: [],
|
||||
servers: [{ url: '/' }],
|
||||
responses: {
|
||||
@@ -193,6 +193,29 @@ export const openApiDocument: JsonObject = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/ready': {
|
||||
get: {
|
||||
tags: ['Health'],
|
||||
summary: 'Readiness probe (database, redis, storage)',
|
||||
security: [],
|
||||
servers: [{ url: '/' }],
|
||||
responses: {
|
||||
'200': { description: 'Ready' },
|
||||
'503': { description: 'Not ready' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/metrics': {
|
||||
get: {
|
||||
tags: ['Health'],
|
||||
summary: 'Prometheus-style ops metrics',
|
||||
security: [],
|
||||
servers: [{ url: '/' }],
|
||||
responses: {
|
||||
'200': { description: 'Prometheus text exposition format' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// AUTH — COMPANY
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import request from 'supertest'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { createApp } from '../../app'
|
||||
import {
|
||||
createCompanyWithEmployee,
|
||||
createVehicle,
|
||||
createCustomer,
|
||||
createReservation,
|
||||
createRentalPayment,
|
||||
signEmployeeToken,
|
||||
authHeader,
|
||||
} from '../helpers/fixtures'
|
||||
|
||||
/**
|
||||
* Phase 3 — cross-tenant negative suite.
|
||||
* Pattern: Company A credentials must not read/mutate Company B resources (404, no leak).
|
||||
*/
|
||||
const app = createApp()
|
||||
|
||||
describe('Cross-tenant isolation (Phase 3)', () => {
|
||||
let companyAId: string
|
||||
let tokenA: string
|
||||
let companyBId: string
|
||||
let employeeBId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
const a = await createCompanyWithEmployee({ role: 'OWNER' })
|
||||
const b = await createCompanyWithEmployee({ role: 'OWNER' })
|
||||
companyAId = a.company.id
|
||||
tokenA = signEmployeeToken(a.employee.id, companyAId, 'OWNER')
|
||||
companyBId = b.company.id
|
||||
employeeBId = b.employee.id
|
||||
})
|
||||
|
||||
it('GET foreign vehicle → 404', async () => {
|
||||
const foreign = await createVehicle(companyBId, { make: 'Foreign' })
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/vehicles/${foreign.id}`)
|
||||
.set(authHeader(tokenA))
|
||||
expect(res.status).toBe(404)
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/Foreign/)
|
||||
})
|
||||
|
||||
it('GET foreign vehicle maintenance → 404', async () => {
|
||||
const foreign = await createVehicle(companyBId)
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/vehicles/${foreign.id}/maintenance`)
|
||||
.set(authHeader(tokenA))
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('GET foreign customer → 404', async () => {
|
||||
const foreign = await createCustomer(companyBId, { firstName: 'SecretTenantB' })
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/customers/${foreign.id}`)
|
||||
.set(authHeader(tokenA))
|
||||
expect(res.status).toBe(404)
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/SecretTenantB/)
|
||||
})
|
||||
|
||||
it('GET foreign reservation → 404', async () => {
|
||||
const vehicle = await createVehicle(companyBId)
|
||||
const customer = await createCustomer(companyBId)
|
||||
const foreign = await createReservation(companyBId, vehicle.id, customer.id)
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/reservations/${foreign.id}`)
|
||||
.set(authHeader(tokenA))
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('PATCH foreign customer → not successful', async () => {
|
||||
const foreign = await createCustomer(companyBId)
|
||||
const res = await request(app)
|
||||
.patch(`/api/v1/customers/${foreign.id}`)
|
||||
.set(authHeader(tokenA))
|
||||
.send({ firstName: 'Hijacked' })
|
||||
expect([404, 403, 405]).toContain(res.status)
|
||||
expect(res.status).not.toBe(200)
|
||||
})
|
||||
|
||||
it('GET foreign offer → 404', async () => {
|
||||
const offer = await prisma.offer.create({
|
||||
data: {
|
||||
companyId: companyBId,
|
||||
title: 'SecretOfferB',
|
||||
type: 'PERCENTAGE',
|
||||
discountValue: 10,
|
||||
validFrom: new Date(),
|
||||
validUntil: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
isActive: true,
|
||||
isPublic: true,
|
||||
} as any,
|
||||
})
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/offers/${offer.id}`)
|
||||
.set(authHeader(tokenA))
|
||||
expect(res.status).toBe(404)
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/SecretOfferB/)
|
||||
})
|
||||
|
||||
it('team list does not include foreign employees', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/team')
|
||||
.set(authHeader(tokenA))
|
||||
expect(res.status).toBe(200)
|
||||
const body = JSON.stringify(res.body)
|
||||
expect(body).not.toContain(employeeBId)
|
||||
})
|
||||
|
||||
it('cannot deactivate foreign team member', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/v1/team/${employeeBId}/deactivate`)
|
||||
.set(authHeader(tokenA))
|
||||
expect([404, 403]).toContain(res.status)
|
||||
})
|
||||
|
||||
it('GET payments for foreign reservation → 404 or empty without leak', async () => {
|
||||
const vehicle = await createVehicle(companyBId)
|
||||
const customer = await createCustomer(companyBId, { firstName: 'PayeeSecret' })
|
||||
const reservation = await createReservation(companyBId, vehicle.id, customer.id)
|
||||
await createRentalPayment(companyBId, reservation.id, { amount: 9999 })
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/payments/reservations/${reservation.id}`)
|
||||
.set(authHeader(tokenA))
|
||||
expect([404, 200]).toContain(res.status)
|
||||
if (res.status === 200) {
|
||||
const payments = res.body.data ?? res.body
|
||||
expect(Array.isArray(payments) ? payments.length : 0).toBe(0)
|
||||
}
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/PayeeSecret|9999/)
|
||||
})
|
||||
|
||||
it('reservation JSON never includes reviewToken for own company either (S11)', async () => {
|
||||
const vehicle = await createVehicle(companyAId)
|
||||
const customer = await createCustomer(companyAId)
|
||||
const reservation = await createReservation(companyAId, vehicle.id, customer.id, {
|
||||
reviewToken: 'should-not-leak-phase3',
|
||||
})
|
||||
const res = await request(app)
|
||||
.get(`/api/v1/reservations/${reservation.id}`)
|
||||
.set(authHeader(tokenA))
|
||||
expect(res.status).toBe(200)
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/should-not-leak-phase3/)
|
||||
expect(res.body.data?.reviewToken).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import http from 'node:http'
|
||||
import { assertStorageConfiguration } from '../lib/storage'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { redis } from '../lib/redis'
|
||||
import { renderPrometheusText, setGauge } from '../lib/opsMetrics'
|
||||
import { startOutboxWorker, startScheduledJobs } from './jobs'
|
||||
|
||||
assertStorageConfiguration()
|
||||
|
||||
const workerId = process.env.WORKER_ID ?? String(process.pid)
|
||||
const metricsPort = Number(process.env.WORKER_METRICS_PORT ?? 0)
|
||||
|
||||
console.log(`[worker] starting jobs worker id=${workerId}`)
|
||||
|
||||
startOutboxWorker()
|
||||
startScheduledJobs()
|
||||
|
||||
if (Number.isFinite(metricsPort) && metricsPort > 0) {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ status: 'ok', role: 'worker', workerId }))
|
||||
return
|
||||
}
|
||||
if (req.url === '/metrics') {
|
||||
try {
|
||||
const pending = await prisma.notificationOutbox.count({ where: { status: 'PENDING' } })
|
||||
setGauge('notification_outbox_pending', pending)
|
||||
} catch {
|
||||
/* leave previous gauge */
|
||||
}
|
||||
const body = renderPrometheusText()
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' })
|
||||
res.end(body)
|
||||
return
|
||||
}
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
})
|
||||
server.listen(metricsPort, () => {
|
||||
console.log(`[worker] metrics listening on :${metricsPort}`)
|
||||
})
|
||||
}
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[worker] ${signal} received, shutting down`)
|
||||
try {
|
||||
await redis.quit()
|
||||
} catch {
|
||||
redis.disconnect()
|
||||
}
|
||||
await prisma.$disconnect()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'))
|
||||
@@ -0,0 +1,230 @@
|
||||
import cron from 'node-cron'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { redis } from '../lib/redis'
|
||||
import { processNotificationOutbox, sendNotification } from '../services/notificationService'
|
||||
import { observeOutboxProcessed } from '../lib/opsMetrics'
|
||||
import {
|
||||
runTrialExpirationJob,
|
||||
runPeriodEndCancellationJob,
|
||||
} from '../modules/subscriptions/subscription.service'
|
||||
import { runCollectionsWorker } from '../modules/subscriptions/subscription.collections.service'
|
||||
|
||||
const WORKER_ID = process.env.WORKER_ID ?? `jobs:${process.pid}`
|
||||
const LEADER_KEY = 'rentaldrivego:jobs:leader'
|
||||
const LEADER_TTL_SECONDS = 30
|
||||
|
||||
async function withLeaderLock<T>(fn: () => Promise<T>): Promise<T | null> {
|
||||
const acquired = await redis.set(LEADER_KEY, WORKER_ID, 'EX', LEADER_TTL_SECONDS, 'NX')
|
||||
if (acquired !== 'OK') {
|
||||
const current = await redis.get(LEADER_KEY)
|
||||
if (current !== WORKER_ID) return null
|
||||
} else {
|
||||
// refresh TTL periodically while holding
|
||||
}
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
const current = await redis.get(LEADER_KEY)
|
||||
if (current === WORKER_ID) await redis.del(LEADER_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
async function renewLeaderIfOwned() {
|
||||
const current = await redis.get(LEADER_KEY)
|
||||
if (current === WORKER_ID) await redis.expire(LEADER_KEY, LEADER_TTL_SECONDS)
|
||||
}
|
||||
|
||||
export async function runLicenseExpiryJob() {
|
||||
const customers = await prisma.customer.findMany({ where: { licenseExpiry: { not: null } } })
|
||||
for (const c of customers) {
|
||||
if (!c.licenseExpiry) continue
|
||||
const daysLeft = Math.ceil((c.licenseExpiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
const expired = c.licenseExpiry <= new Date()
|
||||
const expiring = !expired && daysLeft < 90
|
||||
if (expired !== c.licenseExpired || expiring !== c.licenseExpiringSoon) {
|
||||
await prisma.customer.update({
|
||||
where: { id: c.id },
|
||||
data: {
|
||||
licenseExpired: expired,
|
||||
licenseExpiringSoon: expiring,
|
||||
licenseValidationStatus: expired ? 'EXPIRED' : expiring ? 'EXPIRING' : 'VALID',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTrialEndingRemindersJob() {
|
||||
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
|
||||
const subscriptions = await prisma.subscription.findMany({
|
||||
where: { status: 'TRIALING', trialEndAt: { lte: soon, gte: new Date() } },
|
||||
include: { company: { include: { employees: { where: { role: 'OWNER' } } } } },
|
||||
})
|
||||
for (const sub of subscriptions) {
|
||||
const owner = sub.company.employees[0]
|
||||
if (!owner) continue
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function runMaintenanceRemindersJob() {
|
||||
const now = new Date()
|
||||
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const allCandidates = await prisma.maintenanceLog.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ nextDueAt: { lte: in30Days } },
|
||||
{ nextDueMileage: { not: null } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
vehicle: {
|
||||
include: {
|
||||
company: {
|
||||
include: {
|
||||
employees: { where: { role: { in: ['OWNER', 'MANAGER'] }, isActive: true }, take: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { performedAt: 'desc' },
|
||||
})
|
||||
|
||||
const latestByKey = new Map<string, (typeof allCandidates)[number]>()
|
||||
for (const log of allCandidates) {
|
||||
const key = `${log.vehicleId}:${log.type}`
|
||||
if (!latestByKey.has(key)) latestByKey.set(key, log)
|
||||
}
|
||||
|
||||
for (const log of latestByKey.values()) {
|
||||
const vehicle = log.vehicle
|
||||
const company = vehicle.company
|
||||
const recipient = company.employees[0]
|
||||
if (!recipient) continue
|
||||
|
||||
let isOverdueByDate = false
|
||||
let daysLeft: number | null = null
|
||||
let dueSoonByDate = false
|
||||
if (log.nextDueAt) {
|
||||
daysLeft = Math.ceil((log.nextDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
isOverdueByDate = log.nextDueAt <= now
|
||||
dueSoonByDate = !isOverdueByDate && daysLeft <= 30
|
||||
}
|
||||
|
||||
let isOverdueByOdometer = false
|
||||
let kmLeft: number | null = null
|
||||
let dueSoonByOdometer = false
|
||||
if (log.nextDueMileage != null && vehicle.mileage != null) {
|
||||
kmLeft = log.nextDueMileage - vehicle.mileage
|
||||
isOverdueByOdometer = kmLeft <= 0
|
||||
dueSoonByOdometer = !isOverdueByOdometer && kmLeft <= 500
|
||||
}
|
||||
|
||||
const isOverdue = isOverdueByDate || isOverdueByOdometer
|
||||
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
|
||||
if (!isOverdue && !isDueSoon) continue
|
||||
|
||||
const dueParts: string[] = []
|
||||
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
|
||||
else if (dueSoonByDate && daysLeft != null) dueParts.push(`due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`)
|
||||
if (isOverdueByOdometer) dueParts.push(`overdue by odometer (${Math.abs(kmLeft!).toLocaleString()} km ago)`)
|
||||
else if (dueSoonByOdometer && kmLeft != null) dueParts.push(`${kmLeft.toLocaleString()} km remaining`)
|
||||
|
||||
const title = isOverdue
|
||||
? `Overdue: ${log.type} — ${vehicle.make} ${vehicle.model}`
|
||||
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
|
||||
const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`
|
||||
|
||||
const reminderDate = now.toISOString().slice(0, 10)
|
||||
await sendNotification({
|
||||
type: 'VEHICLE_MAINTENANCE_DUE',
|
||||
title,
|
||||
body,
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
maintenanceLogId: log.id,
|
||||
maintenanceType: log.type,
|
||||
isOverdue,
|
||||
daysLeft,
|
||||
kmLeft,
|
||||
isOverdueByDate,
|
||||
isOverdueByOdometer,
|
||||
},
|
||||
companyId: company.id,
|
||||
employeeId: recipient.id,
|
||||
channels: ['IN_APP'],
|
||||
sourceType: 'maintenance_log',
|
||||
sourceId: log.id,
|
||||
idempotencyKey: `maintenance:${log.id}:${recipient.id}:${reminderDate}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Outbox dispatch does its own DB leasing — safe across workers without Redis leader. */
|
||||
export function startOutboxWorker() {
|
||||
cron.schedule('* * * * *', async () => {
|
||||
try {
|
||||
const n = await processNotificationOutbox(50, WORKER_ID)
|
||||
if (n > 0) {
|
||||
observeOutboxProcessed(n)
|
||||
console.log(`[notifications] outbox: ${n} events completed`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[notifications] outbox worker failed:', err?.message ?? err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Scheduled domain jobs — only one leader executes them. */
|
||||
export function startScheduledJobs() {
|
||||
setInterval(() => {
|
||||
void renewLeaderIfOwned()
|
||||
}, 10_000)
|
||||
|
||||
cron.schedule('0 8 * * *', async () => {
|
||||
await withLeaderLock(async () => {
|
||||
await runLicenseExpiryJob()
|
||||
await runMaintenanceRemindersJob()
|
||||
})
|
||||
})
|
||||
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
await withLeaderLock(async () => {
|
||||
const n = await runTrialExpirationJob()
|
||||
if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`)
|
||||
})
|
||||
})
|
||||
|
||||
cron.schedule('0 1 * * *', async () => {
|
||||
await withLeaderLock(async () => {
|
||||
const nPeriod = await runPeriodEndCancellationJob()
|
||||
if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`)
|
||||
})
|
||||
})
|
||||
|
||||
cron.schedule('*/15 * * * *', async () => {
|
||||
await withLeaderLock(async () => {
|
||||
const n = await runCollectionsWorker()
|
||||
if (n > 0) console.log(`[subscription] collections: ${n} cases processed`)
|
||||
})
|
||||
})
|
||||
|
||||
cron.schedule('0 9 * * *', async () => {
|
||||
await withLeaderLock(async () => {
|
||||
await runTrialEndingRemindersJob()
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user