Vehicle drop down and Gas Station fixes

This commit is contained in:
Eric Gullickson
2025-12-17 10:49:29 -06:00
parent 0925a31fd4
commit cd0cfa8913
26 changed files with 133025 additions and 1779 deletions

View File

@@ -1,331 +0,0 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}=== MotoVaultPro ETL Plan V1 - Automated Application ===${NC}"
echo ""
# Get script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Backup directory
BACKUP_DIR="$SCRIPT_DIR/.etl-plan-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
echo -e "${GREEN}[1/4] Backing up files...${NC}"
# Backup ETL files
mkdir -p "$BACKUP_DIR/data/vehicle-etl"
cp data/vehicle-etl/vehapi_fetch_snapshot.py "$BACKUP_DIR/data/vehicle-etl/" 2>/dev/null || true
cp data/vehicle-etl/qa_validate.py "$BACKUP_DIR/data/vehicle-etl/" 2>/dev/null || true
# Backup backend files
mkdir -p "$BACKUP_DIR/backend/src/features/vehicles/api"
mkdir -p "$BACKUP_DIR/backend/src/features/vehicles/domain"
mkdir -p "$BACKUP_DIR/backend/src/features/platform/api"
mkdir -p "$BACKUP_DIR/backend/src/features/platform/domain"
mkdir -p "$BACKUP_DIR/backend/src/features/platform/data"
cp backend/src/features/vehicles/api/vehicles.controller.ts "$BACKUP_DIR/backend/src/features/vehicles/api/" 2>/dev/null || true
cp backend/src/features/vehicles/api/vehicles.routes.ts "$BACKUP_DIR/backend/src/features/vehicles/api/" 2>/dev/null || true
cp backend/src/features/vehicles/domain/vehicles.service.ts "$BACKUP_DIR/backend/src/features/vehicles/domain/" 2>/dev/null || true
cp backend/src/features/platform/api/platform.controller.ts "$BACKUP_DIR/backend/src/features/platform/api/" 2>/dev/null || true
cp backend/src/features/platform/index.ts "$BACKUP_DIR/backend/src/features/platform/" 2>/dev/null || true
cp backend/src/features/platform/domain/vin-decode.service.ts "$BACKUP_DIR/backend/src/features/platform/domain/" 2>/dev/null || true
cp backend/src/features/platform/data/vpic-client.ts "$BACKUP_DIR/backend/src/features/platform/data/" 2>/dev/null || true
# Backup frontend files
mkdir -p "$BACKUP_DIR/frontend/src/features/vehicles/components"
mkdir -p "$BACKUP_DIR/frontend/src/features/vehicles/api"
mkdir -p "$BACKUP_DIR/frontend/src/features/vehicles/types"
cp frontend/src/features/vehicles/components/VehicleForm.tsx "$BACKUP_DIR/frontend/src/features/vehicles/components/" 2>/dev/null || true
cp frontend/src/features/vehicles/api/vehicles.api.ts "$BACKUP_DIR/frontend/src/features/vehicles/api/" 2>/dev/null || true
cp frontend/src/features/vehicles/types/vehicles.types.ts "$BACKUP_DIR/frontend/src/features/vehicles/types/" 2>/dev/null || true
echo -e "${GREEN} Backup created at: $BACKUP_DIR${NC}"
echo ""
echo -e "${GREEN}[2/4] Applying ETL Configuration Changes...${NC}"
# Update vehapi_fetch_snapshot.py
if [ -f "data/vehicle-etl/vehapi_fetch_snapshot.py" ]; then
echo " - Updating DEFAULT_MIN_YEAR from 1980 to 2017..."
sed -i.bak 's/DEFAULT_MIN_YEAR = 1980/DEFAULT_MIN_YEAR = 2017/g' data/vehicle-etl/vehapi_fetch_snapshot.py
sed -i.bak 's/default env MIN_YEAR or 1980/default env MIN_YEAR or 2017/g' data/vehicle-etl/vehapi_fetch_snapshot.py
rm data/vehicle-etl/vehapi_fetch_snapshot.py.bak
echo -e " ${GREEN}${NC} vehapi_fetch_snapshot.py updated"
else
echo -e " ${RED}${NC} vehapi_fetch_snapshot.py not found"
fi
# Update qa_validate.py
if [ -f "data/vehicle-etl/qa_validate.py" ]; then
echo " - Updating year range validation from 1980-2022 to 2017-2022..."
sed -i.bak 's/year < 1980 OR year > 2022/year < 2017 OR year > 2022/g' data/vehicle-etl/qa_validate.py
rm data/vehicle-etl/qa_validate.py.bak
echo -e " ${GREEN}${NC} qa_validate.py updated"
else
echo -e " ${RED}${NC} qa_validate.py not found"
fi
# Delete old ETL documentation
echo " - Removing old ETL documentation..."
rm -f data/vehicle-etl/ETL-FIX-V2.md
rm -f data/vehicle-etl/ETL-FIXES.md
rm -f data/vehicle-etl/ETL-VEHAPI-PLAN.md
rm -f data/vehicle-etl/ETL-VEHAPI-REDESIGN.md
echo -e " ${GREEN}${NC} Old ETL documentation removed"
echo ""
echo -e "${GREEN}[3/4] Applying Backend Changes...${NC}"
# Update vehicles.controller.ts MIN_YEAR
if [ -f "backend/src/features/vehicles/api/vehicles.controller.ts" ]; then
echo " - Updating MIN_YEAR from 1980 to 2017..."
sed -i.bak 's/private static readonly MIN_YEAR = 1980;/private static readonly MIN_YEAR = 2017;/g' backend/src/features/vehicles/api/vehicles.controller.ts
rm backend/src/features/vehicles/api/vehicles.controller.ts.bak
echo -e " ${GREEN}${NC} vehicles.controller.ts MIN_YEAR updated"
else
echo -e " ${RED}${NC} vehicles.controller.ts not found"
fi
# Remove VIN decode route from vehicles.routes.ts
if [ -f "backend/src/features/vehicles/api/vehicles.routes.ts" ]; then
echo " - Removing VIN decode route..."
python3 << 'PYTHON_EOF'
import re
with open('backend/src/features/vehicles/api/vehicles.routes.ts', 'r') as f:
content = f.read()
# Remove the decode-vin route (multi-line pattern)
pattern = r"\n\s*fastify\.post<\{ Body: \{ vin: string \} \}>\('/vehicles/decode-vin',\s*\{[^}]*\}\);"
content = re.sub(pattern, '', content, flags=re.DOTALL)
with open('backend/src/features/vehicles/api/vehicles.routes.ts', 'w') as f:
f.write(content)
PYTHON_EOF
echo -e " ${GREEN}${NC} VIN decode route removed from vehicles.routes.ts"
else
echo -e " ${YELLOW}${NC} vehicles.routes.ts not found (may not exist)"
fi
# Remove VIN decode from vehicles.service.ts
if [ -f "backend/src/features/vehicles/domain/vehicles.service.ts" ]; then
echo " - Removing VIN decode logic from vehicles.service.ts..."
python3 << 'PYTHON_EOF'
import re
with open('backend/src/features/vehicles/domain/vehicles.service.ts', 'r') as f:
content = f.read()
# Remove import statement for vin-decode
content = re.sub(r"import\s*\{[^}]*getVINDecodeService[^}]*\}[^;]*;?\n?", '', content)
content = re.sub(r"import\s*\{[^}]*VINDecodeService[^}]*\}[^;]*;?\n?", '', content)
# Remove decodeVIN method if it exists
pattern = r'\n\s*async decodeVIN\([^)]*\)[^{]*\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}\n'
content = re.sub(pattern, '\n', content, flags=re.DOTALL)
# Remove VIN decode logic from createVehicle method (between specific comments or patterns)
# This is more conservative - just remove obvious decode blocks
content = re.sub(r'\n\s*// VIN decode logic.*?(?=\n\s*(?:const|return|if|//|$))', '', content, flags=re.DOTALL)
with open('backend/src/features/vehicles/domain/vehicles.service.ts', 'w') as f:
f.write(content)
PYTHON_EOF
echo -e " ${GREEN}${NC} VIN decode logic removed from vehicles.service.ts"
else
echo -e " ${RED}${NC} vehicles.service.ts not found"
fi
# Remove VIN decode from platform.controller.ts
if [ -f "backend/src/features/platform/api/platform.controller.ts" ]; then
echo " - Removing VIN decode from platform.controller.ts..."
python3 << 'PYTHON_EOF'
import re
with open('backend/src/features/platform/api/platform.controller.ts', 'r') as f:
content = f.read()
# Remove VINDecodeService import
content = re.sub(r"import\s*\{[^}]*VINDecodeService[^}]*\}[^;]*;?\n?", '', content)
# Remove VINDecodeService from constructor
content = re.sub(r',?\s*private\s+vinDecodeService:\s*VINDecodeService', '', content)
content = re.sub(r'private\s+vinDecodeService:\s*VINDecodeService\s*,?', '', content)
# Remove decodeVIN method
pattern = r'\n\s*async decodeVIN\([^)]*\)[^{]*\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}\n'
content = re.sub(pattern, '\n', content, flags=re.DOTALL)
with open('backend/src/features/platform/api/platform.controller.ts', 'w') as f:
f.write(content)
PYTHON_EOF
echo -e " ${GREEN}${NC} VIN decode removed from platform.controller.ts"
else
echo -e " ${YELLOW}${NC} platform.controller.ts not found"
fi
# Remove VINDecodeService from platform/index.ts
if [ -f "backend/src/features/platform/index.ts" ]; then
echo " - Removing VINDecodeService from platform/index.ts..."
python3 << 'PYTHON_EOF'
import re
with open('backend/src/features/platform/index.ts', 'r') as f:
content = f.read()
# Remove VINDecodeService import
content = re.sub(r"import\s*\{[^}]*VINDecodeService[^}]*\}[^;]*;?\n?", '', content)
# Remove VINDecodeService export
content = re.sub(r"export\s*\{[^}]*VINDecodeService[^}]*\}[^;]*;?\n?", '', content)
content = re.sub(r",\s*VINDecodeService\s*", '', content)
content = re.sub(r"VINDecodeService\s*,\s*", '', content)
# Remove getVINDecodeService function
pattern = r'\n\s*export\s+function\s+getVINDecodeService\([^)]*\)[^{]*\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}\n'
content = re.sub(pattern, '\n', content, flags=re.DOTALL)
with open('backend/src/features/platform/index.ts', 'w') as f:
f.write(content)
PYTHON_EOF
echo -e " ${GREEN}${NC} VINDecodeService removed from platform/index.ts"
else
echo -e " ${YELLOW}${NC} platform/index.ts not found"
fi
# Delete VIN decode service files
echo " - Deleting VIN decode service files..."
rm -f backend/src/features/platform/domain/vin-decode.service.ts
rm -f backend/src/features/platform/data/vpic-client.ts
echo -e " ${GREEN}${NC} VIN decode service files deleted"
echo ""
echo -e "${GREEN}[4/4] Applying Frontend Changes...${NC}"
# Update VehicleForm.tsx
if [ -f "frontend/src/features/vehicles/components/VehicleForm.tsx" ]; then
echo " - Removing VIN decode UI from VehicleForm.tsx..."
python3 << 'PYTHON_EOF'
import re
with open('frontend/src/features/vehicles/components/VehicleForm.tsx', 'r') as f:
content = f.read()
# Remove decodingVIN and decodeSuccess state variables
content = re.sub(r"\n\s*const \[decodingVIN, setDecodingVIN\] = useState\(false\);", '', content)
content = re.sub(r"\n\s*const \[decodeSuccess, setDecodeSuccess\] = useState\(false\);", '', content)
content = re.sub(r"\n\s*const \[decodeSuccess, setDecodeSuccess\] = useState<boolean \| null>\(null\);", '', content)
# Remove watchedVIN if it exists
content = re.sub(r"\n\s*const watchedVIN = watch\('vin'\);", '', content)
# Remove handleDecodeVIN function
pattern = r'\n\s*const handleDecodeVIN = async \(\) => \{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\};'
content = re.sub(pattern, '', content, flags=re.DOTALL)
# Update helper text
content = re.sub(
r'Enter VIN to auto-fill vehicle details OR manually select from dropdowns below',
'Enter vehicle VIN (optional)',
content
)
content = re.sub(
r'Enter VIN to auto-populate fields',
'Enter vehicle VIN (optional)',
content
)
# Remove the Decode VIN button and surrounding div
# Pattern 1: div with flex layout containing input and button
pattern1 = r'<div className="flex flex-col sm:flex-row gap-2">\s*<input\s+\{\.\.\.register\(\'vin\'\)\}[^>]*>\s*</input>\s*<Button[^>]*onClick=\{handleDecodeVIN\}[^>]*>.*?</Button>\s*</div>'
replacement1 = r'<input {...register(\'vin\')} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 text-base" placeholder="Enter 17-character VIN (optional if License Plate provided)" style={{ fontSize: \'16px\' }} />'
content = re.sub(pattern1, replacement1, content, flags=re.DOTALL)
# Pattern 2: self-closing input with button after
pattern2 = r'<input\s+\{\.\.\.register\(\'vin\'\)\}[^/]*/>\s*<Button[^>]*onClick=\{handleDecodeVIN\}[^>]*>.*?</Button>'
content = re.sub(pattern2, replacement1, content, flags=re.DOTALL)
# Remove decode success message
content = re.sub(r'\n\s*\{decodeSuccess && \([^)]*\)\}', '', content, flags=re.DOTALL)
content = re.sub(r'\n\s*<p className="mt-1 text-sm text-green-600">VIN decoded successfully.*?</p>', '', content, flags=re.DOTALL)
with open('frontend/src/features/vehicles/components/VehicleForm.tsx', 'w') as f:
f.write(content)
PYTHON_EOF
echo -e " ${GREEN}${NC} VIN decode UI removed from VehicleForm.tsx"
else
echo -e " ${RED}${NC} VehicleForm.tsx not found"
fi
# Update vehicles.api.ts
if [ -f "frontend/src/features/vehicles/api/vehicles.api.ts" ]; then
echo " - Removing decodeVIN method from vehicles.api.ts..."
python3 << 'PYTHON_EOF'
import re
with open('frontend/src/features/vehicles/api/vehicles.api.ts', 'r') as f:
content = f.read()
# Remove VINDecodeResponse from imports
content = re.sub(r',\s*VINDecodeResponse', '', content)
content = re.sub(r'VINDecodeResponse\s*,', '', content)
# Remove decodeVIN method
pattern = r'\n\s*decodeVIN:\s*async\s*\([^)]*\)[^{]*\{[^}]*\},?'
content = re.sub(pattern, '', content, flags=re.DOTALL)
# Remove trailing comma before closing brace if present
content = re.sub(r',(\s*\};?\s*$)', r'\1', content)
with open('frontend/src/features/vehicles/api/vehicles.api.ts', 'w') as f:
f.write(content)
PYTHON_EOF
echo -e " ${GREEN}${NC} decodeVIN method removed from vehicles.api.ts"
else
echo -e " ${RED}${NC} vehicles.api.ts not found"
fi
# Update vehicles.types.ts
if [ -f "frontend/src/features/vehicles/types/vehicles.types.ts" ]; then
echo " - Removing VINDecodeResponse interface from vehicles.types.ts..."
python3 << 'PYTHON_EOF'
import re
with open('frontend/src/features/vehicles/types/vehicles.types.ts', 'r') as f:
content = f.read()
# Remove VINDecodeResponse interface
pattern = r'\n\s*export interface VINDecodeResponse \{[^}]*\}\n?'
content = re.sub(pattern, '\n', content, flags=re.DOTALL)
with open('frontend/src/features/vehicles/types/vehicles.types.ts', 'w') as f:
f.write(content)
PYTHON_EOF
echo -e " ${GREEN}${NC} VINDecodeResponse interface removed from vehicles.types.ts"
else
echo -e " ${RED}${NC} vehicles.types.ts not found"
fi
echo ""
echo -e "${GREEN}=== Changes Applied Successfully ===${NC}"
echo ""
echo -e "${YELLOW}Verification Commands:${NC}"
echo ""
echo "1. ETL Configuration:"
echo " cd data/vehicle-etl && python3 -c \"import vehapi_fetch_snapshot; print(vehapi_fetch_snapshot.DEFAULT_MIN_YEAR)\""
echo " Expected output: 2017"
echo ""
echo "2. Backend TypeScript:"
echo " cd backend && npx tsc --noEmit"
echo " Expected: No errors"
echo ""
echo "3. Frontend TypeScript:"
echo " cd frontend && npx tsc --noEmit"
echo " Expected: No errors"
echo ""
echo -e "${YELLOW}Next Steps:${NC}"
echo "1. Run the verification commands above"
echo "2. If all checks pass, proceed with Agent 4 (ETL Execution)"
echo "3. If there are issues, restore from backup: $BACKUP_DIR"
echo ""
echo -e "${GREEN}Backup location: $BACKUP_DIR${NC}"

View File

@@ -27,6 +27,7 @@ import { pool } from './core/config/database';
async function buildApp(): Promise<FastifyInstance> { async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ const app = Fastify({
logger: false, // Use custom logging plugin instead logger: false, // Use custom logging plugin instead
maxParamLength: 1000, // Required for long Google Maps photo references
}); });
// Core middleware plugins // Core middleware plugins

View File

@@ -10,15 +10,12 @@ import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger'; import { logger } from '../../../core/logging/logger';
import { CreateVehicleBody, UpdateVehicleBody, VehicleParams } from '../domain/vehicles.types'; import { CreateVehicleBody, UpdateVehicleBody, VehicleParams } from '../domain/vehicles.types';
import { getStorageService } from '../../../core/storage/storage.service'; import { getStorageService } from '../../../core/storage/storage.service';
import { Transform, TransformCallback, Readable } from 'stream';
import crypto from 'crypto'; import crypto from 'crypto';
import FileType from 'file-type'; import FileType from 'file-type';
import path from 'path'; import path from 'path';
export class VehiclesController { export class VehiclesController {
private vehiclesService: VehiclesService; private vehiclesService: VehiclesService;
private static readonly MIN_YEAR = 2017;
private static readonly MAX_YEAR = 2022;
constructor() { constructor() {
const repository = new VehiclesRepository(pool); const repository = new VehiclesRepository(pool);
@@ -160,13 +157,13 @@ export class VehiclesController {
async getDropdownMakes(request: FastifyRequest<{ Querystring: { year: number } }>, reply: FastifyReply) { async getDropdownMakes(request: FastifyRequest<{ Querystring: { year: number } }>, reply: FastifyReply) {
try { try {
const { year } = request.query; const { year } = request.query;
if (!year || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR) { if (!year || isNaN(year)) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Bad Request', error: 'Bad Request',
message: `Valid year parameter is required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})` message: 'Valid year parameter is required'
}); });
} }
const makes = await this.vehiclesService.getDropdownMakes(year); const makes = await this.vehiclesService.getDropdownMakes(year);
return reply.code(200).send(makes); return reply.code(200).send(makes);
} catch (error) { } catch (error) {
@@ -181,10 +178,10 @@ export class VehiclesController {
async getDropdownModels(request: FastifyRequest<{ Querystring: { year: number; make: string } }>, reply: FastifyReply) { async getDropdownModels(request: FastifyRequest<{ Querystring: { year: number; make: string } }>, reply: FastifyReply) {
try { try {
const { year, make } = request.query; const { year, make } = request.query;
if (!year || !make || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0) { if (!year || isNaN(year) || !make || make.trim().length === 0) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Bad Request', error: 'Bad Request',
message: `Valid year and make parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})` message: 'Valid year and make parameters are required'
}); });
} }
@@ -202,10 +199,10 @@ export class VehiclesController {
async getDropdownTransmissions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string } }>, reply: FastifyReply) { async getDropdownTransmissions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string } }>, reply: FastifyReply) {
try { try {
const { year, make, model, trim } = request.query; const { year, make, model, trim } = request.query;
if (!year || !make || !model || !trim || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) { if (!year || isNaN(year) || !make || !model || !trim || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Bad Request', error: 'Bad Request',
message: `Valid year, make, model, and trim parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})` message: 'Valid year, make, model, and trim parameters are required'
}); });
} }
@@ -223,10 +220,10 @@ export class VehiclesController {
async getDropdownEngines(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string } }>, reply: FastifyReply) { async getDropdownEngines(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string } }>, reply: FastifyReply) {
try { try {
const { year, make, model, trim } = request.query; const { year, make, model, trim } = request.query;
if (!year || !make || !model || !trim || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) { if (!year || isNaN(year) || !make || !model || !trim || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Bad Request', error: 'Bad Request',
message: `Valid year, make, model, and trim parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})` message: 'Valid year, make, model, and trim parameters are required'
}); });
} }
@@ -244,10 +241,10 @@ export class VehiclesController {
async getDropdownTrims(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string } }>, reply: FastifyReply) { async getDropdownTrims(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string } }>, reply: FastifyReply) {
try { try {
const { year, make, model } = request.query; const { year, make, model } = request.query;
if (!year || !make || !model || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0) { if (!year || isNaN(year) || !make || !model || make.trim().length === 0 || model.trim().length === 0) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Bad Request', error: 'Bad Request',
message: `Valid year, make, and model parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})` message: 'Valid year, make, and model parameters are required'
}); });
} }
@@ -279,10 +276,10 @@ export class VehiclesController {
async getDropdownOptions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string; engine?: string; transmission?: string } }>, reply: FastifyReply) { async getDropdownOptions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string; engine?: string; transmission?: string } }>, reply: FastifyReply) {
try { try {
const { year, make, model, trim, engine, transmission } = request.query; const { year, make, model, trim, engine, transmission } = request.query;
if (!year || !make || !model || !trim || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) { if (!year || isNaN(year) || !make || !model || !trim || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
return reply.code(400).send({ return reply.code(400).send({
error: 'Bad Request', error: 'Bad Request',
message: `Valid year, make, model, and trim parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})` message: 'Valid year, make, model, and trim parameters are required'
}); });
} }
@@ -350,23 +347,16 @@ export class VehiclesController {
}); });
} }
// Read first 4100 bytes to detect file type via magic bytes // Buffer the entire file for reliable processing
// Vehicle images are typically small (< 10MB) so this is safe
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
let totalBytes = 0;
const targetBytes = 4100;
for await (const chunk of mp.file) { for await (const chunk of mp.file) {
chunks.push(chunk); chunks.push(chunk);
totalBytes += chunk.length;
if (totalBytes >= targetBytes) {
break;
}
} }
const fileBuffer = Buffer.concat(chunks);
const headerBuffer = Buffer.concat(chunks);
// Validate actual file content using magic bytes // Validate actual file content using magic bytes
const detectedType = await FileType.fromBuffer(headerBuffer); const detectedType = await FileType.fromBuffer(fileBuffer);
if (!detectedType) { if (!detectedType) {
logger.warn('Unable to detect file type from content', { logger.warn('Unable to detect file type from content', {
@@ -424,39 +414,20 @@ export class VehiclesController {
const originalName: string = mp.filename || 'vehicle-image'; const originalName: string = mp.filename || 'vehicle-image';
const ext = contentType === 'image/jpeg' ? 'jpg' : 'png'; const ext = contentType === 'image/jpeg' ? 'jpg' : 'png';
class CountingStream extends Transform {
public bytes = 0;
override _transform(chunk: any, _enc: BufferEncoding, cb: TransformCallback) {
this.bytes += chunk.length || 0;
cb(null, chunk);
}
}
const counter = new CountingStream();
// Create a new readable stream from the header buffer + remaining file chunks
const headerStream = Readable.from([headerBuffer]);
const remainingStream = mp.file;
// Pipe header first, then remaining content through counter
headerStream.pipe(counter, { end: false });
headerStream.on('end', () => {
remainingStream.pipe(counter);
});
const storage = getStorageService(); const storage = getStorageService();
const bucket = 'vehicle-images'; const bucket = 'vehicle-images';
const unique = crypto.randomBytes(32).toString('hex'); const unique = crypto.randomBytes(32).toString('hex');
const key = `vehicle-images/${userId}/${vehicleId}/${unique}.${ext}`; const key = `vehicle-images/${userId}/${vehicleId}/${unique}.${ext}`;
await storage.putObject(bucket, key, counter, contentType, { 'x-original-filename': originalName }); // Write buffer directly to storage
await storage.putObject(bucket, key, fileBuffer, contentType, { 'x-original-filename': originalName });
const updated = await this.vehiclesService.updateVehicleImage(vehicleId, userId, { const updated = await this.vehiclesService.updateVehicleImage(vehicleId, userId, {
imageStorageBucket: bucket, imageStorageBucket: bucket,
imageStorageKey: key, imageStorageKey: key,
imageFileName: originalName, imageFileName: originalName,
imageContentType: contentType, imageContentType: contentType,
imageFileSize: counter.bytes, imageFileSize: fileBuffer.length,
}); });
logger.info('Vehicle image upload completed', { logger.info('Vehicle image upload completed', {
@@ -466,7 +437,7 @@ export class VehiclesController {
fileName: originalName, fileName: originalName,
contentType, contentType,
detectedType: detectedType.mime, detectedType: detectedType.mime,
fileSize: counter.bytes, fileSize: fileBuffer.length,
storageKey: key, storageKey: key,
}); });

View File

@@ -0,0 +1,4 @@
{
"x-original-filename": "C7Z06-BlackRose.jpg",
"content-type": "image/jpeg"
}

View File

@@ -0,0 +1,4 @@
{
"x-original-filename": "C7Z06-BlackRose.jpg",
"content-type": "image/jpeg"
}

View File

@@ -0,0 +1,4 @@
{
"x-original-filename": "C7Z06-BlackRose.jpg",
"content-type": "image/jpeg"
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,19 +2,30 @@
INSERT INTO transmissions (id, type) VALUES INSERT INTO transmissions (id, type) VALUES
(1,'Automatic'), (1,'Automatic'),
(2,'Manual'), (2,'Manual'),
(3,'8-Speed Dual Clutch'), (3,'3-Speed Automatic'),
(4,'9-Speed Automatic'), (4,'5-Speed Manual'),
(5,'7-Speed Dual Clutch'), (5,'4-Speed Manual'),
(6,'9-Speed Dual Clutch'), (6,'3-Speed Manual'),
(7,'6-Speed Automatic'), (7,'4-Speed Automatic'),
(8,'6-Speed Dual Clutch'), (8,'6-Speed Manual'),
(9,'6-Speed Manual'), (9,'4-Speed Automatic Overdrive'),
(10,'8-Speed Automatic'), (10,'5-Speed Manual Overdrive'),
(11,'1-Speed Dual Clutch'), (11,'Continuously Variable Transmission'),
(12,'6-Speed Automatic Overdrive'), (12,'5-Speed Automatic'),
(13,'4-Speed Automatic'), (13,'6-Speed Manual Overdrive'),
(14,'10-Speed Automatic'), (14,'1-Speed Dual Clutch'),
(15,'Continuously Variable Transmission'), (15,'5-Speed Automatic Overdrive'),
(16,'7-Speed Manual'), (16,'6-Speed Automatic'),
(17,'5-Speed Manual'); (17,'6-Speed Automatic Overdrive'),
(18,'6-Speed CVT'),
(19,'7-Speed Automatic'),
(20,'6-Speed Dual Clutch'),
(21,'8-Speed Dual Clutch'),
(22,'9-Speed Automatic'),
(23,'7-Speed Dual Clutch'),
(24,'9-Speed Dual Clutch'),
(25,'8-Speed Automatic'),
(26,'10-Speed Automatic'),
(27,'7-Speed Manual'),
(28,'7-Speed CVT');

File diff suppressed because it is too large Load Diff

View File

@@ -18,15 +18,16 @@ Your task is to create a plan that can be dispatched to a seprate set of AI agen
- - "Navigate in Wave" with a link to Waze - - "Navigate in Wave" with a link to Waze
*** PERSONALITY *** *** PERSONALITY ***
You are a senior application architect specializing in modern web applications. Your task is to create a plan to improve the admin settings screen for managing the vehicle catalog. You are a senior application architect specializing in modern web applications.
Then read @docs/admin*.md files then read README.md CLAUDE.md and AI-INDEX.md to understand this code repository in the context of this change. Read README.md CLAUDE.md and AI-INDEX.md to understand this code repository in the context of this change.
*** FEATURE *** *** FEATURE ***
- Admin Settings feature. Specifically improvements in the speed and UX for editing the vehicle catalog. - Vehicles feature. This will include all vehicle cards and details. The photo should display where ever possible that it own't distort the original image too much.
*** BUGS TO FIX ***
- Loading the data just spins right now. The amount of data overflows the client browser so the UX never shows.
*** CHANGES TO IMPLEMENT *** *** CHANGES TO IMPLEMENT ***
- Recommend an improved workflow to create, update and delete vehicle catalog combinations. Your task is to create a plan to improve the vehicles feature. You need to add the ability to upload a custom image of the vehicle. If there is no custom image uploaded, default it to the make logo which is located at @frontend/public/images/makes/. If the make photo does exist, default to a neutral color in gray.
*** Explore Agent ***
When you explore for this implementation, look to see if any work has already been done.

View File

@@ -11,5 +11,5 @@ export function getStationPhotoUrl(photoReference: string | undefined): string |
return undefined; return undefined;
} }
return `/api/stations/photo/${encodeURIComponent(photoReference)}`; return `/stations/photo/${encodeURIComponent(photoReference)}`;
} }

View File

@@ -67,7 +67,8 @@ export const vehiclesApi = {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const response = await apiClient.post(`/vehicles/${vehicleId}/image`, formData, { const response = await apiClient.post(`/vehicles/${vehicleId}/image`, formData, {
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000 // 60 seconds for file uploads
}); });
return response.data; return response.data;
}, },

View File

@@ -61,6 +61,7 @@ interface VehicleFormProps {
initialData?: Partial<CreateVehicleRequest> & { id?: string; imageUrl?: string }; initialData?: Partial<CreateVehicleRequest> & { id?: string; imageUrl?: string };
loading?: boolean; loading?: boolean;
onImageUpdate?: (vehicle: Vehicle) => void; onImageUpdate?: (vehicle: Vehicle) => void;
onStagedImage?: (file: File | null) => void;
} }
export const VehicleForm: React.FC<VehicleFormProps> = ({ export const VehicleForm: React.FC<VehicleFormProps> = ({
@@ -69,6 +70,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
initialData, initialData,
loading, loading,
onImageUpdate, onImageUpdate,
onStagedImage,
}) => { }) => {
const [years, setYears] = useState<number[]>([]); const [years, setYears] = useState<number[]>([]);
const [makes, setMakes] = useState<string[]>([]); const [makes, setMakes] = useState<string[]>([]);
@@ -84,6 +86,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
const hasInitialized = useRef(false); const hasInitialized = useRef(false);
const isInitializing = useRef(false); const isInitializing = useRef(false);
const [currentImageUrl, setCurrentImageUrl] = useState<string | undefined>(initialData?.imageUrl); const [currentImageUrl, setCurrentImageUrl] = useState<string | undefined>(initialData?.imageUrl);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const isEditMode = !!initialData?.id; const isEditMode = !!initialData?.id;
const vehicleId = initialData?.id; const vehicleId = initialData?.id;
@@ -340,51 +343,69 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
}, [watchedYear, selectedMake, selectedModel, watch('trimLevel')]); }, [watchedYear, selectedMake, selectedModel, watch('trimLevel')]);
const handleImageUpload = async (file: File) => { const handleImageUpload = async (file: File) => {
if (!vehicleId) return; if (isEditMode && vehicleId) {
const updated = await vehiclesApi.uploadImage(vehicleId, file); // Edit mode: upload immediately to server
setCurrentImageUrl(updated.imageUrl); const updated = await vehiclesApi.uploadImage(vehicleId, file);
onImageUpdate?.(updated); setCurrentImageUrl(updated.imageUrl);
onImageUpdate?.(updated);
} else {
// Create mode: stage file locally for upload after vehicle creation
const objectUrl = URL.createObjectURL(file);
setPreviewUrl(objectUrl);
onStagedImage?.(file);
}
}; };
const handleImageRemove = async () => { const handleImageRemove = async () => {
if (!vehicleId) return; if (isEditMode && vehicleId) {
await vehiclesApi.deleteImage(vehicleId); // Edit mode: delete from server
setCurrentImageUrl(undefined); await vehiclesApi.deleteImage(vehicleId);
if (initialData) { setCurrentImageUrl(undefined);
onImageUpdate?.({ ...initialData, imageUrl: undefined } as Vehicle); if (initialData) {
onImageUpdate?.({ ...initialData, imageUrl: undefined } as Vehicle);
}
} else {
// Create mode: clear staged file
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewUrl(null);
onStagedImage?.(null);
} }
}; };
// Watch current form values for image preview (uses make for logo fallback)
const watchedColor = watch('color');
const currentMake = watch('make') || initialData?.make;
const vehicleForImage: Vehicle = { const vehicleForImage: Vehicle = {
id: vehicleId || '', id: vehicleId || '',
userId: '', userId: '',
vin: initialData?.vin || '', vin: initialData?.vin || '',
make: initialData?.make, make: currentMake,
model: initialData?.model, model: initialData?.model,
year: initialData?.year, year: initialData?.year,
color: initialData?.color, color: watchedColor || initialData?.color,
odometerReading: initialData?.odometerReading || 0, odometerReading: initialData?.odometerReading || 0,
isActive: true, isActive: true,
createdAt: '', createdAt: '',
updatedAt: '', updatedAt: '',
imageUrl: currentImageUrl, imageUrl: previewUrl || currentImageUrl,
}; };
return ( return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{isEditMode && ( <div className="mb-6">
<div className="mb-6"> <label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 mb-2"> Vehicle Photo
Vehicle Photo </label>
</label> <VehicleImageUpload
<VehicleImageUpload vehicle={vehicleForImage}
vehicle={vehicleForImage} onUpload={handleImageUpload}
onUpload={handleImageUpload} onRemove={handleImageRemove}
onRemove={handleImageRemove} disabled={loading || loadingDropdowns}
disabled={loading || loadingDropdowns} />
/> </div>
</div>
)}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">

View File

@@ -1,6 +1,6 @@
/** /**
* @ai-summary Vehicle image display with three-tier fallback * @ai-summary Vehicle image display with three-tier fallback
* Tier 1: Custom uploaded image * Tier 1: Custom uploaded image (fetched with auth headers)
* Tier 2: Make logo from /images/makes/ * Tier 2: Make logo from /images/makes/
* Tier 3: Color box placeholder * Tier 3: Color box placeholder
*/ */
@@ -8,6 +8,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import { Vehicle } from '../types/vehicles.types'; import { Vehicle } from '../types/vehicles.types';
import { apiClient } from '../../../core/api/client';
interface VehicleImageProps { interface VehicleImageProps {
vehicle: Vehicle; vehicle: Vehicle;
@@ -28,21 +29,72 @@ export const VehicleImage: React.FC<VehicleImageProps> = ({
}) => { }) => {
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
const [logoError, setLogoError] = useState(false); const [logoError, setLogoError] = useState(false);
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Fetch authenticated image and create blob URL
useEffect(() => { useEffect(() => {
setImgError(false); setImgError(false);
setLogoError(false); setLogoError(false);
if (!vehicle.imageUrl) {
setBlobUrl(null);
return;
}
// If imageUrl is already a blob URL (from preview), use it directly
if (vehicle.imageUrl.startsWith('blob:')) {
setBlobUrl(vehicle.imageUrl);
return;
}
let cancelled = false;
setIsLoading(true);
// Strip /api prefix if present since apiClient adds it
const url = vehicle.imageUrl.startsWith('/api/')
? vehicle.imageUrl.slice(4)
: vehicle.imageUrl;
apiClient.get(url, { responseType: 'blob' })
.then(response => {
if (cancelled) return;
const blobUrl = URL.createObjectURL(response.data);
setBlobUrl(blobUrl);
})
.catch(() => {
if (cancelled) return;
setImgError(true);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [vehicle.id, vehicle.imageUrl]); }, [vehicle.id, vehicle.imageUrl]);
if (vehicle.imageUrl && !imgError) { // Clean up blob URL on unmount (only if we created it, not if it was passed in)
useEffect(() => {
return () => {
if (blobUrl && vehicle.imageUrl !== blobUrl) {
URL.revokeObjectURL(blobUrl);
}
};
}, [blobUrl, vehicle.imageUrl]);
if (vehicle.imageUrl && !imgError && (blobUrl || isLoading)) {
return ( return (
<Box sx={{ height, borderRadius, overflow: 'hidden', mb: 2 }}> <Box sx={{ height, borderRadius, overflow: 'hidden', mb: 2, bgcolor: isLoading ? '#F2EAEA' : undefined }}>
<img {blobUrl && (
src={vehicle.imageUrl} <img
alt={`${vehicle.make || ''} ${vehicle.model || ''}`.trim() || 'Vehicle'} src={blobUrl}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt={`${vehicle.make || ''} ${vehicle.model || ''}`.trim() || 'Vehicle'}
onError={() => setImgError(true)} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/> onError={() => setImgError(true)}
/>
)}
</Box> </Box>
); );
} }

View File

@@ -17,9 +17,12 @@ import { Card } from '../../../shared-minimal/components/Card';
import { VehicleListSuspense, FormSuspense } from '../../../components/SuspenseWrappers'; import { VehicleListSuspense, FormSuspense } from '../../../components/SuspenseWrappers';
import { useAppStore } from '../../../core/store'; import { useAppStore } from '../../../core/store';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { vehiclesApi } from '../api/vehicles.api';
export const VehiclesPage: React.FC = () => { export const VehiclesPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: vehicles, isLoading } = useVehicles(); const { data: vehicles, isLoading } = useVehicles();
const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle); const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle);
@@ -42,6 +45,7 @@ export const VehiclesPage: React.FC = () => {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [stagedImageFile, setStagedImageFile] = useState<File | null>(null);
// Update search vehicles when optimistic vehicles change // Update search vehicles when optimistic vehicles change
useEffect(() => { useEffect(() => {
@@ -64,7 +68,37 @@ export const VehiclesPage: React.FC = () => {
}; };
const handleCreateVehicle = async (data: any) => { const handleCreateVehicle = async (data: any) => {
await optimisticCreateVehicle(data); const newVehicle = await optimisticCreateVehicle(data);
console.log('[VehiclesPage] Vehicle created:', newVehicle?.id, 'stagedImageFile:', !!stagedImageFile);
// Upload staged image if one was selected during creation
if (stagedImageFile && newVehicle?.id) {
// Don't upload if ID is temporary (optimistic)
if (newVehicle.id.startsWith('temp-')) {
console.warn('[VehiclesPage] Cannot upload image - vehicle has temporary ID:', newVehicle.id);
} else {
try {
console.log('[VehiclesPage] Uploading image for vehicle:', newVehicle.id);
const updatedVehicle = await vehiclesApi.uploadImage(newVehicle.id, stagedImageFile);
console.log('[VehiclesPage] Image uploaded, updated vehicle:', updatedVehicle);
// Directly update the cache with the vehicle that has imageUrl
queryClient.setQueryData(['vehicles'], (old: typeof vehicles) => {
if (!old || !Array.isArray(old)) return old;
return old.map(v => v.id === updatedVehicle.id ? updatedVehicle : v);
});
} catch (err: any) {
console.error('[VehiclesPage] Failed to upload vehicle image:', {
error: err,
message: err?.message,
response: err?.response?.data,
status: err?.response?.status
});
}
}
}
setStagedImageFile(null);
// Use transition for UI state change // Use transition for UI state change
startTransition(() => { startTransition(() => {
setShowForm(false); setShowForm(false);
@@ -150,6 +184,7 @@ export const VehiclesPage: React.FC = () => {
onSubmit={handleCreateVehicle} onSubmit={handleCreateVehicle}
onCancel={() => startTransition(() => setShowForm(false))} onCancel={() => startTransition(() => setShowForm(false))}
loading={isOptimisticPending} loading={isOptimisticPending}
onStagedImage={setStagedImageFile}
/> />
</Card> </Card>
</FormSuspense> </FormSuspense>