feat: add TCO types and repository updates (refs #15)
- Add CostInterval type and PAYMENTS_PER_YEAR constant - Add 7 TCO fields to Vehicle, CreateVehicleRequest, UpdateVehicleRequest - Update VehicleResponse and Body types - Update mapRow() with snake_case to camelCase mapping - Update create(), update(), batchInsert() for new fields - Add Zod validation for TCO fields with interval enum 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,9 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { isValidVIN } from '../../../shared-minimal/utils/validators';
|
import { isValidVIN } from '../../../shared-minimal/utils/validators';
|
||||||
|
|
||||||
|
// Cost interval enum for TCO recurring costs
|
||||||
|
const costIntervalSchema = z.enum(['monthly', 'semi_annual', 'annual']);
|
||||||
|
|
||||||
export const createVehicleSchema = z.object({
|
export const createVehicleSchema = z.object({
|
||||||
vin: z.string()
|
vin: z.string()
|
||||||
.length(17, 'VIN must be exactly 17 characters')
|
.length(17, 'VIN must be exactly 17 characters')
|
||||||
@@ -14,6 +17,14 @@ export const createVehicleSchema = z.object({
|
|||||||
color: z.string().min(1).max(50).optional(),
|
color: z.string().min(1).max(50).optional(),
|
||||||
licensePlate: z.string().min(1).max(20).optional(),
|
licensePlate: z.string().min(1).max(20).optional(),
|
||||||
odometerReading: z.number().min(0).max(9999999).optional(),
|
odometerReading: z.number().min(0).max(9999999).optional(),
|
||||||
|
// TCO fields
|
||||||
|
purchasePrice: z.number().min(0).max(99999999.99).optional(),
|
||||||
|
purchaseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD').optional(),
|
||||||
|
insuranceCost: z.number().min(0).max(9999999.99).optional(),
|
||||||
|
insuranceInterval: costIntervalSchema.optional(),
|
||||||
|
registrationCost: z.number().min(0).max(9999999.99).optional(),
|
||||||
|
registrationInterval: costIntervalSchema.optional(),
|
||||||
|
tcoEnabled: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateVehicleSchema = z.object({
|
export const updateVehicleSchema = z.object({
|
||||||
@@ -30,6 +41,14 @@ export const updateVehicleSchema = z.object({
|
|||||||
color: z.string().min(1).max(50).optional(),
|
color: z.string().min(1).max(50).optional(),
|
||||||
licensePlate: z.string().min(1).max(20).optional(),
|
licensePlate: z.string().min(1).max(20).optional(),
|
||||||
odometerReading: z.number().min(0).max(9999999).optional(),
|
odometerReading: z.number().min(0).max(9999999).optional(),
|
||||||
|
// TCO fields
|
||||||
|
purchasePrice: z.number().min(0).max(99999999.99).optional().nullable(),
|
||||||
|
purchaseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD').optional().nullable(),
|
||||||
|
insuranceCost: z.number().min(0).max(9999999.99).optional().nullable(),
|
||||||
|
insuranceInterval: costIntervalSchema.optional().nullable(),
|
||||||
|
registrationCost: z.number().min(0).max(9999999.99).optional().nullable(),
|
||||||
|
registrationInterval: costIntervalSchema.optional().nullable(),
|
||||||
|
tcoEnabled: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const vehicleIdSchema = z.object({
|
export const vehicleIdSchema = z.object({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import { Vehicle, CreateVehicleRequest, VehicleImageMeta } from '../domain/vehicles.types';
|
import { Vehicle, CreateVehicleRequest, VehicleImageMeta, CostInterval } from '../domain/vehicles.types';
|
||||||
|
|
||||||
export class VehiclesRepository {
|
export class VehiclesRepository {
|
||||||
constructor(private pool: Pool) {}
|
constructor(private pool: Pool) {}
|
||||||
@@ -12,14 +12,16 @@ export class VehiclesRepository {
|
|||||||
async create(data: CreateVehicleRequest & { userId: string, make?: string, model?: string, year?: number }): Promise<Vehicle> {
|
async create(data: CreateVehicleRequest & { userId: string, make?: string, model?: string, year?: number }): Promise<Vehicle> {
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO vehicles (
|
INSERT INTO vehicles (
|
||||||
user_id, vin, make, model, year,
|
user_id, vin, make, model, year,
|
||||||
engine, transmission, trim_level, drive_type, fuel_type,
|
engine, transmission, trim_level, drive_type, fuel_type,
|
||||||
nickname, color, license_plate, odometer_reading
|
nickname, color, license_plate, odometer_reading,
|
||||||
|
purchase_price, purchase_date, insurance_cost, insurance_interval,
|
||||||
|
registration_cost, registration_interval, tco_enabled
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const values = [
|
const values = [
|
||||||
data.userId,
|
data.userId,
|
||||||
(data.vin && data.vin.trim().length > 0) ? data.vin.trim() : null,
|
(data.vin && data.vin.trim().length > 0) ? data.vin.trim() : null,
|
||||||
@@ -34,7 +36,14 @@ export class VehiclesRepository {
|
|||||||
data.nickname,
|
data.nickname,
|
||||||
data.color,
|
data.color,
|
||||||
data.licensePlate,
|
data.licensePlate,
|
||||||
data.odometerReading || 0
|
data.odometerReading || 0,
|
||||||
|
data.purchasePrice ?? null,
|
||||||
|
data.purchaseDate ?? null,
|
||||||
|
data.insuranceCost ?? null,
|
||||||
|
data.insuranceInterval ?? null,
|
||||||
|
data.registrationCost ?? null,
|
||||||
|
data.registrationInterval ?? null,
|
||||||
|
data.tcoEnabled ?? false
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = await this.pool.query(query, values);
|
const result = await this.pool.query(query, values);
|
||||||
@@ -142,6 +151,35 @@ export class VehiclesRepository {
|
|||||||
fields.push(`odometer_reading = $${paramCount++}`);
|
fields.push(`odometer_reading = $${paramCount++}`);
|
||||||
values.push(data.odometerReading);
|
values.push(data.odometerReading);
|
||||||
}
|
}
|
||||||
|
// TCO fields
|
||||||
|
if (data.purchasePrice !== undefined) {
|
||||||
|
fields.push(`purchase_price = $${paramCount++}`);
|
||||||
|
values.push(data.purchasePrice);
|
||||||
|
}
|
||||||
|
if (data.purchaseDate !== undefined) {
|
||||||
|
fields.push(`purchase_date = $${paramCount++}`);
|
||||||
|
values.push(data.purchaseDate);
|
||||||
|
}
|
||||||
|
if (data.insuranceCost !== undefined) {
|
||||||
|
fields.push(`insurance_cost = $${paramCount++}`);
|
||||||
|
values.push(data.insuranceCost);
|
||||||
|
}
|
||||||
|
if (data.insuranceInterval !== undefined) {
|
||||||
|
fields.push(`insurance_interval = $${paramCount++}`);
|
||||||
|
values.push(data.insuranceInterval);
|
||||||
|
}
|
||||||
|
if (data.registrationCost !== undefined) {
|
||||||
|
fields.push(`registration_cost = $${paramCount++}`);
|
||||||
|
values.push(data.registrationCost);
|
||||||
|
}
|
||||||
|
if (data.registrationInterval !== undefined) {
|
||||||
|
fields.push(`registration_interval = $${paramCount++}`);
|
||||||
|
values.push(data.registrationInterval);
|
||||||
|
}
|
||||||
|
if (data.tcoEnabled !== undefined) {
|
||||||
|
fields.push(`tco_enabled = $${paramCount++}`);
|
||||||
|
values.push(data.tcoEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
if (fields.length === 0) {
|
if (fields.length === 0) {
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
@@ -193,10 +231,17 @@ export class VehiclesRepository {
|
|||||||
vehicle.nickname,
|
vehicle.nickname,
|
||||||
vehicle.color,
|
vehicle.color,
|
||||||
vehicle.licensePlate,
|
vehicle.licensePlate,
|
||||||
vehicle.odometerReading || 0
|
vehicle.odometerReading || 0,
|
||||||
|
vehicle.purchasePrice ?? null,
|
||||||
|
vehicle.purchaseDate ?? null,
|
||||||
|
vehicle.insuranceCost ?? null,
|
||||||
|
vehicle.insuranceInterval ?? null,
|
||||||
|
vehicle.registrationCost ?? null,
|
||||||
|
vehicle.registrationInterval ?? null,
|
||||||
|
vehicle.tcoEnabled ?? false
|
||||||
];
|
];
|
||||||
|
|
||||||
const placeholder = `($${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++})`;
|
const placeholder = `($${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++})`;
|
||||||
placeholders.push(placeholder);
|
placeholders.push(placeholder);
|
||||||
values.push(...vehicleParams);
|
values.push(...vehicleParams);
|
||||||
});
|
});
|
||||||
@@ -205,7 +250,9 @@ export class VehiclesRepository {
|
|||||||
INSERT INTO vehicles (
|
INSERT INTO vehicles (
|
||||||
user_id, vin, make, model, year,
|
user_id, vin, make, model, year,
|
||||||
engine, transmission, trim_level, drive_type, fuel_type,
|
engine, transmission, trim_level, drive_type, fuel_type,
|
||||||
nickname, color, license_plate, odometer_reading
|
nickname, color, license_plate, odometer_reading,
|
||||||
|
purchase_price, purchase_date, insurance_cost, insurance_interval,
|
||||||
|
registration_cost, registration_interval, tco_enabled
|
||||||
)
|
)
|
||||||
VALUES ${placeholders.join(', ')}
|
VALUES ${placeholders.join(', ')}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
@@ -292,6 +339,14 @@ export class VehiclesRepository {
|
|||||||
imageFileName: row.image_file_name,
|
imageFileName: row.image_file_name,
|
||||||
imageContentType: row.image_content_type,
|
imageContentType: row.image_content_type,
|
||||||
imageFileSize: row.image_file_size,
|
imageFileSize: row.image_file_size,
|
||||||
|
// TCO fields
|
||||||
|
purchasePrice: row.purchase_price ? Number(row.purchase_price) : undefined,
|
||||||
|
purchaseDate: row.purchase_date,
|
||||||
|
insuranceCost: row.insurance_cost ? Number(row.insurance_cost) : undefined,
|
||||||
|
insuranceInterval: row.insurance_interval as CostInterval | undefined,
|
||||||
|
registrationCost: row.registration_cost ? Number(row.registration_cost) : undefined,
|
||||||
|
registrationInterval: row.registration_interval as CostInterval | undefined,
|
||||||
|
tcoEnabled: row.tco_enabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
* @ai-context Core business types, no external dependencies
|
* @ai-context Core business types, no external dependencies
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// TCO cost interval types
|
||||||
|
export type CostInterval = 'monthly' | 'semi_annual' | 'annual';
|
||||||
|
|
||||||
|
export const PAYMENTS_PER_YEAR: Record<CostInterval, number> = {
|
||||||
|
monthly: 12,
|
||||||
|
semi_annual: 2,
|
||||||
|
annual: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface Vehicle {
|
export interface Vehicle {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -28,6 +37,14 @@ export interface Vehicle {
|
|||||||
imageFileName?: string;
|
imageFileName?: string;
|
||||||
imageContentType?: string;
|
imageContentType?: string;
|
||||||
imageFileSize?: number;
|
imageFileSize?: number;
|
||||||
|
// TCO fields
|
||||||
|
purchasePrice?: number;
|
||||||
|
purchaseDate?: string;
|
||||||
|
insuranceCost?: number;
|
||||||
|
insuranceInterval?: CostInterval;
|
||||||
|
registrationCost?: number;
|
||||||
|
registrationInterval?: CostInterval;
|
||||||
|
tcoEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateVehicleRequest {
|
export interface CreateVehicleRequest {
|
||||||
@@ -44,6 +61,14 @@ export interface CreateVehicleRequest {
|
|||||||
color?: string;
|
color?: string;
|
||||||
licensePlate?: string;
|
licensePlate?: string;
|
||||||
odometerReading?: number;
|
odometerReading?: number;
|
||||||
|
// TCO fields
|
||||||
|
purchasePrice?: number;
|
||||||
|
purchaseDate?: string;
|
||||||
|
insuranceCost?: number;
|
||||||
|
insuranceInterval?: CostInterval;
|
||||||
|
registrationCost?: number;
|
||||||
|
registrationInterval?: CostInterval;
|
||||||
|
tcoEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateVehicleRequest {
|
export interface UpdateVehicleRequest {
|
||||||
@@ -60,6 +85,14 @@ export interface UpdateVehicleRequest {
|
|||||||
color?: string;
|
color?: string;
|
||||||
licensePlate?: string;
|
licensePlate?: string;
|
||||||
odometerReading?: number;
|
odometerReading?: number;
|
||||||
|
// TCO fields
|
||||||
|
purchasePrice?: number;
|
||||||
|
purchaseDate?: string;
|
||||||
|
insuranceCost?: number;
|
||||||
|
insuranceInterval?: CostInterval;
|
||||||
|
registrationCost?: number;
|
||||||
|
registrationInterval?: CostInterval;
|
||||||
|
tcoEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VehicleResponse {
|
export interface VehicleResponse {
|
||||||
@@ -82,6 +115,14 @@ export interface VehicleResponse {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
// TCO fields
|
||||||
|
purchasePrice?: number;
|
||||||
|
purchaseDate?: string;
|
||||||
|
insuranceCost?: number;
|
||||||
|
insuranceInterval?: CostInterval;
|
||||||
|
registrationCost?: number;
|
||||||
|
registrationInterval?: CostInterval;
|
||||||
|
tcoEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VehicleImageMeta {
|
export interface VehicleImageMeta {
|
||||||
@@ -116,6 +157,14 @@ export interface CreateVehicleBody {
|
|||||||
color?: string;
|
color?: string;
|
||||||
licensePlate?: string;
|
licensePlate?: string;
|
||||||
odometerReading?: number;
|
odometerReading?: number;
|
||||||
|
// TCO fields
|
||||||
|
purchasePrice?: number;
|
||||||
|
purchaseDate?: string;
|
||||||
|
insuranceCost?: number;
|
||||||
|
insuranceInterval?: CostInterval;
|
||||||
|
registrationCost?: number;
|
||||||
|
registrationInterval?: CostInterval;
|
||||||
|
tcoEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateVehicleBody {
|
export interface UpdateVehicleBody {
|
||||||
@@ -132,6 +181,14 @@ export interface UpdateVehicleBody {
|
|||||||
color?: string;
|
color?: string;
|
||||||
licensePlate?: string;
|
licensePlate?: string;
|
||||||
odometerReading?: number;
|
odometerReading?: number;
|
||||||
|
// TCO fields
|
||||||
|
purchasePrice?: number;
|
||||||
|
purchaseDate?: string;
|
||||||
|
insuranceCost?: number;
|
||||||
|
insuranceInterval?: CostInterval;
|
||||||
|
registrationCost?: number;
|
||||||
|
registrationInterval?: CostInterval;
|
||||||
|
tcoEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VehicleParams {
|
export interface VehicleParams {
|
||||||
|
|||||||
Reference in New Issue
Block a user