Redesign
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
93
backend/src/core/storage/adapters/filesystem.adapter.ts
Normal file
93
backend/src/core/storage/adapters/filesystem.adapter.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user