This commit is contained in:
Eric Gullickson
2025-11-01 21:27:42 -05:00
parent 20953c6dee
commit 046c66fc7d
203 changed files with 5699 additions and 404943 deletions

View File

@@ -17,7 +17,6 @@ import { appConfig } from './core/config/config-loader';
import { vehiclesRoutes } from './features/vehicles/api/vehicles.routes';
import { fuelLogsRoutes } from './features/fuel-logs/api/fuel-logs.routes';
import { stationsRoutes } from './features/stations/api/stations.routes';
import tenantManagementRoutes from './features/tenant-management/index';
import { documentsRoutes } from './features/documents/api/documents.routes';
import { maintenanceRoutes } from './features/maintenance';
@@ -65,8 +64,6 @@ async function buildApp(): Promise<FastifyInstance> {
// Authentication plugin
await app.register(authPlugin);
// Tenant detection is applied at route level after authentication
// Health check
app.get('/health', async (_request, reply) => {
return reply.code(200).send({
@@ -104,7 +101,6 @@ async function buildApp(): Promise<FastifyInstance> {
status: 'verified',
userId,
roles,
tenantId: user['https://motovaultpro.com/tenant_id'] ?? null,
verifiedAt: new Date().toISOString()
});
});
@@ -115,7 +111,6 @@ async function buildApp(): Promise<FastifyInstance> {
await app.register(fuelLogsRoutes, { prefix: '/api' });
await app.register(stationsRoutes, { prefix: '/api' });
await app.register(maintenanceRoutes, { prefix: '/api' });
await app.register(tenantManagementRoutes);
// 404 handler
app.setNotFoundHandler(async (_request, reply) => {

View File

@@ -19,5 +19,5 @@
## Storage (`src/core/storage/`)
- `storage.service.ts` — Storage abstraction
- `adapters/minio.adapter.ts`MinIO S3-compatible adapter
- `adapters/filesystem.adapter.ts`Filesystem storage adapter

View File

@@ -16,7 +16,6 @@ const configSchema = z.object({
name: z.string(),
port: z.number(),
environment: z.string(),
tenant_id: z.string(),
node_env: z.string(),
}),
@@ -49,19 +48,9 @@ const configSchema = z.object({
url: z.string(),
timeout: z.string(),
}),
tenants: z.object({
url: z.string(),
timeout: z.string(),
}),
}),
}),
// MinIO configuration
minio: z.object({
endpoint: z.string(),
port: z.number(),
bucket: z.string(),
}),
// External APIs configuration
external: z.object({
@@ -85,7 +74,6 @@ const configSchema = z.object({
// Frontend configuration
frontend: z.object({
tenant_id: z.string(),
api_base_url: z.string(),
auth0: z.object({
domain: z.string(),
@@ -144,9 +132,6 @@ const configSchema = z.object({
// Secrets schema definition
const secretsSchema = z.object({
postgres_password: z.string(),
minio_access_key: z.string(),
minio_secret_key: z.string(),
platform_vehicles_api_key: z.string(),
auth0_client_secret: z.string(),
google_maps_api_key: z.string(),
});
@@ -162,8 +147,7 @@ export interface AppConfiguration {
getDatabaseUrl(): string;
getRedisUrl(): string;
getAuth0Config(): { domain: string; audience: string; clientSecret: string };
getPlatformServiceConfig(service: 'vehicles' | 'tenants'): { url: string; apiKey: string };
getMinioConfig(): { endpoint: string; port: number; accessKey: string; secretKey: string; bucket: string };
getPlatformVehiclesUrl(): string;
}
class ConfigurationLoader {
@@ -197,9 +181,6 @@ class ConfigurationLoader {
const secretFiles = [
'postgres-password',
'minio-access-key',
'minio-secret-key',
'platform-vehicles-api-key',
'auth0-client-secret',
'google-maps-api-key',
];
@@ -257,24 +238,8 @@ class ConfigurationLoader {
};
},
getPlatformServiceConfig(service: 'vehicles' | 'tenants') {
const serviceConfig = config.platform.services[service];
const apiKey = service === 'vehicles' ? secrets.platform_vehicles_api_key : 'mvp-platform-tenants-secret-key';
return {
url: serviceConfig.url,
apiKey,
};
},
getMinioConfig() {
return {
endpoint: config.minio.endpoint,
port: config.minio.port,
accessKey: secrets.minio_access_key,
secretKey: secrets.minio_secret_key,
bucket: config.minio.bucket,
};
getPlatformVehiclesUrl(): string {
return config.platform.services.vehicles.url;
},
};

View File

@@ -4,12 +4,10 @@
*/
import { Pool } from 'pg';
import { logger } from '../logging/logger';
import { getTenantConfig } from './tenant';
const tenant = getTenantConfig();
import { appConfig } from './config-loader';
export const pool = new Pool({
connectionString: tenant.databaseUrl,
connectionString: appConfig.getDatabaseUrl(),
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,

View File

@@ -4,11 +4,9 @@
*/
import Redis from 'ioredis';
import { logger } from '../logging/logger';
import { getTenantConfig } from './tenant';
import { appConfig } from './config-loader';
const tenant = getTenantConfig();
export const redis = new Redis(tenant.redisUrl, {
export const redis = new Redis(appConfig.getRedisUrl(), {
retryStrategy: (times) => Math.min(times * 50, 2000),
});

View File

@@ -1,69 +0,0 @@
import axios from 'axios';
import { appConfig } from './config-loader';
// Simple in-memory cache for tenant validation
const tenantValidityCache = new Map<string, { ok: boolean; ts: number }>();
const TENANT_CACHE_TTL_MS = 60_000; // 1 minute
/**
* Tenant-aware configuration for multi-tenant architecture
*/
export interface TenantConfig {
tenantId: string;
databaseUrl: string;
redisUrl: string;
platformServicesUrl: string;
isAdminTenant: boolean;
}
export const getTenantConfig = (): TenantConfig => {
const tenantId = appConfig.config.server.tenant_id;
const databaseUrl = tenantId === 'admin'
? appConfig.getDatabaseUrl()
: `postgresql://${appConfig.config.database.user}:${appConfig.secrets.postgres_password}@${tenantId}-postgres:5432/${appConfig.config.database.name}`;
const redisUrl = tenantId === 'admin'
? appConfig.getRedisUrl()
: `redis://${tenantId}-redis:6379`;
const platformServicesUrl = appConfig.getPlatformServiceConfig('tenants').url;
return {
tenantId,
databaseUrl,
redisUrl,
platformServicesUrl,
isAdminTenant: tenantId === 'admin'
};
};
export const isValidTenant = async (tenantId: string): Promise<boolean> => {
// Check cache
const now = Date.now();
const cached = tenantValidityCache.get(tenantId);
if (cached && (now - cached.ts) < TENANT_CACHE_TTL_MS) {
return cached.ok;
}
let ok = false;
try {
const baseUrl = appConfig.getPlatformServiceConfig('tenants').url;
const url = `${baseUrl}/api/v1/tenants/${encodeURIComponent(tenantId)}`;
const resp = await axios.get(url, { timeout: 2000 });
ok = resp.status === 200;
} catch { ok = false; }
tenantValidityCache.set(tenantId, { ok, ts: now });
return ok;
};
export const extractTenantId = (options: {
envTenantId?: string;
jwtTenantId?: string;
subdomain?: string;
}): string => {
const { envTenantId, jwtTenantId, subdomain } = options;
return envTenantId || jwtTenantId || subdomain || 'admin';
};

View File

@@ -1,84 +0,0 @@
/**
* Tenant detection and validation middleware for multi-tenant architecture
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { getTenantConfig, isValidTenant, extractTenantId } from '../config/tenant';
import { logger } from '../logging/logger';
// Extend FastifyRequest to include tenant context
declare module 'fastify' {
interface FastifyRequest {
tenantId: string;
tenantConfig: {
tenantId: string;
databaseUrl: string;
redisUrl: string;
platformServicesUrl: string;
isAdminTenant: boolean;
};
}
}
export const tenantMiddleware = async (
request: FastifyRequest,
reply: FastifyReply
) => {
try {
// Method 1: From environment variable (container-level)
const envTenantId = process.env.TENANT_ID;
// Method 2: From JWT token claims (verify or decode if available)
let jwtTenantId = (request as any).user?.['https://motovaultpro.com/tenant_id'] as string | undefined;
if (!jwtTenantId && typeof (request as any).jwtDecode === 'function') {
try {
const decoded = (request as any).jwtDecode();
jwtTenantId = decoded?.payload?.['https://motovaultpro.com/tenant_id']
|| decoded?.['https://motovaultpro.com/tenant_id'];
} catch { /* ignore decode errors */ }
}
// Method 3: From subdomain parsing (if needed)
const host = request.headers.host || '';
const subdomain = host.split('.')[0];
const subdomainTenantId = subdomain !== 'admin' && subdomain !== 'localhost' ? subdomain : undefined;
// Extract tenant ID with priority: Environment > JWT > Subdomain > Default
const tenantId = extractTenantId({
envTenantId,
jwtTenantId,
subdomain: subdomainTenantId
});
// Validate tenant exists
const isValid = await isValidTenant(tenantId);
if (!isValid) {
logger.warn('Invalid tenant access attempt', {
tenantId,
host,
path: request.url,
method: request.method
});
reply.code(403).send({ error: 'Invalid or unauthorized tenant' });
return;
}
// Get tenant configuration
const tenantConfig = getTenantConfig();
// Attach tenant context to request
request.tenantId = tenantId;
request.tenantConfig = tenantConfig;
logger.info('Tenant context established', {
tenantId,
isAdmin: tenantConfig.isAdminTenant,
path: request.url
});
return;
} catch (error) {
logger.error('Tenant middleware error', { error });
reply.code(500).send({ error: 'Internal server error' });
}
};

View File

@@ -0,0 +1,93 @@
import type { StorageService, HeadObjectResult, SignedUrlOptions, ObjectBody } from '../storage.service';
import * as fs from 'fs/promises';
import * as path from 'path';
import { createReadStream, createWriteStream } from 'fs';
import type { Readable } from 'stream';
import { pipeline } from 'stream/promises';
export class FilesystemAdapter implements StorageService {
private basePath: string;
constructor(basePath: string = '/app/data/documents') {
this.basePath = basePath;
}
async putObject(
_bucket: string,
key: string,
body: ObjectBody,
contentType?: string,
metadata?: Record<string, string>
): Promise<void> {
const filePath = path.join(this.basePath, key);
await fs.mkdir(path.dirname(filePath), { recursive: true });
if (Buffer.isBuffer(body) || typeof body === 'string') {
// For Buffer or string, write directly
await fs.writeFile(filePath, body);
} else {
// For Readable stream, pipe to file
const writeStream = createWriteStream(filePath);
await pipeline(body as Readable, writeStream);
}
// Store metadata in a sidecar file if provided
if (metadata || contentType) {
const metaPath = `${filePath}.meta.json`;
const meta: Record<string, string> = { ...(metadata || {}) };
if (contentType) {
meta['content-type'] = contentType;
}
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
}
}
async getObjectStream(_bucket: string, key: string): Promise<Readable> {
const filePath = path.join(this.basePath, key);
return createReadStream(filePath);
}
async deleteObject(_bucket: string, key: string): Promise<void> {
const filePath = path.join(this.basePath, key);
await fs.unlink(filePath);
// Also delete metadata file if it exists
const metaPath = `${filePath}.meta.json`;
try {
await fs.unlink(metaPath);
} catch {
// Ignore if metadata file doesn't exist
}
}
async headObject(_bucket: string, key: string): Promise<HeadObjectResult> {
const filePath = path.join(this.basePath, key);
const stats = await fs.stat(filePath);
// Try to read metadata file
let contentType: string | undefined;
let metadata: Record<string, string> | undefined;
const metaPath = `${filePath}.meta.json`;
try {
const metaContent = await fs.readFile(metaPath, 'utf-8');
const meta = JSON.parse(metaContent);
contentType = meta['content-type'] || meta['Content-Type'];
metadata = meta;
} catch {
// Ignore if metadata file doesn't exist
}
return {
size: stats.size,
lastModified: stats.mtime,
contentType,
metadata,
};
}
async getSignedUrl(_bucket: string, key: string, _options?: SignedUrlOptions): Promise<string> {
// For filesystem storage, we don't use signed URLs
// The documents controller will handle serving the file directly
return `/api/documents/download/${key}`;
}
}

View File

@@ -1,66 +0,0 @@
import { Client as MinioClient } from 'minio';
import type { Readable } from 'stream';
import { appConfig } from '../../config/config-loader';
import type { HeadObjectResult, SignedUrlOptions, StorageService } from '../storage.service';
export function createMinioAdapter(): StorageService {
const { endpoint, port, accessKey, secretKey } = appConfig.getMinioConfig();
const client = new MinioClient({
endPoint: endpoint,
port,
useSSL: false,
accessKey,
secretKey,
});
const normalizeMeta = (contentType?: string, metadata?: Record<string, string>) => {
const meta: Record<string, string> = { ...(metadata || {}) };
if (contentType) meta['Content-Type'] = contentType;
return meta;
};
const adapter: StorageService = {
async putObject(bucket, key, body, contentType, metadata) {
const meta = normalizeMeta(contentType, metadata);
// For Buffer or string, size is known. For Readable, omit size for chunked encoding.
if (Buffer.isBuffer(body) || typeof body === 'string') {
await client.putObject(bucket, key, body as any, (body as any).length ?? undefined, meta);
} else {
await client.putObject(bucket, key, body as Readable, undefined, meta);
}
},
async getObjectStream(bucket, key) {
return client.getObject(bucket, key);
},
async deleteObject(bucket, key) {
await client.removeObject(bucket, key);
},
async headObject(bucket, key): Promise<HeadObjectResult> {
const stat = await client.statObject(bucket, key);
// minio types: size, etag, lastModified, metaData
return {
size: stat.size,
etag: stat.etag,
lastModified: stat.lastModified ? new Date(stat.lastModified) : undefined,
contentType: (stat.metaData && (stat.metaData['content-type'] || stat.metaData['Content-Type'])) || undefined,
metadata: stat.metaData || undefined,
};
},
async getSignedUrl(bucket, key, options?: SignedUrlOptions) {
const expires = Math.max(1, Math.min(7 * 24 * 3600, options?.expiresSeconds ?? 300));
if (options?.method === 'PUT') {
// MinIO SDK has presignedPutObject for PUT
return client.presignedPutObject(bucket, key, expires);
}
// Default GET
return client.presignedGetObject(bucket, key, expires);
},
};
return adapter;
}

View File

@@ -1,9 +1,9 @@
/**
* Provider-agnostic storage facade with S3-compatible surface.
* Initial implementation backed by MinIO using the official SDK.
* Provider-agnostic storage facade with filesystem storage.
* Uses local filesystem for document storage in /app/data/documents.
*/
import type { Readable } from 'stream';
import { createMinioAdapter } from './adapters/minio.adapter';
import { FilesystemAdapter } from './adapters/filesystem.adapter';
export type ObjectBody = Buffer | Readable | string;
@@ -38,11 +38,11 @@ export interface StorageService {
getSignedUrl(bucket: string, key: string, options?: SignedUrlOptions): Promise<string>;
}
// Simple factory — currently only MinIO; can add S3 in future without changing feature code
// Simple factory — uses filesystem storage
let singleton: StorageService | null = null;
export function getStorageService(): StorageService {
if (!singleton) {
singleton = createMinioAdapter();
singleton = new FilesystemAdapter('/app/data/documents');
}
return singleton;
}

View File

@@ -2,7 +2,6 @@ import { FastifyReply, FastifyRequest } from 'fastify';
import { DocumentsService } from '../domain/documents.service';
import type { CreateBody, IdParams, ListQuery, UpdateBody } from './documents.validation';
import { getStorageService } from '../../../core/storage/storage.service';
import { appConfig } from '../../../core/config/config-loader';
import { logger } from '../../../core/logging/logger';
import path from 'path';
import { Transform, TransformCallback } from 'stream';
@@ -238,7 +237,7 @@ export class DocumentsController {
mp.file.pipe(counter);
const storage = getStorageService();
const bucket = (doc.storage_bucket || appConfig.getMinioConfig().bucket);
const bucket = 'documents'; // Filesystem storage ignores bucket, but keep for interface compatibility
const version = 'v1';
const unique = cryptoRandom();
const key = `documents/${userId}/${doc.vehicle_id}/${doc.id}/${version}/${unique}.${ext}`;

View File

@@ -2,7 +2,6 @@
* @ai-summary Fastify routes for documents API
*/
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify';
import { tenantMiddleware } from '../../../core/middleware/tenant';
import { DocumentsController } from './documents.controller';
// Note: Validation uses TypeScript types at handler level; follow existing repo pattern (no JSON schema registration)
@@ -14,17 +13,17 @@ export const documentsRoutes: FastifyPluginAsync = async (
const requireAuth = fastify.authenticate.bind(fastify);
fastify.get('/documents', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.list.bind(ctrl)
});
fastify.get<{ Params: any }>('/documents/:id', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.get.bind(ctrl)
});
fastify.get<{ Params: any }>('/documents/vehicle/:vehicleId', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: async (req, reply) => {
const userId = (req as any).user?.sub as string;
const query = { vehicleId: (req.params as any).vehicleId };
@@ -34,27 +33,27 @@ export const documentsRoutes: FastifyPluginAsync = async (
});
fastify.post<{ Body: any }>('/documents', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.create.bind(ctrl)
});
fastify.put<{ Params: any; Body: any }>('/documents/:id', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.update.bind(ctrl)
});
fastify.delete<{ Params: any }>('/documents/:id', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.remove.bind(ctrl)
});
fastify.post<{ Params: any }>('/documents/:id/upload', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.upload.bind(ctrl)
});
fastify.get<{ Params: any }>('/documents/:id/download', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.download.bind(ctrl)
});
};

View File

@@ -1,256 +0,0 @@
/**
* @ai-summary Unit tests for MinIO storage adapter
* @ai-context Tests storage layer with mocked MinIO client
*/
import { createMinioAdapter } from '../../../../core/storage/adapters/minio.adapter';
import { Client as MinioClient } from 'minio';
import { appConfig } from '../../../../core/config/config-loader';
import { Readable } from 'stream';
// Mock dependencies
jest.mock('minio');
jest.mock('../../../../core/config/config-loader');
const mockMinioClient = jest.mocked(MinioClient);
const mockAppConfig = jest.mocked(appConfig);
describe('MinIO Storage Adapter', () => {
let clientInstance: jest.Mocked<MinioClient>;
let adapter: ReturnType<typeof createMinioAdapter>;
beforeEach(() => {
jest.clearAllMocks();
clientInstance = {
putObject: jest.fn(),
getObject: jest.fn(),
removeObject: jest.fn(),
statObject: jest.fn(),
presignedGetObject: jest.fn(),
presignedPutObject: jest.fn(),
} as any;
mockMinioClient.mockImplementation(() => clientInstance);
mockAppConfig.getMinioConfig.mockReturnValue({
endpoint: 'localhost',
port: 9000,
accessKey: 'testkey',
secretKey: 'testsecret',
bucket: 'test-bucket',
});
adapter = createMinioAdapter();
});
describe('putObject', () => {
it('should upload Buffer with correct parameters', async () => {
const buffer = Buffer.from('test content');
clientInstance.putObject.mockResolvedValue('etag-123');
await adapter.putObject('test-bucket', 'test-key', buffer, 'text/plain', { 'x-custom': 'value' });
expect(clientInstance.putObject).toHaveBeenCalledWith(
'test-bucket',
'test-key',
buffer,
buffer.length,
{
'Content-Type': 'text/plain',
'x-custom': 'value',
}
);
});
it('should upload string with correct parameters', async () => {
const content = 'test content';
clientInstance.putObject.mockResolvedValue('etag-123');
await adapter.putObject('test-bucket', 'test-key', content, 'text/plain');
expect(clientInstance.putObject).toHaveBeenCalledWith(
'test-bucket',
'test-key',
content,
content.length,
{ 'Content-Type': 'text/plain' }
);
});
it('should upload stream without size', async () => {
const stream = new Readable();
clientInstance.putObject.mockResolvedValue('etag-123');
await adapter.putObject('test-bucket', 'test-key', stream, 'application/octet-stream');
expect(clientInstance.putObject).toHaveBeenCalledWith(
'test-bucket',
'test-key',
stream,
undefined,
{ 'Content-Type': 'application/octet-stream' }
);
});
it('should handle upload without content type', async () => {
const buffer = Buffer.from('test');
clientInstance.putObject.mockResolvedValue('etag-123');
await adapter.putObject('test-bucket', 'test-key', buffer);
expect(clientInstance.putObject).toHaveBeenCalledWith(
'test-bucket',
'test-key',
buffer,
buffer.length,
{}
);
});
});
describe('getObjectStream', () => {
it('should return object stream', async () => {
const mockStream = new Readable();
clientInstance.getObject.mockResolvedValue(mockStream);
const result = await adapter.getObjectStream('test-bucket', 'test-key');
expect(clientInstance.getObject).toHaveBeenCalledWith('test-bucket', 'test-key');
expect(result).toBe(mockStream);
});
});
describe('deleteObject', () => {
it('should remove object', async () => {
clientInstance.removeObject.mockResolvedValue(undefined);
await adapter.deleteObject('test-bucket', 'test-key');
expect(clientInstance.removeObject).toHaveBeenCalledWith('test-bucket', 'test-key');
});
});
describe('headObject', () => {
it('should return object metadata', async () => {
const mockStat = {
size: 1024,
etag: 'test-etag',
lastModified: '2024-01-01T00:00:00Z',
metaData: {
'content-type': 'application/pdf',
'x-custom-header': 'custom-value',
},
};
clientInstance.statObject.mockResolvedValue(mockStat);
const result = await adapter.headObject('test-bucket', 'test-key');
expect(clientInstance.statObject).toHaveBeenCalledWith('test-bucket', 'test-key');
expect(result).toEqual({
size: 1024,
etag: 'test-etag',
lastModified: new Date('2024-01-01T00:00:00Z'),
contentType: 'application/pdf',
metadata: mockStat.metaData,
});
});
it('should handle metadata with Content-Type header', async () => {
const mockStat = {
size: 1024,
etag: 'test-etag',
lastModified: '2024-01-01T00:00:00Z',
metaData: {
'Content-Type': 'image/jpeg',
},
};
clientInstance.statObject.mockResolvedValue(mockStat);
const result = await adapter.headObject('test-bucket', 'test-key');
expect(result.contentType).toBe('image/jpeg');
});
it('should handle missing optional fields', async () => {
const mockStat = {
size: 1024,
etag: 'test-etag',
};
clientInstance.statObject.mockResolvedValue(mockStat);
const result = await adapter.headObject('test-bucket', 'test-key');
expect(result).toEqual({
size: 1024,
etag: 'test-etag',
lastModified: undefined,
contentType: undefined,
metadata: undefined,
});
});
});
describe('getSignedUrl', () => {
it('should generate GET signed URL with default expiry', async () => {
clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url');
const result = await adapter.getSignedUrl('test-bucket', 'test-key');
expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 300);
expect(result).toBe('https://example.com/signed-url');
});
it('should generate GET signed URL with custom expiry', async () => {
clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url');
const result = await adapter.getSignedUrl('test-bucket', 'test-key', {
method: 'GET',
expiresSeconds: 600,
});
expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 600);
expect(result).toBe('https://example.com/signed-url');
});
it('should generate PUT signed URL', async () => {
clientInstance.presignedPutObject.mockResolvedValue('https://example.com/put-url');
const result = await adapter.getSignedUrl('test-bucket', 'test-key', {
method: 'PUT',
expiresSeconds: 300,
});
expect(clientInstance.presignedPutObject).toHaveBeenCalledWith('test-bucket', 'test-key', 300);
expect(result).toBe('https://example.com/put-url');
});
it('should enforce minimum expiry time', async () => {
clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url');
await adapter.getSignedUrl('test-bucket', 'test-key', { expiresSeconds: 0 });
expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 1);
});
it('should enforce maximum expiry time', async () => {
clientInstance.presignedGetObject.mockResolvedValue('https://example.com/signed-url');
await adapter.getSignedUrl('test-bucket', 'test-key', { expiresSeconds: 10000000 });
expect(clientInstance.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'test-key', 604800); // 7 days max
});
});
describe('MinioClient instantiation', () => {
it('should create client with correct configuration', () => {
expect(mockMinioClient).toHaveBeenCalledWith({
endPoint: 'localhost',
port: 9000,
useSSL: false,
accessKey: 'testkey',
secretKey: 'testsecret',
});
});
});
});

View File

@@ -8,7 +8,6 @@ import { FastifyPluginAsync } from 'fastify';
// Types handled in controllers; no explicit generics required here
import { FuelLogsController } from './fuel-logs.controller';
import { FuelGradeController } from './fuel-grade.controller';
import { tenantMiddleware } from '../../../core/middleware/tenant';
export const fuelLogsRoutes: FastifyPluginAsync = async (
fastify: FastifyInstance,
@@ -19,53 +18,53 @@ export const fuelLogsRoutes: FastifyPluginAsync = async (
// GET /api/fuel-logs - Get user's fuel logs
fastify.get('/fuel-logs', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: fuelLogsController.getUserFuelLogs.bind(fuelLogsController)
});
// POST /api/fuel-logs - Create new fuel log
fastify.post('/fuel-logs', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: fuelLogsController.createFuelLog.bind(fuelLogsController)
});
// GET /api/fuel-logs/:id - Get specific fuel log
fastify.get('/fuel-logs/:id', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: fuelLogsController.getFuelLog.bind(fuelLogsController)
});
// PUT /api/fuel-logs/:id - Update fuel log
fastify.put('/fuel-logs/:id', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: fuelLogsController.updateFuelLog.bind(fuelLogsController)
});
// DELETE /api/fuel-logs/:id - Delete fuel log
fastify.delete('/fuel-logs/:id', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: fuelLogsController.deleteFuelLog.bind(fuelLogsController)
});
// NEW ENDPOINTS under /api/fuel-logs
fastify.get('/fuel-logs/vehicle/:vehicleId', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: fuelLogsController.getFuelLogsByVehicle.bind(fuelLogsController)
});
fastify.get('/fuel-logs/vehicle/:vehicleId/stats', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: fuelLogsController.getFuelStats.bind(fuelLogsController)
});
// Fuel type/grade discovery
fastify.get('/fuel-logs/fuel-types', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: fuelGradeController.getAllFuelTypes.bind(fuelGradeController)
});
fastify.get('/fuel-logs/fuel-grades/:fuelType', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: fuelGradeController.getFuelGrades.bind(fuelGradeController)
});
};

View File

@@ -2,7 +2,6 @@
* @ai-summary Fastify routes for maintenance API
*/
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify';
import { tenantMiddleware } from '../../../core/middleware/tenant';
import { MaintenanceController } from './maintenance.controller';
export const maintenanceRoutes: FastifyPluginAsync = async (
@@ -14,64 +13,64 @@ export const maintenanceRoutes: FastifyPluginAsync = async (
// Maintenance Records
fastify.get('/maintenance/records', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.listRecords.bind(ctrl)
});
fastify.get<{ Params: any }>('/maintenance/records/:id', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.getRecord.bind(ctrl)
});
fastify.get<{ Params: any }>('/maintenance/records/vehicle/:vehicleId', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.getRecordsByVehicle.bind(ctrl)
});
fastify.post<{ Body: any }>('/maintenance/records', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.createRecord.bind(ctrl)
});
fastify.put<{ Params: any; Body: any }>('/maintenance/records/:id', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.updateRecord.bind(ctrl)
});
fastify.delete<{ Params: any }>('/maintenance/records/:id', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.deleteRecord.bind(ctrl)
});
// Maintenance Schedules
fastify.get<{ Params: any }>('/maintenance/schedules/vehicle/:vehicleId', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.getSchedulesByVehicle.bind(ctrl)
});
fastify.post<{ Body: any }>('/maintenance/schedules', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.createSchedule.bind(ctrl)
});
fastify.put<{ Params: any; Body: any }>('/maintenance/schedules/:id', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.updateSchedule.bind(ctrl)
});
fastify.delete<{ Params: any }>('/maintenance/schedules/:id', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.deleteSchedule.bind(ctrl)
});
// Utility Routes
fastify.get<{ Params: { vehicleId: string }; Querystring: { currentMileage?: string } }>('/maintenance/upcoming/:vehicleId', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.getUpcoming.bind(ctrl)
});
fastify.get<{ Params: any }>('/maintenance/subtypes/:category', {
preHandler: [requireAuth, tenantMiddleware as any],
preHandler: [requireAuth],
handler: ctrl.getSubtypes.bind(ctrl)
});
};

View File

@@ -11,7 +11,6 @@ import {
StationParams
} from '../domain/stations.types';
import { StationsController } from './stations.controller';
import { tenantMiddleware } from '../../../core/middleware/tenant';
export const stationsRoutes: FastifyPluginAsync = async (
fastify: FastifyInstance,
@@ -21,25 +20,25 @@ export const stationsRoutes: FastifyPluginAsync = async (
// POST /api/stations/search - Search nearby stations
fastify.post<{ Body: StationSearchBody }>('/stations/search', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: stationsController.searchStations.bind(stationsController)
});
// POST /api/stations/save - Save a station to user's favorites
fastify.post<{ Body: SaveStationBody }>('/stations/save', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: stationsController.saveStation.bind(stationsController)
});
// GET /api/stations/saved - Get user's saved stations
fastify.get('/stations/saved', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: stationsController.getSavedStations.bind(stationsController)
});
// DELETE /api/stations/saved/:placeId - Remove saved station
fastify.delete<{ Params: StationParams }>('/stations/saved/:placeId', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: stationsController.removeSavedStation.bind(stationsController)
});
};

View File

@@ -1,95 +0,0 @@
import { FastifyInstance, FastifyPluginAsync } from 'fastify';
import axios from 'axios';
import { tenantMiddleware } from '../../core/middleware/tenant';
import { getTenantConfig } from '../../core/config/tenant';
import { logger } from '../../core/logging/logger';
export const tenantManagementRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
const baseUrl = getTenantConfig().platformServicesUrl;
// Require JWT on all routes
const requireAuth = fastify.authenticate.bind(fastify);
// Admin-only guard using tenant context from middleware
const requireAdmin = async (request: any, reply: any) => {
if (request.tenantId !== 'admin') {
reply.code(403).send({ error: 'Admin access required' });
return;
}
};
const forwardAuthHeader = (request: any) => {
const auth = request.headers['authorization'];
return auth ? { Authorization: auth as string } : {};
};
// List all tenants
fastify.get('/api/admin/tenants', { preHandler: [requireAuth, tenantMiddleware as any, requireAdmin] }, async (request, reply) => {
try {
const resp = await axios.get(`${baseUrl}/api/v1/tenants`, {
headers: forwardAuthHeader(request),
});
return reply.code(200).send(resp.data);
} catch (error: any) {
logger.error('Failed to list tenants', { error: error?.message });
return reply.code(error?.response?.status || 500).send(error?.response?.data || { error: 'Failed to list tenants' });
}
});
// Create new tenant
fastify.post('/api/admin/tenants', { preHandler: [requireAuth, tenantMiddleware as any, requireAdmin] }, async (request: any, reply) => {
try {
const resp = await axios.post(`${baseUrl}/api/v1/tenants`, request.body, {
headers: { ...forwardAuthHeader(request), 'Content-Type': 'application/json' },
});
return reply.code(201).send(resp.data);
} catch (error: any) {
logger.error('Failed to create tenant', { error: error?.message });
return reply.code(error?.response?.status || 500).send(error?.response?.data || { error: 'Failed to create tenant' });
}
});
// List pending signups for a tenant
fastify.get('/api/admin/tenants/:tenantId/signups', { preHandler: [requireAuth, tenantMiddleware as any, requireAdmin] }, async (request: any, reply) => {
try {
const { tenantId } = request.params;
const resp = await axios.get(`${baseUrl}/api/v1/tenants/${encodeURIComponent(tenantId)}/signups`, {
headers: forwardAuthHeader(request),
});
return reply.code(200).send(resp.data);
} catch (error: any) {
logger.error('Failed to list signups', { error: error?.message });
return reply.code(error?.response?.status || 500).send(error?.response?.data || { error: 'Failed to list signups' });
}
});
// Approve signup
fastify.put('/api/admin/signups/:signupId/approve', { preHandler: [requireAuth, tenantMiddleware as any, requireAdmin] }, async (request: any, reply) => {
try {
const { signupId } = request.params;
const resp = await axios.put(`${baseUrl}/api/v1/signups/${encodeURIComponent(signupId)}/approve`, {}, {
headers: forwardAuthHeader(request),
});
return reply.code(200).send(resp.data);
} catch (error: any) {
logger.error('Failed to approve signup', { error: error?.message });
return reply.code(error?.response?.status || 500).send(error?.response?.data || { error: 'Failed to approve signup' });
}
});
// Reject signup
fastify.put('/api/admin/signups/:signupId/reject', { preHandler: [requireAuth, tenantMiddleware as any, requireAdmin] }, async (request: any, reply) => {
try {
const { signupId } = request.params;
const resp = await axios.put(`${baseUrl}/api/v1/signups/${encodeURIComponent(signupId)}/reject`, {}, {
headers: forwardAuthHeader(request),
});
return reply.code(200).send(resp.data);
} catch (error: any) {
logger.error('Failed to reject signup', { error: error?.message });
return reply.code(error?.response?.status || 500).send(error?.response?.data || { error: 'Failed to reject signup' });
}
});
};
export default tenantManagementRoutes;

View File

@@ -228,7 +228,7 @@ npm test -- features/vehicles --coverage
- Both features depend on vehicles as primary entity
### Potential Enhancements
- Vehicle image uploads (MinIO integration)
- Vehicle image uploads (filesystem storage integration)
- Enhanced platform service integration for real-time updates
- Vehicle value estimation via additional platform services
- Maintenance scheduling based on vehicle age/mileage

View File

@@ -11,7 +11,6 @@ import {
VehicleParams
} from '../domain/vehicles.types';
import { VehiclesController } from './vehicles.controller';
import { tenantMiddleware } from '../../../core/middleware/tenant';
export const vehiclesRoutes: FastifyPluginAsync = async (
fastify: FastifyInstance,
@@ -21,31 +20,31 @@ export const vehiclesRoutes: FastifyPluginAsync = async (
// GET /api/vehicles - Get user's vehicles
fastify.get('/vehicles', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: vehiclesController.getUserVehicles.bind(vehiclesController)
});
// POST /api/vehicles - Create new vehicle
fastify.post<{ Body: CreateVehicleBody }>('/vehicles', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: vehiclesController.createVehicle.bind(vehiclesController)
});
// GET /api/vehicles/:id - Get specific vehicle
fastify.get<{ Params: VehicleParams }>('/vehicles/:id', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: vehiclesController.getVehicle.bind(vehiclesController)
});
// PUT /api/vehicles/:id - Update vehicle
fastify.put<{ Params: VehicleParams; Body: UpdateVehicleBody }>('/vehicles/:id', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: vehiclesController.updateVehicle.bind(vehiclesController)
});
// DELETE /api/vehicles/:id - Delete vehicle
fastify.delete<{ Params: VehicleParams }>('/vehicles/:id', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: vehiclesController.deleteVehicle.bind(vehiclesController)
});
@@ -53,43 +52,43 @@ export const vehiclesRoutes: FastifyPluginAsync = async (
// GET /api/vehicles/dropdown/years - Available model years
fastify.get('/vehicles/dropdown/years', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: vehiclesController.getDropdownYears.bind(vehiclesController)
});
// GET /api/vehicles/dropdown/makes?year=2024 - Get makes for year (Level 1)
fastify.get<{ Querystring: { year: number } }>('/vehicles/dropdown/makes', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: vehiclesController.getDropdownMakes.bind(vehiclesController)
});
// GET /api/vehicles/dropdown/models?year=2024&make_id=1 - Get models for year/make (Level 2)
fastify.get<{ Querystring: { year: number; make_id: number } }>('/vehicles/dropdown/models', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: vehiclesController.getDropdownModels.bind(vehiclesController)
});
// GET /api/vehicles/dropdown/trims?year=2024&make_id=1&model_id=1 - Get trims (Level 3)
fastify.get<{ Querystring: { year: number; make_id: number; model_id: number } }>('/vehicles/dropdown/trims', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: vehiclesController.getDropdownTrims.bind(vehiclesController)
});
// GET /api/vehicles/dropdown/engines?year=2024&make_id=1&model_id=1&trim_id=1 - Get engines (Level 4)
fastify.get<{ Querystring: { year: number; make_id: number; model_id: number; trim_id: number } }>('/vehicles/dropdown/engines', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: vehiclesController.getDropdownEngines.bind(vehiclesController)
});
// GET /api/vehicles/dropdown/transmissions?year=2024&make_id=1&model_id=1 - Get transmissions (Level 3)
fastify.get<{ Querystring: { year: number; make_id: number; model_id: number } }>('/vehicles/dropdown/transmissions', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: vehiclesController.getDropdownTransmissions.bind(vehiclesController)
});
// POST /api/vehicles/decode-vin - Decode VIN and return vehicle information
fastify.post<{ Body: { vin: string } }>('/vehicles/decode-vin', {
preHandler: [fastify.authenticate, tenantMiddleware],
preHandler: [fastify.authenticate],
handler: vehiclesController.decodeVIN.bind(vehiclesController)
});
};

View File

@@ -26,11 +26,9 @@ export class VehiclesService {
constructor(private repository: VehiclesRepository) {
// Initialize platform vehicles client
const platformConfig = appConfig.getPlatformServiceConfig('vehicles');
const platformVehiclesUrl = appConfig.getPlatformVehiclesUrl();
const platformClient = new PlatformVehiclesClient({
baseURL: platformConfig.url,
apiKey: platformConfig.apiKey,
tenantId: appConfig.config.server.tenant_id,
baseURL: platformVehiclesUrl,
timeout: 3000,
logger
});

View File

@@ -44,8 +44,6 @@ export interface VINDecodeResponse {
export interface PlatformVehiclesClientConfig {
baseURL: string;
apiKey: string;
tenantId?: string;
timeout?: number;
logger?: Logger;
}
@@ -58,27 +56,19 @@ export class PlatformVehiclesClient {
private readonly httpClient: AxiosInstance;
private readonly logger: Logger | undefined;
private readonly circuitBreakers: Map<string, CircuitBreaker> = new Map();
private readonly tenantId: string | undefined;
constructor(config: PlatformVehiclesClientConfig) {
this.logger = config.logger;
this.tenantId = config.tenantId || process.env.TENANT_ID;
// Initialize HTTP client
this.httpClient = axios.create({
baseURL: config.baseURL,
timeout: config.timeout || 3000,
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
});
// Inject tenant header for all requests when available
if (this.tenantId) {
this.httpClient.defaults.headers.common['X-Tenant-ID'] = this.tenantId;
}
// Setup response interceptors for logging
this.httpClient.interceptors.response.use(
(response) => {