Admin User v1
This commit is contained in:
130
backend/src/features/admin/domain/admin.service.ts
Normal file
130
backend/src/features/admin/domain/admin.service.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @ai-summary Admin feature business logic
|
||||
* @ai-context Handles admin user management with audit logging
|
||||
*/
|
||||
|
||||
import { AdminRepository } from '../data/admin.repository';
|
||||
import { AdminUser, AdminAuditLog } from './admin.types';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class AdminService {
|
||||
constructor(private repository: AdminRepository) {}
|
||||
|
||||
async getAdminByAuth0Sub(auth0Sub: string): Promise<AdminUser | null> {
|
||||
try {
|
||||
return await this.repository.getAdminByAuth0Sub(auth0Sub);
|
||||
} catch (error) {
|
||||
logger.error('Error getting admin by auth0_sub', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAdminByEmail(email: string): Promise<AdminUser | null> {
|
||||
try {
|
||||
return await this.repository.getAdminByEmail(email);
|
||||
} catch (error) {
|
||||
logger.error('Error getting admin by email', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllAdmins(): Promise<AdminUser[]> {
|
||||
try {
|
||||
return await this.repository.getAllAdmins();
|
||||
} catch (error) {
|
||||
logger.error('Error getting all admins', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveAdmins(): Promise<AdminUser[]> {
|
||||
try {
|
||||
return await this.repository.getActiveAdmins();
|
||||
} catch (error) {
|
||||
logger.error('Error getting active admins', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createAdmin(email: string, role: string, auth0Sub: string, createdBy: string): Promise<AdminUser> {
|
||||
try {
|
||||
// Check if admin already exists
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const existing = await this.repository.getAdminByEmail(normalizedEmail);
|
||||
if (existing) {
|
||||
throw new Error(`Admin user with email ${normalizedEmail} already exists`);
|
||||
}
|
||||
|
||||
// Create new admin
|
||||
const admin = await this.repository.createAdmin(auth0Sub, normalizedEmail, role, createdBy);
|
||||
|
||||
// Log audit action
|
||||
await this.repository.logAuditAction(createdBy, 'CREATE', admin.auth0Sub, 'admin_user', admin.email, {
|
||||
email,
|
||||
role
|
||||
});
|
||||
|
||||
logger.info('Admin user created', { email, role });
|
||||
return admin;
|
||||
} catch (error) {
|
||||
logger.error('Error creating admin', { error, email });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async revokeAdmin(auth0Sub: string, revokedBy: string): Promise<AdminUser> {
|
||||
try {
|
||||
// Check that at least one active admin will remain
|
||||
const activeAdmins = await this.repository.getActiveAdmins();
|
||||
if (activeAdmins.length <= 1) {
|
||||
throw new Error('Cannot revoke the last active admin');
|
||||
}
|
||||
|
||||
// Revoke the admin
|
||||
const admin = await this.repository.revokeAdmin(auth0Sub);
|
||||
|
||||
// Log audit action
|
||||
await this.repository.logAuditAction(revokedBy, 'REVOKE', auth0Sub, 'admin_user', admin.email);
|
||||
|
||||
logger.info('Admin user revoked', { auth0Sub, email: admin.email });
|
||||
return admin;
|
||||
} catch (error) {
|
||||
logger.error('Error revoking admin', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async reinstateAdmin(auth0Sub: string, reinstatedBy: string): Promise<AdminUser> {
|
||||
try {
|
||||
// Reinstate the admin
|
||||
const admin = await this.repository.reinstateAdmin(auth0Sub);
|
||||
|
||||
// Log audit action
|
||||
await this.repository.logAuditAction(reinstatedBy, 'REINSTATE', auth0Sub, 'admin_user', admin.email);
|
||||
|
||||
logger.info('Admin user reinstated', { auth0Sub, email: admin.email });
|
||||
return admin;
|
||||
} catch (error) {
|
||||
logger.error('Error reinstating admin', { error, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAuditLogs(limit: number = 100, offset: number = 0): Promise<{ logs: AdminAuditLog[]; total: number }> {
|
||||
try {
|
||||
return await this.repository.getAuditLogs(limit, offset);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching audit logs', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async linkAdminAuth0Sub(email: string, auth0Sub: string): Promise<AdminUser> {
|
||||
try {
|
||||
return await this.repository.updateAuth0SubByEmail(email.trim().toLowerCase(), auth0Sub);
|
||||
} catch (error) {
|
||||
logger.error('Error linking admin auth0_sub to email', { error, email, auth0Sub });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
55
backend/src/features/admin/domain/admin.types.ts
Normal file
55
backend/src/features/admin/domain/admin.types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @ai-summary Admin feature types and interfaces
|
||||
* @ai-context Defines admin user, audit log, and related data structures
|
||||
*/
|
||||
|
||||
export interface AdminUser {
|
||||
auth0Sub: string;
|
||||
email: string;
|
||||
role: 'admin' | 'super_admin';
|
||||
createdAt: Date;
|
||||
createdBy: string;
|
||||
revokedAt: Date | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateAdminRequest {
|
||||
email: string;
|
||||
role?: 'admin' | 'super_admin';
|
||||
}
|
||||
|
||||
export interface RevokeAdminRequest {
|
||||
auth0Sub: string;
|
||||
}
|
||||
|
||||
export interface ReinstateAdminRequest {
|
||||
auth0Sub: string;
|
||||
}
|
||||
|
||||
export interface AdminAuditLog {
|
||||
id: string;
|
||||
actorAdminId: string;
|
||||
targetAdminId: string | null;
|
||||
action: 'CREATE' | 'REVOKE' | 'REINSTATE' | 'UPDATE' | 'DELETE';
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
context?: Record<string, any>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface AdminContext {
|
||||
userId: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
adminRecord?: AdminUser;
|
||||
}
|
||||
|
||||
export interface AdminListResponse {
|
||||
total: number;
|
||||
admins: AdminUser[];
|
||||
}
|
||||
|
||||
export interface AdminAuditResponse {
|
||||
total: number;
|
||||
logs: AdminAuditLog[];
|
||||
}
|
||||
436
backend/src/features/admin/domain/station-oversight.service.ts
Normal file
436
backend/src/features/admin/domain/station-oversight.service.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* @ai-summary Station oversight business logic for admin operations
|
||||
* @ai-context Manages global stations and user-saved stations with audit logging
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { redis } from '../../../core/config/redis';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { AdminRepository } from '../data/admin.repository';
|
||||
import { StationsRepository } from '../../stations/data/stations.repository';
|
||||
import { Station, SavedStation } from '../../stations/domain/stations.types';
|
||||
|
||||
interface CreateStationData {
|
||||
placeId: string;
|
||||
name: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
priceRegular?: number;
|
||||
pricePremium?: number;
|
||||
priceDiesel?: number;
|
||||
rating?: number;
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
interface UpdateStationData {
|
||||
name?: string;
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
priceRegular?: number;
|
||||
pricePremium?: number;
|
||||
priceDiesel?: number;
|
||||
rating?: number;
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
interface StationListResult {
|
||||
total: number;
|
||||
stations: Station[];
|
||||
}
|
||||
|
||||
export class StationOversightService {
|
||||
private stationsRepository: StationsRepository;
|
||||
|
||||
constructor(
|
||||
private pool: Pool,
|
||||
private adminRepository: AdminRepository
|
||||
) {
|
||||
this.stationsRepository = new StationsRepository(pool);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all stations globally with pagination and search
|
||||
*/
|
||||
async listAllStations(
|
||||
limit: number = 100,
|
||||
offset: number = 0,
|
||||
search?: string
|
||||
): Promise<StationListResult> {
|
||||
try {
|
||||
let countQuery = 'SELECT COUNT(*) as total FROM station_cache';
|
||||
let dataQuery = `
|
||||
SELECT
|
||||
id, place_id, name, address, latitude, longitude,
|
||||
price_regular, price_premium, price_diesel, rating, photo_url, cached_at
|
||||
FROM station_cache
|
||||
`;
|
||||
const params: any[] = [];
|
||||
|
||||
// Add search filter if provided
|
||||
if (search) {
|
||||
const searchCondition = ` WHERE name ILIKE $1 OR address ILIKE $1`;
|
||||
countQuery += searchCondition;
|
||||
dataQuery += searchCondition;
|
||||
params.push(`%${search}%`);
|
||||
}
|
||||
|
||||
dataQuery += ' ORDER BY cached_at DESC LIMIT $' + (params.length + 1) + ' OFFSET $' + (params.length + 2);
|
||||
params.push(limit, offset);
|
||||
|
||||
const [countResult, dataResult] = await Promise.all([
|
||||
this.pool.query(countQuery, search ? [`%${search}%`] : []),
|
||||
this.pool.query(dataQuery, params),
|
||||
]);
|
||||
|
||||
const total = parseInt(countResult.rows[0].total, 10);
|
||||
const stations = dataResult.rows.map(row => this.mapStationRow(row));
|
||||
|
||||
return { total, stations };
|
||||
} catch (error) {
|
||||
logger.error('Error listing all stations', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new station in the cache
|
||||
*/
|
||||
async createStation(
|
||||
actorId: string,
|
||||
data: CreateStationData
|
||||
): Promise<Station> {
|
||||
try {
|
||||
// Create station using repository
|
||||
const station: Station = {
|
||||
id: '', // Will be generated by database
|
||||
placeId: data.placeId,
|
||||
name: data.name,
|
||||
address: data.address,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
priceRegular: data.priceRegular,
|
||||
pricePremium: data.pricePremium,
|
||||
priceDiesel: data.priceDiesel,
|
||||
rating: data.rating,
|
||||
photoUrl: data.photoUrl,
|
||||
};
|
||||
|
||||
await this.stationsRepository.cacheStation(station);
|
||||
|
||||
// Get the created station
|
||||
const created = await this.stationsRepository.getCachedStation(data.placeId);
|
||||
if (!created) {
|
||||
throw new Error('Failed to retrieve created station');
|
||||
}
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateStationCaches();
|
||||
|
||||
// Log audit action
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorId,
|
||||
'CREATE',
|
||||
undefined,
|
||||
'station',
|
||||
data.placeId,
|
||||
{ name: data.name, address: data.address }
|
||||
);
|
||||
|
||||
logger.info('Station created by admin', { actorId, placeId: data.placeId });
|
||||
return created;
|
||||
} catch (error) {
|
||||
logger.error('Error creating station', { error, data });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing station
|
||||
*/
|
||||
async updateStation(
|
||||
actorId: string,
|
||||
stationId: string,
|
||||
data: UpdateStationData
|
||||
): Promise<Station> {
|
||||
try {
|
||||
// First verify station exists
|
||||
const existing = await this.stationsRepository.getCachedStation(stationId);
|
||||
if (!existing) {
|
||||
throw new Error('Station not found');
|
||||
}
|
||||
|
||||
// Build update query dynamically based on provided fields
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.name !== undefined) {
|
||||
updates.push(`name = $${paramIndex++}`);
|
||||
values.push(data.name);
|
||||
}
|
||||
if (data.address !== undefined) {
|
||||
updates.push(`address = $${paramIndex++}`);
|
||||
values.push(data.address);
|
||||
}
|
||||
if (data.latitude !== undefined) {
|
||||
updates.push(`latitude = $${paramIndex++}`);
|
||||
values.push(data.latitude);
|
||||
}
|
||||
if (data.longitude !== undefined) {
|
||||
updates.push(`longitude = $${paramIndex++}`);
|
||||
values.push(data.longitude);
|
||||
}
|
||||
if (data.priceRegular !== undefined) {
|
||||
updates.push(`price_regular = $${paramIndex++}`);
|
||||
values.push(data.priceRegular);
|
||||
}
|
||||
if (data.pricePremium !== undefined) {
|
||||
updates.push(`price_premium = $${paramIndex++}`);
|
||||
values.push(data.pricePremium);
|
||||
}
|
||||
if (data.priceDiesel !== undefined) {
|
||||
updates.push(`price_diesel = $${paramIndex++}`);
|
||||
values.push(data.priceDiesel);
|
||||
}
|
||||
if (data.rating !== undefined) {
|
||||
updates.push(`rating = $${paramIndex++}`);
|
||||
values.push(data.rating);
|
||||
}
|
||||
if (data.photoUrl !== undefined) {
|
||||
updates.push(`photo_url = $${paramIndex++}`);
|
||||
values.push(data.photoUrl);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
updates.push(`cached_at = NOW()`);
|
||||
values.push(stationId);
|
||||
|
||||
const query = `
|
||||
UPDATE station_cache
|
||||
SET ${updates.join(', ')}
|
||||
WHERE place_id = $${paramIndex}
|
||||
`;
|
||||
|
||||
await this.pool.query(query, values);
|
||||
|
||||
// Get updated station
|
||||
const updated = await this.stationsRepository.getCachedStation(stationId);
|
||||
if (!updated) {
|
||||
throw new Error('Failed to retrieve updated station');
|
||||
}
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateStationCaches(stationId);
|
||||
|
||||
// Log audit action
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorId,
|
||||
'UPDATE',
|
||||
undefined,
|
||||
'station',
|
||||
stationId,
|
||||
data
|
||||
);
|
||||
|
||||
logger.info('Station updated by admin', { actorId, stationId });
|
||||
return updated;
|
||||
} catch (error) {
|
||||
logger.error('Error updating station', { error, stationId, data });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a station (soft delete by default, hard delete with force flag)
|
||||
*/
|
||||
async deleteStation(
|
||||
actorId: string,
|
||||
stationId: string,
|
||||
force: boolean = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Verify station exists
|
||||
const existing = await this.stationsRepository.getCachedStation(stationId);
|
||||
if (!existing) {
|
||||
throw new Error('Station not found');
|
||||
}
|
||||
|
||||
if (force) {
|
||||
// Hard delete - remove from both tables
|
||||
await this.pool.query('DELETE FROM station_cache WHERE place_id = $1', [stationId]);
|
||||
await this.pool.query('DELETE FROM saved_stations WHERE place_id = $1', [stationId]);
|
||||
|
||||
logger.info('Station hard deleted by admin', { actorId, stationId });
|
||||
} else {
|
||||
// Soft delete - add deleted_at column if not exists, then set it
|
||||
// First check if column exists
|
||||
const columnCheck = await this.pool.query(`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'station_cache' AND column_name = 'deleted_at'
|
||||
`);
|
||||
|
||||
if (columnCheck.rows.length === 0) {
|
||||
// Add deleted_at column
|
||||
await this.pool.query(`
|
||||
ALTER TABLE station_cache
|
||||
ADD COLUMN deleted_at TIMESTAMP WITH TIME ZONE
|
||||
`);
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
await this.pool.query(
|
||||
'UPDATE station_cache SET deleted_at = NOW() WHERE place_id = $1',
|
||||
[stationId]
|
||||
);
|
||||
|
||||
logger.info('Station soft deleted by admin', { actorId, stationId });
|
||||
}
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateStationCaches(stationId);
|
||||
|
||||
// Log audit action
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorId,
|
||||
'DELETE',
|
||||
undefined,
|
||||
'station',
|
||||
stationId,
|
||||
{ force }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting station', { error, stationId, force });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's saved stations
|
||||
*/
|
||||
async getUserSavedStations(userId: string): Promise<SavedStation[]> {
|
||||
try {
|
||||
const stations = await this.stationsRepository.getUserSavedStations(userId);
|
||||
return stations;
|
||||
} catch (error) {
|
||||
logger.error('Error getting user saved stations', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user's saved station (soft delete by default, hard delete with force)
|
||||
*/
|
||||
async removeUserSavedStation(
|
||||
actorId: string,
|
||||
userId: string,
|
||||
stationId: string,
|
||||
force: boolean = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (force) {
|
||||
// Hard delete
|
||||
const result = await this.pool.query(
|
||||
'DELETE FROM saved_stations WHERE user_id = $1 AND place_id = $2',
|
||||
[userId, stationId]
|
||||
);
|
||||
|
||||
if ((result.rowCount ?? 0) === 0) {
|
||||
throw new Error('Saved station not found');
|
||||
}
|
||||
|
||||
logger.info('User saved station hard deleted by admin', { actorId, userId, stationId });
|
||||
} else {
|
||||
// Soft delete - add deleted_at column if not exists
|
||||
const columnCheck = await this.pool.query(`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'saved_stations' AND column_name = 'deleted_at'
|
||||
`);
|
||||
|
||||
if (columnCheck.rows.length === 0) {
|
||||
// Column already exists in migration, but double check
|
||||
logger.warn('deleted_at column check executed', { table: 'saved_stations' });
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
const result = await this.pool.query(
|
||||
'UPDATE saved_stations SET deleted_at = NOW() WHERE user_id = $1 AND place_id = $2 AND deleted_at IS NULL',
|
||||
[userId, stationId]
|
||||
);
|
||||
|
||||
if ((result.rowCount ?? 0) === 0) {
|
||||
throw new Error('Saved station not found or already deleted');
|
||||
}
|
||||
|
||||
logger.info('User saved station soft deleted by admin', { actorId, userId, stationId });
|
||||
}
|
||||
|
||||
// Invalidate user's saved stations cache
|
||||
await redis.del(`mvp:stations:saved:${userId}`);
|
||||
|
||||
// Log audit action
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorId,
|
||||
'DELETE',
|
||||
undefined,
|
||||
'saved_station',
|
||||
`${userId}:${stationId}`,
|
||||
{ userId, stationId, force }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error removing user saved station', { error, userId, stationId, force });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate station-related Redis caches
|
||||
*/
|
||||
private async invalidateStationCaches(stationId?: string): Promise<void> {
|
||||
try {
|
||||
// Get all keys matching station cache patterns
|
||||
const patterns = [
|
||||
'mvp:stations:*',
|
||||
'mvp:stations:search:*',
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const keys = await redis.keys(pattern);
|
||||
if (keys.length > 0) {
|
||||
await redis.del(...keys);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Station caches invalidated', { stationId });
|
||||
} catch (error) {
|
||||
logger.error('Error invalidating station caches', { error, stationId });
|
||||
// Don't throw - cache invalidation failure shouldn't fail the operation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map database row to Station object
|
||||
*/
|
||||
private mapStationRow(row: any): Station {
|
||||
return {
|
||||
id: row.id,
|
||||
placeId: row.place_id,
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
latitude: parseFloat(row.latitude),
|
||||
longitude: parseFloat(row.longitude),
|
||||
priceRegular: row.price_regular ? parseFloat(row.price_regular) : undefined,
|
||||
pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined,
|
||||
priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined,
|
||||
rating: row.rating ? parseFloat(row.rating) : undefined,
|
||||
photoUrl: row.photo_url,
|
||||
lastUpdated: row.cached_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
975
backend/src/features/admin/domain/vehicle-catalog.service.ts
Normal file
975
backend/src/features/admin/domain/vehicle-catalog.service.ts
Normal file
@@ -0,0 +1,975 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user