Initial Commit
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { join, resolve } from 'path';
|
||||
import { env } from '../../core/config/environment';
|
||||
|
||||
const pool = new Pool({
|
||||
@@ -14,26 +14,79 @@ const pool = new Pool({
|
||||
password: env.DB_PASSWORD,
|
||||
});
|
||||
|
||||
// Define migration order based on dependencies
|
||||
// Define migration order based on dependencies and packaging layout
|
||||
// We package migrations under /app/migrations with two roots: features/ and core/
|
||||
// The update_updated_at_column() function is defined in features/vehicles first,
|
||||
// and user-preferences trigger depends on it; so run vehicles before core/user-preferences.
|
||||
const MIGRATION_ORDER = [
|
||||
'vehicles', // Primary entity, no dependencies
|
||||
'fuel-logs', // Depends on vehicles
|
||||
'maintenance', // Depends on vehicles
|
||||
'stations', // Independent
|
||||
'features/vehicles', // Primary entity, defines update_updated_at_column()
|
||||
'core/user-preferences', // Depends on update_updated_at_column()
|
||||
'features/fuel-logs', // Depends on vehicles
|
||||
'features/maintenance', // Depends on vehicles
|
||||
'features/stations', // Independent
|
||||
];
|
||||
|
||||
// Base directory where migrations are copied inside the image (set by Dockerfile)
|
||||
const MIGRATIONS_DIR = resolve(process.env.MIGRATIONS_DIR || join(__dirname, '../../../migrations'));
|
||||
|
||||
async function getExecutedMigrations(): Promise<Record<string, Set<string>>> {
|
||||
const executed: Record<string, Set<string>> = {};
|
||||
// Ensure tracking table exists (retry across transient DB restarts)
|
||||
const retry = async <T>(op: () => Promise<T>, timeoutMs = 60000): Promise<T> => {
|
||||
const start = Date.now();
|
||||
while (true) {
|
||||
try { return await op(); } catch (e) {
|
||||
if (Date.now() - start > timeoutMs) throw e;
|
||||
await new Promise(res => setTimeout(res, 2000));
|
||||
}
|
||||
}
|
||||
};
|
||||
await retry(() => pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
feature VARCHAR(100) NOT NULL,
|
||||
file VARCHAR(255) NOT NULL,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(feature, file)
|
||||
);
|
||||
`));
|
||||
const res = await retry(() => pool.query('SELECT feature, file FROM _migrations'));
|
||||
for (const row of res.rows) {
|
||||
if (!executed[row.feature]) executed[row.feature] = new Set();
|
||||
executed[row.feature].add(row.file);
|
||||
}
|
||||
return executed;
|
||||
}
|
||||
|
||||
async function runFeatureMigrations(featureName: string) {
|
||||
const migrationDir = join(__dirname, '../../features', featureName, 'migrations');
|
||||
const migrationDir = join(MIGRATIONS_DIR, featureName, 'migrations');
|
||||
|
||||
try {
|
||||
// Guard per-feature in case DB becomes available slightly later on cold start
|
||||
const ping = async (timeoutMs = 60000) => {
|
||||
const start = Date.now();
|
||||
while (true) {
|
||||
try { await pool.query('SELECT 1'); return; } catch (e) {
|
||||
if (Date.now() - start > timeoutMs) throw e; await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
};
|
||||
await ping();
|
||||
const files = readdirSync(migrationDir)
|
||||
.filter(f => f.endsWith('.sql'))
|
||||
.sort();
|
||||
const executed = await getExecutedMigrations();
|
||||
const already = executed[featureName] || new Set<string>();
|
||||
|
||||
for (const file of files) {
|
||||
if (already.has(file)) {
|
||||
console.log(`↷ Skipping already executed migration: ${featureName}/${file}`);
|
||||
continue;
|
||||
}
|
||||
const sql = readFileSync(join(migrationDir, file), 'utf-8');
|
||||
console.log(`Running migration: ${featureName}/${file}`);
|
||||
await pool.query(sql);
|
||||
await pool.query('INSERT INTO _migrations(feature, file) VALUES ($1, $2) ON CONFLICT DO NOTHING', [featureName, file]);
|
||||
console.log(`✅ Completed: ${featureName}/${file}`);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -45,17 +98,22 @@ async function runFeatureMigrations(featureName: string) {
|
||||
async function main() {
|
||||
try {
|
||||
console.log('Starting migration orchestration...');
|
||||
|
||||
// Create migrations tracking table
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
feature VARCHAR(100) NOT NULL,
|
||||
file VARCHAR(255) NOT NULL,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(feature, file)
|
||||
);
|
||||
`);
|
||||
console.log(`Using migrations directory: ${MIGRATIONS_DIR}`);
|
||||
// Wait for database to be reachable (handles cold starts)
|
||||
const waitForDb = async (timeoutMs = 60000) => {
|
||||
const start = Date.now();
|
||||
/* eslint-disable no-constant-condition */
|
||||
while (true) {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
return;
|
||||
} catch (e) {
|
||||
if (Date.now() - start > timeoutMs) throw e;
|
||||
await new Promise(res => setTimeout(res, 2000));
|
||||
}
|
||||
}
|
||||
};
|
||||
await waitForDb();
|
||||
|
||||
// Run migrations in order
|
||||
for (const feature of MIGRATION_ORDER) {
|
||||
@@ -74,4 +132,4 @@ async function main() {
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import errorPlugin from './core/plugins/error.plugin';
|
||||
import { vehiclesRoutes } from './features/vehicles/api/vehicles.routes';
|
||||
import { fuelLogsRoutes } from './features/fuel-logs/api/fuel-logs.routes';
|
||||
import { stationsRoutes } from './features/stations/api/stations.routes';
|
||||
import tenantManagementRoutes from './features/tenant-management/index';
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
const app = Fastify({
|
||||
@@ -30,6 +31,8 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
// Authentication plugin
|
||||
await app.register(authPlugin);
|
||||
|
||||
// Tenant detection is applied at route level after authentication
|
||||
|
||||
// Health check
|
||||
app.get('/health', async (_request, reply) => {
|
||||
return reply.code(200).send({
|
||||
@@ -44,6 +47,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
await app.register(vehiclesRoutes, { prefix: '/api' });
|
||||
await app.register(fuelLogsRoutes, { prefix: '/api' });
|
||||
await app.register(stationsRoutes, { prefix: '/api' });
|
||||
await app.register(tenantManagementRoutes);
|
||||
|
||||
// Maintenance feature placeholder (not yet implemented)
|
||||
await app.register(async (fastify) => {
|
||||
@@ -83,4 +87,4 @@ export async function getApp(): Promise<FastifyInstance> {
|
||||
return appInstance;
|
||||
}
|
||||
|
||||
export default buildApp;
|
||||
export default buildApp;
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { logger } from '../logging/logger';
|
||||
import { env } from './environment';
|
||||
import { getTenantConfig } from './tenant';
|
||||
|
||||
const tenant = getTenantConfig();
|
||||
|
||||
export const pool = new Pool({
|
||||
host: env.DB_HOST,
|
||||
port: env.DB_PORT,
|
||||
database: env.DB_NAME,
|
||||
user: env.DB_USER,
|
||||
password: env.DB_PASSWORD,
|
||||
connectionString: tenant.databaseUrl,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 10000,
|
||||
@@ -30,4 +28,4 @@ process.on('SIGTERM', async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
export default pool;
|
||||
export default pool;
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.string().default('development'),
|
||||
NODE_ENV: z.string().default('production'),
|
||||
PORT: z.string().transform(Number).default('3001'),
|
||||
|
||||
// Database
|
||||
@@ -32,6 +32,10 @@ const envSchema = z.object({
|
||||
GOOGLE_MAPS_API_KEY: z.string().default('development'),
|
||||
VPIC_API_URL: z.string().default('https://vpic.nhtsa.dot.gov/api/vehicles'),
|
||||
|
||||
// Platform Services
|
||||
PLATFORM_VEHICLES_API_URL: z.string().default('http://mvp-platform-vehicles-api:8000'),
|
||||
PLATFORM_VEHICLES_API_KEY: z.string().default('mvp-platform-vehicles-secret-key'),
|
||||
|
||||
// MinIO
|
||||
MINIO_ENDPOINT: z.string().default('localhost'),
|
||||
MINIO_PORT: z.string().transform(Number).default('9000'),
|
||||
@@ -45,4 +49,4 @@ export type Environment = z.infer<typeof envSchema>;
|
||||
// Validate and export - now with defaults for build-time compilation
|
||||
export const env = envSchema.parse(process.env);
|
||||
|
||||
// Environment configuration validated and exported
|
||||
// Environment configuration validated and exported
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
*/
|
||||
import Redis from 'ioredis';
|
||||
import { logger } from '../logging/logger';
|
||||
import { env } from './environment';
|
||||
import { getTenantConfig } from './tenant';
|
||||
|
||||
export const redis = new Redis({
|
||||
host: env.REDIS_HOST,
|
||||
port: env.REDIS_PORT,
|
||||
const tenant = getTenantConfig();
|
||||
|
||||
export const redis = new Redis(tenant.redisUrl, {
|
||||
retryStrategy: (times) => Math.min(times * 50, 2000),
|
||||
});
|
||||
|
||||
@@ -55,4 +55,4 @@ export class CacheService {
|
||||
}
|
||||
}
|
||||
|
||||
export const cacheService = new CacheService();
|
||||
export const cacheService = new CacheService();
|
||||
|
||||
68
backend/src/core/config/tenant.ts
Normal file
68
backend/src/core/config/tenant.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Simple in-memory cache for tenant validation
|
||||
const tenantValidityCache = new Map<string, { ok: boolean; ts: number }>();
|
||||
const TENANT_CACHE_TTL_MS = 60_000; // 1 minute
|
||||
|
||||
/**
|
||||
* Tenant-aware configuration for multi-tenant architecture
|
||||
*/
|
||||
|
||||
export interface TenantConfig {
|
||||
tenantId: string;
|
||||
databaseUrl: string;
|
||||
redisUrl: string;
|
||||
platformServicesUrl: string;
|
||||
isAdminTenant: boolean;
|
||||
}
|
||||
|
||||
export const getTenantConfig = (): TenantConfig => {
|
||||
const tenantId = process.env.TENANT_ID || 'admin';
|
||||
|
||||
const databaseUrl = tenantId === 'admin'
|
||||
? `postgresql://${process.env.DB_USER || 'motovault_user'}:${process.env.DB_PASSWORD}@${process.env.DB_HOST || 'postgres'}:${process.env.DB_PORT || '5432'}/${process.env.DB_NAME || 'motovault'}`
|
||||
: `postgresql://motovault_user:${process.env.DB_PASSWORD}@${tenantId}-postgres:5432/motovault`;
|
||||
|
||||
const redisUrl = tenantId === 'admin'
|
||||
? `redis://${process.env.REDIS_HOST || 'redis'}:${process.env.REDIS_PORT || '6379'}`
|
||||
: `redis://${tenantId}-redis:6379`;
|
||||
|
||||
const platformServicesUrl = process.env.PLATFORM_TENANTS_API_URL || 'http://mvp-platform-tenants:8000';
|
||||
|
||||
return {
|
||||
tenantId,
|
||||
databaseUrl,
|
||||
redisUrl,
|
||||
platformServicesUrl,
|
||||
isAdminTenant: tenantId === 'admin'
|
||||
};
|
||||
};
|
||||
|
||||
export const isValidTenant = async (tenantId: string): Promise<boolean> => {
|
||||
// Check cache
|
||||
const now = Date.now();
|
||||
const cached = tenantValidityCache.get(tenantId);
|
||||
if (cached && (now - cached.ts) < TENANT_CACHE_TTL_MS) {
|
||||
return cached.ok;
|
||||
}
|
||||
|
||||
let ok = false;
|
||||
try {
|
||||
const baseUrl = process.env.PLATFORM_TENANTS_API_URL || 'http://mvp-platform-tenants:8000';
|
||||
const url = `${baseUrl}/api/v1/tenants/${encodeURIComponent(tenantId)}`;
|
||||
const resp = await axios.get(url, { timeout: 2000 });
|
||||
ok = resp.status === 200;
|
||||
} catch { ok = false; }
|
||||
|
||||
tenantValidityCache.set(tenantId, { ok, ts: now });
|
||||
return ok;
|
||||
};
|
||||
|
||||
export const extractTenantId = (options: {
|
||||
envTenantId?: string;
|
||||
jwtTenantId?: string;
|
||||
subdomain?: string;
|
||||
}): string => {
|
||||
const { envTenantId, jwtTenantId, subdomain } = options;
|
||||
return envTenantId || jwtTenantId || subdomain || 'admin';
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* @ai-summary Global error handling middleware
|
||||
*/
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '../logging/logger';
|
||||
|
||||
export const errorHandler = (
|
||||
err: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
) => {
|
||||
logger.error('Unhandled error', {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
|
||||
});
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* @ai-summary Request logging middleware
|
||||
*/
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '../logging/logger';
|
||||
|
||||
export const requestLogger = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
logger.info('Request processed', {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
status: res.statusCode,
|
||||
duration,
|
||||
ip: req.ip,
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
84
backend/src/core/middleware/tenant.ts
Normal file
84
backend/src/core/middleware/tenant.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
|
||||
/**
|
||||
* Tenant detection and validation middleware for multi-tenant architecture
|
||||
*/
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { getTenantConfig, isValidTenant, extractTenantId } from '../config/tenant';
|
||||
import { logger } from '../logging/logger';
|
||||
|
||||
// Extend FastifyRequest to include tenant context
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
tenantId: string;
|
||||
tenantConfig: {
|
||||
tenantId: string;
|
||||
databaseUrl: string;
|
||||
redisUrl: string;
|
||||
platformServicesUrl: string;
|
||||
isAdminTenant: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const tenantMiddleware = async (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) => {
|
||||
try {
|
||||
// Method 1: From environment variable (container-level)
|
||||
const envTenantId = process.env.TENANT_ID;
|
||||
|
||||
// Method 2: From JWT token claims (verify or decode if available)
|
||||
let jwtTenantId = (request as any).user?.['https://motovaultpro.com/tenant_id'] as string | undefined;
|
||||
if (!jwtTenantId && typeof (request as any).jwtDecode === 'function') {
|
||||
try {
|
||||
const decoded = (request as any).jwtDecode();
|
||||
jwtTenantId = decoded?.payload?.['https://motovaultpro.com/tenant_id']
|
||||
|| decoded?.['https://motovaultpro.com/tenant_id'];
|
||||
} catch { /* ignore decode errors */ }
|
||||
}
|
||||
|
||||
// Method 3: From subdomain parsing (if needed)
|
||||
const host = request.headers.host || '';
|
||||
const subdomain = host.split('.')[0];
|
||||
const subdomainTenantId = subdomain !== 'admin' && subdomain !== 'localhost' ? subdomain : undefined;
|
||||
|
||||
// Extract tenant ID with priority: Environment > JWT > Subdomain > Default
|
||||
const tenantId = extractTenantId({
|
||||
envTenantId,
|
||||
jwtTenantId,
|
||||
subdomain: subdomainTenantId
|
||||
});
|
||||
|
||||
// Validate tenant exists
|
||||
const isValid = await isValidTenant(tenantId);
|
||||
if (!isValid) {
|
||||
logger.warn('Invalid tenant access attempt', {
|
||||
tenantId,
|
||||
host,
|
||||
path: request.url,
|
||||
method: request.method
|
||||
});
|
||||
reply.code(403).send({ error: 'Invalid or unauthorized tenant' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get tenant configuration
|
||||
const tenantConfig = getTenantConfig();
|
||||
|
||||
// Attach tenant context to request
|
||||
request.tenantId = tenantId;
|
||||
request.tenantConfig = tenantConfig;
|
||||
|
||||
logger.info('Tenant context established', {
|
||||
tenantId,
|
||||
isAdmin: tenantConfig.isAdminTenant,
|
||||
path: request.url
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.error('Tenant middleware error', { error });
|
||||
reply.code(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* @ai-summary JWT authentication middleware using Auth0
|
||||
* @ai-context Validates JWT tokens, adds user context to requests
|
||||
*/
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { expressjwt as jwt } from 'express-jwt';
|
||||
import jwks from 'jwks-rsa';
|
||||
import { env } from '../config/environment';
|
||||
import { logger } from '../logging/logger';
|
||||
|
||||
// Extend Express Request type
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: any;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const authMiddleware = jwt({
|
||||
secret: jwks.expressJwtSecret({
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
jwksRequestsPerMinute: 5,
|
||||
jwksUri: `https://${env.AUTH0_DOMAIN}/.well-known/jwks.json`,
|
||||
}),
|
||||
audience: env.AUTH0_AUDIENCE,
|
||||
issuer: `https://${env.AUTH0_DOMAIN}/`,
|
||||
algorithms: ['RS256'],
|
||||
});
|
||||
|
||||
export const errorHandler = (
|
||||
err: any,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
if (err.name === 'UnauthorizedError') {
|
||||
logger.warn('Unauthorized request', {
|
||||
path: req.path,
|
||||
ip: req.ip,
|
||||
error: err.message,
|
||||
});
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
} else {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @ai-summary Database operations for user preferences
|
||||
* @ai-context Repository pattern for user preference CRUD operations
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { UserPreferences, CreateUserPreferencesRequest, UpdateUserPreferencesRequest } from '../user-preferences.types';
|
||||
|
||||
export class UserPreferencesRepository {
|
||||
constructor(private db: Pool) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<UserPreferences | null> {
|
||||
const query = `
|
||||
SELECT id, user_id, unit_system, currency_code, time_zone, created_at, updated_at
|
||||
FROM user_preferences
|
||||
WHERE user_id = $1
|
||||
`;
|
||||
|
||||
const result = await this.db.query(query, [userId]);
|
||||
return result.rows.length > 0 ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async create(data: CreateUserPreferencesRequest): Promise<UserPreferences> {
|
||||
const query = `
|
||||
INSERT INTO user_preferences (user_id, unit_system, currency_code, time_zone)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const values = [
|
||||
data.userId,
|
||||
data.unitSystem || 'imperial',
|
||||
(data as any).currencyCode || 'USD',
|
||||
(data as any).timeZone || 'UTC'
|
||||
];
|
||||
|
||||
const result = await this.db.query(query, values);
|
||||
return this.mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async update(userId: string, data: UpdateUserPreferencesRequest): Promise<UserPreferences | null> {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (data.unitSystem !== undefined) {
|
||||
fields.push(`unit_system = $${paramCount++}`);
|
||||
values.push(data.unitSystem);
|
||||
}
|
||||
if ((data as any).currencyCode !== undefined) {
|
||||
fields.push(`currency_code = $${paramCount++}`);
|
||||
values.push((data as any).currencyCode);
|
||||
}
|
||||
if ((data as any).timeZone !== undefined) {
|
||||
fields.push(`time_zone = $${paramCount++}`);
|
||||
values.push((data as any).timeZone);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return this.findByUserId(userId);
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE user_preferences
|
||||
SET ${fields.join(', ')}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = $${paramCount}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
values.push(userId);
|
||||
const result = await this.db.query(query, values);
|
||||
return result.rows.length > 0 ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async upsert(data: CreateUserPreferencesRequest): Promise<UserPreferences> {
|
||||
const existing = await this.findByUserId(data.userId);
|
||||
|
||||
if (existing) {
|
||||
const updated = await this.update(data.userId, { unitSystem: data.unitSystem });
|
||||
return updated!;
|
||||
}
|
||||
|
||||
return this.create(data);
|
||||
}
|
||||
|
||||
private mapRow(row: any): UserPreferences {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
unitSystem: row.unit_system,
|
||||
currencyCode: row.currency_code || 'USD',
|
||||
timeZone: row.time_zone || 'UTC',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Create user_preferences table for storing user settings
|
||||
CREATE TYPE unit_system AS ENUM ('imperial', 'metric');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
unit_system unit_system NOT NULL DEFAULT 'imperial',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_user_preferences_user_id ON user_preferences(user_id);
|
||||
|
||||
-- Add trigger for updated_at
|
||||
CREATE TRIGGER update_user_preferences_updated_at
|
||||
BEFORE UPDATE ON user_preferences
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Add currency_code and time_zone to user_preferences
|
||||
ALTER TABLE user_preferences
|
||||
ADD COLUMN IF NOT EXISTS currency_code VARCHAR(3) DEFAULT 'USD',
|
||||
ADD COLUMN IF NOT EXISTS time_zone VARCHAR(100) DEFAULT 'UTC';
|
||||
|
||||
-- Optional: basic length/format checks can be enforced at application layer
|
||||
|
||||
37
backend/src/core/user-preferences/user-preferences.types.ts
Normal file
37
backend/src/core/user-preferences/user-preferences.types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for user preferences system
|
||||
* @ai-context Manages user settings including unit preferences
|
||||
*/
|
||||
|
||||
export type UnitSystem = 'imperial' | 'metric';
|
||||
|
||||
export interface UserPreferences {
|
||||
id: string;
|
||||
userId: string;
|
||||
unitSystem: UnitSystem;
|
||||
currencyCode: string;
|
||||
timeZone: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateUserPreferencesRequest {
|
||||
userId: string;
|
||||
unitSystem?: UnitSystem;
|
||||
}
|
||||
|
||||
export interface UpdateUserPreferencesRequest {
|
||||
unitSystem?: UnitSystem;
|
||||
currencyCode?: string;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export interface UserPreferencesResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
unitSystem: UnitSystem;
|
||||
currencyCode: string;
|
||||
timeZone: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -197,8 +197,8 @@ npm test -- features/fuel-logs --coverage
|
||||
# Run migrations
|
||||
make migrate
|
||||
|
||||
# Start development environment
|
||||
make dev
|
||||
# Start environment
|
||||
make start
|
||||
|
||||
# View feature logs
|
||||
make logs-backend | grep fuel-logs
|
||||
|
||||
38
backend/src/features/fuel-logs/api/fuel-grade.controller.ts
Normal file
38
backend/src/features/fuel-logs/api/fuel-grade.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { FuelGradeService } from '../domain/fuel-grade.service';
|
||||
import { FuelType } from '../domain/fuel-logs.types';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class FuelGradeController {
|
||||
async getFuelGrades(
|
||||
request: FastifyRequest<{ Params: { fuelType: FuelType } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { fuelType } = request.params;
|
||||
if (!Object.values(FuelType).includes(fuelType)) {
|
||||
return reply.code(400).send({ error: 'Bad Request', message: `Invalid fuel type: ${fuelType}` });
|
||||
}
|
||||
const grades = FuelGradeService.getFuelGradeOptions(fuelType);
|
||||
return reply.code(200).send({ fuelType, grades });
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting fuel grades', { error });
|
||||
return reply.code(500).send({ error: 'Internal server error', message: 'Failed to get fuel grades' });
|
||||
}
|
||||
}
|
||||
|
||||
async getAllFuelTypes(_request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const fuelTypes = Object.values(FuelType).map(type => ({
|
||||
value: type,
|
||||
label: type.charAt(0).toUpperCase() + type.slice(1),
|
||||
grades: FuelGradeService.getFuelGradeOptions(type)
|
||||
}));
|
||||
return reply.code(200).send({ fuelTypes });
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting fuel types', { error });
|
||||
return reply.code(500).send({ error: 'Internal server error', message: 'Failed to get fuel types' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FuelLogsService } from '../domain/fuel-logs.service';
|
||||
import { FuelLogsRepository } from '../data/fuel-logs.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { CreateFuelLogBody, UpdateFuelLogBody, FuelLogParams, VehicleParams } from '../domain/fuel-logs.types';
|
||||
import { FuelLogParams, VehicleParams, EnhancedCreateFuelLogRequest } from '../domain/fuel-logs.types';
|
||||
|
||||
export class FuelLogsController {
|
||||
private fuelLogsService: FuelLogsService;
|
||||
@@ -18,7 +18,7 @@ export class FuelLogsController {
|
||||
this.fuelLogsService = new FuelLogsService(repository);
|
||||
}
|
||||
|
||||
async createFuelLog(request: FastifyRequest<{ Body: CreateFuelLogBody }>, reply: FastifyReply) {
|
||||
async createFuelLog(request: FastifyRequest<{ Body: EnhancedCreateFuelLogRequest }>, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const fuelLog = await this.fuelLogsService.createFuelLog(request.body, userId);
|
||||
@@ -124,16 +124,12 @@ export class FuelLogsController {
|
||||
}
|
||||
}
|
||||
|
||||
async updateFuelLog(request: FastifyRequest<{ Params: FuelLogParams; Body: UpdateFuelLogBody }>, reply: FastifyReply) {
|
||||
async updateFuelLog(_request: FastifyRequest<{ Params: FuelLogParams; Body: any }>, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const { id } = request.params;
|
||||
|
||||
const fuelLog = await this.fuelLogsService.updateFuelLog(id, request.body, userId);
|
||||
|
||||
return reply.code(200).send(fuelLog);
|
||||
// Update not implemented in enhanced flow
|
||||
return reply.code(501).send({ error: 'Not Implemented', message: 'Update fuel log not implemented' });
|
||||
} catch (error: any) {
|
||||
logger.error('Error updating fuel log', { error, fuelLogId: request.params.id, userId: (request as any).user?.sub });
|
||||
logger.error('Error updating fuel log', { error });
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
@@ -216,4 +212,4 @@ export class FuelLogsController {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,64 +5,72 @@
|
||||
|
||||
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import {
|
||||
CreateFuelLogBody,
|
||||
UpdateFuelLogBody,
|
||||
FuelLogParams,
|
||||
VehicleParams
|
||||
} from '../domain/fuel-logs.types';
|
||||
// Types handled in controllers; no explicit generics required here
|
||||
import { FuelLogsController } from './fuel-logs.controller';
|
||||
import { FuelGradeController } from './fuel-grade.controller';
|
||||
import { tenantMiddleware } from '../../../core/middleware/tenant';
|
||||
|
||||
export const fuelLogsRoutes: FastifyPluginAsync = async (
|
||||
fastify: FastifyInstance,
|
||||
_opts: FastifyPluginOptions
|
||||
) => {
|
||||
const fuelLogsController = new FuelLogsController();
|
||||
const fuelGradeController = new FuelGradeController();
|
||||
|
||||
// GET /api/fuel-logs - Get user's fuel logs
|
||||
fastify.get('/fuel-logs', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelLogsController.getUserFuelLogs.bind(fuelLogsController)
|
||||
});
|
||||
|
||||
// POST /api/fuel-logs - Create new fuel log
|
||||
fastify.post<{ Body: CreateFuelLogBody }>('/fuel-logs', {
|
||||
preHandler: fastify.authenticate,
|
||||
fastify.post('/fuel-logs', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelLogsController.createFuelLog.bind(fuelLogsController)
|
||||
});
|
||||
|
||||
// GET /api/fuel-logs/:id - Get specific fuel log
|
||||
fastify.get<{ Params: FuelLogParams }>('/fuel-logs/:id', {
|
||||
preHandler: fastify.authenticate,
|
||||
fastify.get('/fuel-logs/:id', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelLogsController.getFuelLog.bind(fuelLogsController)
|
||||
});
|
||||
|
||||
// PUT /api/fuel-logs/:id - Update fuel log
|
||||
fastify.put<{ Params: FuelLogParams; Body: UpdateFuelLogBody }>('/fuel-logs/:id', {
|
||||
preHandler: fastify.authenticate,
|
||||
fastify.put('/fuel-logs/:id', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelLogsController.updateFuelLog.bind(fuelLogsController)
|
||||
});
|
||||
|
||||
// DELETE /api/fuel-logs/:id - Delete fuel log
|
||||
fastify.delete<{ Params: FuelLogParams }>('/fuel-logs/:id', {
|
||||
preHandler: fastify.authenticate,
|
||||
fastify.delete('/fuel-logs/:id', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelLogsController.deleteFuelLog.bind(fuelLogsController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/:vehicleId/fuel-logs - Get fuel logs for specific vehicle
|
||||
fastify.get<{ Params: VehicleParams }>('/vehicles/:vehicleId/fuel-logs', {
|
||||
preHandler: fastify.authenticate,
|
||||
// NEW ENDPOINTS under /api/fuel-logs
|
||||
fastify.get('/fuel-logs/vehicle/:vehicleId', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelLogsController.getFuelLogsByVehicle.bind(fuelLogsController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/:vehicleId/fuel-stats - Get fuel stats for specific vehicle
|
||||
fastify.get<{ Params: VehicleParams }>('/vehicles/:vehicleId/fuel-stats', {
|
||||
preHandler: fastify.authenticate,
|
||||
fastify.get('/fuel-logs/vehicle/:vehicleId/stats', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelLogsController.getFuelStats.bind(fuelLogsController)
|
||||
});
|
||||
|
||||
// Fuel type/grade discovery
|
||||
fastify.get('/fuel-logs/fuel-types', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelGradeController.getAllFuelTypes.bind(fuelGradeController)
|
||||
});
|
||||
|
||||
fastify.get('/fuel-logs/fuel-grades/:fuelType', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: fuelGradeController.getFuelGrades.bind(fuelGradeController)
|
||||
});
|
||||
};
|
||||
|
||||
// For backward compatibility during migration
|
||||
export function registerFuelLogsRoutes() {
|
||||
throw new Error('registerFuelLogsRoutes is deprecated - use fuelLogsRoutes Fastify plugin instead');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,31 +3,52 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { FuelType } from '../domain/fuel-logs.types';
|
||||
|
||||
// Enhanced create schema (Phase 3)
|
||||
export const createFuelLogSchema = z.object({
|
||||
vehicleId: z.string().uuid(),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
odometer: z.number().int().positive(),
|
||||
gallons: z.number().positive(),
|
||||
pricePerGallon: z.number().positive(),
|
||||
totalCost: z.number().positive(),
|
||||
station: z.string().max(200).optional(),
|
||||
location: z.string().max(200).optional(),
|
||||
dateTime: z.string().datetime(),
|
||||
// Distance (one required)
|
||||
odometerReading: z.number().int().positive().optional(),
|
||||
tripDistance: z.number().positive().optional(),
|
||||
// Fuel system
|
||||
fuelType: z.nativeEnum(FuelType),
|
||||
fuelGrade: z.string().nullable().optional(),
|
||||
fuelUnits: z.number().positive(),
|
||||
costPerUnit: z.number().positive(),
|
||||
// Location (optional)
|
||||
locationData: z.object({
|
||||
address: z.string().optional(),
|
||||
coordinates: z.object({ latitude: z.number(), longitude: z.number() }).optional(),
|
||||
googlePlaceId: z.string().optional(),
|
||||
stationName: z.string().optional()
|
||||
}).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
}).refine((data) => (data.odometerReading && data.odometerReading > 0) || (data.tripDistance && data.tripDistance > 0), {
|
||||
message: 'Either odometer reading or trip distance is required',
|
||||
path: ['odometerReading']
|
||||
}).refine((data) => !(data.odometerReading && data.tripDistance), {
|
||||
message: 'Cannot specify both odometer reading and trip distance',
|
||||
path: ['odometerReading']
|
||||
});
|
||||
|
||||
export const updateFuelLogSchema = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
odometer: z.number().int().positive().optional(),
|
||||
gallons: z.number().positive().optional(),
|
||||
pricePerGallon: z.number().positive().optional(),
|
||||
totalCost: z.number().positive().optional(),
|
||||
station: z.string().max(200).optional(),
|
||||
location: z.string().max(200).optional(),
|
||||
dateTime: z.string().datetime().optional(),
|
||||
odometerReading: z.number().int().positive().optional(),
|
||||
tripDistance: z.number().positive().optional(),
|
||||
fuelType: z.nativeEnum(FuelType).optional(),
|
||||
fuelGrade: z.string().nullable().optional(),
|
||||
fuelUnits: z.number().positive().optional(),
|
||||
costPerUnit: z.number().positive().optional(),
|
||||
locationData: z.object({
|
||||
address: z.string().optional(),
|
||||
coordinates: z.object({ latitude: z.number(), longitude: z.number() }).optional(),
|
||||
googlePlaceId: z.string().optional(),
|
||||
stationName: z.string().optional()
|
||||
}).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
}).refine(data => Object.keys(data).length > 0, {
|
||||
message: 'At least one field must be provided for update'
|
||||
});
|
||||
}).refine(data => Object.keys(data).length > 0, { message: 'At least one field must be provided for update' });
|
||||
|
||||
export function validateCreateFuelLog(data: unknown) {
|
||||
return createFuelLogSchema.safeParse(data);
|
||||
@@ -35,4 +56,4 @@ export function validateCreateFuelLog(data: unknown) {
|
||||
|
||||
export function validateUpdateFuelLog(data: unknown) {
|
||||
return updateFuelLogSchema.safeParse(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ export class FuelLogsRepository {
|
||||
const query = `
|
||||
INSERT INTO fuel_logs (
|
||||
user_id, vehicle_id, date, odometer, gallons,
|
||||
price_per_gallon, total_cost, station, location, notes, mpg
|
||||
price_per_gallon, total_cost, station, location, notes
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
@@ -29,8 +29,7 @@ export class FuelLogsRepository {
|
||||
data.totalCost,
|
||||
data.station,
|
||||
data.location,
|
||||
data.notes,
|
||||
data.mpg
|
||||
data.notes
|
||||
];
|
||||
|
||||
const result = await this.pool.query(query, values);
|
||||
@@ -126,10 +125,7 @@ export class FuelLogsRepository {
|
||||
fields.push(`notes = $${paramCount++}`);
|
||||
values.push(data.notes);
|
||||
}
|
||||
if (data.mpg !== undefined) {
|
||||
fields.push(`mpg = $${paramCount++}`);
|
||||
values.push(data.mpg);
|
||||
}
|
||||
// mpg column removed; efficiency is computed dynamically
|
||||
|
||||
if (fields.length === 0) {
|
||||
return this.findById(id);
|
||||
@@ -165,7 +161,6 @@ export class FuelLogsRepository {
|
||||
SUM(gallons) as total_gallons,
|
||||
SUM(total_cost) as total_cost,
|
||||
AVG(price_per_gallon) as avg_price_per_gallon,
|
||||
AVG(mpg) as avg_mpg,
|
||||
MAX(odometer) - MIN(odometer) as total_miles
|
||||
FROM fuel_logs
|
||||
WHERE vehicle_id = $1
|
||||
@@ -183,7 +178,7 @@ export class FuelLogsRepository {
|
||||
totalGallons: parseFloat(row.total_gallons) || 0,
|
||||
totalCost: parseFloat(row.total_cost) || 0,
|
||||
averagePricePerGallon: parseFloat(row.avg_price_per_gallon) || 0,
|
||||
averageMPG: parseFloat(row.avg_mpg) || 0,
|
||||
averageMPG: 0,
|
||||
totalMiles: parseInt(row.total_miles) || 0,
|
||||
};
|
||||
}
|
||||
@@ -201,9 +196,94 @@ export class FuelLogsRepository {
|
||||
station: row.station,
|
||||
location: row.location,
|
||||
notes: row.notes,
|
||||
mpg: row.mpg ? parseFloat(row.mpg) : undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced API support (new schema)
|
||||
async createEnhanced(data: {
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
dateTime: Date;
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelType: string;
|
||||
fuelGrade?: string | null;
|
||||
fuelUnits: number;
|
||||
costPerUnit: number;
|
||||
totalCost: number;
|
||||
locationData?: any;
|
||||
notes?: string;
|
||||
}): Promise<any> {
|
||||
const query = `
|
||||
INSERT INTO fuel_logs (
|
||||
user_id, vehicle_id, date, date_time, odometer, trip_distance,
|
||||
fuel_type, fuel_grade, fuel_units, cost_per_unit,
|
||||
gallons, price_per_gallon, total_cost, location_data, notes
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15
|
||||
)
|
||||
RETURNING *
|
||||
`;
|
||||
const values = [
|
||||
data.userId,
|
||||
data.vehicleId,
|
||||
data.dateTime.toISOString().slice(0, 10),
|
||||
data.dateTime,
|
||||
data.odometerReading ?? null,
|
||||
data.tripDistance ?? null,
|
||||
data.fuelType,
|
||||
data.fuelGrade ?? null,
|
||||
data.fuelUnits,
|
||||
data.costPerUnit,
|
||||
data.fuelUnits, // legacy support
|
||||
data.costPerUnit, // legacy support
|
||||
data.totalCost,
|
||||
data.locationData ?? null,
|
||||
data.notes ?? null
|
||||
];
|
||||
const res = await this.pool.query(query, values);
|
||||
return res.rows[0];
|
||||
}
|
||||
|
||||
async findByVehicleIdEnhanced(vehicleId: string): Promise<any[]> {
|
||||
const res = await this.pool.query(
|
||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`,
|
||||
[vehicleId]
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async findByUserIdEnhanced(userId: string): Promise<any[]> {
|
||||
const res = await this.pool.query(
|
||||
`SELECT * FROM fuel_logs WHERE user_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async findByIdEnhanced(id: string): Promise<any | null> {
|
||||
const res = await this.pool.query(`SELECT * FROM fuel_logs WHERE id = $1`, [id]);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
|
||||
async getPreviousLogByOdometer(vehicleId: string, odometerReading: number): Promise<any | null> {
|
||||
const res = await this.pool.query(
|
||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 AND odometer IS NOT NULL AND odometer < $2 ORDER BY odometer DESC LIMIT 1`,
|
||||
[vehicleId, odometerReading]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
|
||||
async getLatestLogForVehicle(vehicleId: string): Promise<any | null> {
|
||||
const res = await this.pool.query(
|
||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC LIMIT 1`,
|
||||
[vehicleId]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { UnitConversionService, UnitSystem } from './unit-conversion.service';
|
||||
|
||||
export interface EfficiencyResult {
|
||||
value: number;
|
||||
unitSystem: UnitSystem;
|
||||
label: string;
|
||||
calculationMethod: 'odometer' | 'trip_distance';
|
||||
}
|
||||
|
||||
export interface PartialEnhancedLog {
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelUnits?: number;
|
||||
}
|
||||
|
||||
export class EfficiencyCalculationService {
|
||||
static calculateEfficiency(
|
||||
currentLog: PartialEnhancedLog,
|
||||
previousOdometerReading: number | null,
|
||||
unitSystem: UnitSystem
|
||||
): EfficiencyResult | null {
|
||||
let distance: number | undefined;
|
||||
let method: 'odometer' | 'trip_distance' | undefined;
|
||||
|
||||
if (currentLog.tripDistance && currentLog.tripDistance > 0) {
|
||||
distance = currentLog.tripDistance;
|
||||
method = 'trip_distance';
|
||||
} else if (currentLog.odometerReading && previousOdometerReading !== null) {
|
||||
const d = currentLog.odometerReading - previousOdometerReading;
|
||||
if (d > 0) {
|
||||
distance = d;
|
||||
method = 'odometer';
|
||||
}
|
||||
}
|
||||
|
||||
if (!distance || !currentLog.fuelUnits || currentLog.fuelUnits <= 0 || !method) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = UnitConversionService.calculateEfficiency(distance, currentLog.fuelUnits, unitSystem);
|
||||
const labels = UnitConversionService.getUnitLabels(unitSystem);
|
||||
return { value, unitSystem, label: labels.efficiencyUnits, calculationMethod: method };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FuelType, FuelGrade, EnhancedCreateFuelLogRequest } from './fuel-logs.types';
|
||||
import { FuelGradeService } from './fuel-grade.service';
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export class EnhancedValidationService {
|
||||
static validateFuelLogData(data: Partial<EnhancedCreateFuelLogRequest>): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Distance requirement
|
||||
const hasOdo = data.odometerReading && data.odometerReading > 0;
|
||||
const hasTrip = data.tripDistance && data.tripDistance > 0;
|
||||
if (!hasOdo && !hasTrip) errors.push('Either odometer reading or trip distance is required');
|
||||
if (hasOdo && hasTrip) errors.push('Cannot specify both odometer reading and trip distance');
|
||||
|
||||
// Fuel type/grade
|
||||
if (!data.fuelType) errors.push('Fuel type is required');
|
||||
if (data.fuelType && !Object.values(FuelType).includes(data.fuelType)) {
|
||||
errors.push(`Invalid fuel type: ${data.fuelType}`);
|
||||
} else if (data.fuelType && !FuelGradeService.isValidGradeForFuelType(data.fuelType, data.fuelGrade as FuelGrade)) {
|
||||
errors.push(`Invalid fuel grade '${data.fuelGrade}' for fuel type '${data.fuelType}'`);
|
||||
}
|
||||
|
||||
// Numeric
|
||||
if (data.fuelUnits !== undefined && data.fuelUnits <= 0) errors.push('Fuel units must be positive');
|
||||
if (data.costPerUnit !== undefined && data.costPerUnit <= 0) errors.push('Cost per unit must be positive');
|
||||
if (data.odometerReading !== undefined && data.odometerReading <= 0) errors.push('Odometer reading must be positive');
|
||||
if (data.tripDistance !== undefined && data.tripDistance <= 0) errors.push('Trip distance must be positive');
|
||||
|
||||
// Date/time
|
||||
if (data.dateTime) {
|
||||
const dt = new Date(data.dateTime);
|
||||
const now = new Date();
|
||||
if (isNaN(dt.getTime())) errors.push('Invalid date/time format');
|
||||
if (dt > now) errors.push('Cannot create fuel logs in the future');
|
||||
}
|
||||
|
||||
// Heuristics warnings
|
||||
if (data.fuelUnits && data.fuelUnits > 100) warnings.push('Fuel amount seems unusually high (>100 units)');
|
||||
if (data.costPerUnit && data.costPerUnit > 10) warnings.push('Cost per unit seems unusually high (>$10)');
|
||||
if (data.tripDistance && data.tripDistance > 1000) warnings.push('Trip distance seems unusually high (>1000 miles)');
|
||||
|
||||
return { isValid: errors.length === 0, errors, warnings };
|
||||
}
|
||||
}
|
||||
50
backend/src/features/fuel-logs/domain/fuel-grade.service.ts
Normal file
50
backend/src/features/fuel-logs/domain/fuel-grade.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { FuelType, FuelGrade } from './fuel-logs.types';
|
||||
|
||||
export interface FuelGradeOption {
|
||||
value: FuelGrade;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class FuelGradeService {
|
||||
static getFuelGradeOptions(fuelType: FuelType): FuelGradeOption[] {
|
||||
switch (fuelType) {
|
||||
case FuelType.GASOLINE:
|
||||
return [
|
||||
{ value: '87', label: '87 (Regular)', description: 'Regular unleaded gasoline' },
|
||||
{ value: '88', label: '88 (Mid-Grade)' },
|
||||
{ value: '89', label: '89 (Mid-Grade Plus)' },
|
||||
{ value: '91', label: '91 (Premium)' },
|
||||
{ value: '93', label: '93 (Premium Plus)' }
|
||||
];
|
||||
case FuelType.DIESEL:
|
||||
return [
|
||||
{ value: '#1', label: '#1 Diesel', description: 'Light diesel fuel' },
|
||||
{ value: '#2', label: '#2 Diesel', description: 'Standard diesel fuel' }
|
||||
];
|
||||
case FuelType.ELECTRIC:
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static isValidGradeForFuelType(fuelType: FuelType, fuelGrade?: FuelGrade): boolean {
|
||||
if (!fuelGrade) return fuelType === FuelType.ELECTRIC;
|
||||
return this.getFuelGradeOptions(fuelType).some(opt => opt.value === fuelGrade);
|
||||
}
|
||||
|
||||
static getDefaultGrade(fuelType: FuelType): FuelGrade {
|
||||
switch (fuelType) {
|
||||
case FuelType.GASOLINE:
|
||||
return '87';
|
||||
case FuelType.DIESEL:
|
||||
return '#2';
|
||||
case FuelType.ELECTRIC:
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,249 +1,192 @@
|
||||
/**
|
||||
* @ai-summary Business logic for fuel logs feature
|
||||
* @ai-context Handles MPG calculations and vehicle validation
|
||||
* @ai-summary Enhanced business logic for fuel logs feature
|
||||
* @ai-context Unit-agnostic efficiency and user preferences integration
|
||||
*/
|
||||
|
||||
import { FuelLogsRepository } from '../data/fuel-logs.repository';
|
||||
import {
|
||||
FuelLog,
|
||||
CreateFuelLogRequest,
|
||||
UpdateFuelLogRequest,
|
||||
FuelLogResponse,
|
||||
FuelStats
|
||||
} from './fuel-logs.types';
|
||||
import { EnhancedCreateFuelLogRequest, EnhancedFuelLogResponse, FuelType } from './fuel-logs.types';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { cacheService } from '../../../core/config/redis';
|
||||
import pool from '../../../core/config/database';
|
||||
import { EnhancedValidationService } from './enhanced-validation.service';
|
||||
import { UnitConversionService } from './unit-conversion.service';
|
||||
import { EfficiencyCalculationService } from './efficiency-calculation.service';
|
||||
import { UserSettingsService } from '../external/user-settings.service';
|
||||
|
||||
export class FuelLogsService {
|
||||
private readonly cachePrefix = 'fuel-logs';
|
||||
private readonly cacheTTL = 300; // 5 minutes
|
||||
|
||||
|
||||
constructor(private repository: FuelLogsRepository) {}
|
||||
|
||||
async createFuelLog(data: CreateFuelLogRequest, userId: string): Promise<FuelLogResponse> {
|
||||
logger.info('Creating fuel log', { userId, vehicleId: data.vehicleId });
|
||||
|
||||
|
||||
async createFuelLog(data: EnhancedCreateFuelLogRequest, userId: string): Promise<EnhancedFuelLogResponse> {
|
||||
logger.info('Creating enhanced fuel log', { userId, vehicleId: data.vehicleId, fuelType: data.fuelType });
|
||||
const userSettings = await UserSettingsService.getUserSettings(userId);
|
||||
|
||||
const validation = EnhancedValidationService.validateFuelLogData(data);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.errors.join(', '));
|
||||
}
|
||||
|
||||
// Verify vehicle ownership
|
||||
const vehicleCheck = await pool.query(
|
||||
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
|
||||
[data.vehicleId, userId]
|
||||
);
|
||||
|
||||
if (vehicleCheck.rows.length === 0) {
|
||||
throw new Error('Vehicle not found or unauthorized');
|
||||
}
|
||||
|
||||
// Calculate MPG based on previous log
|
||||
let mpg: number | undefined;
|
||||
const previousLog = await this.repository.getPreviousLog(
|
||||
data.vehicleId,
|
||||
data.date,
|
||||
data.odometer
|
||||
if (vehicleCheck.rows.length === 0) throw new Error('Vehicle not found or unauthorized');
|
||||
|
||||
const totalCost = data.fuelUnits * data.costPerUnit;
|
||||
|
||||
// Previous log for efficiency
|
||||
const prev = data.odometerReading
|
||||
? await this.repository.getPreviousLogByOdometer(data.vehicleId, data.odometerReading)
|
||||
: await this.repository.getLatestLogForVehicle(data.vehicleId);
|
||||
|
||||
const eff = EfficiencyCalculationService.calculateEfficiency(
|
||||
{ odometerReading: data.odometerReading, tripDistance: data.tripDistance, fuelUnits: data.fuelUnits },
|
||||
prev?.odometer ?? null,
|
||||
userSettings.unitSystem
|
||||
);
|
||||
|
||||
if (previousLog && previousLog.odometer < data.odometer) {
|
||||
const milesDriven = data.odometer - previousLog.odometer;
|
||||
mpg = milesDriven / data.gallons;
|
||||
}
|
||||
|
||||
// Create fuel log
|
||||
const fuelLog = await this.repository.create({
|
||||
...data,
|
||||
|
||||
const inserted = await this.repository.createEnhanced({
|
||||
userId,
|
||||
mpg
|
||||
vehicleId: data.vehicleId,
|
||||
dateTime: new Date(data.dateTime),
|
||||
odometerReading: data.odometerReading,
|
||||
tripDistance: data.tripDistance,
|
||||
fuelType: data.fuelType,
|
||||
fuelGrade: data.fuelGrade ?? null,
|
||||
fuelUnits: data.fuelUnits,
|
||||
costPerUnit: data.costPerUnit,
|
||||
totalCost,
|
||||
locationData: data.locationData ?? null,
|
||||
notes: data.notes
|
||||
});
|
||||
|
||||
// Update vehicle odometer
|
||||
await pool.query(
|
||||
'UPDATE vehicles SET odometer_reading = $1 WHERE id = $2 AND odometer_reading < $1',
|
||||
[data.odometer, data.vehicleId]
|
||||
);
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateCaches(userId, data.vehicleId);
|
||||
|
||||
return this.toResponse(fuelLog);
|
||||
}
|
||||
|
||||
async getFuelLogsByVehicle(vehicleId: string, userId: string): Promise<FuelLogResponse[]> {
|
||||
// Verify vehicle ownership
|
||||
const vehicleCheck = await pool.query(
|
||||
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
|
||||
[vehicleId, userId]
|
||||
);
|
||||
|
||||
if (vehicleCheck.rows.length === 0) {
|
||||
throw new Error('Vehicle not found or unauthorized');
|
||||
}
|
||||
|
||||
const cacheKey = `${this.cachePrefix}:vehicle:${vehicleId}`;
|
||||
|
||||
// Check cache
|
||||
const cached = await cacheService.get<FuelLogResponse[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Get from database
|
||||
const logs = await this.repository.findByVehicleId(vehicleId);
|
||||
const response = logs.map((log: FuelLog) => this.toResponse(log));
|
||||
|
||||
// Cache result
|
||||
await cacheService.set(cacheKey, response, this.cacheTTL);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async getUserFuelLogs(userId: string): Promise<FuelLogResponse[]> {
|
||||
const cacheKey = `${this.cachePrefix}:user:${userId}`;
|
||||
|
||||
// Check cache
|
||||
const cached = await cacheService.get<FuelLogResponse[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Get from database
|
||||
const logs = await this.repository.findByUserId(userId);
|
||||
const response = logs.map((log: FuelLog) => this.toResponse(log));
|
||||
|
||||
// Cache result
|
||||
await cacheService.set(cacheKey, response, this.cacheTTL);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async getFuelLog(id: string, userId: string): Promise<FuelLogResponse> {
|
||||
const log = await this.repository.findById(id);
|
||||
|
||||
if (!log) {
|
||||
throw new Error('Fuel log not found');
|
||||
}
|
||||
|
||||
if (log.userId !== userId) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
return this.toResponse(log);
|
||||
}
|
||||
|
||||
async updateFuelLog(
|
||||
id: string,
|
||||
data: UpdateFuelLogRequest,
|
||||
userId: string
|
||||
): Promise<FuelLogResponse> {
|
||||
// Verify ownership
|
||||
const existing = await this.repository.findById(id);
|
||||
if (!existing) {
|
||||
throw new Error('Fuel log not found');
|
||||
}
|
||||
if (existing.userId !== userId) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
// Recalculate MPG if odometer or gallons changed
|
||||
let mpg = existing.mpg;
|
||||
if (data.odometer || data.gallons) {
|
||||
const previousLog = await this.repository.getPreviousLog(
|
||||
existing.vehicleId,
|
||||
data.date || existing.date.toISOString(),
|
||||
data.odometer || existing.odometer
|
||||
|
||||
if (data.odometerReading) {
|
||||
await pool.query(
|
||||
'UPDATE vehicles SET odometer_reading = $1 WHERE id = $2 AND (odometer_reading IS NULL OR odometer_reading < $1)',
|
||||
[data.odometerReading, data.vehicleId]
|
||||
);
|
||||
|
||||
if (previousLog) {
|
||||
const odometer = data.odometer || existing.odometer;
|
||||
const gallons = data.gallons || existing.gallons;
|
||||
const milesDriven = odometer - previousLog.odometer;
|
||||
mpg = milesDriven / gallons;
|
||||
}
|
||||
|
||||
await this.invalidateCaches(userId, data.vehicleId, userSettings.unitSystem);
|
||||
return this.toEnhancedResponse(inserted, eff?.value ?? undefined, userSettings.unitSystem);
|
||||
}
|
||||
|
||||
async getFuelLogsByVehicle(vehicleId: string, userId: string): Promise<EnhancedFuelLogResponse[]> {
|
||||
const vehicleCheck = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]);
|
||||
if (vehicleCheck.rows.length === 0) throw new Error('Vehicle not found or unauthorized');
|
||||
|
||||
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
|
||||
const cacheKey = `${this.cachePrefix}:vehicle:${vehicleId}:${unitSystem}`;
|
||||
|
||||
const cached = await cacheService.get<EnhancedFuelLogResponse[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const rows = await this.repository.findByVehicleIdEnhanced(vehicleId);
|
||||
const response = rows.map(r => this.toEnhancedResponse(r, undefined, unitSystem));
|
||||
await cacheService.set(cacheKey, response, this.cacheTTL);
|
||||
return response;
|
||||
}
|
||||
|
||||
async getUserFuelLogs(userId: string): Promise<EnhancedFuelLogResponse[]> {
|
||||
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
|
||||
const cacheKey = `${this.cachePrefix}:user:${userId}:${unitSystem}`;
|
||||
const cached = await cacheService.get<EnhancedFuelLogResponse[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
const rows = await this.repository.findByUserIdEnhanced(userId);
|
||||
const response = rows.map(r => this.toEnhancedResponse(r, undefined, unitSystem));
|
||||
await cacheService.set(cacheKey, response, this.cacheTTL);
|
||||
return response;
|
||||
}
|
||||
|
||||
async getFuelLog(id: string, userId: string): Promise<EnhancedFuelLogResponse> {
|
||||
const row = await this.repository.findByIdEnhanced(id);
|
||||
if (!row) throw new Error('Fuel log not found');
|
||||
if (row.user_id !== userId) throw new Error('Unauthorized');
|
||||
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
|
||||
return this.toEnhancedResponse(row, undefined, unitSystem);
|
||||
}
|
||||
|
||||
async updateFuelLog(): Promise<any> { throw new Error('Not Implemented'); }
|
||||
|
||||
async deleteFuelLog(id: string, userId: string): Promise<void> {
|
||||
const existing = await this.repository.findByIdEnhanced(id);
|
||||
if (!existing) throw new Error('Fuel log not found');
|
||||
if (existing.user_id !== userId) throw new Error('Unauthorized');
|
||||
await this.repository.delete(id);
|
||||
await this.invalidateCaches(userId, existing.vehicle_id, 'imperial'); // cache keys include unit; simple sweep below
|
||||
}
|
||||
|
||||
async getVehicleStats(vehicleId: string, userId: string): Promise<any> {
|
||||
const vehicleCheck = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]);
|
||||
if (vehicleCheck.rows.length === 0) throw new Error('Vehicle not found or unauthorized');
|
||||
|
||||
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
|
||||
const rows = await this.repository.findByVehicleIdEnhanced(vehicleId);
|
||||
const labels = UnitConversionService.getUnitLabels(unitSystem);
|
||||
if (rows.length === 0) {
|
||||
return { logCount: 0, totalFuelUnits: 0, totalCost: 0, averageCostPerUnit: 0, totalDistance: 0, averageEfficiency: 0, unitLabels: labels };
|
||||
}
|
||||
|
||||
const totalFuelUnits = rows.reduce((s, r) => s + (Number(r.fuel_units) || 0), 0);
|
||||
const totalCost = rows.reduce((s, r) => s + (Number(r.total_cost) || 0), 0);
|
||||
const averageCostPerUnit = totalFuelUnits > 0 ? totalCost / totalFuelUnits : 0;
|
||||
|
||||
const sorted = [...rows].sort((a, b) => (new Date(b.date_time || b.date)).getTime() - (new Date(a.date_time || a.date)).getTime());
|
||||
let totalDistance = 0;
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const cur = sorted[i];
|
||||
const prev = sorted[i + 1];
|
||||
if (Number(cur.trip_distance) > 0) totalDistance += Number(cur.trip_distance);
|
||||
else if (prev && cur.odometer != null && prev.odometer != null) {
|
||||
const d = Number(cur.odometer) - Number(prev.odometer);
|
||||
if (d > 0) totalDistance += d;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data with proper types
|
||||
const updateData: Partial<FuelLog> = {
|
||||
...data,
|
||||
date: data.date ? new Date(data.date) : undefined,
|
||||
mpg
|
||||
};
|
||||
|
||||
// Update
|
||||
const updated = await this.repository.update(id, updateData);
|
||||
if (!updated) {
|
||||
throw new Error('Update failed');
|
||||
}
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateCaches(userId, existing.vehicleId);
|
||||
|
||||
return this.toResponse(updated);
|
||||
|
||||
const efficiencies: number[] = sorted.map(l => {
|
||||
const e = EfficiencyCalculationService.calculateEfficiency(
|
||||
{ odometerReading: l.odometer ?? undefined, tripDistance: l.trip_distance ?? undefined, fuelUnits: l.fuel_units ?? undefined },
|
||||
null,
|
||||
unitSystem
|
||||
);
|
||||
return e?.value || 0;
|
||||
}).filter(v => v > 0);
|
||||
const averageEfficiency = efficiencies.length ? (efficiencies.reduce((a, b) => a + b, 0) / efficiencies.length) : 0;
|
||||
|
||||
return { logCount: rows.length, totalFuelUnits, totalCost, averageCostPerUnit, totalDistance, averageEfficiency, unitLabels: labels };
|
||||
}
|
||||
|
||||
async deleteFuelLog(id: string, userId: string): Promise<void> {
|
||||
// Verify ownership
|
||||
const existing = await this.repository.findById(id);
|
||||
if (!existing) {
|
||||
throw new Error('Fuel log not found');
|
||||
}
|
||||
if (existing.userId !== userId) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
await this.repository.delete(id);
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateCaches(userId, existing.vehicleId);
|
||||
}
|
||||
|
||||
async getVehicleStats(vehicleId: string, userId: string): Promise<FuelStats> {
|
||||
// Verify vehicle ownership
|
||||
const vehicleCheck = await pool.query(
|
||||
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
|
||||
[vehicleId, userId]
|
||||
);
|
||||
|
||||
if (vehicleCheck.rows.length === 0) {
|
||||
throw new Error('Vehicle not found or unauthorized');
|
||||
}
|
||||
|
||||
const stats = await this.repository.getStats(vehicleId);
|
||||
|
||||
if (!stats) {
|
||||
return {
|
||||
logCount: 0,
|
||||
totalGallons: 0,
|
||||
totalCost: 0,
|
||||
averagePricePerGallon: 0,
|
||||
averageMPG: 0,
|
||||
totalMiles: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
private async invalidateCaches(userId: string, vehicleId: string): Promise<void> {
|
||||
|
||||
private async invalidateCaches(userId: string, vehicleId: string, unitSystem: 'imperial' | 'metric'): Promise<void> {
|
||||
await Promise.all([
|
||||
cacheService.del(`${this.cachePrefix}:user:${userId}`),
|
||||
cacheService.del(`${this.cachePrefix}:vehicle:${vehicleId}`)
|
||||
cacheService.del(`${this.cachePrefix}:user:${userId}:${unitSystem}`),
|
||||
cacheService.del(`${this.cachePrefix}:vehicle:${vehicleId}:${unitSystem}`)
|
||||
]);
|
||||
}
|
||||
|
||||
private toResponse(log: FuelLog): FuelLogResponse {
|
||||
|
||||
private toEnhancedResponse(row: any, efficiency: number | undefined, unitSystem: 'imperial' | 'metric'): EnhancedFuelLogResponse {
|
||||
const labels = UnitConversionService.getUnitLabels(unitSystem);
|
||||
const dateTime = row.date_time ? new Date(row.date_time) : (row.date ? new Date(row.date) : new Date());
|
||||
return {
|
||||
id: log.id,
|
||||
userId: log.userId,
|
||||
vehicleId: log.vehicleId,
|
||||
date: log.date.toISOString().split('T')[0],
|
||||
odometer: log.odometer,
|
||||
gallons: log.gallons,
|
||||
pricePerGallon: log.pricePerGallon,
|
||||
totalCost: log.totalCost,
|
||||
station: log.station,
|
||||
location: log.location,
|
||||
notes: log.notes,
|
||||
mpg: log.mpg,
|
||||
createdAt: log.createdAt.toISOString(),
|
||||
updatedAt: log.updatedAt.toISOString(),
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
vehicleId: row.vehicle_id,
|
||||
dateTime: dateTime.toISOString(),
|
||||
odometerReading: row.odometer ?? undefined,
|
||||
tripDistance: row.trip_distance ?? undefined,
|
||||
fuelType: row.fuel_type as FuelType,
|
||||
fuelGrade: row.fuel_grade ?? undefined,
|
||||
fuelUnits: row.fuel_units,
|
||||
costPerUnit: row.cost_per_unit,
|
||||
totalCost: Number(row.total_cost),
|
||||
locationData: row.location_data ?? undefined,
|
||||
notes: row.notes ?? undefined,
|
||||
efficiency: efficiency,
|
||||
efficiencyLabel: labels.efficiencyUnits,
|
||||
createdAt: new Date(row.created_at).toISOString(),
|
||||
updatedAt: new Date(row.updated_at).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface FuelLog {
|
||||
station?: string;
|
||||
location?: string;
|
||||
notes?: string;
|
||||
mpg?: number; // Calculated field
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -55,7 +54,55 @@ export interface FuelLogResponse {
|
||||
station?: string;
|
||||
location?: string;
|
||||
notes?: string;
|
||||
mpg?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Enhanced types for upgraded schema (Phase 2/3)
|
||||
export enum FuelType {
|
||||
GASOLINE = 'gasoline',
|
||||
DIESEL = 'diesel',
|
||||
ELECTRIC = 'electric'
|
||||
}
|
||||
|
||||
export type FuelGrade = '87' | '88' | '89' | '91' | '93' | '#1' | '#2' | null;
|
||||
|
||||
export interface LocationData {
|
||||
address?: string;
|
||||
coordinates?: { latitude: number; longitude: number };
|
||||
googlePlaceId?: string;
|
||||
stationName?: string;
|
||||
}
|
||||
|
||||
export interface EnhancedCreateFuelLogRequest {
|
||||
vehicleId: string;
|
||||
dateTime: string; // ISO
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelType: FuelType;
|
||||
fuelGrade?: FuelGrade;
|
||||
fuelUnits: number;
|
||||
costPerUnit: number;
|
||||
locationData?: LocationData;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface EnhancedFuelLogResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
dateTime: string;
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelType: FuelType;
|
||||
fuelGrade?: FuelGrade;
|
||||
fuelUnits: number;
|
||||
costPerUnit: number;
|
||||
totalCost: number;
|
||||
locationData?: LocationData;
|
||||
efficiency?: number;
|
||||
efficiencyLabel: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -99,4 +146,4 @@ export interface FuelLogParams {
|
||||
|
||||
export interface VehicleParams {
|
||||
vehicleId: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { UnitSystem as CoreUnitSystem } from '../../../shared-minimal/utils/units';
|
||||
|
||||
export type UnitSystem = CoreUnitSystem;
|
||||
|
||||
export class UnitConversionService {
|
||||
private static readonly MPG_TO_L100KM = 235.214;
|
||||
|
||||
static getUnitLabels(unitSystem: UnitSystem) {
|
||||
return unitSystem === 'metric'
|
||||
? { fuelUnits: 'liters', distanceUnits: 'kilometers', efficiencyUnits: 'L/100km' }
|
||||
: { fuelUnits: 'gallons', distanceUnits: 'miles', efficiencyUnits: 'mpg' };
|
||||
}
|
||||
|
||||
static calculateEfficiency(distance: number, fuelUnits: number, unitSystem: UnitSystem): number {
|
||||
if (fuelUnits <= 0 || distance <= 0) return 0;
|
||||
return unitSystem === 'metric'
|
||||
? (fuelUnits / distance) * 100
|
||||
: distance / fuelUnits;
|
||||
}
|
||||
|
||||
static convertEfficiency(efficiency: number, from: UnitSystem, to: UnitSystem): number {
|
||||
if (from === to) return efficiency;
|
||||
if (from === 'imperial' && to === 'metric') {
|
||||
return efficiency > 0 ? this.MPG_TO_L100KM / efficiency : 0;
|
||||
}
|
||||
if (from === 'metric' && to === 'imperial') {
|
||||
return efficiency > 0 ? this.MPG_TO_L100KM / efficiency : 0;
|
||||
}
|
||||
return efficiency;
|
||||
}
|
||||
}
|
||||
|
||||
37
backend/src/features/fuel-logs/external/user-settings.service.ts
vendored
Normal file
37
backend/src/features/fuel-logs/external/user-settings.service.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @ai-summary User settings facade for fuel-logs feature
|
||||
* @ai-context Reads user preferences (unit system, currency, timezone) from app DB
|
||||
*/
|
||||
|
||||
import { UserPreferencesRepository } from '../../../core/user-preferences/data/user-preferences.repository';
|
||||
import pool from '../../../core/config/database';
|
||||
import { UnitSystem } from '../../../core/user-preferences/user-preferences.types';
|
||||
|
||||
export interface UserSettings {
|
||||
unitSystem: UnitSystem;
|
||||
currencyCode: string;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
export class UserSettingsService {
|
||||
private static repo = new UserPreferencesRepository(pool);
|
||||
|
||||
static async getUserSettings(userId: string): Promise<UserSettings> {
|
||||
const existing = await this.repo.findByUserId(userId);
|
||||
if (existing) {
|
||||
return {
|
||||
unitSystem: existing.unitSystem,
|
||||
currencyCode: existing.currencyCode || 'USD',
|
||||
timeZone: existing.timeZone || 'UTC',
|
||||
};
|
||||
}
|
||||
// Upsert with sensible defaults if missing
|
||||
const created = await this.repo.upsert({ userId, unitSystem: 'imperial' });
|
||||
return {
|
||||
unitSystem: created.unitSystem,
|
||||
currencyCode: created.currencyCode || 'USD',
|
||||
timeZone: created.timeZone || 'UTC',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,13 @@ CREATE TABLE IF NOT EXISTS fuel_logs (
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_fuel_logs_user_id ON fuel_logs(user_id);
|
||||
CREATE INDEX idx_fuel_logs_vehicle_id ON fuel_logs(vehicle_id);
|
||||
CREATE INDEX idx_fuel_logs_date ON fuel_logs(date DESC);
|
||||
CREATE INDEX idx_fuel_logs_created_at ON fuel_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_fuel_logs_user_id ON fuel_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fuel_logs_vehicle_id ON fuel_logs(vehicle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fuel_logs_date ON fuel_logs(date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_fuel_logs_created_at ON fuel_logs(created_at DESC);
|
||||
|
||||
-- Add trigger for updated_at
|
||||
DROP TRIGGER IF EXISTS update_fuel_logs_updated_at ON fuel_logs;
|
||||
CREATE TRIGGER update_fuel_logs_updated_at
|
||||
BEFORE UPDATE ON fuel_logs
|
||||
FOR EACH ROW
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
-- Migration: 002_enhance_fuel_logs_schema.sql
|
||||
-- Enhance fuel_logs schema with new fields and constraints
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Add new columns (nullable initially for backfill)
|
||||
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS trip_distance INTEGER;
|
||||
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS fuel_type VARCHAR(20);
|
||||
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS fuel_grade VARCHAR(10);
|
||||
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS fuel_units DECIMAL(8,3);
|
||||
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS cost_per_unit DECIMAL(6,3);
|
||||
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS location_data JSONB;
|
||||
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS date_time TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
-- Backfill existing data
|
||||
UPDATE fuel_logs SET
|
||||
fuel_type = 'gasoline',
|
||||
fuel_units = gallons,
|
||||
cost_per_unit = price_per_gallon,
|
||||
date_time = (date::timestamp AT TIME ZONE 'UTC') + interval '12 hours'
|
||||
WHERE fuel_type IS NULL;
|
||||
|
||||
-- Set NOT NULL and defaults where applicable
|
||||
ALTER TABLE fuel_logs ALTER COLUMN fuel_type SET NOT NULL;
|
||||
ALTER TABLE fuel_logs ALTER COLUMN fuel_type SET DEFAULT 'gasoline';
|
||||
|
||||
-- Check constraints
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fuel_type_check'
|
||||
) THEN
|
||||
ALTER TABLE fuel_logs ADD CONSTRAINT fuel_type_check
|
||||
CHECK (fuel_type IN ('gasoline', 'diesel', 'electric'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Either trip_distance OR odometer required (> 0)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'distance_required_check'
|
||||
) THEN
|
||||
ALTER TABLE fuel_logs ADD CONSTRAINT distance_required_check
|
||||
CHECK ((trip_distance IS NOT NULL AND trip_distance > 0) OR
|
||||
(odometer IS NOT NULL AND odometer > 0));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Fuel grade validation trigger
|
||||
CREATE OR REPLACE FUNCTION validate_fuel_grade()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Gasoline
|
||||
IF NEW.fuel_type = 'gasoline' AND NEW.fuel_grade IS NOT NULL AND
|
||||
NEW.fuel_grade NOT IN ('87', '88', '89', '91', '93') THEN
|
||||
RAISE EXCEPTION 'Invalid fuel grade % for gasoline', NEW.fuel_grade;
|
||||
END IF;
|
||||
|
||||
-- Diesel
|
||||
IF NEW.fuel_type = 'diesel' AND NEW.fuel_grade IS NOT NULL AND
|
||||
NEW.fuel_grade NOT IN ('#1', '#2') THEN
|
||||
RAISE EXCEPTION 'Invalid fuel grade % for diesel', NEW.fuel_grade;
|
||||
END IF;
|
||||
|
||||
-- Electric: no grade allowed
|
||||
IF NEW.fuel_type = 'electric' AND NEW.fuel_grade IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Electric fuel type cannot have a grade';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger WHERE tgname = 'fuel_grade_validation_trigger'
|
||||
) THEN
|
||||
CREATE TRIGGER fuel_grade_validation_trigger
|
||||
BEFORE INSERT OR UPDATE ON fuel_logs
|
||||
FOR EACH ROW EXECUTE FUNCTION validate_fuel_grade();
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_fuel_logs_fuel_type ON fuel_logs(fuel_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_fuel_logs_date_time ON fuel_logs(date_time);
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Migration: 003_drop_mpg_column.sql
|
||||
-- Remove deprecated mpg column; efficiency is computed dynamically
|
||||
|
||||
ALTER TABLE fuel_logs DROP COLUMN IF EXISTS mpg;
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
# Umaintenance Feature Capsule
|
||||
# Maintenance Feature Capsule
|
||||
|
||||
## Quick Summary (50 tokens)
|
||||
[AI: Complete feature description, main operations, dependencies, caching strategy]
|
||||
|
||||
## API Endpoints
|
||||
- GET /api/maintenance - List all maintenance
|
||||
- GET /api/maintenance/:id - Get specific lUmaintenance
|
||||
- POST /api/maintenance - Create new lUmaintenance
|
||||
- PUT /api/maintenance/:id - Update lUmaintenance
|
||||
- DELETE /api/maintenance/:id - Delete lUmaintenance
|
||||
## Status
|
||||
- Scaffolded; implementation pending. Endpoints and behavior to be defined.
|
||||
|
||||
## Structure
|
||||
- **api/** - HTTP endpoints, routes, validators
|
||||
@@ -22,8 +15,8 @@
|
||||
|
||||
## Dependencies
|
||||
- Internal: core/auth, core/cache
|
||||
- External: [List any external APIs]
|
||||
- Database: maintenance table
|
||||
- External: (none defined yet)
|
||||
- Database: maintenance table (see docs/DATABASE-SCHEMA.md)
|
||||
|
||||
## Quick Commands
|
||||
```bash
|
||||
@@ -33,3 +26,9 @@ npm test -- features/maintenance
|
||||
# Run feature migrations
|
||||
npm run migrate:feature maintenance
|
||||
```
|
||||
|
||||
## Clarifications Needed
|
||||
- Entities/fields and validation rules (e.g., due date, mileage, completion criteria)?
|
||||
- Planned endpoints and request/response shapes?
|
||||
- Relationship to vehicles (required foreign keys, cascades)?
|
||||
- Caching requirements (e.g., upcoming maintenance TTL)?
|
||||
|
||||
@@ -46,20 +46,22 @@ CREATE TABLE IF NOT EXISTS maintenance_schedules (
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_maintenance_logs_user_id ON maintenance_logs(user_id);
|
||||
CREATE INDEX idx_maintenance_logs_vehicle_id ON maintenance_logs(vehicle_id);
|
||||
CREATE INDEX idx_maintenance_logs_date ON maintenance_logs(date DESC);
|
||||
CREATE INDEX idx_maintenance_logs_type ON maintenance_logs(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_maintenance_logs_user_id ON maintenance_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_maintenance_logs_vehicle_id ON maintenance_logs(vehicle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_maintenance_logs_date ON maintenance_logs(date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_maintenance_logs_type ON maintenance_logs(type);
|
||||
|
||||
CREATE INDEX idx_maintenance_schedules_vehicle_id ON maintenance_schedules(vehicle_id);
|
||||
CREATE INDEX idx_maintenance_schedules_next_due_date ON maintenance_schedules(next_due_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_maintenance_schedules_vehicle_id ON maintenance_schedules(vehicle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_maintenance_schedules_next_due_date ON maintenance_schedules(next_due_date);
|
||||
|
||||
-- Add triggers
|
||||
DROP TRIGGER IF EXISTS update_maintenance_logs_updated_at ON maintenance_logs;
|
||||
CREATE TRIGGER update_maintenance_logs_updated_at
|
||||
BEFORE UPDATE ON maintenance_logs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_maintenance_schedules_updated_at ON maintenance_schedules;
|
||||
CREATE TRIGGER update_maintenance_schedules_updated_at
|
||||
BEFORE UPDATE ON maintenance_schedules
|
||||
FOR EACH ROW
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# Ustations Feature Capsule
|
||||
# Stations Feature Capsule
|
||||
|
||||
## Quick Summary (50 tokens)
|
||||
[AI: Complete feature description, main operations, dependencies, caching strategy]
|
||||
## Summary
|
||||
Search nearby gas stations via Google Maps and manage users' saved stations.
|
||||
|
||||
## API Endpoints
|
||||
- GET /api/stations - List all stations
|
||||
- GET /api/stations/:id - Get specific lUstations
|
||||
- POST /api/stations - Create new lUstations
|
||||
- PUT /api/stations/:id - Update lUstations
|
||||
- DELETE /api/stations/:id - Delete lUstations
|
||||
## API Endpoints (JWT required)
|
||||
- `POST /api/stations/search` — Search nearby stations
|
||||
- `POST /api/stations/save` — Save a station to user's favorites
|
||||
- `GET /api/stations/saved` — List saved stations for the user
|
||||
- `DELETE /api/stations/saved/:placeId` — Remove a saved station
|
||||
|
||||
## Structure
|
||||
- **api/** - HTTP endpoints, routes, validators
|
||||
@@ -22,7 +21,7 @@
|
||||
|
||||
## Dependencies
|
||||
- Internal: core/auth, core/cache
|
||||
- External: [List any external APIs]
|
||||
- External: Google Maps API (Places)
|
||||
- Database: stations table
|
||||
|
||||
## Quick Commands
|
||||
@@ -33,3 +32,9 @@ npm test -- features/stations
|
||||
# Run feature migrations
|
||||
npm run migrate:feature stations
|
||||
```
|
||||
|
||||
## Clarifications Needed
|
||||
- Search payload structure (required fields, radius/filters)?
|
||||
- Saved station schema and required fields?
|
||||
- Caching policy for searches (TTL, cache keys)?
|
||||
- Rate limits or quotas for Google Maps calls?
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
StationParams
|
||||
} from '../domain/stations.types';
|
||||
import { StationsController } from './stations.controller';
|
||||
import { tenantMiddleware } from '../../../core/middleware/tenant';
|
||||
|
||||
export const stationsRoutes: FastifyPluginAsync = async (
|
||||
fastify: FastifyInstance,
|
||||
@@ -20,25 +21,25 @@ export const stationsRoutes: FastifyPluginAsync = async (
|
||||
|
||||
// POST /api/stations/search - Search nearby stations
|
||||
fastify.post<{ Body: StationSearchBody }>('/stations/search', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: stationsController.searchStations.bind(stationsController)
|
||||
});
|
||||
|
||||
// POST /api/stations/save - Save a station to user's favorites
|
||||
fastify.post<{ Body: SaveStationBody }>('/stations/save', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: stationsController.saveStation.bind(stationsController)
|
||||
});
|
||||
|
||||
// GET /api/stations/saved - Get user's saved stations
|
||||
fastify.get('/stations/saved', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: stationsController.getSavedStations.bind(stationsController)
|
||||
});
|
||||
|
||||
// DELETE /api/stations/saved/:placeId - Remove saved station
|
||||
fastify.delete<{ Params: StationParams }>('/stations/saved/:placeId', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: stationsController.removeSavedStation.bind(stationsController)
|
||||
});
|
||||
};
|
||||
@@ -46,4 +47,4 @@ export const stationsRoutes: FastifyPluginAsync = async (
|
||||
// For backward compatibility during migration
|
||||
export function registerStationsRoutes() {
|
||||
throw new Error('registerStationsRoutes is deprecated - use stationsRoutes Fastify plugin instead');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,14 +30,15 @@ CREATE TABLE IF NOT EXISTS saved_stations (
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_station_cache_place_id ON station_cache(place_id);
|
||||
CREATE INDEX idx_station_cache_location ON station_cache(latitude, longitude);
|
||||
CREATE INDEX idx_station_cache_cached_at ON station_cache(cached_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_station_cache_place_id ON station_cache(place_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_station_cache_location ON station_cache(latitude, longitude);
|
||||
CREATE INDEX IF NOT EXISTS idx_station_cache_cached_at ON station_cache(cached_at);
|
||||
|
||||
CREATE INDEX idx_saved_stations_user_id ON saved_stations(user_id);
|
||||
CREATE INDEX idx_saved_stations_is_favorite ON saved_stations(is_favorite);
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_stations_user_id ON saved_stations(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_stations_is_favorite ON saved_stations(is_favorite);
|
||||
|
||||
-- Add trigger for updated_at
|
||||
DROP TRIGGER IF EXISTS update_saved_stations_updated_at ON saved_stations;
|
||||
CREATE TRIGGER update_saved_stations_updated_at
|
||||
BEFORE UPDATE ON saved_stations
|
||||
FOR EACH ROW
|
||||
|
||||
95
backend/src/features/tenant-management/index.ts
Normal file
95
backend/src/features/tenant-management/index.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { FastifyInstance, FastifyPluginAsync } from 'fastify';
|
||||
import axios from 'axios';
|
||||
import { tenantMiddleware } from '../../core/middleware/tenant';
|
||||
import { getTenantConfig } from '../../core/config/tenant';
|
||||
import { logger } from '../../core/logging/logger';
|
||||
|
||||
export const tenantManagementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||
const baseUrl = getTenantConfig().platformServicesUrl;
|
||||
|
||||
// Require JWT on all routes
|
||||
const requireAuth = fastify.authenticate.bind(fastify);
|
||||
|
||||
// Admin-only guard using tenant context from middleware
|
||||
const requireAdmin = async (request: any, reply: any) => {
|
||||
if (request.tenantId !== 'admin') {
|
||||
reply.code(403).send({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const forwardAuthHeader = (request: any) => {
|
||||
const auth = request.headers['authorization'];
|
||||
return auth ? { Authorization: auth as string } : {};
|
||||
};
|
||||
|
||||
// List all tenants
|
||||
fastify.get('/api/admin/tenants', { preHandler: [requireAuth, tenantMiddleware as any, requireAdmin] }, async (request, reply) => {
|
||||
try {
|
||||
const resp = await axios.get(`${baseUrl}/api/v1/tenants`, {
|
||||
headers: forwardAuthHeader(request),
|
||||
});
|
||||
return reply.code(200).send(resp.data);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to list tenants', { error: error?.message });
|
||||
return reply.code(error?.response?.status || 500).send(error?.response?.data || { error: 'Failed to list tenants' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create new tenant
|
||||
fastify.post('/api/admin/tenants', { preHandler: [requireAuth, tenantMiddleware as any, requireAdmin] }, async (request: any, reply) => {
|
||||
try {
|
||||
const resp = await axios.post(`${baseUrl}/api/v1/tenants`, request.body, {
|
||||
headers: { ...forwardAuthHeader(request), 'Content-Type': 'application/json' },
|
||||
});
|
||||
return reply.code(201).send(resp.data);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to create tenant', { error: error?.message });
|
||||
return reply.code(error?.response?.status || 500).send(error?.response?.data || { error: 'Failed to create tenant' });
|
||||
}
|
||||
});
|
||||
|
||||
// List pending signups for a tenant
|
||||
fastify.get('/api/admin/tenants/:tenantId/signups', { preHandler: [requireAuth, tenantMiddleware as any, requireAdmin] }, async (request: any, reply) => {
|
||||
try {
|
||||
const { tenantId } = request.params;
|
||||
const resp = await axios.get(`${baseUrl}/api/v1/tenants/${encodeURIComponent(tenantId)}/signups`, {
|
||||
headers: forwardAuthHeader(request),
|
||||
});
|
||||
return reply.code(200).send(resp.data);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to list signups', { error: error?.message });
|
||||
return reply.code(error?.response?.status || 500).send(error?.response?.data || { error: 'Failed to list signups' });
|
||||
}
|
||||
});
|
||||
|
||||
// Approve signup
|
||||
fastify.put('/api/admin/signups/:signupId/approve', { preHandler: [requireAuth, tenantMiddleware as any, requireAdmin] }, async (request: any, reply) => {
|
||||
try {
|
||||
const { signupId } = request.params;
|
||||
const resp = await axios.put(`${baseUrl}/api/v1/signups/${encodeURIComponent(signupId)}/approve`, {}, {
|
||||
headers: forwardAuthHeader(request),
|
||||
});
|
||||
return reply.code(200).send(resp.data);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to approve signup', { error: error?.message });
|
||||
return reply.code(error?.response?.status || 500).send(error?.response?.data || { error: 'Failed to approve signup' });
|
||||
}
|
||||
});
|
||||
|
||||
// Reject signup
|
||||
fastify.put('/api/admin/signups/:signupId/reject', { preHandler: [requireAuth, tenantMiddleware as any, requireAdmin] }, async (request: any, reply) => {
|
||||
try {
|
||||
const { signupId } = request.params;
|
||||
const resp = await axios.put(`${baseUrl}/api/v1/signups/${encodeURIComponent(signupId)}/reject`, {}, {
|
||||
headers: forwardAuthHeader(request),
|
||||
});
|
||||
return reply.code(200).send(resp.data);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to reject signup', { error: error?.message });
|
||||
return reply.code(error?.response?.status || 500).send(error?.response?.data || { error: 'Failed to reject signup' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default tenantManagementRoutes;
|
||||
@@ -1,17 +1,26 @@
|
||||
# Vehicles Feature Capsule
|
||||
|
||||
## Quick Summary (50 tokens)
|
||||
Primary entity for vehicle management with VIN decoding via NHTSA vPIC API. Handles CRUD operations, automatic vehicle data population, user ownership validation, caching strategy (VIN lookups: 30 days, user lists: 5 minutes). Foundation for fuel-logs and maintenance features.
|
||||
Primary entity for vehicle management consuming MVP Platform Vehicles Service. Handles CRUD operations, hierarchical vehicle dropdowns, VIN decoding via platform service, user ownership validation, caching strategy (user lists: 5 minutes). Foundation for fuel-logs and maintenance features.
|
||||
|
||||
## API Endpoints
|
||||
- `POST /api/vehicles` - Create new vehicle with VIN decoding
|
||||
|
||||
### Vehicle Management
|
||||
- `POST /api/vehicles` - Create new vehicle with platform VIN decoding
|
||||
- `GET /api/vehicles` - List all user's vehicles (cached 5 min)
|
||||
- `GET /api/vehicles/:id` - Get specific vehicle
|
||||
- `PUT /api/vehicles/:id` - Update vehicle details
|
||||
- `DELETE /api/vehicles/:id` - Soft delete vehicle
|
||||
|
||||
## Authentication Required
|
||||
All endpoints require valid JWT token with user context.
|
||||
### Hierarchical Vehicle Dropdowns (Platform Service Proxy)
|
||||
- `GET /api/vehicles/dropdown/makes?year={year}` - Get makes for year
|
||||
- `GET /api/vehicles/dropdown/models?year={year}&make_id={make_id}` - Get models for make/year
|
||||
- `GET /api/vehicles/dropdown/trims?year={year}&make_id={make_id}&model_id={model_id}` - Get trims
|
||||
- `GET /api/vehicles/dropdown/engines?year={year}&make_id={make_id}&model_id={model_id}` - Get engines
|
||||
- `GET /api/vehicles/dropdown/transmissions?year={year}&make_id={make_id}&model_id={model_id}` - Get transmissions
|
||||
|
||||
## Authentication
|
||||
- All vehicles endpoints (including dropdowns) require a valid JWT (Auth0).
|
||||
|
||||
## Request/Response Examples
|
||||
|
||||
@@ -31,9 +40,9 @@ Response (201):
|
||||
"id": "uuid-here",
|
||||
"userId": "user-id",
|
||||
"vin": "1HGBH41JXMN109186",
|
||||
"make": "Honda", // Auto-decoded
|
||||
"model": "Civic", // Auto-decoded
|
||||
"year": 2021, // Auto-decoded
|
||||
"make": "Honda", // Auto-decoded via platform service
|
||||
"model": "Civic", // Auto-decoded via platform service
|
||||
"year": 2021, // Auto-decoded via platform service
|
||||
"nickname": "My Honda",
|
||||
"color": "Blue",
|
||||
"licensePlate": "ABC123",
|
||||
@@ -44,6 +53,30 @@ Response (201):
|
||||
}
|
||||
```
|
||||
|
||||
### Get Makes for Year
|
||||
```json
|
||||
GET /api/vehicles/dropdown/makes?year=2024
|
||||
|
||||
Response (200):
|
||||
[
|
||||
{"id": 1, "name": "Honda"},
|
||||
{"id": 2, "name": "Toyota"},
|
||||
{"id": 3, "name": "Ford"}
|
||||
]
|
||||
```
|
||||
|
||||
### Get Models for Make/Year
|
||||
```json
|
||||
GET /api/vehicles/dropdown/models?year=2024&make_id=1
|
||||
|
||||
Response (200):
|
||||
[
|
||||
{"id": 101, "name": "Civic"},
|
||||
{"id": 102, "name": "Accord"},
|
||||
{"id": 103, "name": "CR-V"}
|
||||
]
|
||||
```
|
||||
|
||||
## Feature Architecture
|
||||
|
||||
### Complete Self-Contained Structure
|
||||
@@ -62,14 +95,14 @@ vehicles/
|
||||
│ └── vehicles.repository.ts
|
||||
├── migrations/ # Feature schema
|
||||
│ └── 001_create_vehicles_tables.sql
|
||||
├── external/ # External APIs
|
||||
│ └── vpic/
|
||||
│ ├── vpic.client.ts
|
||||
│ └── vpic.types.ts
|
||||
├── external/ # Platform Service Integration
|
||||
│ └── platform-vehicles/
|
||||
│ ├── platform-vehicles.client.ts
|
||||
│ └── platform-vehicles.types.ts
|
||||
├── tests/ # All tests
|
||||
│ ├── unit/
|
||||
│ │ ├── vehicles.service.test.ts
|
||||
│ │ └── vpic.client.test.ts
|
||||
│ │ └── platform-vehicles.client.test.ts
|
||||
│ └── integration/
|
||||
│ └── vehicles.integration.test.ts
|
||||
└── docs/ # Additional docs
|
||||
@@ -78,21 +111,28 @@ vehicles/
|
||||
## Key Features
|
||||
|
||||
### 🔍 Automatic VIN Decoding
|
||||
- **External API**: NHTSA vPIC (Vehicle Product Information Catalog)
|
||||
- **Caching**: 30-day Redis cache for VIN lookups
|
||||
- **Fallback**: Graceful handling of decode failures
|
||||
- **Platform Service**: MVP Platform Vehicles Service VIN decode endpoint
|
||||
- **Caching**: Platform service handles caching strategy
|
||||
- **Fallback**: Circuit breaker pattern with graceful degradation
|
||||
- **Validation**: 17-character VIN format validation
|
||||
|
||||
### 📋 Hierarchical Vehicle Dropdowns
|
||||
- **Platform Service**: Consumes year-based hierarchical vehicle API
|
||||
- **Performance**: < 100ms response times via platform service caching
|
||||
- **Parameters**: Hierarchical filtering (year → make → model → trims/engines/transmissions)
|
||||
- **Circuit Breaker**: Graceful degradation with cached fallbacks
|
||||
|
||||
### 🏗️ Database Schema
|
||||
- **Primary Table**: `vehicles` with soft delete
|
||||
- **Cache Table**: `vin_cache` for external API results
|
||||
- **Indexes**: Optimized for user queries and VIN lookups
|
||||
- **Constraints**: Unique VIN per user, proper foreign keys
|
||||
- **Platform Integration**: No duplicate caching - relies on platform service
|
||||
|
||||
### 🚀 Performance Optimizations
|
||||
- **Redis Caching**: User vehicle lists cached for 5 minutes
|
||||
- **VIN Cache**: 30-day persistent cache in PostgreSQL
|
||||
- **Indexes**: Strategic database indexes for fast queries
|
||||
- **Platform Service**: Offloads heavy VIN decoding and vehicle data caching
|
||||
- **Circuit Breaker**: Prevents cascading failures with fallback responses
|
||||
- **Indexes**: Strategic database indexes for fast user queries
|
||||
- **Soft Deletes**: Maintains referential integrity
|
||||
|
||||
## Business Rules
|
||||
@@ -101,7 +141,7 @@ vehicles/
|
||||
- Must be exactly 17 characters
|
||||
- Cannot contain letters I, O, or Q
|
||||
- Must pass basic checksum validation
|
||||
- Auto-populates make, model, year from vPIC API
|
||||
- Auto-populates make, model, year from MVP Platform Vehicles Service
|
||||
|
||||
### User Ownership
|
||||
- Each user can have multiple vehicles
|
||||
@@ -117,32 +157,36 @@ vehicles/
|
||||
- `core/logging` - Structured logging with Winston
|
||||
- `shared-minimal/utils` - Pure validation utilities
|
||||
|
||||
### External Services
|
||||
- **NHTSA vPIC API** - VIN decoding service
|
||||
### Platform Services
|
||||
- **MVP Platform Vehicles Service** - VIN decoding and hierarchical vehicle data
|
||||
- **PostgreSQL** - Primary data storage
|
||||
- **Redis** - Caching layer
|
||||
|
||||
### Database Tables
|
||||
- `vehicles` - Primary vehicle data
|
||||
- `vin_cache` - External API response cache
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
### VIN Decode Cache (30 days)
|
||||
- **Key**: `vpic:vin:{vin}`
|
||||
- **TTL**: 2,592,000 seconds (30 days)
|
||||
- **Rationale**: Vehicle specifications never change
|
||||
### Platform Service Caching
|
||||
- **VIN Decoding**: Handled entirely by MVP Platform Vehicles Service
|
||||
- **Hierarchical Data**: Year-based caching strategy managed by platform service
|
||||
- **Performance**: < 100ms responses via platform service optimization
|
||||
|
||||
### User Vehicle List (5 minutes)
|
||||
- **Key**: `vehicles:user:{userId}`
|
||||
- **TTL**: 300 seconds (5 minutes)
|
||||
- **Invalidation**: On create, update, delete
|
||||
|
||||
### Platform Service Integration
|
||||
- **Circuit Breaker**: Prevent cascading failures
|
||||
- **Fallback Strategy**: Cached responses when platform service unavailable
|
||||
- **Timeout**: 3 second timeout with automatic retry
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
- `vehicles.service.test.ts` - Business logic with mocked dependencies
|
||||
- `vpic.client.test.ts` - External API client with mocked HTTP
|
||||
- `platform-vehicles.client.test.ts` - Platform service client with mocked HTTP
|
||||
|
||||
### Integration Tests
|
||||
- `vehicles.integration.test.ts` - Complete API workflow with test database
|
||||
@@ -172,8 +216,9 @@ npm test -- features/vehicles --coverage
|
||||
- `409` - Duplicate VIN for user
|
||||
|
||||
### Server Errors (5xx)
|
||||
- `500` - Database connection, VIN API failures
|
||||
- Graceful degradation when vPIC API unavailable
|
||||
- `500` - Database connection, platform service failures
|
||||
- `503` - Platform service unavailable (circuit breaker open)
|
||||
- Graceful degradation when platform service unavailable
|
||||
|
||||
## Future Considerations
|
||||
|
||||
@@ -184,9 +229,10 @@ npm test -- features/vehicles --coverage
|
||||
|
||||
### Potential Enhancements
|
||||
- Vehicle image uploads (MinIO integration)
|
||||
- VIN decode webhook for real-time updates
|
||||
- Vehicle value estimation integration
|
||||
- Enhanced platform service integration for real-time updates
|
||||
- Vehicle value estimation via additional platform services
|
||||
- Maintenance scheduling based on vehicle age/mileage
|
||||
- Advanced dropdown features (trim-specific engines/transmissions)
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -194,8 +240,8 @@ npm test -- features/vehicles --coverage
|
||||
# Run migrations
|
||||
make migrate
|
||||
|
||||
# Start development environment
|
||||
make dev
|
||||
# Start environment
|
||||
make start
|
||||
|
||||
# View feature logs
|
||||
make logs-backend | grep vehicles
|
||||
|
||||
@@ -35,6 +35,18 @@ export class VehiclesController {
|
||||
|
||||
async createVehicle(request: FastifyRequest<{ Body: CreateVehicleBody }>, reply: FastifyReply) {
|
||||
try {
|
||||
// Require either a valid 17-char VIN or a non-empty license plate
|
||||
const vin = request.body?.vin?.trim();
|
||||
const plate = request.body?.licensePlate?.trim();
|
||||
const hasValidVin = !!vin && vin.length === 17;
|
||||
const hasPlate = !!plate && plate.length > 0;
|
||||
if (!hasValidVin && !hasPlate) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Either a valid 17-character VIN or a license plate is required'
|
||||
});
|
||||
}
|
||||
|
||||
const userId = (request as any).user.sub;
|
||||
const vehicle = await this.vehiclesService.createVehicle(request.body, userId);
|
||||
|
||||
@@ -138,12 +150,20 @@ export class VehiclesController {
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownMakes(_request: FastifyRequest, reply: FastifyReply) {
|
||||
async getDropdownMakes(request: FastifyRequest<{ Querystring: { year: number } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const makes = await this.vehiclesService.getDropdownMakes();
|
||||
const { year } = request.query;
|
||||
if (!year || year < 1980 || year > new Date().getFullYear() + 1) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year parameter is required (1980-' + (new Date().getFullYear() + 1) + ')'
|
||||
});
|
||||
}
|
||||
|
||||
const makes = await this.vehiclesService.getDropdownMakes(year);
|
||||
return reply.code(200).send(makes);
|
||||
} catch (error) {
|
||||
logger.error('Error getting dropdown makes', { error });
|
||||
logger.error('Error getting dropdown makes', { error, year: request.query?.year });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get makes'
|
||||
@@ -151,13 +171,20 @@ export class VehiclesController {
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownModels(request: FastifyRequest<{ Params: { make: string } }>, reply: FastifyReply) {
|
||||
async getDropdownModels(request: FastifyRequest<{ Querystring: { year: number; make_id: number } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { make } = request.params;
|
||||
const models = await this.vehiclesService.getDropdownModels(make);
|
||||
const { year, make_id } = request.query;
|
||||
if (!year || !make_id || year < 1980 || year > new Date().getFullYear() + 1 || make_id < 1) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year and make_id parameters are required'
|
||||
});
|
||||
}
|
||||
|
||||
const models = await this.vehiclesService.getDropdownModels(year, make_id);
|
||||
return reply.code(200).send(models);
|
||||
} catch (error) {
|
||||
logger.error('Error getting dropdown models', { error, make: request.params.make });
|
||||
logger.error('Error getting dropdown models', { error, year: request.query?.year, make_id: request.query?.make_id });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get models'
|
||||
@@ -165,12 +192,20 @@ export class VehiclesController {
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownTransmissions(_request: FastifyRequest, reply: FastifyReply) {
|
||||
async getDropdownTransmissions(request: FastifyRequest<{ Querystring: { year: number; make_id: number; model_id: number } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const transmissions = await this.vehiclesService.getDropdownTransmissions();
|
||||
const { year, make_id, model_id } = request.query;
|
||||
if (!year || !make_id || !model_id || year < 1980 || year > new Date().getFullYear() + 1 || make_id < 1 || model_id < 1) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year, make_id, and model_id parameters are required'
|
||||
});
|
||||
}
|
||||
|
||||
const transmissions = await this.vehiclesService.getDropdownTransmissions(year, make_id, model_id);
|
||||
return reply.code(200).send(transmissions);
|
||||
} catch (error) {
|
||||
logger.error('Error getting dropdown transmissions', { error });
|
||||
logger.error('Error getting dropdown transmissions', { error, year: request.query?.year, make_id: request.query?.make_id, model_id: request.query?.model_id });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get transmissions'
|
||||
@@ -178,12 +213,20 @@ export class VehiclesController {
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownEngines(_request: FastifyRequest, reply: FastifyReply) {
|
||||
async getDropdownEngines(request: FastifyRequest<{ Querystring: { year: number; make_id: number; model_id: number; trim_id: number } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const engines = await this.vehiclesService.getDropdownEngines();
|
||||
const { year, make_id, model_id, trim_id } = request.query;
|
||||
if (!year || !make_id || !model_id || !trim_id || year < 1980 || year > new Date().getFullYear() + 1 || make_id < 1 || model_id < 1 || trim_id < 1) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year, make_id, model_id, and trim_id parameters are required'
|
||||
});
|
||||
}
|
||||
|
||||
const engines = await this.vehiclesService.getDropdownEngines(year, make_id, model_id, trim_id);
|
||||
return reply.code(200).send(engines);
|
||||
} catch (error) {
|
||||
logger.error('Error getting dropdown engines', { error });
|
||||
logger.error('Error getting dropdown engines', { error, year: request.query?.year, make_id: request.query?.make_id, model_id: request.query?.model_id, trim_id: request.query?.trim_id });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get engines'
|
||||
@@ -191,16 +234,62 @@ export class VehiclesController {
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownTrims(_request: FastifyRequest, reply: FastifyReply) {
|
||||
async getDropdownTrims(request: FastifyRequest<{ Querystring: { year: number; make_id: number; model_id: number } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const trims = await this.vehiclesService.getDropdownTrims();
|
||||
const { year, make_id, model_id } = request.query;
|
||||
if (!year || !make_id || !model_id || year < 1980 || year > new Date().getFullYear() + 1 || make_id < 1 || model_id < 1) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Valid year, make_id, and model_id parameters are required'
|
||||
});
|
||||
}
|
||||
|
||||
const trims = await this.vehiclesService.getDropdownTrims(year, make_id, model_id);
|
||||
return reply.code(200).send(trims);
|
||||
} catch (error) {
|
||||
logger.error('Error getting dropdown trims', { error });
|
||||
logger.error('Error getting dropdown trims', { error, year: request.query?.year, make_id: request.query?.make_id, model_id: request.query?.model_id });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get trims'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownYears(_request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
// Use platform client through VehiclesService's integration
|
||||
const years = await this.vehiclesService.getDropdownYears();
|
||||
return reply.code(200).send(years);
|
||||
} catch (error) {
|
||||
logger.error('Error getting dropdown years', { error });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get years'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async decodeVIN(request: FastifyRequest<{ Body: { vin: string } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { vin } = request.body;
|
||||
|
||||
if (!vin || vin.length !== 17) {
|
||||
return reply.code(400).send({
|
||||
vin: vin || '',
|
||||
success: false,
|
||||
error: 'VIN must be exactly 17 characters'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.vehiclesService.decodeVIN(vin);
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Error decoding VIN', { error, vin: request.body?.vin });
|
||||
return reply.code(500).send({
|
||||
vin: request.body?.vin || '',
|
||||
success: false,
|
||||
error: 'VIN decode failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
VehicleParams
|
||||
} from '../domain/vehicles.types';
|
||||
import { VehiclesController } from './vehicles.controller';
|
||||
import { tenantMiddleware } from '../../../core/middleware/tenant';
|
||||
|
||||
export const vehiclesRoutes: FastifyPluginAsync = async (
|
||||
fastify: FastifyInstance,
|
||||
@@ -20,61 +21,80 @@ export const vehiclesRoutes: FastifyPluginAsync = async (
|
||||
|
||||
// GET /api/vehicles - Get user's vehicles
|
||||
fastify.get('/vehicles', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getUserVehicles.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// POST /api/vehicles - Create new vehicle
|
||||
fastify.post<{ Body: CreateVehicleBody }>('/vehicles', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.createVehicle.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/:id - Get specific vehicle
|
||||
fastify.get<{ Params: VehicleParams }>('/vehicles/:id', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getVehicle.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// PUT /api/vehicles/:id - Update vehicle
|
||||
fastify.put<{ Params: VehicleParams; Body: UpdateVehicleBody }>('/vehicles/:id', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.updateVehicle.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// DELETE /api/vehicles/:id - Delete vehicle
|
||||
fastify.delete<{ Params: VehicleParams }>('/vehicles/:id', {
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.deleteVehicle.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dropdown/makes - Get vehicle makes
|
||||
fastify.get('/vehicles/dropdown/makes', {
|
||||
// Hierarchical Vehicle API - mirrors MVP Platform Vehicles Service structure
|
||||
|
||||
// GET /api/vehicles/dropdown/years - Available model years
|
||||
fastify.get('/vehicles/dropdown/years', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getDropdownYears.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dropdown/makes?year=2024 - Get makes for year (Level 1)
|
||||
fastify.get<{ Querystring: { year: number } }>('/vehicles/dropdown/makes', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getDropdownMakes.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dropdown/models/:make - Get models for make
|
||||
fastify.get<{ Params: { make: string } }>('/vehicles/dropdown/models/:make', {
|
||||
// GET /api/vehicles/dropdown/models?year=2024&make_id=1 - Get models for year/make (Level 2)
|
||||
fastify.get<{ Querystring: { year: number; make_id: number } }>('/vehicles/dropdown/models', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getDropdownModels.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dropdown/transmissions - Get transmission types
|
||||
fastify.get('/vehicles/dropdown/transmissions', {
|
||||
handler: vehiclesController.getDropdownTransmissions.bind(vehiclesController)
|
||||
// GET /api/vehicles/dropdown/trims?year=2024&make_id=1&model_id=1 - Get trims (Level 3)
|
||||
fastify.get<{ Querystring: { year: number; make_id: number; model_id: number } }>('/vehicles/dropdown/trims', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getDropdownTrims.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dropdown/engines - Get engine configurations
|
||||
fastify.get('/vehicles/dropdown/engines', {
|
||||
// GET /api/vehicles/dropdown/engines?year=2024&make_id=1&model_id=1&trim_id=1 - Get engines (Level 4)
|
||||
fastify.get<{ Querystring: { year: number; make_id: number; model_id: number; trim_id: number } }>('/vehicles/dropdown/engines', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getDropdownEngines.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dropdown/trims - Get trim levels
|
||||
fastify.get('/vehicles/dropdown/trims', {
|
||||
handler: vehiclesController.getDropdownTrims.bind(vehiclesController)
|
||||
// GET /api/vehicles/dropdown/transmissions?year=2024&make_id=1&model_id=1 - Get transmissions (Level 3)
|
||||
fastify.get<{ Querystring: { year: number; make_id: number; model_id: number } }>('/vehicles/dropdown/transmissions', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.getDropdownTransmissions.bind(vehiclesController)
|
||||
});
|
||||
|
||||
// POST /api/vehicles/decode-vin - Decode VIN and return vehicle information
|
||||
fastify.post<{ Body: { vin: string } }>('/vehicles/decode-vin', {
|
||||
preHandler: [fastify.authenticate, tenantMiddleware],
|
||||
handler: vehiclesController.decodeVIN.bind(vehiclesController)
|
||||
});
|
||||
};
|
||||
|
||||
// For backward compatibility during migration
|
||||
export function registerVehiclesRoutes() {
|
||||
throw new Error('registerVehiclesRoutes is deprecated - use vehiclesRoutes Fastify plugin instead');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,18 +13,24 @@ export class VehiclesRepository {
|
||||
const query = `
|
||||
INSERT INTO vehicles (
|
||||
user_id, vin, make, model, year,
|
||||
engine, transmission, trim_level, drive_type, fuel_type,
|
||||
nickname, color, license_plate, odometer_reading
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const values = [
|
||||
data.userId,
|
||||
data.vin,
|
||||
(data.vin && data.vin.trim().length > 0) ? data.vin.trim() : null,
|
||||
data.make,
|
||||
data.model,
|
||||
data.year,
|
||||
data.engine,
|
||||
data.transmission,
|
||||
data.trimLevel,
|
||||
data.driveType,
|
||||
data.fuelType,
|
||||
data.nickname,
|
||||
data.color,
|
||||
data.licensePlate,
|
||||
@@ -74,6 +80,38 @@ export class VehiclesRepository {
|
||||
let paramCount = 1;
|
||||
|
||||
// Build dynamic update query
|
||||
if (data.make !== undefined) {
|
||||
fields.push(`make = $${paramCount++}`);
|
||||
values.push(data.make);
|
||||
}
|
||||
if (data.model !== undefined) {
|
||||
fields.push(`model = $${paramCount++}`);
|
||||
values.push(data.model);
|
||||
}
|
||||
if (data.year !== undefined) {
|
||||
fields.push(`year = $${paramCount++}`);
|
||||
values.push(data.year);
|
||||
}
|
||||
if (data.engine !== undefined) {
|
||||
fields.push(`engine = $${paramCount++}`);
|
||||
values.push(data.engine);
|
||||
}
|
||||
if (data.transmission !== undefined) {
|
||||
fields.push(`transmission = $${paramCount++}`);
|
||||
values.push(data.transmission);
|
||||
}
|
||||
if (data.trimLevel !== undefined) {
|
||||
fields.push(`trim_level = $${paramCount++}`);
|
||||
values.push(data.trimLevel);
|
||||
}
|
||||
if (data.driveType !== undefined) {
|
||||
fields.push(`drive_type = $${paramCount++}`);
|
||||
values.push(data.driveType);
|
||||
}
|
||||
if (data.fuelType !== undefined) {
|
||||
fields.push(`fuel_type = $${paramCount++}`);
|
||||
values.push(data.fuelType);
|
||||
}
|
||||
if (data.nickname !== undefined) {
|
||||
fields.push(`nickname = $${paramCount++}`);
|
||||
values.push(data.nickname);
|
||||
@@ -164,6 +202,11 @@ export class VehiclesRepository {
|
||||
make: row.make,
|
||||
model: row.model,
|
||||
year: row.year,
|
||||
engine: row.engine,
|
||||
transmission: row.transmission,
|
||||
trimLevel: row.trim_level,
|
||||
driveType: row.drive_type,
|
||||
fuelType: row.fuel_type,
|
||||
nickname: row.nickname,
|
||||
color: row.color,
|
||||
licensePlate: row.license_plate,
|
||||
@@ -174,4 +217,4 @@ export class VehiclesRepository {
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
backend/src/features/vehicles/domain/name-normalizer.ts
Normal file
52
backend/src/features/vehicles/domain/name-normalizer.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Normalizes vehicle make and model names for human-friendly display.
|
||||
* - Replaces underscores with spaces
|
||||
* - Collapses whitespace
|
||||
* - Title-cases standard words
|
||||
* - Uppercases common acronyms (e.g., HD, GT, Z06)
|
||||
*/
|
||||
|
||||
const MODEL_ACRONYMS = new Set([
|
||||
'HD','GT','GL','SE','LE','XLE','RS','SVT','XR','ST','FX4','TRD','ZR1','Z06','GTI','GLI','SI','SS','LT','LTZ','RT','SRT','SR','SR5','XSE','SEL'
|
||||
]);
|
||||
|
||||
export function normalizeModelName(input?: string | null): string | undefined {
|
||||
if (input == null) return input ?? undefined;
|
||||
let s = String(input).replace(/_/g, ' ');
|
||||
s = s.replace(/\s+/g, ' ').trim();
|
||||
if (s.length === 0) return s;
|
||||
|
||||
const tokens = s.split(' ');
|
||||
const normalized = tokens.map(t => {
|
||||
const raw = t;
|
||||
const upper = raw.toUpperCase();
|
||||
const lower = raw.toLowerCase();
|
||||
// Uppercase known acronyms (match case-insensitively)
|
||||
if (MODEL_ACRONYMS.has(upper)) return upper;
|
||||
// Tokens with letters+digits (e.g., Z06) – prefer uppercase
|
||||
if (/^[a-z0-9]+$/i.test(raw) && /[a-z]/i.test(raw) && /\d/.test(raw) && raw.length <= 4) {
|
||||
return upper;
|
||||
}
|
||||
// Pure letters: title case
|
||||
if (/^[a-z]+$/i.test(raw)) {
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
}
|
||||
// Numbers or mixed/punctuated tokens: keep as-is except collapse case
|
||||
return raw;
|
||||
});
|
||||
return normalized.join(' ');
|
||||
}
|
||||
|
||||
export function normalizeMakeName(input?: string | null): string | undefined {
|
||||
if (input == null) return input ?? undefined;
|
||||
let s = String(input).replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (s.length === 0) return s;
|
||||
const title = s.toLowerCase().split(' ').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
// Special cases
|
||||
if (/^bmw$/i.test(s)) return 'BMW';
|
||||
if (/^gmc$/i.test(s)) return 'GMC';
|
||||
if (/^mini$/i.test(s)) return 'MINI';
|
||||
if (/^mclaren$/i.test(s)) return 'McLaren';
|
||||
return title;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Logger } from 'winston';
|
||||
import { PlatformVehiclesClient } from '../external/platform-vehicles/platform-vehicles.client';
|
||||
import { VPICClient } from '../external/vpic/vpic.client';
|
||||
import { env } from '../../../core/config/environment';
|
||||
|
||||
|
||||
/**
|
||||
* Integration service that manages switching between external vPIC API
|
||||
* and MVP Platform Vehicles Service with feature flags and fallbacks
|
||||
*/
|
||||
export class PlatformIntegrationService {
|
||||
private readonly platformClient: PlatformVehiclesClient;
|
||||
private readonly vpicClient: VPICClient;
|
||||
private readonly usePlatformService: boolean;
|
||||
|
||||
constructor(
|
||||
platformClient: PlatformVehiclesClient,
|
||||
vpicClient: VPICClient,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.platformClient = platformClient;
|
||||
this.vpicClient = vpicClient;
|
||||
|
||||
// Feature flag - can be environment variable or runtime config
|
||||
this.usePlatformService = env.NODE_ENV !== 'test'; // Use platform service except in tests
|
||||
|
||||
this.logger.info(`Vehicle service integration initialized: usePlatformService=${this.usePlatformService}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get makes with platform service or fallback to vPIC
|
||||
*/
|
||||
async getMakes(year: number): Promise<Array<{ id: number; name: string }>> {
|
||||
if (this.usePlatformService) {
|
||||
try {
|
||||
const makes = await this.platformClient.getMakes(year);
|
||||
this.logger.debug(`Platform service returned ${makes.length} makes for year ${year}`);
|
||||
return makes;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Platform service failed for makes, falling back to vPIC: ${error}`);
|
||||
return this.getFallbackMakes(year);
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFallbackMakes(year);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models with platform service or fallback to vPIC
|
||||
*/
|
||||
async getModels(year: number, makeId: number): Promise<Array<{ id: number; name: string }>> {
|
||||
if (this.usePlatformService) {
|
||||
try {
|
||||
const models = await this.platformClient.getModels(year, makeId);
|
||||
this.logger.debug(`Platform service returned ${models.length} models for year ${year}, make ${makeId}`);
|
||||
return models;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Platform service failed for models, falling back to vPIC: ${error}`);
|
||||
return this.getFallbackModels(year, makeId);
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFallbackModels(year, makeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trims - platform service only (not available in external vPIC)
|
||||
*/
|
||||
async getTrims(year: number, makeId: number, modelId: number): Promise<Array<{ name: string }>> {
|
||||
if (this.usePlatformService) {
|
||||
try {
|
||||
const trims = await this.platformClient.getTrims(year, makeId, modelId);
|
||||
this.logger.debug(`Platform service returned ${trims.length} trims`);
|
||||
return trims;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Platform service failed for trims: ${error}`);
|
||||
return []; // No fallback available for trims
|
||||
}
|
||||
}
|
||||
|
||||
return []; // Trims not available without platform service
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engines - platform service only (not available in external vPIC)
|
||||
*/
|
||||
async getEngines(year: number, makeId: number, modelId: number, trimId: number): Promise<Array<{ name: string }>> {
|
||||
if (this.usePlatformService) {
|
||||
try {
|
||||
const engines = await this.platformClient.getEngines(year, makeId, modelId, trimId);
|
||||
this.logger.debug(`Platform service returned ${engines.length} engines for trim ${trimId}`);
|
||||
return engines;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Platform service failed for engines: ${error}`);
|
||||
return []; // No fallback available for engines
|
||||
}
|
||||
}
|
||||
|
||||
return []; // Engines not available without platform service
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transmissions - platform service only (not available in external vPIC)
|
||||
*/
|
||||
async getTransmissions(year: number, makeId: number, modelId: number): Promise<Array<{ name: string }>> {
|
||||
if (this.usePlatformService) {
|
||||
try {
|
||||
const transmissions = await this.platformClient.getTransmissions(year, makeId, modelId);
|
||||
this.logger.debug(`Platform service returned ${transmissions.length} transmissions`);
|
||||
return transmissions;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Platform service failed for transmissions: ${error}`);
|
||||
return []; // No fallback available for transmissions
|
||||
}
|
||||
}
|
||||
|
||||
return []; // Transmissions not available without platform service
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available years from platform service
|
||||
*/
|
||||
async getYears(): Promise<number[]> {
|
||||
try {
|
||||
return await this.platformClient.getYears();
|
||||
} catch (error) {
|
||||
this.logger.warn(`Platform service failed for years: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode VIN with platform service or fallback to external vPIC
|
||||
*/
|
||||
async decodeVIN(vin: string): Promise<{
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
trim?: string;
|
||||
engine?: string;
|
||||
transmission?: string;
|
||||
success: boolean;
|
||||
}> {
|
||||
if (this.usePlatformService) {
|
||||
try {
|
||||
const response = await this.platformClient.decodeVIN(vin);
|
||||
if (response.success && response.result) {
|
||||
this.logger.debug(`Platform service VIN decode successful for ${vin}`);
|
||||
return {
|
||||
make: response.result.make,
|
||||
model: response.result.model,
|
||||
year: response.result.year,
|
||||
trim: response.result.trim_name,
|
||||
engine: response.result.engine_description,
|
||||
transmission: response.result.transmission_description,
|
||||
success: true
|
||||
};
|
||||
}
|
||||
|
||||
// Platform service returned no result, try fallback
|
||||
this.logger.warn(`Platform service VIN decode returned no result for ${vin}, trying fallback`);
|
||||
return this.getFallbackVinDecode(vin);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Platform service VIN decode failed for ${vin}, falling back to vPIC: ${error}`);
|
||||
return this.getFallbackVinDecode(vin);
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFallbackVinDecode(vin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for both services
|
||||
*/
|
||||
async healthCheck(): Promise<{
|
||||
platformService: boolean;
|
||||
externalVpic: boolean;
|
||||
overall: boolean;
|
||||
}> {
|
||||
const [platformHealthy, vpicHealthy] = await Promise.allSettled([
|
||||
this.platformClient.healthCheck(),
|
||||
this.checkVpicHealth()
|
||||
]);
|
||||
|
||||
const platformService = platformHealthy.status === 'fulfilled' && platformHealthy.value;
|
||||
const externalVpic = vpicHealthy.status === 'fulfilled' && vpicHealthy.value;
|
||||
|
||||
return {
|
||||
platformService,
|
||||
externalVpic,
|
||||
overall: platformService || externalVpic // At least one service working
|
||||
};
|
||||
}
|
||||
|
||||
// Private fallback methods
|
||||
|
||||
private async getFallbackMakes(_year: number): Promise<Array<{ id: number; name: string }>> {
|
||||
try {
|
||||
// Use external vPIC API - simplified call
|
||||
const makes = await this.vpicClient.getAllMakes();
|
||||
return makes.map((make: any) => ({ id: make.MakeId, name: make.MakeName }));
|
||||
} catch (error) {
|
||||
this.logger.error(`Fallback vPIC makes failed: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getFallbackModels(_year: number, makeId: number): Promise<Array<{ id: number; name: string }>> {
|
||||
try {
|
||||
// Use external vPIC API
|
||||
const models = await this.vpicClient.getModelsForMake(makeId.toString());
|
||||
return models.map((model: any) => ({ id: model.ModelId, name: model.ModelName }));
|
||||
} catch (error) {
|
||||
this.logger.error(`Fallback vPIC models failed: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getFallbackVinDecode(vin: string): Promise<{
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
success: boolean;
|
||||
}> {
|
||||
try {
|
||||
const result = await this.vpicClient.decodeVIN(vin);
|
||||
return {
|
||||
make: result?.make,
|
||||
model: result?.model,
|
||||
year: result?.year,
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Fallback vPIC VIN decode failed: ${error}`);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
private async checkVpicHealth(): Promise<boolean> {
|
||||
try {
|
||||
// Simple health check - try to get makes
|
||||
await this.vpicClient.getAllMakes();
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { VehiclesRepository } from '../data/vehicles.repository';
|
||||
import { vpicClient } from '../external/vpic/vpic.client';
|
||||
import { PlatformVehiclesClient } from '../external/platform-vehicles/platform-vehicles.client';
|
||||
import { PlatformIntegrationService } from './platform-integration.service';
|
||||
import {
|
||||
Vehicle,
|
||||
CreateVehicleRequest,
|
||||
@@ -14,44 +16,76 @@ import {
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { cacheService } from '../../../core/config/redis';
|
||||
import { isValidVIN } from '../../../shared-minimal/utils/validators';
|
||||
import { env } from '../../../core/config/environment';
|
||||
import { normalizeMakeName, normalizeModelName } from './name-normalizer';
|
||||
|
||||
export class VehiclesService {
|
||||
private readonly cachePrefix = 'vehicles';
|
||||
private readonly listCacheTTL = 300; // 5 minutes
|
||||
private readonly platformIntegration: PlatformIntegrationService;
|
||||
|
||||
constructor(private repository: VehiclesRepository) {}
|
||||
constructor(private repository: VehiclesRepository) {
|
||||
// Initialize platform vehicles client
|
||||
const platformClient = new PlatformVehiclesClient({
|
||||
baseURL: env.PLATFORM_VEHICLES_API_URL,
|
||||
apiKey: env.PLATFORM_VEHICLES_API_KEY,
|
||||
tenantId: process.env.TENANT_ID,
|
||||
timeout: 3000,
|
||||
logger
|
||||
});
|
||||
|
||||
// Initialize platform integration service with feature flag
|
||||
this.platformIntegration = new PlatformIntegrationService(
|
||||
platformClient,
|
||||
vpicClient,
|
||||
logger
|
||||
);
|
||||
}
|
||||
|
||||
async createVehicle(data: CreateVehicleRequest, userId: string): Promise<VehicleResponse> {
|
||||
logger.info('Creating vehicle', { userId, vin: data.vin });
|
||||
logger.info('Creating vehicle', { userId, vin: data.vin, licensePlate: (data as any).licensePlate });
|
||||
|
||||
// Validate VIN
|
||||
if (!isValidVIN(data.vin)) {
|
||||
throw new Error('Invalid VIN format');
|
||||
let make: string | undefined;
|
||||
let model: string | undefined;
|
||||
let year: number | undefined;
|
||||
|
||||
if (data.vin) {
|
||||
// Validate VIN if provided
|
||||
if (!isValidVIN(data.vin)) {
|
||||
throw new Error('Invalid VIN format');
|
||||
}
|
||||
// Duplicate check only when VIN is present
|
||||
const existing = await this.repository.findByUserAndVIN(userId, data.vin);
|
||||
if (existing) {
|
||||
throw new Error('Vehicle with this VIN already exists');
|
||||
}
|
||||
// Attempt VIN decode to enrich fields
|
||||
const vinDecodeResult = await this.platformIntegration.decodeVIN(data.vin);
|
||||
if (vinDecodeResult.success) {
|
||||
make = normalizeMakeName(vinDecodeResult.make);
|
||||
model = normalizeModelName(vinDecodeResult.model);
|
||||
year = vinDecodeResult.year;
|
||||
// Cache VIN decode result if successful
|
||||
await this.repository.cacheVINDecode(data.vin, {
|
||||
make: vinDecodeResult.make,
|
||||
model: vinDecodeResult.model,
|
||||
year: vinDecodeResult.year
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await this.repository.findByUserAndVIN(userId, data.vin);
|
||||
if (existing) {
|
||||
throw new Error('Vehicle with this VIN already exists');
|
||||
}
|
||||
|
||||
// Decode VIN
|
||||
const vinData = await vpicClient.decodeVIN(data.vin);
|
||||
|
||||
// Create vehicle with decoded data
|
||||
// Create vehicle (VIN optional). Client-sent make/model/year override decode if provided.
|
||||
const inputMake = (data as any).make ?? make;
|
||||
const inputModel = (data as any).model ?? model;
|
||||
|
||||
const vehicle = await this.repository.create({
|
||||
...data,
|
||||
userId,
|
||||
make: vinData?.make,
|
||||
model: vinData?.model,
|
||||
year: vinData?.year,
|
||||
make: normalizeMakeName(inputMake),
|
||||
model: normalizeModelName(inputModel),
|
||||
year: (data as any).year ?? year,
|
||||
});
|
||||
|
||||
// Cache VIN decode result
|
||||
if (vinData) {
|
||||
await this.repository.cacheVINDecode(data.vin, vinData);
|
||||
}
|
||||
|
||||
// Invalidate user's vehicle list cache
|
||||
await this.invalidateUserCache(userId);
|
||||
|
||||
@@ -106,8 +140,17 @@ export class VehiclesService {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
// Normalize any provided name fields
|
||||
const normalized: UpdateVehicleRequest = { ...data } as any;
|
||||
if (data.make !== undefined) {
|
||||
(normalized as any).make = normalizeMakeName(data.make);
|
||||
}
|
||||
if (data.model !== undefined) {
|
||||
(normalized as any).model = normalizeModelName(data.model);
|
||||
}
|
||||
|
||||
// Update vehicle
|
||||
const updated = await this.repository.update(id, data);
|
||||
const updated = await this.repository.update(id, normalized);
|
||||
if (!updated) {
|
||||
throw new Error('Update failed');
|
||||
}
|
||||
@@ -140,81 +183,117 @@ export class VehiclesService {
|
||||
await cacheService.del(cacheKey);
|
||||
}
|
||||
|
||||
async getDropdownMakes(): Promise<{ id: number; name: string }[]> {
|
||||
async getDropdownMakes(year: number): Promise<{ id: number; name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown makes');
|
||||
const makes = await vpicClient.getAllMakes();
|
||||
|
||||
return makes.map(make => ({
|
||||
id: make.Make_ID,
|
||||
name: make.Make_Name
|
||||
}));
|
||||
logger.info('Getting dropdown makes', { year });
|
||||
return await this.platformIntegration.getMakes(year);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown makes', { error });
|
||||
logger.error('Failed to get dropdown makes', { year, error });
|
||||
throw new Error('Failed to load makes');
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownModels(make: string): Promise<{ id: number; name: string }[]> {
|
||||
async getDropdownModels(year: number, makeId: number): Promise<{ id: number; name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown models', { make });
|
||||
const models = await vpicClient.getModelsForMake(make);
|
||||
|
||||
return models.map(model => ({
|
||||
id: model.Model_ID,
|
||||
name: model.Model_Name
|
||||
}));
|
||||
logger.info('Getting dropdown models', { year, makeId });
|
||||
return await this.platformIntegration.getModels(year, makeId);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown models', { make, error });
|
||||
logger.error('Failed to get dropdown models', { year, makeId, error });
|
||||
throw new Error('Failed to load models');
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownTransmissions(): Promise<{ id: number; name: string }[]> {
|
||||
async getDropdownTransmissions(year: number, makeId: number, modelId: number): Promise<{ name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown transmissions');
|
||||
const transmissions = await vpicClient.getTransmissionTypes();
|
||||
|
||||
return transmissions.map(transmission => ({
|
||||
id: transmission.Id,
|
||||
name: transmission.Name
|
||||
}));
|
||||
logger.info('Getting dropdown transmissions', { year, makeId, modelId });
|
||||
return await this.platformIntegration.getTransmissions(year, makeId, modelId);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown transmissions', { error });
|
||||
logger.error('Failed to get dropdown transmissions', { year, makeId, modelId, error });
|
||||
throw new Error('Failed to load transmissions');
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownEngines(): Promise<{ id: number; name: string }[]> {
|
||||
async getDropdownEngines(year: number, makeId: number, modelId: number, trimId: number): Promise<{ name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown engines');
|
||||
const engines = await vpicClient.getEngineConfigurations();
|
||||
|
||||
return engines.map(engine => ({
|
||||
id: engine.Id,
|
||||
name: engine.Name
|
||||
}));
|
||||
logger.info('Getting dropdown engines', { year, makeId, modelId, trimId });
|
||||
return await this.platformIntegration.getEngines(year, makeId, modelId, trimId);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown engines', { error });
|
||||
logger.error('Failed to get dropdown engines', { year, makeId, modelId, trimId, error });
|
||||
throw new Error('Failed to load engines');
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownTrims(): Promise<{ id: number; name: string }[]> {
|
||||
async getDropdownTrims(year: number, makeId: number, modelId: number): Promise<{ name: string }[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown trims');
|
||||
const trims = await vpicClient.getTrimLevels();
|
||||
|
||||
return trims.map(trim => ({
|
||||
id: trim.Id,
|
||||
name: trim.Name
|
||||
}));
|
||||
logger.info('Getting dropdown trims', { year, makeId, modelId });
|
||||
return await this.platformIntegration.getTrims(year, makeId, modelId);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown trims', { error });
|
||||
logger.error('Failed to get dropdown trims', { year, makeId, modelId, error });
|
||||
throw new Error('Failed to load trims');
|
||||
}
|
||||
}
|
||||
|
||||
async getDropdownYears(): Promise<number[]> {
|
||||
try {
|
||||
logger.info('Getting dropdown years');
|
||||
return await this.platformIntegration.getYears();
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dropdown years', { error });
|
||||
// Fallback: generate recent years if platform unavailable
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years: number[] = [];
|
||||
for (let y = currentYear + 1; y >= 1980; y--) years.push(y);
|
||||
return years;
|
||||
}
|
||||
}
|
||||
|
||||
async decodeVIN(vin: string): Promise<{
|
||||
vin: string;
|
||||
success: boolean;
|
||||
year?: number;
|
||||
make?: string;
|
||||
model?: string;
|
||||
trimLevel?: string;
|
||||
engine?: string;
|
||||
transmission?: string;
|
||||
confidence?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
logger.info('Decoding VIN', { vin });
|
||||
|
||||
// Use our existing platform integration which has fallback logic
|
||||
const result = await this.platformIntegration.decodeVIN(vin);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
vin,
|
||||
success: true,
|
||||
year: result.year,
|
||||
make: result.make,
|
||||
model: result.model,
|
||||
trimLevel: result.trim,
|
||||
engine: result.engine,
|
||||
transmission: result.transmission,
|
||||
confidence: 85 // High confidence since we have good data
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
vin,
|
||||
success: false,
|
||||
error: 'Unable to decode VIN'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to decode VIN', { vin, error });
|
||||
return {
|
||||
vin,
|
||||
success: false,
|
||||
error: 'VIN decode service unavailable'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private toResponse(vehicle: Vehicle): VehicleResponse {
|
||||
return {
|
||||
id: vehicle.id,
|
||||
@@ -237,4 +316,4 @@ export class VehiclesService {
|
||||
updatedAt: vehicle.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
export interface Vehicle {
|
||||
id: string;
|
||||
userId: string;
|
||||
vin: string;
|
||||
vin?: string;
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
@@ -26,7 +26,7 @@ export interface Vehicle {
|
||||
}
|
||||
|
||||
export interface CreateVehicleRequest {
|
||||
vin: string;
|
||||
vin?: string;
|
||||
make?: string;
|
||||
model?: string;
|
||||
engine?: string;
|
||||
@@ -57,7 +57,7 @@ export interface UpdateVehicleRequest {
|
||||
export interface VehicleResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
vin: string;
|
||||
vin?: string;
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
@@ -86,7 +86,7 @@ export interface VINDecodeResult {
|
||||
|
||||
// Fastify-specific types for HTTP handling
|
||||
export interface CreateVehicleBody {
|
||||
vin: string;
|
||||
vin?: string;
|
||||
nickname?: string;
|
||||
color?: string;
|
||||
licensePlate?: string;
|
||||
@@ -102,4 +102,4 @@ export interface UpdateVehicleBody {
|
||||
|
||||
export interface VehicleParams {
|
||||
id: string;
|
||||
}
|
||||
}
|
||||
|
||||
293
backend/src/features/vehicles/external/platform-vehicles/platform-vehicles.client.ts
vendored
Normal file
293
backend/src/features/vehicles/external/platform-vehicles/platform-vehicles.client.ts
vendored
Normal file
@@ -0,0 +1,293 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import CircuitBreaker from 'opossum';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
export interface MakeItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ModelItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TrimItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface EngineItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TransmissionItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface VINDecodeResult {
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
trim_name?: string;
|
||||
engine_description?: string;
|
||||
transmission_description?: string;
|
||||
confidence_score?: number;
|
||||
vehicle_type?: string;
|
||||
}
|
||||
|
||||
export interface VINDecodeResponse {
|
||||
vin: string;
|
||||
result?: VINDecodeResult;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PlatformVehiclesClientConfig {
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
tenantId?: string;
|
||||
timeout?: number;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for MVP Platform Vehicles Service
|
||||
* Provides hierarchical vehicle API and VIN decoding with circuit breaker pattern
|
||||
*/
|
||||
export class PlatformVehiclesClient {
|
||||
private readonly httpClient: AxiosInstance;
|
||||
private readonly logger: Logger | undefined;
|
||||
private readonly circuitBreakers: Map<string, CircuitBreaker> = new Map();
|
||||
private readonly tenantId: string | undefined;
|
||||
|
||||
constructor(config: PlatformVehiclesClientConfig) {
|
||||
this.logger = config.logger;
|
||||
this.tenantId = config.tenantId || process.env.TENANT_ID;
|
||||
|
||||
// Initialize HTTP client
|
||||
this.httpClient = axios.create({
|
||||
baseURL: config.baseURL,
|
||||
timeout: config.timeout || 3000,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Inject tenant header for all requests when available
|
||||
if (this.tenantId) {
|
||||
this.httpClient.defaults.headers.common['X-Tenant-ID'] = this.tenantId;
|
||||
}
|
||||
|
||||
// Setup response interceptors for logging
|
||||
this.httpClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const processingTime = response.headers['x-process-time'];
|
||||
if (processingTime) {
|
||||
this.logger?.debug(`Platform API response time: ${processingTime}ms`);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
this.logger?.error(`Platform API error: ${error.message}`);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Initialize circuit breakers for each endpoint
|
||||
this.initializeCircuitBreakers();
|
||||
}
|
||||
|
||||
private initializeCircuitBreakers(): void {
|
||||
const circuitBreakerOptions = {
|
||||
timeout: 3000,
|
||||
errorThresholdPercentage: 50,
|
||||
resetTimeout: 30000,
|
||||
name: 'platform-vehicles',
|
||||
};
|
||||
|
||||
// Create circuit breakers for each endpoint type
|
||||
const endpoints = ['years', 'makes', 'models', 'trims', 'engines', 'transmissions', 'vindecode'];
|
||||
|
||||
endpoints.forEach(endpoint => {
|
||||
const breaker = new CircuitBreaker(this.makeRequest.bind(this), {
|
||||
...circuitBreakerOptions,
|
||||
name: `platform-vehicles-${endpoint}`,
|
||||
});
|
||||
|
||||
// Setup fallback handlers
|
||||
breaker.fallback(() => {
|
||||
this.logger?.warn(`Circuit breaker fallback triggered for ${endpoint}`);
|
||||
return this.getFallbackResponse(endpoint);
|
||||
});
|
||||
|
||||
// Setup event handlers
|
||||
breaker.on('open', () => {
|
||||
this.logger?.error(`Circuit breaker opened for ${endpoint}`);
|
||||
});
|
||||
|
||||
breaker.on('halfOpen', () => {
|
||||
this.logger?.info(`Circuit breaker half-open for ${endpoint}`);
|
||||
});
|
||||
|
||||
breaker.on('close', () => {
|
||||
this.logger?.info(`Circuit breaker closed for ${endpoint}`);
|
||||
});
|
||||
|
||||
this.circuitBreakers.set(endpoint, breaker);
|
||||
});
|
||||
}
|
||||
|
||||
private async makeRequest(endpoint: string, params?: Record<string, any>): Promise<any> {
|
||||
const response = await this.httpClient.get(`/api/v1/vehicles/${endpoint}`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
private getFallbackResponse(endpoint: string): any {
|
||||
// Return empty arrays/objects for fallback
|
||||
switch (endpoint) {
|
||||
case 'makes':
|
||||
return { makes: [] };
|
||||
case 'models':
|
||||
return { models: [] };
|
||||
case 'trims':
|
||||
return { trims: [] };
|
||||
case 'engines':
|
||||
return { engines: [] };
|
||||
case 'transmissions':
|
||||
return { transmissions: [] };
|
||||
case 'vindecode':
|
||||
return { vin: '', result: null, success: false, error: 'Service unavailable' };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available model years
|
||||
*/
|
||||
async getYears(): Promise<number[]> {
|
||||
const breaker = this.circuitBreakers.get('years')!;
|
||||
try {
|
||||
const response: any = await breaker.fire('years');
|
||||
return Array.isArray(response) ? response : [];
|
||||
} catch (error) {
|
||||
this.logger?.error(`Failed to get years: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get makes for a specific year
|
||||
* Hierarchical API: First level - requires year only
|
||||
*/
|
||||
async getMakes(year: number): Promise<MakeItem[]> {
|
||||
const breaker = this.circuitBreakers.get('makes')!;
|
||||
|
||||
try {
|
||||
const response: any = await breaker.fire('makes', { year });
|
||||
this.logger?.debug(`Retrieved ${response.makes?.length || 0} makes for year ${year}`);
|
||||
return response.makes || [];
|
||||
} catch (error) {
|
||||
this.logger?.error(`Failed to get makes for year ${year}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models for year and make
|
||||
* Hierarchical API: Second level - requires year and make_id
|
||||
*/
|
||||
async getModels(year: number, makeId: number): Promise<ModelItem[]> {
|
||||
const breaker = this.circuitBreakers.get('models')!;
|
||||
|
||||
try {
|
||||
const response: any = await breaker.fire('models', { year, make_id: makeId });
|
||||
this.logger?.debug(`Retrieved ${response.models?.length || 0} models for year ${year}, make ${makeId}`);
|
||||
return response.models || [];
|
||||
} catch (error) {
|
||||
this.logger?.error(`Failed to get models for year ${year}, make ${makeId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trims for year, make, and model
|
||||
* Hierarchical API: Third level - requires year, make_id, and model_id
|
||||
*/
|
||||
async getTrims(year: number, makeId: number, modelId: number): Promise<TrimItem[]> {
|
||||
const breaker = this.circuitBreakers.get('trims')!;
|
||||
|
||||
try {
|
||||
const response: any = await breaker.fire('trims', { year, make_id: makeId, model_id: modelId });
|
||||
this.logger?.debug(`Retrieved ${response.trims?.length || 0} trims for year ${year}, make ${makeId}, model ${modelId}`);
|
||||
return response.trims || [];
|
||||
} catch (error) {
|
||||
this.logger?.error(`Failed to get trims for year ${year}, make ${makeId}, model ${modelId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engines for year, make, and model
|
||||
* Hierarchical API: Third level - requires year, make_id, and model_id
|
||||
*/
|
||||
async getEngines(year: number, makeId: number, modelId: number, trimId: number): Promise<EngineItem[]> {
|
||||
const breaker = this.circuitBreakers.get('engines')!;
|
||||
|
||||
try {
|
||||
const response: any = await breaker.fire('engines', { year, make_id: makeId, model_id: modelId, trim_id: trimId });
|
||||
this.logger?.debug(`Retrieved ${response.engines?.length || 0} engines for year ${year}, make ${makeId}, model ${modelId}, trim ${trimId}`);
|
||||
return response.engines || [];
|
||||
} catch (error) {
|
||||
this.logger?.error(`Failed to get engines for year ${year}, make ${makeId}, model ${modelId}, trim ${trimId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transmissions for year, make, and model
|
||||
* Hierarchical API: Third level - requires year, make_id, and model_id
|
||||
*/
|
||||
async getTransmissions(year: number, makeId: number, modelId: number): Promise<TransmissionItem[]> {
|
||||
const breaker = this.circuitBreakers.get('transmissions')!;
|
||||
|
||||
try {
|
||||
const response: any = await breaker.fire('transmissions', { year, make_id: makeId, model_id: modelId });
|
||||
this.logger?.debug(`Retrieved ${response.transmissions?.length || 0} transmissions for year ${year}, make ${makeId}, model ${modelId}`);
|
||||
return response.transmissions || [];
|
||||
} catch (error) {
|
||||
this.logger?.error(`Failed to get transmissions for year ${year}, make ${makeId}, model ${modelId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode VIN using platform service
|
||||
* Uses PostgreSQL vpic.f_decode_vin() function with confidence scoring
|
||||
*/
|
||||
async decodeVIN(vin: string): Promise<VINDecodeResponse> {
|
||||
|
||||
try {
|
||||
const response = await this.httpClient.post('/api/v1/vehicles/vindecode', { vin });
|
||||
this.logger?.debug(`VIN decode response for ${vin}: success=${response.data.success}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger?.error(`Failed to decode VIN ${vin}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for the platform service
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.httpClient.get('/health');
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger?.error(`Platform service health check failed: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
91
backend/src/features/vehicles/external/platform-vehicles/platform-vehicles.types.ts
vendored
Normal file
91
backend/src/features/vehicles/external/platform-vehicles/platform-vehicles.types.ts
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
// Types for MVP Platform Vehicles Service integration
|
||||
// These types match the FastAPI response models
|
||||
|
||||
export interface MakeItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ModelItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TrimItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface EngineItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TransmissionItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MakesResponse {
|
||||
makes: MakeItem[];
|
||||
}
|
||||
|
||||
export interface ModelsResponse {
|
||||
models: ModelItem[];
|
||||
}
|
||||
|
||||
export interface TrimsResponse {
|
||||
trims: TrimItem[];
|
||||
}
|
||||
|
||||
export interface EnginesResponse {
|
||||
engines: EngineItem[];
|
||||
}
|
||||
|
||||
export interface TransmissionsResponse {
|
||||
transmissions: TransmissionItem[];
|
||||
}
|
||||
|
||||
export interface VINDecodeResult {
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
trim_name?: string;
|
||||
engine_description?: string;
|
||||
transmission_description?: string;
|
||||
horsepower?: number;
|
||||
torque?: number; // ft-lb
|
||||
top_speed?: number; // mph
|
||||
fuel?: 'gasoline' | 'diesel' | 'electric';
|
||||
confidence_score?: number;
|
||||
vehicle_type?: string;
|
||||
}
|
||||
|
||||
export interface VINDecodeRequest {
|
||||
vin: string;
|
||||
}
|
||||
|
||||
export interface VINDecodeResponse {
|
||||
vin: string;
|
||||
result?: VINDecodeResult;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
database: string;
|
||||
cache: string;
|
||||
version: string;
|
||||
etl_last_run?: string;
|
||||
}
|
||||
|
||||
// Configuration for platform vehicles client
|
||||
export interface PlatformVehiclesConfig {
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
timeout?: number;
|
||||
retryAttempts?: number;
|
||||
circuitBreakerOptions?: {
|
||||
timeout: number;
|
||||
errorThresholdPercentage: number;
|
||||
resetTimeout: number;
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE TABLE IF NOT EXISTS vehicles (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
vin VARCHAR(17) NOT NULL,
|
||||
vin VARCHAR(17),
|
||||
make VARCHAR(100),
|
||||
model VARCHAR(100),
|
||||
year INTEGER,
|
||||
@@ -22,10 +22,10 @@ CREATE TABLE IF NOT EXISTS vehicles (
|
||||
);
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX idx_vehicles_user_id ON vehicles(user_id);
|
||||
CREATE INDEX idx_vehicles_vin ON vehicles(vin);
|
||||
CREATE INDEX idx_vehicles_is_active ON vehicles(is_active);
|
||||
CREATE INDEX idx_vehicles_created_at ON vehicles(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_user_id ON vehicles(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_vin ON vehicles(vin);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_is_active ON vehicles(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_created_at ON vehicles(created_at);
|
||||
|
||||
-- Create VIN cache table for external API results
|
||||
CREATE TABLE IF NOT EXISTS vin_cache (
|
||||
@@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS vin_cache (
|
||||
);
|
||||
|
||||
-- Create index on cache timestamp for cleanup
|
||||
CREATE INDEX idx_vin_cache_cached_at ON vin_cache(cached_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_vin_cache_cached_at ON vin_cache(cached_at);
|
||||
|
||||
-- Create update trigger function
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
@@ -52,7 +52,15 @@ END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Add trigger to vehicles table
|
||||
CREATE TRIGGER update_vehicles_updated_at
|
||||
BEFORE UPDATE ON vehicles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger WHERE tgname = 'update_vehicles_updated_at'
|
||||
) THEN
|
||||
CREATE TRIGGER update_vehicles_updated_at
|
||||
BEFORE UPDATE ON vehicles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
@@ -25,7 +25,10 @@ CREATE TABLE IF NOT EXISTS vehicle_dropdown_cache (
|
||||
CREATE INDEX IF NOT EXISTS idx_dropdown_cache_expires_at ON vehicle_dropdown_cache(expires_at);
|
||||
|
||||
-- Create trigger for updating updated_at on dropdown cache
|
||||
CREATE TRIGGER IF NOT EXISTS update_dropdown_cache_updated_at
|
||||
-- Create trigger to maintain updated_at on vehicle_dropdown_cache
|
||||
-- Use DROP IF EXISTS and CREATE to handle re-runs safely
|
||||
DROP TRIGGER IF EXISTS update_dropdown_cache_updated_at ON vehicle_dropdown_cache;
|
||||
CREATE TRIGGER update_dropdown_cache_updated_at
|
||||
BEFORE UPDATE ON vehicle_dropdown_cache
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -36,4 +39,4 @@ COMMENT ON COLUMN vehicles.transmission IS 'Transmission style from NHTSA vPIC A
|
||||
COMMENT ON COLUMN vehicles.trim_level IS 'Trim level from NHTSA vPIC API';
|
||||
COMMENT ON COLUMN vehicles.drive_type IS 'Drive type (FWD, RWD, AWD, 4WD)';
|
||||
COMMENT ON COLUMN vehicles.fuel_type IS 'Primary fuel type';
|
||||
COMMENT ON TABLE vehicle_dropdown_cache IS 'Cache for dropdown data from NHTSA vPIC API';
|
||||
COMMENT ON TABLE vehicle_dropdown_cache IS 'Cache for dropdown data from NHTSA vPIC API';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Allow vehicles to be created without a VIN (license plate alternative)
|
||||
ALTER TABLE vehicles ALTER COLUMN vin DROP NOT NULL;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Normalize existing model names in application database
|
||||
-- - Replace underscores with spaces
|
||||
-- - Title-case words
|
||||
-- - Uppercase common acronyms (HD, GT, Z06, etc.)
|
||||
|
||||
-- Create helper function to normalize model names
|
||||
CREATE OR REPLACE FUNCTION normalize_model_name_app(input TEXT)
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
s TEXT;
|
||||
BEGIN
|
||||
IF input IS NULL THEN RETURN NULL; END IF;
|
||||
s := input;
|
||||
-- underscores to spaces, collapse whitespace, trim
|
||||
s := regexp_replace(s, '_+', ' ', 'g');
|
||||
s := btrim(regexp_replace(s, '\\s+', ' ', 'g'));
|
||||
-- title case baseline
|
||||
s := initcap(lower(s));
|
||||
-- uppercase common acronyms using word boundaries
|
||||
s := regexp_replace(s, '(^|\\s)(Hd)(\\s|$)', '\\1HD\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Gt)(\\s|$)', '\\1GT\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Gl)(\\s|$)', '\\1GL\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Se)(\\s|$)', '\\1SE\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Le)(\\s|$)', '\\1LE\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Xle)(\\s|$)', '\\1XLE\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Rs)(\\s|$)', '\\1RS\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Svt)(\\s|$)', '\\1SVT\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Xr)(\\s|$)', '\\1XR\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(St)(\\s|$)', '\\1ST\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Fx4)(\\s|$)', '\\1FX4\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Trd)(\\s|$)', '\\1TRD\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Zr1)(\\s|$)', '\\1ZR1\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Z06)(\\s|$)', '\\1Z06\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Gti)(\\s|$)', '\\1GTI\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Gli)(\\s|$)', '\\1GLI\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Si)(\\s|$)', '\\1SI\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Ss)(\\s|$)', '\\1SS\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Lt)(\\s|$)', '\\1LT\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Ltz)(\\s|$)', '\\1LTZ\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Rt)(\\s|$)', '\\1RT\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Srt)(\\s|$)', '\\1SRT\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Sr)(\\s|$)', '\\1SR\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Sr5)(\\s|$)', '\\1SR5\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Xse)(\\s|$)', '\\1XSE\\3', 'gi');
|
||||
s := regexp_replace(s, '(^|\\s)(Sel)(\\s|$)', '\\1SEL\\3', 'gi');
|
||||
RETURN s;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update existing rows in application tables
|
||||
UPDATE vehicles
|
||||
SET model = normalize_model_name_app(model)
|
||||
WHERE model IS NOT NULL AND model <> normalize_model_name_app(model);
|
||||
|
||||
UPDATE vin_cache
|
||||
SET model = normalize_model_name_app(model)
|
||||
WHERE model IS NOT NULL AND model <> normalize_model_name_app(model);
|
||||
|
||||
175
backend/src/shared-minimal/utils/units.ts
Normal file
175
backend/src/shared-minimal/utils/units.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @ai-summary Unit conversion utilities for Imperial/Metric support
|
||||
* @ai-context Pure functions for converting between unit systems
|
||||
*/
|
||||
|
||||
export type UnitSystem = 'imperial' | 'metric';
|
||||
export type DistanceUnit = 'miles' | 'km';
|
||||
export type VolumeUnit = 'gallons' | 'liters';
|
||||
export type FuelEfficiencyUnit = 'mpg' | 'l100km';
|
||||
|
||||
// Conversion constants
|
||||
const MILES_TO_KM = 1.60934;
|
||||
const KM_TO_MILES = 0.621371;
|
||||
const GALLONS_TO_LITERS = 3.78541;
|
||||
const LITERS_TO_GALLONS = 0.264172;
|
||||
const MPG_TO_L100KM_FACTOR = 235.214; // Conversion factor for MPG ↔ L/100km
|
||||
|
||||
// Distance Conversions
|
||||
export function convertDistance(value: number, fromUnit: DistanceUnit, toUnit: DistanceUnit): number {
|
||||
if (fromUnit === toUnit) return value;
|
||||
|
||||
if (fromUnit === 'miles' && toUnit === 'km') {
|
||||
return value * MILES_TO_KM;
|
||||
}
|
||||
|
||||
if (fromUnit === 'km' && toUnit === 'miles') {
|
||||
return value * KM_TO_MILES;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function convertDistanceBySystem(miles: number, toSystem: UnitSystem): number {
|
||||
if (toSystem === 'metric') {
|
||||
return convertDistance(miles, 'miles', 'km');
|
||||
}
|
||||
return miles;
|
||||
}
|
||||
|
||||
// Volume Conversions
|
||||
export function convertVolume(value: number, fromUnit: VolumeUnit, toUnit: VolumeUnit): number {
|
||||
if (fromUnit === toUnit) return value;
|
||||
|
||||
if (fromUnit === 'gallons' && toUnit === 'liters') {
|
||||
return value * GALLONS_TO_LITERS;
|
||||
}
|
||||
|
||||
if (fromUnit === 'liters' && toUnit === 'gallons') {
|
||||
return value * LITERS_TO_GALLONS;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function convertVolumeBySystem(gallons: number, toSystem: UnitSystem): number {
|
||||
if (toSystem === 'metric') {
|
||||
return convertVolume(gallons, 'gallons', 'liters');
|
||||
}
|
||||
return gallons;
|
||||
}
|
||||
|
||||
// Fuel Efficiency Conversions
|
||||
export function convertFuelEfficiency(value: number, fromUnit: FuelEfficiencyUnit, toUnit: FuelEfficiencyUnit): number {
|
||||
if (fromUnit === toUnit) return value;
|
||||
|
||||
if (fromUnit === 'mpg' && toUnit === 'l100km') {
|
||||
return value === 0 ? 0 : MPG_TO_L100KM_FACTOR / value;
|
||||
}
|
||||
|
||||
if (fromUnit === 'l100km' && toUnit === 'mpg') {
|
||||
return value === 0 ? 0 : MPG_TO_L100KM_FACTOR / value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function convertFuelEfficiencyBySystem(mpg: number, toSystem: UnitSystem): number {
|
||||
if (toSystem === 'metric') {
|
||||
return convertFuelEfficiency(mpg, 'mpg', 'l100km');
|
||||
}
|
||||
return mpg;
|
||||
}
|
||||
|
||||
// Display Formatting Functions
|
||||
export function formatDistance(value: number, unit: DistanceUnit, precision = 1): string {
|
||||
const rounded = parseFloat(value.toFixed(precision));
|
||||
|
||||
if (unit === 'miles') {
|
||||
return `${rounded.toLocaleString()} miles`;
|
||||
} else {
|
||||
return `${rounded.toLocaleString()} km`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatVolume(value: number, unit: VolumeUnit, precision = 2): string {
|
||||
const rounded = parseFloat(value.toFixed(precision));
|
||||
|
||||
if (unit === 'gallons') {
|
||||
return `${rounded} gal`;
|
||||
} else {
|
||||
return `${rounded} L`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFuelEfficiency(value: number, unit: FuelEfficiencyUnit, precision = 1): string {
|
||||
const rounded = parseFloat(value.toFixed(precision));
|
||||
|
||||
if (unit === 'mpg') {
|
||||
return `${rounded} MPG`;
|
||||
} else {
|
||||
return `${rounded} L/100km`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPrice(value: number, unit: VolumeUnit, currency = 'USD', precision = 3): string {
|
||||
const rounded = parseFloat(value.toFixed(precision));
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
|
||||
if (unit === 'gallons') {
|
||||
return `${formatter.format(rounded)}/gal`;
|
||||
} else {
|
||||
return `${formatter.format(rounded)}/L`;
|
||||
}
|
||||
}
|
||||
|
||||
// System-based formatting (convenience functions)
|
||||
export function formatDistanceBySystem(miles: number, system: UnitSystem, precision = 1): string {
|
||||
if (system === 'metric') {
|
||||
const km = convertDistanceBySystem(miles, system);
|
||||
return formatDistance(km, 'km', precision);
|
||||
}
|
||||
return formatDistance(miles, 'miles', precision);
|
||||
}
|
||||
|
||||
export function formatVolumeBySystem(gallons: number, system: UnitSystem, precision = 2): string {
|
||||
if (system === 'metric') {
|
||||
const liters = convertVolumeBySystem(gallons, system);
|
||||
return formatVolume(liters, 'liters', precision);
|
||||
}
|
||||
return formatVolume(gallons, 'gallons', precision);
|
||||
}
|
||||
|
||||
export function formatFuelEfficiencyBySystem(mpg: number, system: UnitSystem, precision = 1): string {
|
||||
if (system === 'metric') {
|
||||
const l100km = convertFuelEfficiencyBySystem(mpg, system);
|
||||
return formatFuelEfficiency(l100km, 'l100km', precision);
|
||||
}
|
||||
return formatFuelEfficiency(mpg, 'mpg', precision);
|
||||
}
|
||||
|
||||
export function formatPriceBySystem(pricePerGallon: number, system: UnitSystem, currency = 'USD', precision = 3): string {
|
||||
if (system === 'metric') {
|
||||
const pricePerLiter = pricePerGallon * LITERS_TO_GALLONS;
|
||||
return formatPrice(pricePerLiter, 'liters', currency, precision);
|
||||
}
|
||||
return formatPrice(pricePerGallon, 'gallons', currency, precision);
|
||||
}
|
||||
|
||||
// Unit system helpers
|
||||
export function getDistanceUnit(system: UnitSystem): DistanceUnit {
|
||||
return system === 'metric' ? 'km' : 'miles';
|
||||
}
|
||||
|
||||
export function getVolumeUnit(system: UnitSystem): VolumeUnit {
|
||||
return system === 'metric' ? 'liters' : 'gallons';
|
||||
}
|
||||
|
||||
export function getFuelEfficiencyUnit(system: UnitSystem): FuelEfficiencyUnit {
|
||||
return system === 'metric' ? 'l100km' : 'mpg';
|
||||
}
|
||||
Reference in New Issue
Block a user