fix: before admin stations removal
This commit is contained in:
@@ -27,6 +27,7 @@ import { adminRoutes } from './features/admin/api/admin.routes';
|
||||
import { notificationsRoutes } from './features/notifications';
|
||||
import { userProfileRoutes } from './features/user-profile';
|
||||
import { onboardingRoutes } from './features/onboarding';
|
||||
import { userPreferencesRoutes } from './features/user-preferences';
|
||||
import { pool } from './core/config/database';
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
@@ -84,7 +85,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env['NODE_ENV'],
|
||||
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile']
|
||||
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +95,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
scope: 'api',
|
||||
timestamp: new Date().toISOString(),
|
||||
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile']
|
||||
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,6 +133,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
await app.register(adminRoutes, { prefix: '/api' });
|
||||
await app.register(notificationsRoutes, { prefix: '/api' });
|
||||
await app.register(userProfileRoutes, { prefix: '/api' });
|
||||
await app.register(userPreferencesRoutes, { prefix: '/api' });
|
||||
|
||||
// 404 handler
|
||||
app.setNotFoundHandler(async (_request, reply) => {
|
||||
|
||||
@@ -50,7 +50,7 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// Initialize catalog dependencies
|
||||
const platformCacheService = new PlatformCacheService(cacheService);
|
||||
const catalogService = new VehicleCatalogService(pool, platformCacheService);
|
||||
const catalogImportService = new CatalogImportService(pool);
|
||||
const catalogImportService = new CatalogImportService(pool, platformCacheService);
|
||||
const catalogController = new CatalogController(catalogService);
|
||||
catalogController.setImportService(catalogImportService);
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { PlatformCacheService } from '../../platform/domain/platform-cache.service';
|
||||
|
||||
export interface ImportRow {
|
||||
action: 'add' | 'update' | 'delete';
|
||||
year: number;
|
||||
make: string;
|
||||
model: string;
|
||||
@@ -25,7 +25,6 @@ export interface ImportPreviewResult {
|
||||
previewId: string;
|
||||
toCreate: ImportRow[];
|
||||
toUpdate: ImportRow[];
|
||||
toDelete: ImportRow[];
|
||||
errors: ImportError[];
|
||||
valid: boolean;
|
||||
}
|
||||
@@ -33,7 +32,6 @@ export interface ImportPreviewResult {
|
||||
export interface ImportApplyResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
errors: ImportError[];
|
||||
}
|
||||
|
||||
@@ -50,7 +48,10 @@ export interface ExportRow {
|
||||
const previewCache = new Map<string, { data: ImportPreviewResult; expiresAt: number }>();
|
||||
|
||||
export class CatalogImportService {
|
||||
constructor(private pool: Pool) {}
|
||||
constructor(
|
||||
private pool: Pool,
|
||||
private platformCacheService?: PlatformCacheService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Parse CSV content and validate without applying changes
|
||||
@@ -59,7 +60,6 @@ export class CatalogImportService {
|
||||
const previewId = uuidv4();
|
||||
const toCreate: ImportRow[] = [];
|
||||
const toUpdate: ImportRow[] = [];
|
||||
const toDelete: ImportRow[] = [];
|
||||
const errors: ImportError[] = [];
|
||||
|
||||
const lines = csvContent.trim().split('\n');
|
||||
@@ -68,7 +68,6 @@ export class CatalogImportService {
|
||||
previewId,
|
||||
toCreate,
|
||||
toUpdate,
|
||||
toDelete,
|
||||
errors: [{ row: 0, error: 'CSV must have a header row and at least one data row' }],
|
||||
valid: false,
|
||||
};
|
||||
@@ -78,15 +77,14 @@ export class CatalogImportService {
|
||||
const header = this.parseCSVLine(lines[0]);
|
||||
const headerLower = header.map(h => h.toLowerCase().trim());
|
||||
|
||||
// Validate required headers
|
||||
const requiredHeaders = ['action', 'year', 'make', 'model', 'trim'];
|
||||
// Validate required headers (no action column required - matches export format)
|
||||
const requiredHeaders = ['year', 'make', 'model', 'trim'];
|
||||
for (const required of requiredHeaders) {
|
||||
if (!headerLower.includes(required)) {
|
||||
return {
|
||||
previewId,
|
||||
toCreate,
|
||||
toUpdate,
|
||||
toDelete,
|
||||
errors: [{ row: 1, error: `Missing required header: ${required}` }],
|
||||
valid: false,
|
||||
};
|
||||
@@ -95,7 +93,6 @@ export class CatalogImportService {
|
||||
|
||||
// Find column indices
|
||||
const colIndices = {
|
||||
action: headerLower.indexOf('action'),
|
||||
year: headerLower.indexOf('year'),
|
||||
make: headerLower.indexOf('make'),
|
||||
model: headerLower.indexOf('model'),
|
||||
@@ -113,7 +110,6 @@ export class CatalogImportService {
|
||||
const rowNum = i + 1;
|
||||
|
||||
try {
|
||||
const action = values[colIndices.action]?.toLowerCase().trim();
|
||||
const year = parseInt(values[colIndices.year], 10);
|
||||
const make = values[colIndices.make]?.trim();
|
||||
const model = values[colIndices.model]?.trim();
|
||||
@@ -121,12 +117,6 @@ export class CatalogImportService {
|
||||
const engineName = colIndices.engineName >= 0 ? values[colIndices.engineName]?.trim() || null : null;
|
||||
const transmissionType = colIndices.transmissionType >= 0 ? values[colIndices.transmissionType]?.trim() || null : null;
|
||||
|
||||
// Validate action
|
||||
if (!['add', 'update', 'delete'].includes(action)) {
|
||||
errors.push({ row: rowNum, error: `Invalid action: ${action}. Must be add, update, or delete` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate year
|
||||
if (isNaN(year) || year < 1900 || year > 2100) {
|
||||
errors.push({ row: rowNum, error: `Invalid year: ${values[colIndices.year]}` });
|
||||
@@ -148,7 +138,6 @@ export class CatalogImportService {
|
||||
}
|
||||
|
||||
const row: ImportRow = {
|
||||
action: action as 'add' | 'update' | 'delete',
|
||||
year,
|
||||
make,
|
||||
model,
|
||||
@@ -157,7 +146,7 @@ export class CatalogImportService {
|
||||
transmissionType,
|
||||
};
|
||||
|
||||
// Check if record exists for validation
|
||||
// Check if record exists to determine create vs update (upsert logic)
|
||||
const existsResult = await this.pool.query(
|
||||
`SELECT id FROM vehicle_options
|
||||
WHERE year = $1 AND make = $2 AND model = $3 AND trim = $4
|
||||
@@ -166,26 +155,11 @@ export class CatalogImportService {
|
||||
);
|
||||
const exists = (existsResult.rowCount || 0) > 0;
|
||||
|
||||
if (action === 'add' && exists) {
|
||||
errors.push({ row: rowNum, error: `Record already exists: ${year} ${make} ${model} ${trim}` });
|
||||
continue;
|
||||
}
|
||||
if ((action === 'update' || action === 'delete') && !exists) {
|
||||
errors.push({ row: rowNum, error: `Record not found: ${year} ${make} ${model} ${trim}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort into appropriate bucket
|
||||
switch (action) {
|
||||
case 'add':
|
||||
toCreate.push(row);
|
||||
break;
|
||||
case 'update':
|
||||
toUpdate.push(row);
|
||||
break;
|
||||
case 'delete':
|
||||
toDelete.push(row);
|
||||
break;
|
||||
// Auto-detect: if exists -> update, else -> create
|
||||
if (exists) {
|
||||
toUpdate.push(row);
|
||||
} else {
|
||||
toCreate.push(row);
|
||||
}
|
||||
} catch (error: any) {
|
||||
errors.push({ row: rowNum, error: error.message || 'Parse error' });
|
||||
@@ -196,7 +170,6 @@ export class CatalogImportService {
|
||||
previewId,
|
||||
toCreate,
|
||||
toUpdate,
|
||||
toDelete,
|
||||
errors,
|
||||
valid: errors.length === 0,
|
||||
};
|
||||
@@ -230,7 +203,6 @@ export class CatalogImportService {
|
||||
const result: ImportApplyResult = {
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
@@ -247,7 +219,7 @@ export class CatalogImportService {
|
||||
const engineResult = await client.query(
|
||||
`INSERT INTO engines (name, fuel_type)
|
||||
VALUES ($1, 'Gas')
|
||||
ON CONFLICT (LOWER(name)) DO UPDATE SET name = EXCLUDED.name
|
||||
ON CONFLICT ((lower(name))) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id`,
|
||||
[row.engineName]
|
||||
);
|
||||
@@ -260,7 +232,7 @@ export class CatalogImportService {
|
||||
const transResult = await client.query(
|
||||
`INSERT INTO transmissions (type)
|
||||
VALUES ($1)
|
||||
ON CONFLICT (LOWER(type)) DO UPDATE SET type = EXCLUDED.type
|
||||
ON CONFLICT ((lower(type))) DO UPDATE SET type = EXCLUDED.type
|
||||
RETURNING id`,
|
||||
[row.transmissionType]
|
||||
);
|
||||
@@ -289,7 +261,7 @@ export class CatalogImportService {
|
||||
const engineResult = await client.query(
|
||||
`INSERT INTO engines (name, fuel_type)
|
||||
VALUES ($1, 'Gas')
|
||||
ON CONFLICT (LOWER(name)) DO UPDATE SET name = EXCLUDED.name
|
||||
ON CONFLICT ((lower(name))) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id`,
|
||||
[row.engineName]
|
||||
);
|
||||
@@ -302,7 +274,7 @@ export class CatalogImportService {
|
||||
const transResult = await client.query(
|
||||
`INSERT INTO transmissions (type)
|
||||
VALUES ($1)
|
||||
ON CONFLICT (LOWER(type)) DO UPDATE SET type = EXCLUDED.type
|
||||
ON CONFLICT ((lower(type))) DO UPDATE SET type = EXCLUDED.type
|
||||
RETURNING id`,
|
||||
[row.transmissionType]
|
||||
);
|
||||
@@ -323,41 +295,21 @@ export class CatalogImportService {
|
||||
}
|
||||
}
|
||||
|
||||
// Process deletes
|
||||
for (const row of preview.toDelete) {
|
||||
try {
|
||||
await client.query(
|
||||
`DELETE FROM vehicle_options
|
||||
WHERE year = $1 AND make = $2 AND model = $3 AND trim = $4`,
|
||||
[row.year, row.make, row.model, row.trim]
|
||||
);
|
||||
result.deleted++;
|
||||
} catch (error: any) {
|
||||
result.errors.push({ row: 0, error: `Failed to delete ${row.year} ${row.make} ${row.model} ${row.trim}: ${error.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Log the import action
|
||||
await this.pool.query(
|
||||
`INSERT INTO platform_change_log (change_type, resource_type, resource_id, old_value, new_value, changed_by)
|
||||
VALUES ('CREATE', 'import', $1, NULL, $2, $3)`,
|
||||
[
|
||||
previewId,
|
||||
JSON.stringify({ created: result.created, updated: result.updated, deleted: result.deleted }),
|
||||
changedBy,
|
||||
]
|
||||
);
|
||||
|
||||
// Remove preview from cache
|
||||
previewCache.delete(previewId);
|
||||
|
||||
// Invalidate vehicle data cache so dropdowns reflect new data
|
||||
if (this.platformCacheService) {
|
||||
await this.platformCacheService.invalidateVehicleData();
|
||||
logger.debug('Vehicle data cache invalidated after import');
|
||||
}
|
||||
|
||||
logger.info('Catalog import completed', {
|
||||
previewId,
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
deleted: result.deleted,
|
||||
errors: result.errors.length,
|
||||
changedBy,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @ai-summary Fastify route handlers for user preferences API
|
||||
* @ai-context HTTP request/response handling for unit system and currency preferences
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { UserPreferencesRepository } from '../../../core/user-preferences/data/user-preferences.repository';
|
||||
import { UpdateUserPreferencesRequest } from '../../../core/user-preferences/user-preferences.types';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
|
||||
export class UserPreferencesController {
|
||||
private repository: UserPreferencesRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new UserPreferencesRepository(pool);
|
||||
}
|
||||
|
||||
async getPreferences(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
let preferences = await this.repository.findByUserId(userId);
|
||||
|
||||
// Create default preferences if none exist
|
||||
if (!preferences) {
|
||||
preferences = await this.repository.create({
|
||||
userId,
|
||||
unitSystem: 'imperial',
|
||||
currencyCode: 'USD',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(200).send({
|
||||
id: preferences.id,
|
||||
userId: preferences.userId,
|
||||
unitSystem: preferences.unitSystem,
|
||||
currencyCode: preferences.currencyCode,
|
||||
timeZone: preferences.timeZone,
|
||||
createdAt: preferences.createdAt,
|
||||
updatedAt: preferences.updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting user preferences', { error, userId: (request as any).user?.sub });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get preferences',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updatePreferences(
|
||||
request: FastifyRequest<{ Body: UpdateUserPreferencesRequest }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const { unitSystem, currencyCode, timeZone } = request.body;
|
||||
|
||||
// Validate unitSystem if provided
|
||||
if (unitSystem && !['imperial', 'metric'].includes(unitSystem)) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'unitSystem must be either "imperial" or "metric"',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate currencyCode if provided (basic 3-letter check)
|
||||
if (currencyCode && !/^[A-Z]{3}$/.test(currencyCode)) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'currencyCode must be a 3-letter ISO currency code',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if preferences exist, create if not
|
||||
let preferences = await this.repository.findByUserId(userId);
|
||||
if (!preferences) {
|
||||
preferences = await this.repository.create({
|
||||
userId,
|
||||
unitSystem: unitSystem || 'imperial',
|
||||
currencyCode: currencyCode || 'USD',
|
||||
timeZone: timeZone || 'UTC',
|
||||
});
|
||||
} else {
|
||||
const updated = await this.repository.update(userId, {
|
||||
unitSystem,
|
||||
currencyCode,
|
||||
timeZone,
|
||||
});
|
||||
if (updated) {
|
||||
preferences = updated;
|
||||
}
|
||||
}
|
||||
|
||||
return reply.code(200).send({
|
||||
id: preferences.id,
|
||||
userId: preferences.userId,
|
||||
unitSystem: preferences.unitSystem,
|
||||
currencyCode: preferences.currencyCode,
|
||||
timeZone: preferences.timeZone,
|
||||
createdAt: preferences.createdAt,
|
||||
updatedAt: preferences.updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating user preferences', { error, userId: (request as any).user?.sub });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to update preferences',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @ai-summary Fastify routes for user preferences API
|
||||
* @ai-context Route definitions for unit system, currency, and timezone preferences
|
||||
*/
|
||||
|
||||
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { UserPreferencesController } from './user-preferences.controller';
|
||||
import { UpdateUserPreferencesRequest } from '../../../core/user-preferences/user-preferences.types';
|
||||
|
||||
export const userPreferencesRoutes: FastifyPluginAsync = async (
|
||||
fastify: FastifyInstance,
|
||||
_opts: FastifyPluginOptions
|
||||
) => {
|
||||
const controller = new UserPreferencesController();
|
||||
|
||||
// GET /api/user/preferences - Get user preferences (creates defaults if none)
|
||||
fastify.get('/user/preferences', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.getPreferences.bind(controller),
|
||||
});
|
||||
|
||||
// PUT /api/user/preferences - Update user preferences
|
||||
fastify.put<{ Body: UpdateUserPreferencesRequest }>('/user/preferences', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: controller.updatePreferences.bind(controller),
|
||||
});
|
||||
};
|
||||
6
backend/src/features/user-preferences/index.ts
Normal file
6
backend/src/features/user-preferences/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @ai-summary User preferences feature entry point
|
||||
* @ai-context Exports routes for unit system, currency, and timezone preferences
|
||||
*/
|
||||
|
||||
export { userPreferencesRoutes } from './api/user-preferences.routes';
|
||||
Reference in New Issue
Block a user