implement driver license date validation

This commit is contained in:
root
2026-06-11 21:53:48 -04:00
parent b52c6305b5
commit af5b7fda7e
10 changed files with 1143 additions and 15 deletions
+4
View File
@@ -44,6 +44,10 @@ const nextConfig = {
source: '/api/:path*', source: '/api/:path*',
destination: `${apiOrigin}/api/:path*`, destination: `${apiOrigin}/api/:path*`,
}, },
{
source: '/storage/:path*',
destination: `${apiOrigin}/storage/:path*`,
},
] ]
}, },
} }
+2
View File
@@ -31,6 +31,7 @@ import marketplaceRouter from './modules/marketplace/marketplace.routes'
import siteRouter from './modules/site/site.routes' import siteRouter from './modules/site/site.routes'
import reviewsRouter from './modules/reviews/review.routes' import reviewsRouter from './modules/reviews/review.routes'
import complaintsRouter from './modules/complaints/complaint.routes' import complaintsRouter from './modules/complaints/complaint.routes'
import licenseValidationRouter from './modules/licenses/license.validation.routes'
// ─── Centralized error handling ─────────────────────────────── // ─── Centralized error handling ───────────────────────────────
import { errorMiddleware } from './http/errors/errorMiddleware' import { errorMiddleware } from './http/errors/errorMiddleware'
@@ -170,6 +171,7 @@ export function createApp() {
app.use(`${v1}/payments`, apiLimiter, paymentsRouter) app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter) app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter) app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
// ─── Health / Docs ────────────────────────────────────────── // ─── Health / Docs ──────────────────────────────────────────
app.get(v1, (_req, res) => { app.get(v1, (_req, res) => {
@@ -0,0 +1,69 @@
import { Router } from 'express'
import { parseBody } from '../../http/validate'
import { ok } from '../../http/respond'
import { AppError } from '../../http/errors'
import { validateLicenseSchema } from './license.validation.schemas'
import { parseExpirationDate, validateLicenseDate, ErrorCode } from '../../services/licenseValidationService'
const router = Router()
/**
* POST /api/v1/licenses/validate
*
* Validates a driver's license expiration date against the minimum validity rule.
*
* Request body:
* { expiration_date: string, license_number?: string, state?: string }
*
* Success (200): license is valid (≥30 days remaining)
* Error (400): invalid format, missing date, future date
* Error (422): expired or expiring soon (<30 days remaining)
*/
router.post('/validate', async (req, res, next) => {
try {
const { expiration_date } = parseBody(validateLicenseSchema, req)
const parsedDate = parseExpirationDate(expiration_date)
if (!parsedDate) {
throw new AppError(
`Unable to parse date "${expiration_date}". Please use YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY, or DD-MMM-YYYY format.`,
400,
ErrorCode.LICENSE_INVALID_FORMAT,
{
details: {
expiration_date,
suggested_action: 'Please provide the date in a supported format (e.g., YYYY-MM-DD).',
},
},
)
}
const result = validateLicenseDate(parsedDate)
if (!result.valid) {
// Map error codes to HTTP status
const statusCode =
result.error_code === ErrorCode.LICENSE_EXPIRED || result.error_code === ErrorCode.LICENSE_EXPIRING_SOON
? 422
: 400
throw new AppError(result.message, statusCode, result.error_code!, {
details: result.details,
})
}
ok(res, {
valid: true,
days_remaining: result.days_remaining,
expiration_date: result.expiration_date,
message: result.message,
next_reminder: result.days_remaining
? new Date(Date.now() + (result.days_remaining - 30) * 86400000).toISOString().split('T')[0]
: null,
})
} catch (err) {
next(err)
}
})
export default router
@@ -0,0 +1,10 @@
import { z } from 'zod'
/** Request body for POST /api/v1/licenses/validate */
export const validateLicenseSchema = z.object({
expiration_date: z.string().min(1, 'expiration_date is required'),
license_number: z.string().max(100).optional(),
state: z.string().length(2).optional(),
})
export type ValidateLicenseInput = z.infer<typeof validateLicenseSchema>
@@ -12,19 +12,29 @@ vi.mock('../lib/prisma', () => ({
})) }))
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { validateAndFlagLicense, validateLicense } from './licenseValidationService' import {
validateAndFlagLicense,
validateLicense,
validateLicenseDate,
parseExpirationDate,
ErrorCode,
} from './licenseValidationService'
const NOW = new Date('2026-06-09T12:00:00.000Z')
describe('licenseValidationService', () => { describe('licenseValidationService', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.useFakeTimers() vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z')) vi.setSystemTime(NOW)
}) })
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
}) })
// ─── validateLicense (legacy) ────────────────────────────────────────
it('treats missing license expiry as valid but explicitly records that no date exists', () => { it('treats missing license expiry as valid but explicitly records that no date exists', () => {
expect(validateLicense(null)).toEqual({ expect(validateLicense(null)).toEqual({
status: 'VALID', status: 'VALID',
@@ -34,13 +44,22 @@ describe('licenseValidationService', () => {
}) })
}) })
it('requires approval for licenses expiring inside the three-month risk window', () => { it('requires approval for licenses expiring inside the 30-day risk window', () => {
const result = validateLicense(new Date('2026-07-09T12:00:00.000Z')) const result = validateLicense(new Date('2026-07-04T12:00:00.000Z'))
expect(result.status).toBe('EXPIRING') expect(result.status).toBe('EXPIRING')
expect(result.daysUntilExpiry).toBe(30) expect(result.daysUntilExpiry).toBe(25)
expect(result.requiresApproval).toBe(true) expect(result.requiresApproval).toBe(true)
expect(result.message).toContain('License expires in 30 day(s)') expect(result.message).toContain('License expires in 25 day(s)')
})
it('marks exactly 30 days as valid (not expiring)', () => {
// 2026-07-09 is exactly 30 days from 2026-06-09
const result = validateLicense(new Date('2026-07-09T12:00:00.000Z'))
expect(result.status).toBe('VALID')
expect(result.daysUntilExpiry).toBe(30)
expect(result.requiresApproval).toBe(false)
}) })
it('requires approval for already expired licenses and reports negative days until expiry', () => { it('requires approval for already expired licenses and reports negative days until expiry', () => {
@@ -73,4 +92,108 @@ describe('licenseValidationService', () => {
}), }),
}) })
}) })
// ─── validateLicenseDate (new public API) ────────────────────────────
it('rejects missing date', () => {
const result = validateLicenseDate(null)
expect(result.valid).toBe(false)
expect(result.error_code).toBe(ErrorCode.LICENSE_MISSING_DATE)
})
it('rejects undefined date', () => {
const result = validateLicenseDate(undefined)
expect(result.valid).toBe(false)
expect(result.error_code).toBe(ErrorCode.LICENSE_MISSING_DATE)
})
it('rejects already expired license', () => {
const result = validateLicenseDate(new Date('2026-06-04T12:00:00.000Z'))
expect(result.valid).toBe(false)
expect(result.error_code).toBe(ErrorCode.LICENSE_EXPIRED)
expect(result.days_remaining).toBe(-5)
})
it('rejects license expiring in less than 30 days', () => {
const result = validateLicenseDate(new Date('2026-06-20T12:00:00.000Z'))
expect(result.valid).toBe(false)
expect(result.error_code).toBe(ErrorCode.LICENSE_EXPIRING_SOON)
expect(result.days_remaining).toBe(11)
})
it('accepts license with exactly 30 days remaining', () => {
const result = validateLicenseDate(new Date('2026-07-09T12:00:00.000Z'))
expect(result.valid).toBe(true)
expect(result.days_remaining).toBe(30)
})
it('accepts license with more than 30 days remaining', () => {
const result = validateLicenseDate(new Date('2027-06-09T12:00:00.000Z'))
expect(result.valid).toBe(true)
expect(result.days_remaining).toBe(365)
expect(result.message).toBe('License verified successfully')
})
it('rejects future date more than 20 years from now', () => {
const result = validateLicenseDate(new Date('2050-06-09T12:00:00.000Z'))
expect(result.valid).toBe(false)
expect(result.error_code).toBe(ErrorCode.LICENSE_FUTURE_DATE)
})
it('handles leap year date correctly', () => {
const result = validateLicenseDate(new Date('2028-02-29T12:00:00.000Z'))
expect(result.valid).toBe(true)
// 2028-02-29 minus 2026-06-09 = should be > 30 days
expect(result.days_remaining).toBeGreaterThan(600)
})
// ─── parseExpirationDate ─────────────────────────────────────────────
it('parses ISO 8601 date (YYYY-MM-DD)', () => {
const d = parseExpirationDate('2027-06-11')
expect(d).toBeInstanceOf(Date)
expect(d!.getUTCFullYear()).toBe(2027)
expect(d!.getUTCMonth()).toBe(5) // June
expect(d!.getUTCDate()).toBe(11)
})
it('parses US format (MM/DD/YYYY)', () => {
const d = parseExpirationDate('06/11/2027')
expect(d).toBeInstanceOf(Date)
expect(d!.getFullYear()).toBe(2027)
expect(d!.getMonth()).toBe(5) // June
expect(d!.getDate()).toBe(11)
})
it('parses DD/MM/YYYY when first part > 12', () => {
const d = parseExpirationDate('13/06/2027')
expect(d).toBeInstanceOf(Date)
expect(d!.getFullYear()).toBe(2027)
expect(d!.getMonth()).toBe(5) // June
expect(d!.getDate()).toBe(13)
})
it('parses DD-MMM-YYYY (military format)', () => {
const d = parseExpirationDate('11-JUN-2027')
expect(d).toBeInstanceOf(Date)
expect(d!.getFullYear()).toBe(2027)
expect(d!.getMonth()).toBe(5) // June
expect(d!.getDate()).toBe(11)
})
it('returns null for completely invalid date string', () => {
expect(parseExpirationDate('not-a-date')).toBeNull()
})
it('returns null for empty string', () => {
expect(parseExpirationDate('')).toBeNull()
})
it('returns null for null input', () => {
expect(parseExpirationDate(null)).toBeNull()
})
it('returns null for undefined input', () => {
expect(parseExpirationDate(undefined)).toBeNull()
})
}) })
@@ -1,7 +1,29 @@
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { LicenseStatus } from '@rentaldrivego/database' import { LicenseStatus } from '@rentaldrivego/database'
const THREE_MONTHS_MS = 3 * 30 * 24 * 60 * 60 * 1000 const MS_PER_DAY = 1000 * 60 * 60 * 24
/** Minimum days of remaining validity required (configurable via env, default 30). */
function getMinValidityDays(): number {
const raw = process.env.LICENSE_MIN_VALIDITY_DAYS
if (raw) {
const n = Number(raw)
if (Number.isFinite(n) && n > 0) return n
}
return 30
}
/** Maximum years in the future considered reasonable for a license expiration date. */
const MAX_FUTURE_YEARS = 20
// ── Error codes matching doc/error-code reference ────────────────────────
export const ErrorCode = {
LICENSE_EXPIRED: 'LICENSE_EXPIRED',
LICENSE_EXPIRING_SOON: 'LICENSE_EXPIRING_SOON',
LICENSE_INVALID_FORMAT:'LICENSE_INVALID_FORMAT',
LICENSE_FUTURE_DATE: 'LICENSE_FUTURE_DATE',
LICENSE_MISSING_DATE: 'LICENSE_MISSING_DATE',
} as const
export interface LicenseValidationResult { export interface LicenseValidationResult {
status: 'VALID' | 'EXPIRING' | 'EXPIRED' status: 'VALID' | 'EXPIRING' | 'EXPIRED'
@@ -10,19 +32,174 @@ export interface LicenseValidationResult {
message: string message: string
} }
export interface DetailedValidationResult {
valid: boolean
days_remaining: number | null
expiration_date: string | null
message: string
error_code?: string
details?: {
expiration_date?: string
days_remaining?: number
minimum_required?: number
suggested_action?: string
}
}
/**
* Parse an expiration date string into a Date.
* Returns null if the string is not a valid date per ISO 8601 or common
* US/EU formats (YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY).
*/
export function parseExpirationDate(raw: string | null | undefined): Date | null {
if (!raw) return null
// Try ISO 8601 first (YYYY-MM-DD or full ISO)
const iso = new Date(raw)
if (!isNaN(iso.getTime()) && raw.length >= 10) {
// Ensure we don't accept "2026-06" as valid — need at least YYYY-MM-DD
const parts = raw.split('T')[0].split('-')
if (parts.length === 3) return iso
}
// Try MM/DD/YYYY and DD/MM/YYYY
const slashMatch = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/)
if (slashMatch) {
// Prefer US order (MM/DD/YYYY) as default; document says "varies by state"
const m1 = Number(slashMatch[1])
const m2 = Number(slashMatch[2])
const year = Number(slashMatch[3])
// If first part > 12, it's likely DD/MM/YYYY
if (m1 > 12) {
const d = new Date(year, m2 - 1, m1)
if (!isNaN(d.getTime())) return d
}
const d = new Date(year, m1 - 1, m2)
if (!isNaN(d.getTime())) return d
}
// Try DD-MMM-YYYY (e.g., 11-JUN-2027)
const mmmMatch = raw.match(/^(\d{1,2})-([A-Za-z]{3})-(\d{4})$/)
if (mmmMatch) {
const d = new Date(`${mmmMatch[2]} ${mmmMatch[1]}, ${mmmMatch[3]}`)
if (!isNaN(d.getTime())) return d
}
return null
}
/**
* Pure validation logic — no side effects, no DB calls.
*
* Returns a DetailedValidationResult suitable for the public API.
*/
export function validateLicenseDate(expirationDate: Date | null | undefined): DetailedValidationResult {
if (!expirationDate) {
return {
valid: false,
days_remaining: null,
expiration_date: null,
message: 'No expiration date provided. Please provide a valid driver\'s license expiration date.',
error_code: ErrorCode.LICENSE_MISSING_DATE,
details: {
suggested_action: 'Please provide the expiration date from your driver\'s license.',
},
}
}
const now = new Date()
const nowMs = now.getTime()
const expMs = expirationDate.getTime()
const daysRemaining = Math.ceil((expMs - nowMs) / MS_PER_DAY)
const expDateStr = expirationDate.toISOString().split('T')[0]
const minValidityDays = getMinValidityDays()
// Expired
if (expMs <= nowMs) {
return {
valid: false,
days_remaining: daysRemaining,
expiration_date: expDateStr,
message: `License has expired. Your license expired on ${expDateStr}.`,
error_code: ErrorCode.LICENSE_EXPIRED,
details: {
expiration_date: expDateStr,
days_remaining: daysRemaining,
minimum_required: minValidityDays,
suggested_action: 'Please renew your driver\'s license and upload the updated document.',
},
}
}
// Expiring soon (1 minValidityDays-1 days remaining)
if (daysRemaining < minValidityDays) {
return {
valid: false,
days_remaining: daysRemaining,
expiration_date: expDateStr,
message: `License must have at least ${minValidityDays} days remaining validity. Your license expires in ${daysRemaining} days on ${expDateStr}.`,
error_code: ErrorCode.LICENSE_EXPIRING_SOON,
details: {
expiration_date: expDateStr,
days_remaining: daysRemaining,
minimum_required: minValidityDays,
suggested_action: `Please upload a valid license with more than ${minValidityDays} days remaining.`,
},
}
}
// Future date (>MAX_FUTURE_YEARS from now)
const maxFutureMs = nowMs + MAX_FUTURE_YEARS * 365 * MS_PER_DAY
if (expMs > maxFutureMs) {
return {
valid: false,
days_remaining: daysRemaining,
expiration_date: expDateStr,
message: `The provided expiration date (${expDateStr}) is too far in the future. Please verify the date is correct.`,
error_code: ErrorCode.LICENSE_FUTURE_DATE,
details: {
expiration_date: expDateStr,
days_remaining: daysRemaining,
minimum_required: minValidityDays,
suggested_action: 'Please check the expiration date on your license and re-enter it correctly.',
},
}
}
// Valid
const nextReminder = new Date(expMs - minValidityDays * MS_PER_DAY).toISOString().split('T')[0]
return {
valid: true,
days_remaining: daysRemaining,
expiration_date: expDateStr,
message: 'License verified successfully',
details: {
days_remaining: daysRemaining,
minimum_required: minValidityDays,
suggested_action: 'License is valid.',
},
}
}
/**
* Legacy validation function used internally by the customer service.
* Returns a simpler result compatible with existing callers.
*/
export function validateLicense(licenseExpiry: Date | null): LicenseValidationResult { export function validateLicense(licenseExpiry: Date | null): LicenseValidationResult {
if (!licenseExpiry) { if (!licenseExpiry) {
return { status: 'VALID', daysUntilExpiry: null, requiresApproval: false, message: 'No expiry date on record' } return { status: 'VALID', daysUntilExpiry: null, requiresApproval: false, message: 'No expiry date on record' }
} }
const now = new Date() const now = new Date()
const daysUntilExpiry = Math.ceil((licenseExpiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) const daysUntilExpiry = Math.ceil((licenseExpiry.getTime() - now.getTime()) / MS_PER_DAY)
const minValidityDays = getMinValidityDays()
const minValidityMs = minValidityDays * MS_PER_DAY
if (licenseExpiry <= now) { if (licenseExpiry <= now) {
return { status: 'EXPIRED', daysUntilExpiry, requiresApproval: true, message: `License expired ${Math.abs(daysUntilExpiry)} day(s) ago` } return { status: 'EXPIRED', daysUntilExpiry, requiresApproval: true, message: `License expired ${Math.abs(daysUntilExpiry)} day(s) ago` }
} }
if (licenseExpiry.getTime() - now.getTime() < THREE_MONTHS_MS) { if (licenseExpiry.getTime() - now.getTime() < minValidityMs) {
return { status: 'EXPIRING', daysUntilExpiry, requiresApproval: true, message: `License expires in ${daysUntilExpiry} day(s) — approval required` } return { status: 'EXPIRING', daysUntilExpiry, requiresApproval: true, message: `License expires in ${daysUntilExpiry} day(s) — approval required` }
} }
@@ -42,11 +219,11 @@ export async function validateAndFlagLicense(customerId: string, companyId?: str
await prisma.customer.updateMany({ await prisma.customer.updateMany({
where: { id: customerId, companyId }, where: { id: customerId, companyId },
data: { data: {
licenseExpired: result.status === 'EXPIRED', licenseExpired: result.status === 'EXPIRED',
licenseExpiringSoon: result.status === 'EXPIRING', licenseExpiringSoon: result.status === 'EXPIRING',
licenseValidationStatus: status, licenseValidationStatus: status,
flagged: result.requiresApproval ? true : customer.flagged, flagged: result.requiresApproval ? true : customer.flagged,
flagReason: result.requiresApproval ? result.message : customer.flagReason, flagReason: result.requiresApproval ? result.message : customer.flagReason,
}, },
}) })
} else { } else {
@@ -0,0 +1,141 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
vi.mock('../../lib/redis', () => ({
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
}))
import request from 'supertest'
import { createApp } from '../../app'
const app = createApp()
/** Return today's date as YYYY-MM-DD */
function today(): string {
return new Date().toISOString().split('T')[0]
}
/** Return a date offset by `days` from today, as YYYY-MM-DD */
function offsetDate(days: number): string {
const d = new Date()
d.setDate(d.getDate() + days)
return d.toISOString().split('T')[0]
}
describe('License Validation API — POST /api/v1/licenses/validate', () => {
// ─── Success scenarios ──────────────────────────────────────────────
it('accepts a license expiring in 365 days (valid)', async () => {
const future = offsetDate(365)
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: future })
expect(res.status).toBe(200)
expect(res.body.data).toMatchObject({
valid: true,
message: 'License verified successfully',
})
expect(res.body.data.days_remaining).toBeGreaterThanOrEqual(365)
expect(res.body.data.next_reminder).toBeDefined()
})
it('accepts a license expiring in exactly 30 days', async () => {
const future = offsetDate(30)
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: future })
expect(res.status).toBe(200)
expect(res.body.data.valid).toBe(true)
expect(res.body.data.days_remaining).toBe(30)
})
// ─── Rejection scenarios ────────────────────────────────────────────
it('rejects a license expiring in 15 days with 422', async () => {
const nearFuture = offsetDate(15)
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: nearFuture })
expect(res.status).toBe(422)
expect(res.body.error).toBe('LICENSE_EXPIRING_SOON')
expect(res.body.message).toContain('30 days')
expect(res.body.details).toBeDefined()
expect(res.body.details.days_remaining).toBe(15)
expect(res.body.details.minimum_required).toBe(30)
})
it('rejects an expired license with 422', async () => {
const past = offsetDate(-5)
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: past })
expect(res.status).toBe(422)
expect(res.body.error).toBe('LICENSE_EXPIRED')
expect(res.body.message).toContain('expired')
})
it('rejects an invalid date format with 400', async () => {
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: 'not-a-date' })
expect(res.status).toBe(400)
expect(res.body.error).toBe('LICENSE_INVALID_FORMAT')
expect(res.body.message).toContain('Unable to parse')
})
it('rejects an empty expiration_date with 400', async () => {
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: '' })
expect(res.status).toBe(400)
expect(res.body.error).toBe('validation_error')
})
it('rejects a date too far in the future with 400', async () => {
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: '2055-01-01' })
expect(res.status).toBe(400)
expect(res.body.error).toBe('LICENSE_FUTURE_DATE')
})
// ─── Multiple date formats ──────────────────────────────────────────
it('parses MM/DD/YYYY format', async () => {
const future = new Date()
future.setFullYear(future.getFullYear() + 1)
const mm = String(future.getMonth() + 1).padStart(2, '0')
const dd = String(future.getDate()).padStart(2, '0')
const yyyy = future.getFullYear()
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: `${mm}/${dd}/${yyyy}` })
expect(res.status).toBe(200)
expect(res.body.data.valid).toBe(true)
})
it('parses DD-MMM-YYYY format', async () => {
const future = new Date()
future.setFullYear(future.getFullYear() + 1)
const dd = String(future.getDate()).padStart(2, '0')
const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
const mmm = months[future.getMonth()]
const yyyy = future.getFullYear()
const res = await request(app)
.post('/api/v1/licenses/validate')
.send({ expiration_date: `${dd}-${mmm}-${yyyy}` })
expect(res.status).toBe(200)
expect(res.body.data.valid).toBe(true)
})
})
+4
View File
@@ -61,6 +61,10 @@ const nextConfig = {
source: '/api/:path*', source: '/api/:path*',
destination: `${apiOrigin}/api/:path*`, destination: `${apiOrigin}/api/:path*`,
}, },
{
source: '/storage/:path*',
destination: `${apiOrigin}/storage/:path*`,
},
] ]
}, },
} }
+1 -1
View File
@@ -73,7 +73,7 @@ function buildSecurityHeaders({ assetSources = [], connectSources = collectBrows
scriptSrc.push(...assetOrigins) scriptSrc.push(...assetOrigins)
const styleSrc = ["'self'", "'unsafe-inline'", ...assetOrigins] const styleSrc = ["'self'", "'unsafe-inline'", ...assetOrigins]
const imgSrc = ["'self'", 'data:', 'blob:', 'https:', ...assetOrigins] const imgSrc = [...new Set(["'self'", 'data:', 'blob:', 'https:', ...assetOrigins, ...connectOrigins])]
const fontSrc = ["'self'", 'data:', ...assetOrigins] const fontSrc = ["'self'", 'data:', ...assetOrigins]
const connectSrc = [...new Set(["'self'", 'https:', 'wss:', ...assetOrigins, ...connectOrigins, ...websocketOrigins])] const connectSrc = [...new Set(["'self'", 'https:', 'wss:', ...assetOrigins, ...connectOrigins, ...websocketOrigins])]
+598
View File
@@ -0,0 +1,598 @@
Driver's License Expiration Validation Implementation Plan
Document Version: 1.0
Date: 2026-06-11
Status: Draft
Author: Engineering Team
Table of Contents
Overview
Requirements
Architecture Design
Implementation Phases
Technical Specifications
Testing Strategy
Deployment Plan
Monitoring & Maintenance
Risk Assessment
Appendices
Overview
Business Objective
Enforce a mandatory validation rule requiring all renters to possess a driver's license with minimum 30-day remaining validity from the date of submission.
Scope
In Scope: Frontend validation, backend API validation, database schema changes, user notifications, error handling
Out of Scope: Physical license verification, DMV database integration, international license translation services
Success Criteria
100% of license submissions validated against 30-day rule
<1% false rejection rate
<2 second validation response time
99.9% API uptime for validation endpoint
Requirements
Functional Requirements
ID Requirement Priority
FR-1 System shall validate license expiration date is ≥30 days from current date P0
FR-2 System shall reject submissions with <30 days remaining validity P0
FR-3 System shall display clear error messages to users P0
FR-4 System shall support manual date entry and OCR scanning P1
FR-5 System shall log all validation attempts with results P1
FR-6 System shall send reminders at 60, 45, and 30 days before expiration P2
FR-7 System shall support state-specific license formats P2
Non-Functional Requirements
ID Requirement Target
NFR-1 API response time <500ms p95
NFR-2 Validation accuracy >99.9%
NFR-3 System availability 99.95%
NFR-4 Data encryption at rest AES-256
NFR-5 PII data compliance GDPR, CCPA
Architecture Design
System Diagram
text
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT LAYER │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Web App │ │ Mobile App │ │ Admin Dashboard │ │
│ │ (React) │ │ (React │ │ (Internal Tool) │ │
│ │ │ │ Native) │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
│ │ │ │ │
└─────────┼─────────────────┼──────────────────────┼──────────────┘
│ │ │
┌─────────┼─────────────────┼──────────────────────┼──────────────┐
│ │ API GATEWAY LAYER │ │
│ ┌──────┴─────────────────┴──────────────────────┴───────────┐ │
│ │ Kong / AWS API Gateway │ │
│ │ (Rate Limiting, Authentication, Routing) │ │
│ └──────────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌────────────────────┼────────────────────┐ │
│ │ │ │ │
│ ┌──────┴──────┐ ┌────────┴────────┐ ┌──────┴──────────┐ │
│ │ License │ │ Renter │ │ Notification │ │
│ │ Validation │ │ Service │ │ Service │ │
│ │ Service │ │ │ │ │ │
│ └──────┬──────┘ └────────┬────────┘ └──────┬──────────┘ │
│ │ │ │ │
│ └────────────────────┼────────────────────┘ │
│ │ │
│ DATA LAYER│ │
│ ┌───────────────────────────┴──────────────────────────────┐ │
│ │ PostgreSQL (Primary DB) │ │
│ └───────────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┴──────────────────────────────┐ │
│ │ Redis (Cache + Rate Limiting) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
Data Flow Sequence
Implementation Phases
Phase 1: Foundation (Week 1-2)
Week 1: Setup & Core Logic
Day 1-2: Project setup and repository configuration
Initialize Git repository with branch protection rules
Set up CI/CD pipeline (GitHub Actions/Jenkins)
Configure development, staging, and production environments
Day 3-4: Database schema design
sql
-- Migration: Create license_validations table
CREATE TABLE license_validations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
renter_id UUID NOT NULL REFERENCES renters(id),
license_number_encrypted TEXT NOT NULL,
expiration_date DATE NOT NULL,
issuing_state VARCHAR(2),
validation_status VARCHAR(20) NOT NULL,
days_remaining INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
validated_by VARCHAR(50) -- 'SYSTEM' or 'MANUAL'
);
CREATE INDEX idx_license_expiration ON license_validations(expiration_date);
CREATE INDEX idx_license_status ON license_validations(validation_status);
Day 5: Core validation service implementation
Build LicenseValidator class with business logic
Implement date parsing with multiple format support
Add unit tests for all validation scenarios
Week 2: API & Frontend
Day 1-3: REST API endpoint development
yaml
# OpenAPI Specification
/api/v1/licenses/validate:
post:
summary: Validate driver's license expiration
requestBody:
content:
application/json:
schema:
type: object
required:
- expiration_date
properties:
expiration_date:
type: string
format: date
example: "2027-06-11"
license_number:
type: string
state:
type: string
responses:
'200':
description: License is valid
content:
application/json:
schema:
type: object
properties:
valid:
type: boolean
days_remaining:
type: integer
message:
type: string
'422':
description: License validation failed
Day 4-5: Frontend component development
Build LicenseUpload component with date picker
Implement real-time validation feedback
Add accessibility features (ARIA labels, keyboard navigation)
Phase 2: Enhancement (Week 3-4)
Week 3: OCR Integration & Advanced Validation
Day 1-3: OCR service integration
python
class OCRLicenseProcessor:
def __init__(self):
self.textract_client = boto3.client('textract')
async def extract_license_data(self, image_bytes: bytes) -> dict:
"""Extract license data using AWS Textract"""
response = await self.textract_client.analyze_document(
Document={'Bytes': image_bytes},
FeatureTypes=['FORMS']
)
return self._parse_license_fields(response)
def _parse_license_fields(self, textract_response: dict) -> dict:
"""Parse extracted fields into structured data"""
# Extract expiration date using regex patterns
# Map to standard format YYYY-MM-DD
pass
Day 4: State-specific validation rules
Implement validation patterns for all 50 US states
Handle temporary licenses and permits
Add military ID and passport support
Day 5: Error handling and edge cases
Graceful handling of corrupted images
Invalid date formats
Network timeouts and retries
Week 4: Notifications & Admin Tools
Day 1-2: Notification system
python
class LicenseReminderService:
REMINDER_SCHEDULE = [60, 45, 30] # Days before expiration
async def schedule_reminders(self, renter_id: str, expiration_date: date):
for days_before in self.REMINDER_SCHEDULE:
reminder_date = expiration_date - timedelta(days=days_before)
await self.notification_queue.enqueue(
'send_license_reminder',
renter_id=renter_id,
scheduled_at=reminder_date,
days_remaining=days_before
)
Day 3-4: Admin dashboard
Build validation queue for manual review
Analytics dashboard (rejection rates, common errors)
Bulk update and override capabilities
Day 5: Documentation and API docs
Update API documentation with Swagger/OpenAPI
Create internal wiki for support team
Write runbook for common issues
Phase 3: Testing & Hardening (Week 5-6)
Week 5: Comprehensive Testing
Day 1-3: Integration testing
javascript
// Integration test example
describe('License Validation API', () => {
test('Should reject license expiring in 15 days', async () => {
const response = await request(app)
.post('/api/v1/licenses/validate')
.send({
expiration_date: '2026-06-26',
license_number: 'DL123456'
});
expect(response.status).toBe(422);
expect(response.body.valid).toBe(false);
expect(response.body.message).toContain('30 days');
});
test('Should accept license expiring in 60 days', async () => {
const response = await request(app)
.post('/api/v1/licenses/validate')
.send({
expiration_date: '2026-08-10',
license_number: 'DL789012'
});
expect(response.status).toBe(200);
expect(response.body.valid).toBe(true);
expect(response.body.days_remaining).toBeGreaterThan(30);
});
});
Day 4: Performance testing
Load test with 1000 concurrent requests
Stress test OCR processing pipeline
Database query optimization
Day 5: Security audit
PII encryption verification
SQL injection and XSS testing
Authentication/authorization testing
Week 6: UAT & Deployment Preparation
Day 1-2: User acceptance testing
Test with real license images (anonymized)
Validate all error messages and flows
Cross-browser compatibility testing
Day 3: Staging deployment
Deploy to staging environment
Run smoke tests
Monitor for 24 hours
Day 4-5: Production deployment
Database migration execution
Blue-green deployment strategy
Gradual traffic shifting (10% → 50% → 100%)
Technical Specifications
Validation Rules Matrix
Scenario Condition Action Response Code
Valid License ≥30 days remaining Accept 200
Warning Zone 30-60 days remaining Accept + Schedule Reminder 200 + Warning
Expiring Soon 1-29 days remaining Reject 422
Expired ≤0 days remaining Reject 422
Invalid Format Date parse error Reject 400
Future Date >20 years from now Reject 400
Missing Date No date provided Reject 400
API Response Examples
Success Response
json
{
"status": "success",
"data": {
"valid": true,
"days_remaining": 365,
"expiration_date": "2027-06-11",
"message": "License verified successfully",
"next_reminder": "2027-04-12"
}
}
Error Response
json
{
"status": "error",
"error": {
"code": "LICENSE_EXPIRING_SOON",
"message": "License must have at least 30 days remaining validity. Your license expires in 15 days on 2026-06-26.",
"details": {
"expiration_date": "2026-06-26",
"days_remaining": 15,
"minimum_required": 30,
"suggested_action": "Please upload a valid license with more than 30 days remaining."
}
}
}
Environment Variables Configuration
bash
# .env.example
LICENSE_MIN_VALIDITY_DAYS=30
LICENSE_WARNING_THRESHOLD_DAYS=60
OCR_ENABLED=true
OCR_PROVIDER=aws_textract
REDIS_URL=redis://localhost:6379
DATABASE_URL=postgresql://user:pass@localhost:5432/rental_db
ENCRYPTION_KEY=your-256-bit-key
LOG_LEVEL=info
Testing Strategy
Test Pyramid
text
┌──────┐
│ E2E │ 10% - Critical user journeys
│ │
┌┴──────┴┐
│Integrat│ 30% - API contracts, DB interactions
│ ion │
│ │
┌┴────────┴┐
│ Unit │ 60% - Business logic, validation rules
│ │
└──────────┘
Test Cases
Unit Tests (60% coverage)
Test ID Description Input Expected Output
UT-01 Date exactly 30 days from now today + 30 days valid: true
UT-02 Date 29 days from now today + 29 days valid: false
UT-03 Date in past 2020-01-01 valid: false
UT-04 Invalid date string "invalid" Error handled
UT-05 Leap year date 2028-02-29 Correctly calculated
UT-06 Timezone edge case Various timezones Consistent results
Integration Tests
API returns correct status codes for all scenarios
Database correctly stores encrypted license data
Redis cache properly invalidates
Notification queue receives correct messages
OCR pipeline processes images end-to-end
End-to-End Tests
User uploads valid license → Success flow
User uploads expired license → Error flow
User receives reminder notification
Admin can manually override validation
Deployment Plan
Pre-Deployment Checklist
All tests passing (unit, integration, E2E)
Security scan completed (no critical/high vulnerabilities)
Database migration scripts reviewed and tested
Rollback plan documented and tested
Monitoring dashboards configured
Alert thresholds set up
Support team trained on new features
API documentation updated
Performance benchmarks met
Data backup completed
Deployment Steps
Phase 1: Database Migration (30 min downtime)
bash
# 1. Take database snapshot
pg_dump -h production-db -U admin rental_db > backup_20260611.sql
# 2. Run migrations
npm run migrate:production
# 3. Verify migration
SELECT * FROM schema_migrations ORDER BY version DESC LIMIT 5;
Phase 2: Backend Deployment (Blue-Green)
bash
# 1. Deploy to green environment
kubectl apply -f deployment-green.yaml
# 2. Run health checks
curl https://green-api.example.com/health
# 3. Switch traffic (10% → 50% → 100%)
# Update load balancer configuration
# 4. Monitor for 15 minutes
# Watch logs: kubectl logs -f deployment/license-validation-green
Phase 3: Frontend Deployment
bash
# 1. Build and deploy to CDN
npm run build:production
aws s3 sync dist/ s3://rental-app-prod/ --cache-control "max-age=3600"
# 2. Invalidate CloudFront cache
aws cloudfront create-invalidation --distribution-id E1234567890 --paths "/*"
Rollback Plan
bash
# Immediate rollback if error rate >5% or p95 latency >2s
# 1. Revert load balancer to blue environment
# 2. Scale down green deployment
kubectl scale deployment license-validation-green --replicas=0
# 3. Rollback database (if schema changes)
npm run migrate:rollback:production
# 4. Notify on-call engineer
# 5. Create incident postmortem
Monitoring & Maintenance
Key Metrics Dashboard
yaml
Dashboard: License Validation Service
Metrics:
- name: license_validation_total
type: Counter
description: Total validation attempts
labels: [status, source]
- name: license_validation_duration_seconds
type: Histogram
description: Validation processing time
buckets: [0.1, 0.5, 1, 2, 5]
- name: license_rejection_rate
type: Gauge
description: Percentage of rejected licenses
alert: ">20% for 5 minutes"
- name: ocr_processing_errors
type: Counter
description: OCR processing failures
alert: ">10 errors in 5 minutes"
Alert Configuration
yaml
alerts:
- name: HighRejectionRate
condition: license_rejection_rate > 0.20
duration: 5m
severity: warning
channel: "#engineering-alerts"
- name: ValidationServiceDown
condition: up{job="license-validation"} == 0
duration: 1m
severity: critical
channel: "#on-call"
- name: SlowValidationResponses
condition: histogram_quantile(0.95, license_validation_duration) > 2
duration: 5m
severity: warning
channel: "#engineering-alerts"
Maintenance Tasks
Task Frequency Owner Duration
Database index optimization Monthly DBA 1 hour
SSL certificate renewal Annually DevOps 30 min
Dependency updates Bi-weekly Engineering 2 hours
Security patches As needed Security team Varies
Log rotation Automated System N/A
Risk Assessment
Risk Matrix
Risk Impact Probability Mitigation
OCR misreads date High Medium Manual review fallback, confidence scoring
Timezone mismatch Medium Low Store all dates in UTC, convert at presentation layer
Database connection failure High Low Connection pooling, retry logic, circuit breaker
Third-party OCR service outage Medium Low Implement fallback to manual entry, cache provider
PII data breach Critical Low Encryption at rest/transit, access controls, audit logging
Performance degradation under load Medium Medium Auto-scaling, caching, load testing
Contingency Plans
Scenario 1: OCR Service Unavailable
Automatically switch to manual entry mode
Display clear instructions to user
Queue OCR processing for retry when service recovers
Scenario 2: High False Rejection Rate
Temporarily adjust threshold to 15 days
Notify support team to expect inquiries
Investigate root cause (date parsing, timezone, etc.)
Deploy hotfix if needed
Appendices
Appendix A: Supported License Formats
Country/State Format Example Notes
US Standard MM/DD/YYYY 06/11/2027 Varies by state
EU Standard DD/MM/YYYY 11/06/2027
ISO 8601 YYYY-MM-DD 2027-06-11 Preferred internal format
Military ID DD-MMM-YYYY 11-JUN-2027
Appendix B: Error Code Reference
Code HTTP Status Description
LICENSE_EXPIRED 422 License has expired
LICENSE_EXPIRING_SOON 422 Less than 30 days remaining
LICENSE_INVALID_FORMAT 400 Date format not recognized
LICENSE_FUTURE_DATE 400 Date too far in future
LICENSE_MISSING_DATE 400 No date provided
OCR_PROCESSING_FAILED 500 Could not extract from image
LICENSE_ALREADY_EXISTS 409 Duplicate submission
Appendix C: Team Contacts
Role Name Contact
Product Owner [Name] [Email/Phone]
Tech Lead [Name] [Email/Phone]
Backend Developer [Name] [Email/Phone]
Frontend Developer [Name] [Email/Phone]
DevOps Engineer [Name] [Email/Phone]
Security Engineer [Name] [Email/Phone]
On-Call Rotation Schedule [PagerDuty Link]
Document Control
Version Date Author Changes
1.0 2026-06-11 Engineering Team Initial creation