diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..2d14e55 --- /dev/null +++ b/.env.development @@ -0,0 +1,20 @@ +# Development Environment Variables +# This file is for local development only - NOT for production k8s deployment +# In k8s, these values come from ConfigMaps and Secrets + +# Frontend Vite Configuration (build-time only) +VITE_AUTH0_DOMAIN=motovaultpro.us.auth0.com +VITE_AUTH0_CLIENT_ID=yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3 +VITE_AUTH0_AUDIENCE=https://api.motovaultpro.com +VITE_API_BASE_URL=/api + +# Docker Compose Development Configuration +# These variables are used by docker-compose for container build args only +AUTH0_DOMAIN=motovaultpro.us.auth0.com +AUTH0_CLIENT_ID=yspR8zdnSxmV8wFIghHynQ08iXAPoQJ3 +AUTH0_AUDIENCE=https://api.motovaultpro.com + +# NOTE: Backend services no longer use this file +# Backend configuration comes from: +# - /app/config/production.yml (non-sensitive config) +# - /run/secrets/ (sensitive secrets) \ No newline at end of file diff --git a/backend/src/features/admin/domain/vehicle-catalog.service.ts b/backend/src/features/admin/domain/vehicle-catalog.service.ts index 2b9a213..3b9766a 100644 --- a/backend/src/features/admin/domain/vehicle-catalog.service.ts +++ b/backend/src/features/admin/domain/vehicle-catalog.service.ts @@ -10,24 +10,32 @@ import { PlatformCacheService } from '../../platform/domain/platform-cache.servi export interface CatalogMake { id: number; name: string; + createdAt: string; + updatedAt: string; } export interface CatalogModel { id: number; makeId: number; name: string; + createdAt: string; + updatedAt: string; } export interface CatalogYear { id: number; modelId: number; year: number; + createdAt: string; + updatedAt: string; } export interface CatalogTrim { id: number; yearId: number; name: string; + createdAt: string; + updatedAt: string; } export interface CatalogEngine { @@ -35,6 +43,8 @@ export interface CatalogEngine { trimId: number; name: string; description?: string; + createdAt: string; + updatedAt: string; } export interface PlatformChangeLog { @@ -58,7 +68,7 @@ export class VehicleCatalogService { async getAllMakes(): Promise { const query = ` - SELECT cache_key, data + SELECT cache_key, data, created_at, updated_at FROM vehicle_dropdown_cache WHERE cache_key LIKE 'catalog:makes:%' ORDER BY (data->>'name') @@ -66,10 +76,18 @@ export class VehicleCatalogService { try { const result = await this.pool.query(query); - return result.rows.map(row => ({ - id: parseInt(row.cache_key.split(':')[2]), - name: row.data.name - })); + return result.rows.map(row => { + const createdAt = + row.data?.createdAt ?? this.toIsoDate(row.created_at); + const updatedAt = + row.data?.updatedAt ?? this.toIsoDate(row.updated_at); + return { + id: parseInt(row.cache_key.split(':')[2]), + name: row.data.name, + createdAt, + updatedAt, + }; + }); } catch (error) { logger.error('Error getting all makes', { error }); throw error; @@ -91,7 +109,8 @@ export class VehicleCatalogService { const makeId = idResult.rows[0].next_id; // Insert make - const make: CatalogMake = { id: makeId, name }; + const now = new Date().toISOString(); + const make: CatalogMake = { id: makeId, name, createdAt: now, updatedAt: now }; await client.query(` INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) VALUES ($1, $2, NOW() + INTERVAL '10 years') @@ -124,15 +143,23 @@ export class VehicleCatalogService { // Get old value const oldResult = await client.query(` - SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 + SELECT data, created_at 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 }; + const oldRow = oldResult.rows[0]; + const oldValue = oldRow.data; + const createdAt = + oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at); + const newValue: CatalogMake = { + id: makeId, + name, + createdAt, + updatedAt: new Date().toISOString(), + }; // Update make await client.query(` @@ -216,7 +243,7 @@ export class VehicleCatalogService { async getModelsByMake(makeId: number): Promise { const query = ` - SELECT cache_key, data + SELECT cache_key, data, created_at, updated_at FROM vehicle_dropdown_cache WHERE cache_key LIKE 'catalog:models:%' AND (data->>'makeId')::int = $1 @@ -228,7 +255,11 @@ export class VehicleCatalogService { return result.rows.map(row => ({ id: parseInt(row.cache_key.split(':')[2]), makeId: row.data.makeId, - name: row.data.name + name: row.data.name, + createdAt: + row.data?.createdAt ?? this.toIsoDate(row.created_at), + updatedAt: + row.data?.updatedAt ?? this.toIsoDate(row.updated_at), })); } catch (error) { logger.error('Error getting models by make', { error, makeId }); @@ -260,7 +291,14 @@ export class VehicleCatalogService { const modelId = idResult.rows[0].next_id; // Insert model - const model: CatalogModel = { id: modelId, makeId, name }; + const now = new Date().toISOString(); + const model: CatalogModel = { + id: modelId, + makeId, + name, + createdAt: now, + updatedAt: now, + }; await client.query(` INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) VALUES ($1, $2, NOW() + INTERVAL '10 years') @@ -302,15 +340,24 @@ export class VehicleCatalogService { // Get old value const oldResult = await client.query(` - SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 + SELECT data, created_at 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 }; + const oldRow = oldResult.rows[0]; + const oldValue = oldRow.data; + const createdAt = + oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at); + const newValue: CatalogModel = { + id: modelId, + makeId, + name, + createdAt, + updatedAt: new Date().toISOString(), + }; // Update model await client.query(` @@ -394,7 +441,7 @@ export class VehicleCatalogService { async getYearsByModel(modelId: number): Promise { const query = ` - SELECT cache_key, data + SELECT cache_key, data, created_at, updated_at FROM vehicle_dropdown_cache WHERE cache_key LIKE 'catalog:years:%' AND (data->>'modelId')::int = $1 @@ -406,7 +453,11 @@ export class VehicleCatalogService { return result.rows.map(row => ({ id: parseInt(row.cache_key.split(':')[2]), modelId: row.data.modelId, - year: row.data.year + year: row.data.year, + createdAt: + row.data?.createdAt ?? this.toIsoDate(row.created_at), + updatedAt: + row.data?.updatedAt ?? this.toIsoDate(row.updated_at), })); } catch (error) { logger.error('Error getting years by model', { error, modelId }); @@ -438,7 +489,14 @@ export class VehicleCatalogService { const yearId = idResult.rows[0].next_id; // Insert year - const yearData: CatalogYear = { id: yearId, modelId, year }; + const now = new Date().toISOString(); + const yearData: CatalogYear = { + id: yearId, + modelId, + year, + createdAt: now, + updatedAt: now, + }; await client.query(` INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) VALUES ($1, $2, NOW() + INTERVAL '10 years') @@ -480,15 +538,24 @@ export class VehicleCatalogService { // Get old value const oldResult = await client.query(` - SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 + SELECT data, created_at 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 }; + const oldRow = oldResult.rows[0]; + const oldValue = oldRow.data; + const createdAt = + oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at); + const newValue: CatalogYear = { + id: yearId, + modelId, + year, + createdAt, + updatedAt: new Date().toISOString(), + }; // Update year await client.query(` @@ -572,7 +639,7 @@ export class VehicleCatalogService { async getTrimsByYear(yearId: number): Promise { const query = ` - SELECT cache_key, data + SELECT cache_key, data, created_at, updated_at FROM vehicle_dropdown_cache WHERE cache_key LIKE 'catalog:trims:%' AND (data->>'yearId')::int = $1 @@ -584,7 +651,11 @@ export class VehicleCatalogService { return result.rows.map(row => ({ id: parseInt(row.cache_key.split(':')[2]), yearId: row.data.yearId, - name: row.data.name + name: row.data.name, + createdAt: + row.data?.createdAt ?? this.toIsoDate(row.created_at), + updatedAt: + row.data?.updatedAt ?? this.toIsoDate(row.updated_at), })); } catch (error) { logger.error('Error getting trims by year', { error, yearId }); @@ -616,7 +687,14 @@ export class VehicleCatalogService { const trimId = idResult.rows[0].next_id; // Insert trim - const trim: CatalogTrim = { id: trimId, yearId, name }; + const now = new Date().toISOString(); + const trim: CatalogTrim = { + id: trimId, + yearId, + name, + createdAt: now, + updatedAt: now, + }; await client.query(` INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) VALUES ($1, $2, NOW() + INTERVAL '10 years') @@ -658,15 +736,24 @@ export class VehicleCatalogService { // Get old value const oldResult = await client.query(` - SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 + SELECT data, created_at 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 }; + const oldRow = oldResult.rows[0]; + const oldValue = oldRow.data; + const createdAt = + oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at); + const newValue: CatalogTrim = { + id: trimId, + yearId, + name, + createdAt, + updatedAt: new Date().toISOString(), + }; // Update trim await client.query(` @@ -750,7 +837,7 @@ export class VehicleCatalogService { async getEnginesByTrim(trimId: number): Promise { const query = ` - SELECT cache_key, data + SELECT cache_key, data, created_at, updated_at FROM vehicle_dropdown_cache WHERE cache_key LIKE 'catalog:engines:%' AND (data->>'trimId')::int = $1 @@ -763,7 +850,11 @@ export class VehicleCatalogService { id: parseInt(row.cache_key.split(':')[2]), trimId: row.data.trimId, name: row.data.name, - description: row.data.description + description: row.data.description, + createdAt: + row.data?.createdAt ?? this.toIsoDate(row.created_at), + updatedAt: + row.data?.updatedAt ?? this.toIsoDate(row.updated_at), })); } catch (error) { logger.error('Error getting engines by trim', { error, trimId }); @@ -795,7 +886,15 @@ export class VehicleCatalogService { const engineId = idResult.rows[0].next_id; // Insert engine - const engine: CatalogEngine = { id: engineId, trimId, name, description }; + const now = new Date().toISOString(); + const engine: CatalogEngine = { + id: engineId, + trimId, + name, + description, + createdAt: now, + updatedAt: now, + }; await client.query(` INSERT INTO vehicle_dropdown_cache (cache_key, data, expires_at) VALUES ($1, $2, NOW() + INTERVAL '10 years') @@ -837,15 +936,25 @@ export class VehicleCatalogService { // Get old value const oldResult = await client.query(` - SELECT data FROM vehicle_dropdown_cache WHERE cache_key = $1 + SELECT data, created_at 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 }; + const oldRow = oldResult.rows[0]; + const oldValue = oldRow.data; + const createdAt = + oldValue?.createdAt ?? this.toIsoDate(oldRow.created_at); + const newValue: CatalogEngine = { + id: engineId, + trimId, + name, + description, + createdAt, + updatedAt: new Date().toISOString(), + }; // Update engine await client.query(` @@ -915,6 +1024,17 @@ export class VehicleCatalogService { // HELPER METHODS + private toIsoDate(value: Date | string | null | undefined): string { + if (!value) { + return new Date().toISOString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) ? new Date().toISOString() : parsed.toISOString(); + } + private async logChange( client: any, changeType: 'CREATE' | 'UPDATE' | 'DELETE', diff --git a/frontend/src/features/admin/api/admin.api.ts b/frontend/src/features/admin/api/admin.api.ts index f7c2864..c9d4922 100644 --- a/frontend/src/features/admin/api/admin.api.ts +++ b/frontend/src/features/admin/api/admin.api.ts @@ -28,6 +28,11 @@ import { UpdateStationRequest, } from '../types/admin.types'; +export interface AuditLogsResponse { + logs: AdminAuditLog[]; + total: number; +} + // Admin access verification export const adminApi = { // Verify admin access @@ -56,8 +61,8 @@ export const adminApi = { }, // Audit logs - listAuditLogs: async (): Promise => { - const response = await apiClient.get('/admin/audit-logs'); + listAuditLogs: async (): Promise => { + const response = await apiClient.get('/admin/audit-logs'); return response.data; }, diff --git a/frontend/src/features/admin/catalog/catalogSchemas.ts b/frontend/src/features/admin/catalog/catalogSchemas.ts index e308c76..2d5c93d 100644 --- a/frontend/src/features/admin/catalog/catalogSchemas.ts +++ b/frontend/src/features/admin/catalog/catalogSchemas.ts @@ -30,11 +30,11 @@ export const makeSchema = z.object({ export const modelSchema = z.object({ name: z.string().min(1, 'Name is required'), - makeId: z.string().min(1, 'Select a make'), + makeId: z.coerce.string().min(1, 'Select a make'), }); export const yearSchema = z.object({ - modelId: z.string().min(1, 'Select a model'), + modelId: z.coerce.string().min(1, 'Select a model'), year: z .coerce.number() .int() @@ -44,12 +44,12 @@ export const yearSchema = z.object({ export const trimSchema = z.object({ name: z.string().min(1, 'Name is required'), - yearId: z.string().min(1, 'Select a year'), + yearId: z.coerce.string().min(1, 'Select a year'), }); export const engineSchema = z.object({ name: z.string().min(1, 'Name is required'), - trimId: z.string().min(1, 'Select a trim'), + trimId: z.coerce.string().min(1, 'Select a trim'), displacement: z.string().optional(), cylinders: z .preprocess( @@ -96,22 +96,22 @@ export const buildDefaultValues = ( case 'models': return { name: (entity as CatalogModel).name, - makeId: (entity as CatalogModel).makeId, + makeId: String((entity as CatalogModel).makeId), }; case 'years': return { - modelId: (entity as CatalogYear).modelId, + modelId: String((entity as CatalogYear).modelId), year: (entity as CatalogYear).year, }; case 'trims': return { name: (entity as CatalogTrim).name, - yearId: (entity as CatalogTrim).yearId, + yearId: String((entity as CatalogTrim).yearId), }; case 'engines': return { name: (entity as CatalogEngine).name, - trimId: (entity as CatalogEngine).trimId, + trimId: String((entity as CatalogEngine).trimId), displacement: (entity as CatalogEngine).displacement ?? undefined, cylinders: (entity as CatalogEngine).cylinders ?? undefined, fuel_type: (entity as CatalogEngine).fuel_type ?? undefined, @@ -125,22 +125,22 @@ export const buildDefaultValues = ( case 'models': return { name: '', - makeId: context.make?.id ?? '', + makeId: context.make?.id ? String(context.make.id) : '', }; case 'years': return { - modelId: context.model?.id ?? '', + modelId: context.model?.id ? String(context.model.id) : '', year: undefined, }; case 'trims': return { name: '', - yearId: context.year?.id ?? '', + yearId: context.year?.id ? String(context.year.id) : '', }; case 'engines': return { name: '', - trimId: context.trim?.id ?? '', + trimId: context.trim?.id ? String(context.trim.id) : '', displacement: '', fuel_type: '', }; diff --git a/frontend/src/features/admin/hooks/useAuditLogStream.ts b/frontend/src/features/admin/hooks/useAuditLogStream.ts index cde4843..21d3b12 100644 --- a/frontend/src/features/admin/hooks/useAuditLogStream.ts +++ b/frontend/src/features/admin/hooks/useAuditLogStream.ts @@ -6,7 +6,7 @@ import { useState, useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useAuth0 } from '@auth0/auth0-react'; -import { adminApi } from '../api/admin.api'; +import { adminApi, AuditLogsResponse } from '../api/admin.api'; import { AdminAuditLog } from '../types/admin.types'; /** @@ -67,11 +67,11 @@ export function useAuditLogStream( // Query for fetching audit logs const { - data: rawLogs = [], + data: auditLogResponse, isLoading, error, refetch, - } = useQuery({ + } = useQuery({ queryKey: ['auditLogs', resourceType, offset, limit], queryFn: async () => { const logs = await adminApi.listAuditLogs(); @@ -87,21 +87,29 @@ export function useAuditLogStream( }); // Filter logs by resource type if specified + const rawLogs = auditLogResponse?.logs; + const logs = Array.isArray(rawLogs) ? rawLogs : []; + const totalFromApi = + typeof auditLogResponse?.total === 'number' + ? auditLogResponse.total + : logs.length; + const filteredLogs = resourceType - ? rawLogs.filter((log) => log.resourceType === resourceType) - : rawLogs; + ? logs.filter((log) => log.resourceType === resourceType) + : logs; // Apply pagination const paginatedLogs = filteredLogs.slice(offset, offset + limit); // Calculate pagination state + const paginationTotal = resourceType ? filteredLogs.length : totalFromApi; const pagination: PaginationState = { offset, limit, - total: filteredLogs.length, + total: paginationTotal, }; - const hasMore = offset + limit < filteredLogs.length; + const hasMore = offset + limit < paginationTotal; /** * Navigate to next page diff --git a/frontend/src/pages/admin/AdminCatalogPage.tsx b/frontend/src/pages/admin/AdminCatalogPage.tsx index ca9d0d6..32b7789 100644 --- a/frontend/src/pages/admin/AdminCatalogPage.tsx +++ b/frontend/src/pages/admin/AdminCatalogPage.tsx @@ -958,6 +958,16 @@ export const AdminCatalogPage: React.FC = () => { } }, [canViewLevel, currentLevelRequirement.message, selection]); + const formatDateValue = useCallback((value?: string) => { + if (!value) { + return '—'; + } + const date = new Date(value); + return Number.isNaN(date.valueOf()) + ? '—' + : date.toLocaleDateString(); + }, []); + const columns = useMemo[]>(() => { const baseColumns: GridColumn[] = [ { @@ -974,14 +984,14 @@ export const AdminCatalogPage: React.FC = () => { headerName: 'Created', sortable: true, renderCell: (row) => - new Date(row.createdAt).toLocaleDateString(), + formatDateValue(row.createdAt), }, { field: 'updatedAt', headerName: 'Updated', sortable: true, renderCell: (row) => - new Date(row.updatedAt).toLocaleDateString(), + formatDateValue(row.updatedAt), }, { field: 'actions', @@ -1041,7 +1051,7 @@ export const AdminCatalogPage: React.FC = () => { ]; return baseColumns; - }, [handleDeleteSingle, handleDrillDown, openEditDialog, selection.level]); + }, [formatDateValue, handleDeleteSingle, handleDrillDown, openEditDialog, selection.level]); const renderTree = useCallback( (nodes: TreeNode[], depth = 0): React.ReactNode =>