Admin User v1

This commit is contained in:
Eric Gullickson
2025-11-05 19:04:06 -06:00
parent e4e7e32a4f
commit 8174e0d5f9
48 changed files with 11289 additions and 1112 deletions

View 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;
}
}
}

View 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[];
}

View 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,
};
}
}

View 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;
}
}
}