From af5b7fda7e0cab5748c82e4ab4566658772e4115 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 11 Jun 2026 21:53:48 -0400 Subject: [PATCH] implement driver license date validation --- apps/admin/next.config.js | 4 + apps/api/src/app.ts | 2 + .../licenses/license.validation.routes.ts | 69 ++ .../licenses/license.validation.schemas.ts | 10 + .../services/licenseValidationService.test.ts | 135 +++- .../src/services/licenseValidationService.ts | 193 +++++- .../tests/api/license-validation.api.test.ts | 141 +++++ apps/dashboard/next.config.js | 4 + config/nextSecurityHeaders.js | 2 +- docs/driver_license_validation.md | 598 ++++++++++++++++++ 10 files changed, 1143 insertions(+), 15 deletions(-) create mode 100644 apps/api/src/modules/licenses/license.validation.routes.ts create mode 100644 apps/api/src/modules/licenses/license.validation.schemas.ts create mode 100644 apps/api/src/tests/api/license-validation.api.test.ts create mode 100644 docs/driver_license_validation.md diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index 1245bbb..eef4f4a 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -44,6 +44,10 @@ const nextConfig = { source: '/api/:path*', destination: `${apiOrigin}/api/:path*`, }, + { + source: '/storage/:path*', + destination: `${apiOrigin}/storage/:path*`, + }, ] }, } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 3b52695..57a8367 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -31,6 +31,7 @@ import marketplaceRouter from './modules/marketplace/marketplace.routes' import siteRouter from './modules/site/site.routes' import reviewsRouter from './modules/reviews/review.routes' import complaintsRouter from './modules/complaints/complaint.routes' +import licenseValidationRouter from './modules/licenses/license.validation.routes' // ─── Centralized error handling ─────────────────────────────── import { errorMiddleware } from './http/errors/errorMiddleware' @@ -170,6 +171,7 @@ export function createApp() { app.use(`${v1}/payments`, apiLimiter, paymentsRouter) app.use(`${v1}/reviews`, apiLimiter, reviewsRouter) app.use(`${v1}/complaints`, apiLimiter, complaintsRouter) + app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter) // ─── Health / Docs ────────────────────────────────────────── app.get(v1, (_req, res) => { diff --git a/apps/api/src/modules/licenses/license.validation.routes.ts b/apps/api/src/modules/licenses/license.validation.routes.ts new file mode 100644 index 0000000..9f921ef --- /dev/null +++ b/apps/api/src/modules/licenses/license.validation.routes.ts @@ -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 diff --git a/apps/api/src/modules/licenses/license.validation.schemas.ts b/apps/api/src/modules/licenses/license.validation.schemas.ts new file mode 100644 index 0000000..5dfae3b --- /dev/null +++ b/apps/api/src/modules/licenses/license.validation.schemas.ts @@ -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 diff --git a/apps/api/src/services/licenseValidationService.test.ts b/apps/api/src/services/licenseValidationService.test.ts index c7b9313..32eccb2 100644 --- a/apps/api/src/services/licenseValidationService.test.ts +++ b/apps/api/src/services/licenseValidationService.test.ts @@ -12,19 +12,29 @@ vi.mock('../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', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() - vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z')) + vi.setSystemTime(NOW) }) afterEach(() => { vi.useRealTimers() }) + // ─── validateLicense (legacy) ──────────────────────────────────────── + it('treats missing license expiry as valid but explicitly records that no date exists', () => { expect(validateLicense(null)).toEqual({ status: 'VALID', @@ -34,13 +44,22 @@ describe('licenseValidationService', () => { }) }) - it('requires approval for licenses expiring inside the three-month risk window', () => { - const result = validateLicense(new Date('2026-07-09T12:00:00.000Z')) + it('requires approval for licenses expiring inside the 30-day risk window', () => { + const result = validateLicense(new Date('2026-07-04T12:00:00.000Z')) expect(result.status).toBe('EXPIRING') - expect(result.daysUntilExpiry).toBe(30) + expect(result.daysUntilExpiry).toBe(25) 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', () => { @@ -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() + }) }) diff --git a/apps/api/src/services/licenseValidationService.ts b/apps/api/src/services/licenseValidationService.ts index 97cd2c2..a0988c1 100644 --- a/apps/api/src/services/licenseValidationService.ts +++ b/apps/api/src/services/licenseValidationService.ts @@ -1,7 +1,29 @@ import { prisma } from '../lib/prisma' 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 { status: 'VALID' | 'EXPIRING' | 'EXPIRED' @@ -10,19 +32,174 @@ export interface LicenseValidationResult { 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 { if (!licenseExpiry) { return { status: 'VALID', daysUntilExpiry: null, requiresApproval: false, message: 'No expiry date on record' } } 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) { 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` } } @@ -42,11 +219,11 @@ export async function validateAndFlagLicense(customerId: string, companyId?: str await prisma.customer.updateMany({ where: { id: customerId, companyId }, data: { - licenseExpired: result.status === 'EXPIRED', - licenseExpiringSoon: result.status === 'EXPIRING', - licenseValidationStatus: status, - flagged: result.requiresApproval ? true : customer.flagged, - flagReason: result.requiresApproval ? result.message : customer.flagReason, + licenseExpired: result.status === 'EXPIRED', + licenseExpiringSoon: result.status === 'EXPIRING', + licenseValidationStatus: status, + flagged: result.requiresApproval ? true : customer.flagged, + flagReason: result.requiresApproval ? result.message : customer.flagReason, }, }) } else { diff --git a/apps/api/src/tests/api/license-validation.api.test.ts b/apps/api/src/tests/api/license-validation.api.test.ts new file mode 100644 index 0000000..f14047b --- /dev/null +++ b/apps/api/src/tests/api/license-validation.api.test.ts @@ -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) + }) +}) diff --git a/apps/dashboard/next.config.js b/apps/dashboard/next.config.js index 7cec50d..a84f8ac 100644 --- a/apps/dashboard/next.config.js +++ b/apps/dashboard/next.config.js @@ -61,6 +61,10 @@ const nextConfig = { source: '/api/:path*', destination: `${apiOrigin}/api/:path*`, }, + { + source: '/storage/:path*', + destination: `${apiOrigin}/storage/:path*`, + }, ] }, } diff --git a/config/nextSecurityHeaders.js b/config/nextSecurityHeaders.js index d584240..fb25ea8 100644 --- a/config/nextSecurityHeaders.js +++ b/config/nextSecurityHeaders.js @@ -73,7 +73,7 @@ function buildSecurityHeaders({ assetSources = [], connectSources = collectBrows scriptSrc.push(...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 connectSrc = [...new Set(["'self'", 'https:', 'wss:', ...assetOrigins, ...connectOrigins, ...websocketOrigins])] diff --git a/docs/driver_license_validation.md b/docs/driver_license_validation.md new file mode 100644 index 0000000..488e1c8 --- /dev/null +++ b/docs/driver_license_validation.md @@ -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 \ No newline at end of file