976 lines
28 KiB
TypeScript
976 lines
28 KiB
TypeScript
/**
|
|
* @ai-summary Vehicle catalog management service
|
|
* @ai-context Handles CRUD operations on platform vehicle catalog data with transaction support
|
|
*/
|
|
|
|
import { Pool } from 'pg';
|
|
import { logger } from '../../../core/logging/logger';
|
|
import { PlatformCacheService } from '../../platform/domain/platform-cache.service';
|
|
|
|
export interface CatalogMake {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
export interface CatalogModel {
|
|
id: number;
|
|
makeId: number;
|
|
name: string;
|
|
}
|
|
|
|
export interface CatalogYear {
|
|
id: number;
|
|
modelId: number;
|
|
year: number;
|
|
}
|
|
|
|
export interface CatalogTrim {
|
|
id: number;
|
|
yearId: number;
|
|
name: string;
|
|
}
|
|
|
|
export interface CatalogEngine {
|
|
id: number;
|
|
trimId: number;
|
|
name: string;
|
|
description?: string;
|
|
}
|
|
|
|
export interface PlatformChangeLog {
|
|
id: string;
|
|
changeType: 'CREATE' | 'UPDATE' | 'DELETE';
|
|
resourceType: 'makes' | 'models' | 'years' | 'trims' | 'engines';
|
|
resourceId: string;
|
|
oldValue: any;
|
|
newValue: any;
|
|
changedBy: string;
|
|
createdAt: Date;
|
|
}
|
|
|
|
export class VehicleCatalogService {
|
|
constructor(
|
|
private pool: Pool,
|
|
private cacheService: PlatformCacheService
|
|
) {}
|
|
|
|
// MAKES OPERATIONS
|
|
|
|
async getAllMakes(): Promise<CatalogMake[]> {
|
|
const query = `
|
|
SELECT cache_key, data
|
|
FROM vehicle_dropdown_cache
|
|
WHERE cache_key LIKE 'catalog:makes:%'
|
|
ORDER BY (data->>'name')
|
|
`;
|
|
|
|
try {
|
|
const result = await this.pool.query(query);
|
|
return result.rows.map(row => ({
|
|
id: parseInt(row.cache_key.split(':')[2]),
|
|
name: row.data.name
|
|
}));
|
|
} catch (error) {
|
|
logger.error('Error getting all makes', { error });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async createMake(name: string, changedBy: string): Promise<CatalogMake> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Get next ID
|
|
const idResult = await client.query(`
|
|
SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id
|
|
FROM vehicle_dropdown_cache
|
|
WHERE cache_key LIKE 'catalog:makes:%'
|
|
`);
|
|
const makeId = idResult.rows[0].next_id;
|
|
|
|
// Insert make
|
|
const make: CatalogMake = { id: makeId, name };
|
|
await client.query(`
|
|
INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
|
|
VALUES ($1, $2, NOW() + INTERVAL '10 years')
|
|
`, [`catalog:makes:${makeId}`, JSON.stringify(make)]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'CREATE', 'makes', makeId.toString(), null, make, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Make created', { makeId, name, changedBy });
|
|
return make;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error creating make', { error, name });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async updateMake(makeId: number, name: string, changedBy: string): Promise<CatalogMake> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Get old value
|
|
const oldResult = await client.query(`
|
|
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:makes:${makeId}`]);
|
|
|
|
if (oldResult.rows.length === 0) {
|
|
throw new Error(`Make ${makeId} not found`);
|
|
}
|
|
|
|
const oldValue = oldResult.rows[0].data;
|
|
const newValue: CatalogMake = { id: makeId, name };
|
|
|
|
// Update make
|
|
await client.query(`
|
|
UPDATE vehicle_dropdown_cache
|
|
SET data = $1, updated_at = NOW()
|
|
WHERE cache_key = $2
|
|
`, [JSON.stringify(newValue), `catalog:makes:${makeId}`]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'UPDATE', 'makes', makeId.toString(), oldValue, newValue, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Make updated', { makeId, name, changedBy });
|
|
return newValue;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error updating make', { error, makeId, name });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async deleteMake(makeId: number, changedBy: string): Promise<void> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Check for dependent models
|
|
const modelsCheck = await client.query(`
|
|
SELECT COUNT(*) as count
|
|
FROM vehicle_dropdown_cache
|
|
WHERE cache_key LIKE 'catalog:models:%'
|
|
AND (data->>'makeId')::int = $1
|
|
`, [makeId]);
|
|
|
|
if (parseInt(modelsCheck.rows[0].count) > 0) {
|
|
throw new Error('Cannot delete make with existing models');
|
|
}
|
|
|
|
// Get old value
|
|
const oldResult = await client.query(`
|
|
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:makes:${makeId}`]);
|
|
|
|
if (oldResult.rows.length === 0) {
|
|
throw new Error(`Make ${makeId} not found`);
|
|
}
|
|
|
|
const oldValue = oldResult.rows[0].data;
|
|
|
|
// Delete make
|
|
await client.query(`
|
|
DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:makes:${makeId}`]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'DELETE', 'makes', makeId.toString(), oldValue, null, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Make deleted', { makeId, changedBy });
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error deleting make', { error, makeId });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
// MODELS OPERATIONS
|
|
|
|
async getModelsByMake(makeId: number): Promise<CatalogModel[]> {
|
|
const query = `
|
|
SELECT cache_key, data
|
|
FROM vehicle_dropdown_cache
|
|
WHERE cache_key LIKE 'catalog:models:%'
|
|
AND (data->>'makeId')::int = $1
|
|
ORDER BY (data->>'name')
|
|
`;
|
|
|
|
try {
|
|
const result = await this.pool.query(query, [makeId]);
|
|
return result.rows.map(row => ({
|
|
id: parseInt(row.cache_key.split(':')[2]),
|
|
makeId: row.data.makeId,
|
|
name: row.data.name
|
|
}));
|
|
} catch (error) {
|
|
logger.error('Error getting models by make', { error, makeId });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async createModel(makeId: number, name: string, changedBy: string): Promise<CatalogModel> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Verify make exists
|
|
const makeCheck = await client.query(`
|
|
SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:makes:${makeId}`]);
|
|
|
|
if (makeCheck.rows.length === 0) {
|
|
throw new Error(`Make ${makeId} not found`);
|
|
}
|
|
|
|
// Get next ID
|
|
const idResult = await client.query(`
|
|
SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id
|
|
FROM vehicle_dropdown_cache
|
|
WHERE cache_key LIKE 'catalog:models:%'
|
|
`);
|
|
const modelId = idResult.rows[0].next_id;
|
|
|
|
// Insert model
|
|
const model: CatalogModel = { id: modelId, makeId, name };
|
|
await client.query(`
|
|
INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
|
|
VALUES ($1, $2, NOW() + INTERVAL '10 years')
|
|
`, [`catalog:models:${modelId}`, JSON.stringify(model)]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'CREATE', 'models', modelId.toString(), null, model, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Model created', { modelId, makeId, name, changedBy });
|
|
return model;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error creating model', { error, makeId, name });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async updateModel(modelId: number, makeId: number, name: string, changedBy: string): Promise<CatalogModel> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Verify make exists
|
|
const makeCheck = await client.query(`
|
|
SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:makes:${makeId}`]);
|
|
|
|
if (makeCheck.rows.length === 0) {
|
|
throw new Error(`Make ${makeId} not found`);
|
|
}
|
|
|
|
// Get old value
|
|
const oldResult = await client.query(`
|
|
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:models:${modelId}`]);
|
|
|
|
if (oldResult.rows.length === 0) {
|
|
throw new Error(`Model ${modelId} not found`);
|
|
}
|
|
|
|
const oldValue = oldResult.rows[0].data;
|
|
const newValue: CatalogModel = { id: modelId, makeId, name };
|
|
|
|
// Update model
|
|
await client.query(`
|
|
UPDATE vehicle_dropdown_cache
|
|
SET data = $1, updated_at = NOW()
|
|
WHERE cache_key = $2
|
|
`, [JSON.stringify(newValue), `catalog:models:${modelId}`]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'UPDATE', 'models', modelId.toString(), oldValue, newValue, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Model updated', { modelId, makeId, name, changedBy });
|
|
return newValue;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error updating model', { error, modelId, name });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async deleteModel(modelId: number, changedBy: string): Promise<void> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Check for dependent years
|
|
const yearsCheck = await client.query(`
|
|
SELECT COUNT(*) as count
|
|
FROM vehicle_dropdown_cache
|
|
WHERE cache_key LIKE 'catalog:years:%'
|
|
AND (data->>'modelId')::int = $1
|
|
`, [modelId]);
|
|
|
|
if (parseInt(yearsCheck.rows[0].count) > 0) {
|
|
throw new Error('Cannot delete model with existing years');
|
|
}
|
|
|
|
// Get old value
|
|
const oldResult = await client.query(`
|
|
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:models:${modelId}`]);
|
|
|
|
if (oldResult.rows.length === 0) {
|
|
throw new Error(`Model ${modelId} not found`);
|
|
}
|
|
|
|
const oldValue = oldResult.rows[0].data;
|
|
|
|
// Delete model
|
|
await client.query(`
|
|
DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:models:${modelId}`]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'DELETE', 'models', modelId.toString(), oldValue, null, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Model deleted', { modelId, changedBy });
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error deleting model', { error, modelId });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
// YEARS OPERATIONS
|
|
|
|
async getYearsByModel(modelId: number): Promise<CatalogYear[]> {
|
|
const query = `
|
|
SELECT cache_key, data
|
|
FROM vehicle_dropdown_cache
|
|
WHERE cache_key LIKE 'catalog:years:%'
|
|
AND (data->>'modelId')::int = $1
|
|
ORDER BY (data->>'year')::int DESC
|
|
`;
|
|
|
|
try {
|
|
const result = await this.pool.query(query, [modelId]);
|
|
return result.rows.map(row => ({
|
|
id: parseInt(row.cache_key.split(':')[2]),
|
|
modelId: row.data.modelId,
|
|
year: row.data.year
|
|
}));
|
|
} catch (error) {
|
|
logger.error('Error getting years by model', { error, modelId });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async createYear(modelId: number, year: number, changedBy: string): Promise<CatalogYear> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Verify model exists
|
|
const modelCheck = await client.query(`
|
|
SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:models:${modelId}`]);
|
|
|
|
if (modelCheck.rows.length === 0) {
|
|
throw new Error(`Model ${modelId} not found`);
|
|
}
|
|
|
|
// Get next ID
|
|
const idResult = await client.query(`
|
|
SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id
|
|
FROM vehicle_dropdown_cache
|
|
WHERE cache_key LIKE 'catalog:years:%'
|
|
`);
|
|
const yearId = idResult.rows[0].next_id;
|
|
|
|
// Insert year
|
|
const yearData: CatalogYear = { id: yearId, modelId, year };
|
|
await client.query(`
|
|
INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
|
|
VALUES ($1, $2, NOW() + INTERVAL '10 years')
|
|
`, [`catalog:years:${yearId}`, JSON.stringify(yearData)]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'CREATE', 'years', yearId.toString(), null, yearData, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Year created', { yearId, modelId, year, changedBy });
|
|
return yearData;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error creating year', { error, modelId, year });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async updateYear(yearId: number, modelId: number, year: number, changedBy: string): Promise<CatalogYear> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Verify model exists
|
|
const modelCheck = await client.query(`
|
|
SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:models:${modelId}`]);
|
|
|
|
if (modelCheck.rows.length === 0) {
|
|
throw new Error(`Model ${modelId} not found`);
|
|
}
|
|
|
|
// Get old value
|
|
const oldResult = await client.query(`
|
|
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:years:${yearId}`]);
|
|
|
|
if (oldResult.rows.length === 0) {
|
|
throw new Error(`Year ${yearId} not found`);
|
|
}
|
|
|
|
const oldValue = oldResult.rows[0].data;
|
|
const newValue: CatalogYear = { id: yearId, modelId, year };
|
|
|
|
// Update year
|
|
await client.query(`
|
|
UPDATE vehicle_dropdown_cache
|
|
SET data = $1, updated_at = NOW()
|
|
WHERE cache_key = $2
|
|
`, [JSON.stringify(newValue), `catalog:years:${yearId}`]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'UPDATE', 'years', yearId.toString(), oldValue, newValue, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Year updated', { yearId, modelId, year, changedBy });
|
|
return newValue;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error updating year', { error, yearId, year });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async deleteYear(yearId: number, changedBy: string): Promise<void> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Check for dependent trims
|
|
const trimsCheck = await client.query(`
|
|
SELECT COUNT(*) as count
|
|
FROM vehicle_dropdown_cache
|
|
WHERE cache_key LIKE 'catalog:trims:%'
|
|
AND (data->>'yearId')::int = $1
|
|
`, [yearId]);
|
|
|
|
if (parseInt(trimsCheck.rows[0].count) > 0) {
|
|
throw new Error('Cannot delete year with existing trims');
|
|
}
|
|
|
|
// Get old value
|
|
const oldResult = await client.query(`
|
|
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:years:${yearId}`]);
|
|
|
|
if (oldResult.rows.length === 0) {
|
|
throw new Error(`Year ${yearId} not found`);
|
|
}
|
|
|
|
const oldValue = oldResult.rows[0].data;
|
|
|
|
// Delete year
|
|
await client.query(`
|
|
DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:years:${yearId}`]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'DELETE', 'years', yearId.toString(), oldValue, null, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Year deleted', { yearId, changedBy });
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error deleting year', { error, yearId });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
// TRIMS OPERATIONS
|
|
|
|
async getTrimsByYear(yearId: number): Promise<CatalogTrim[]> {
|
|
const query = `
|
|
SELECT cache_key, data
|
|
FROM vehicle_dropdown_cache
|
|
WHERE cache_key LIKE 'catalog:trims:%'
|
|
AND (data->>'yearId')::int = $1
|
|
ORDER BY (data->>'name')
|
|
`;
|
|
|
|
try {
|
|
const result = await this.pool.query(query, [yearId]);
|
|
return result.rows.map(row => ({
|
|
id: parseInt(row.cache_key.split(':')[2]),
|
|
yearId: row.data.yearId,
|
|
name: row.data.name
|
|
}));
|
|
} catch (error) {
|
|
logger.error('Error getting trims by year', { error, yearId });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async createTrim(yearId: number, name: string, changedBy: string): Promise<CatalogTrim> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Verify year exists
|
|
const yearCheck = await client.query(`
|
|
SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:years:${yearId}`]);
|
|
|
|
if (yearCheck.rows.length === 0) {
|
|
throw new Error(`Year ${yearId} not found`);
|
|
}
|
|
|
|
// Get next ID
|
|
const idResult = await client.query(`
|
|
SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id
|
|
FROM vehicle_dropdown_cache
|
|
WHERE cache_key LIKE 'catalog:trims:%'
|
|
`);
|
|
const trimId = idResult.rows[0].next_id;
|
|
|
|
// Insert trim
|
|
const trim: CatalogTrim = { id: trimId, yearId, name };
|
|
await client.query(`
|
|
INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
|
|
VALUES ($1, $2, NOW() + INTERVAL '10 years')
|
|
`, [`catalog:trims:${trimId}`, JSON.stringify(trim)]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'CREATE', 'trims', trimId.toString(), null, trim, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Trim created', { trimId, yearId, name, changedBy });
|
|
return trim;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error creating trim', { error, yearId, name });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async updateTrim(trimId: number, yearId: number, name: string, changedBy: string): Promise<CatalogTrim> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Verify year exists
|
|
const yearCheck = await client.query(`
|
|
SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:years:${yearId}`]);
|
|
|
|
if (yearCheck.rows.length === 0) {
|
|
throw new Error(`Year ${yearId} not found`);
|
|
}
|
|
|
|
// Get old value
|
|
const oldResult = await client.query(`
|
|
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:trims:${trimId}`]);
|
|
|
|
if (oldResult.rows.length === 0) {
|
|
throw new Error(`Trim ${trimId} not found`);
|
|
}
|
|
|
|
const oldValue = oldResult.rows[0].data;
|
|
const newValue: CatalogTrim = { id: trimId, yearId, name };
|
|
|
|
// Update trim
|
|
await client.query(`
|
|
UPDATE vehicle_dropdown_cache
|
|
SET data = $1, updated_at = NOW()
|
|
WHERE cache_key = $2
|
|
`, [JSON.stringify(newValue), `catalog:trims:${trimId}`]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'UPDATE', 'trims', trimId.toString(), oldValue, newValue, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Trim updated', { trimId, yearId, name, changedBy });
|
|
return newValue;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error updating trim', { error, trimId, name });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async deleteTrim(trimId: number, changedBy: string): Promise<void> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Check for dependent engines
|
|
const enginesCheck = await client.query(`
|
|
SELECT COUNT(*) as count
|
|
FROM vehicle_dropdown_cache
|
|
WHERE cache_key LIKE 'catalog:engines:%'
|
|
AND (data->>'trimId')::int = $1
|
|
`, [trimId]);
|
|
|
|
if (parseInt(enginesCheck.rows[0].count) > 0) {
|
|
throw new Error('Cannot delete trim with existing engines');
|
|
}
|
|
|
|
// Get old value
|
|
const oldResult = await client.query(`
|
|
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:trims:${trimId}`]);
|
|
|
|
if (oldResult.rows.length === 0) {
|
|
throw new Error(`Trim ${trimId} not found`);
|
|
}
|
|
|
|
const oldValue = oldResult.rows[0].data;
|
|
|
|
// Delete trim
|
|
await client.query(`
|
|
DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:trims:${trimId}`]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'DELETE', 'trims', trimId.toString(), oldValue, null, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Trim deleted', { trimId, changedBy });
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error deleting trim', { error, trimId });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
// ENGINES OPERATIONS
|
|
|
|
async getEnginesByTrim(trimId: number): Promise<CatalogEngine[]> {
|
|
const query = `
|
|
SELECT cache_key, data
|
|
FROM vehicle_dropdown_cache
|
|
WHERE cache_key LIKE 'catalog:engines:%'
|
|
AND (data->>'trimId')::int = $1
|
|
ORDER BY (data->>'name')
|
|
`;
|
|
|
|
try {
|
|
const result = await this.pool.query(query, [trimId]);
|
|
return result.rows.map(row => ({
|
|
id: parseInt(row.cache_key.split(':')[2]),
|
|
trimId: row.data.trimId,
|
|
name: row.data.name,
|
|
description: row.data.description
|
|
}));
|
|
} catch (error) {
|
|
logger.error('Error getting engines by trim', { error, trimId });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async createEngine(trimId: number, name: string, description: string | undefined, changedBy: string): Promise<CatalogEngine> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Verify trim exists
|
|
const trimCheck = await client.query(`
|
|
SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:trims:${trimId}`]);
|
|
|
|
if (trimCheck.rows.length === 0) {
|
|
throw new Error(`Trim ${trimId} not found`);
|
|
}
|
|
|
|
// Get next ID
|
|
const idResult = await client.query(`
|
|
SELECT COALESCE(MAX(CAST(SPLIT_PART(cache_key, ':', 3) AS INTEGER)), 0) + 1 as next_id
|
|
FROM vehicle_dropdown_cache
|
|
WHERE cache_key LIKE 'catalog:engines:%'
|
|
`);
|
|
const engineId = idResult.rows[0].next_id;
|
|
|
|
// Insert engine
|
|
const engine: CatalogEngine = { id: engineId, trimId, name, description };
|
|
await client.query(`
|
|
INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at)
|
|
VALUES ($1, $2, NOW() + INTERVAL '10 years')
|
|
`, [`catalog:engines:${engineId}`, JSON.stringify(engine)]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'CREATE', 'engines', engineId.toString(), null, engine, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Engine created', { engineId, trimId, name, changedBy });
|
|
return engine;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error creating engine', { error, trimId, name });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async updateEngine(engineId: number, trimId: number, name: string, description: string | undefined, changedBy: string): Promise<CatalogEngine> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Verify trim exists
|
|
const trimCheck = await client.query(`
|
|
SELECT 1 FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:trims:${trimId}`]);
|
|
|
|
if (trimCheck.rows.length === 0) {
|
|
throw new Error(`Trim ${trimId} not found`);
|
|
}
|
|
|
|
// Get old value
|
|
const oldResult = await client.query(`
|
|
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:engines:${engineId}`]);
|
|
|
|
if (oldResult.rows.length === 0) {
|
|
throw new Error(`Engine ${engineId} not found`);
|
|
}
|
|
|
|
const oldValue = oldResult.rows[0].data;
|
|
const newValue: CatalogEngine = { id: engineId, trimId, name, description };
|
|
|
|
// Update engine
|
|
await client.query(`
|
|
UPDATE vehicle_dropdown_cache
|
|
SET data = $1, updated_at = NOW()
|
|
WHERE cache_key = $2
|
|
`, [JSON.stringify(newValue), `catalog:engines:${engineId}`]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'UPDATE', 'engines', engineId.toString(), oldValue, newValue, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Engine updated', { engineId, trimId, name, changedBy });
|
|
return newValue;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error updating engine', { error, engineId, name });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async deleteEngine(engineId: number, changedBy: string): Promise<void> {
|
|
const client = await this.pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Get old value
|
|
const oldResult = await client.query(`
|
|
SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:engines:${engineId}`]);
|
|
|
|
if (oldResult.rows.length === 0) {
|
|
throw new Error(`Engine ${engineId} not found`);
|
|
}
|
|
|
|
const oldValue = oldResult.rows[0].data;
|
|
|
|
// Delete engine
|
|
await client.query(`
|
|
DELETE FROM vehicle_dropdown_cache WHERE cache_key = $1
|
|
`, [`catalog:engines:${engineId}`]);
|
|
|
|
// Log change
|
|
await this.logChange(client, 'DELETE', 'engines', engineId.toString(), oldValue, null, changedBy);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Invalidate cache
|
|
await this.cacheService.invalidateVehicleData();
|
|
|
|
logger.info('Engine deleted', { engineId, changedBy });
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error deleting engine', { error, engineId });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
// HELPER METHODS
|
|
|
|
private async logChange(
|
|
client: any,
|
|
changeType: 'CREATE' | 'UPDATE' | 'DELETE',
|
|
resourceType: 'makes' | 'models' | 'years' | 'trims' | 'engines',
|
|
resourceId: string,
|
|
oldValue: any,
|
|
newValue: any,
|
|
changedBy: string
|
|
): Promise<void> {
|
|
const query = `
|
|
INSERT INTO platform_change_log (change_type, resource_type, resource_id, old_value, new_value, changed_by)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
`;
|
|
|
|
await client.query(query, [
|
|
changeType,
|
|
resourceType,
|
|
resourceId,
|
|
oldValue ? JSON.stringify(oldValue) : null,
|
|
newValue ? JSON.stringify(newValue) : null,
|
|
changedBy
|
|
]);
|
|
}
|
|
|
|
async getChangeLogs(limit: number = 100, offset: number = 0): Promise<{ logs: PlatformChangeLog[]; total: number }> {
|
|
const countQuery = 'SELECT COUNT(*) as total FROM platform_change_log';
|
|
const query = `
|
|
SELECT id, change_type, resource_type, resource_id, old_value, new_value, changed_by, created_at
|
|
FROM platform_change_log
|
|
ORDER BY created_at DESC
|
|
LIMIT $1 OFFSET $2
|
|
`;
|
|
|
|
try {
|
|
const [countResult, dataResult] = await Promise.all([
|
|
this.pool.query(countQuery),
|
|
this.pool.query(query, [limit, offset])
|
|
]);
|
|
|
|
const total = parseInt(countResult.rows[0].total, 10);
|
|
const logs = dataResult.rows.map(row => ({
|
|
id: row.id,
|
|
changeType: row.change_type,
|
|
resourceType: row.resource_type,
|
|
resourceId: row.resource_id,
|
|
oldValue: row.old_value,
|
|
newValue: row.new_value,
|
|
changedBy: row.changed_by,
|
|
createdAt: new Date(row.created_at)
|
|
}));
|
|
|
|
return { logs, total };
|
|
} catch (error) {
|
|
logger.error('Error fetching change logs', { error });
|
|
throw error;
|
|
}
|
|
}
|
|
}
|