Initial Commit
This commit is contained in:
@@ -4,14 +4,12 @@
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { logger } from '../logging/logger';
|
||||
import { env } from './environment';
|
||||
import { getTenantConfig } from './tenant';
|
||||
|
||||
const tenant = getTenantConfig();
|
||||
|
||||
export const pool = new Pool({
|
||||
host: env.DB_HOST,
|
||||
port: env.DB_PORT,
|
||||
database: env.DB_NAME,
|
||||
user: env.DB_USER,
|
||||
password: env.DB_PASSWORD,
|
||||
connectionString: tenant.databaseUrl,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 10000,
|
||||
@@ -30,4 +28,4 @@ process.on('SIGTERM', async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
export default pool;
|
||||
export default pool;
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.string().default('development'),
|
||||
NODE_ENV: z.string().default('production'),
|
||||
PORT: z.string().transform(Number).default('3001'),
|
||||
|
||||
// Database
|
||||
@@ -32,6 +32,10 @@ const envSchema = z.object({
|
||||
GOOGLE_MAPS_API_KEY: z.string().default('development'),
|
||||
VPIC_API_URL: z.string().default('https://vpic.nhtsa.dot.gov/api/vehicles'),
|
||||
|
||||
// Platform Services
|
||||
PLATFORM_VEHICLES_API_URL: z.string().default('http://mvp-platform-vehicles-api:8000'),
|
||||
PLATFORM_VEHICLES_API_KEY: z.string().default('mvp-platform-vehicles-secret-key'),
|
||||
|
||||
// MinIO
|
||||
MINIO_ENDPOINT: z.string().default('localhost'),
|
||||
MINIO_PORT: z.string().transform(Number).default('9000'),
|
||||
@@ -45,4 +49,4 @@ export type Environment = z.infer<typeof envSchema>;
|
||||
// Validate and export - now with defaults for build-time compilation
|
||||
export const env = envSchema.parse(process.env);
|
||||
|
||||
// Environment configuration validated and exported
|
||||
// Environment configuration validated and exported
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
*/
|
||||
import Redis from 'ioredis';
|
||||
import { logger } from '../logging/logger';
|
||||
import { env } from './environment';
|
||||
import { getTenantConfig } from './tenant';
|
||||
|
||||
export const redis = new Redis({
|
||||
host: env.REDIS_HOST,
|
||||
port: env.REDIS_PORT,
|
||||
const tenant = getTenantConfig();
|
||||
|
||||
export const redis = new Redis(tenant.redisUrl, {
|
||||
retryStrategy: (times) => Math.min(times * 50, 2000),
|
||||
});
|
||||
|
||||
@@ -55,4 +55,4 @@ export class CacheService {
|
||||
}
|
||||
}
|
||||
|
||||
export const cacheService = new CacheService();
|
||||
export const cacheService = new CacheService();
|
||||
|
||||
68
backend/src/core/config/tenant.ts
Normal file
68
backend/src/core/config/tenant.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// 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 = process.env.TENANT_ID || 'admin';
|
||||
|
||||
const databaseUrl = tenantId === 'admin'
|
||||
? `postgresql://${process.env.DB_USER || 'motovault_user'}:${process.env.DB_PASSWORD}@${process.env.DB_HOST || 'postgres'}:${process.env.DB_PORT || '5432'}/${process.env.DB_NAME || 'motovault'}`
|
||||
: `postgresql://motovault_user:${process.env.DB_PASSWORD}@${tenantId}-postgres:5432/motovault`;
|
||||
|
||||
const redisUrl = tenantId === 'admin'
|
||||
? `redis://${process.env.REDIS_HOST || 'redis'}:${process.env.REDIS_PORT || '6379'}`
|
||||
: `redis://${tenantId}-redis:6379`;
|
||||
|
||||
const platformServicesUrl = process.env.PLATFORM_TENANTS_API_URL || 'http://mvp-platform-tenants:8000';
|
||||
|
||||
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 = process.env.PLATFORM_TENANTS_API_URL || 'http://mvp-platform-tenants:8000';
|
||||
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,24 +0,0 @@
|
||||
/**
|
||||
* @ai-summary Global error handling middleware
|
||||
*/
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '../logging/logger';
|
||||
|
||||
export const errorHandler = (
|
||||
err: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
) => {
|
||||
logger.error('Unhandled error', {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
|
||||
});
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* @ai-summary Request logging middleware
|
||||
*/
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '../logging/logger';
|
||||
|
||||
export const requestLogger = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
logger.info('Request processed', {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
status: res.statusCode,
|
||||
duration,
|
||||
ip: req.ip,
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
84
backend/src/core/middleware/tenant.ts
Normal file
84
backend/src/core/middleware/tenant.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
|
||||
/**
|
||||
* 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' });
|
||||
}
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* @ai-summary JWT authentication middleware using Auth0
|
||||
* @ai-context Validates JWT tokens, adds user context to requests
|
||||
*/
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { expressjwt as jwt } from 'express-jwt';
|
||||
import jwks from 'jwks-rsa';
|
||||
import { env } from '../config/environment';
|
||||
import { logger } from '../logging/logger';
|
||||
|
||||
// Extend Express Request type
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: any;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const authMiddleware = jwt({
|
||||
secret: jwks.expressJwtSecret({
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
jwksRequestsPerMinute: 5,
|
||||
jwksUri: `https://${env.AUTH0_DOMAIN}/.well-known/jwks.json`,
|
||||
}),
|
||||
audience: env.AUTH0_AUDIENCE,
|
||||
issuer: `https://${env.AUTH0_DOMAIN}/`,
|
||||
algorithms: ['RS256'],
|
||||
});
|
||||
|
||||
export const errorHandler = (
|
||||
err: any,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
if (err.name === 'UnauthorizedError') {
|
||||
logger.warn('Unauthorized request', {
|
||||
path: req.path,
|
||||
ip: req.ip,
|
||||
error: err.message,
|
||||
});
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
} else {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @ai-summary Database operations for user preferences
|
||||
* @ai-context Repository pattern for user preference CRUD operations
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { UserPreferences, CreateUserPreferencesRequest, UpdateUserPreferencesRequest } from '../user-preferences.types';
|
||||
|
||||
export class UserPreferencesRepository {
|
||||
constructor(private db: Pool) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<UserPreferences | null> {
|
||||
const query = `
|
||||
SELECT id, user_id, unit_system, currency_code, time_zone, created_at, updated_at
|
||||
FROM user_preferences
|
||||
WHERE user_id = $1
|
||||
`;
|
||||
|
||||
const result = await this.db.query(query, [userId]);
|
||||
return result.rows.length > 0 ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async create(data: CreateUserPreferencesRequest): Promise<UserPreferences> {
|
||||
const query = `
|
||||
INSERT INTO user_preferences (user_id, unit_system, currency_code, time_zone)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const values = [
|
||||
data.userId,
|
||||
data.unitSystem || 'imperial',
|
||||
(data as any).currencyCode || 'USD',
|
||||
(data as any).timeZone || 'UTC'
|
||||
];
|
||||
|
||||
const result = await this.db.query(query, values);
|
||||
return this.mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async update(userId: string, data: UpdateUserPreferencesRequest): Promise<UserPreferences | null> {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (data.unitSystem !== undefined) {
|
||||
fields.push(`unit_system = $${paramCount++}`);
|
||||
values.push(data.unitSystem);
|
||||
}
|
||||
if ((data as any).currencyCode !== undefined) {
|
||||
fields.push(`currency_code = $${paramCount++}`);
|
||||
values.push((data as any).currencyCode);
|
||||
}
|
||||
if ((data as any).timeZone !== undefined) {
|
||||
fields.push(`time_zone = $${paramCount++}`);
|
||||
values.push((data as any).timeZone);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return this.findByUserId(userId);
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE user_preferences
|
||||
SET ${fields.join(', ')}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = $${paramCount}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
values.push(userId);
|
||||
const result = await this.db.query(query, values);
|
||||
return result.rows.length > 0 ? this.mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async upsert(data: CreateUserPreferencesRequest): Promise<UserPreferences> {
|
||||
const existing = await this.findByUserId(data.userId);
|
||||
|
||||
if (existing) {
|
||||
const updated = await this.update(data.userId, { unitSystem: data.unitSystem });
|
||||
return updated!;
|
||||
}
|
||||
|
||||
return this.create(data);
|
||||
}
|
||||
|
||||
private mapRow(row: any): UserPreferences {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
unitSystem: row.unit_system,
|
||||
currencyCode: row.currency_code || 'USD',
|
||||
timeZone: row.time_zone || 'UTC',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Create user_preferences table for storing user settings
|
||||
CREATE TYPE unit_system AS ENUM ('imperial', 'metric');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
unit_system unit_system NOT NULL DEFAULT 'imperial',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_user_preferences_user_id ON user_preferences(user_id);
|
||||
|
||||
-- Add trigger for updated_at
|
||||
CREATE TRIGGER update_user_preferences_updated_at
|
||||
BEFORE UPDATE ON user_preferences
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Add currency_code and time_zone to user_preferences
|
||||
ALTER TABLE user_preferences
|
||||
ADD COLUMN IF NOT EXISTS currency_code VARCHAR(3) DEFAULT 'USD',
|
||||
ADD COLUMN IF NOT EXISTS time_zone VARCHAR(100) DEFAULT 'UTC';
|
||||
|
||||
-- Optional: basic length/format checks can be enforced at application layer
|
||||
|
||||
37
backend/src/core/user-preferences/user-preferences.types.ts
Normal file
37
backend/src/core/user-preferences/user-preferences.types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @ai-summary Type definitions for user preferences system
|
||||
* @ai-context Manages user settings including unit preferences
|
||||
*/
|
||||
|
||||
export type UnitSystem = 'imperial' | 'metric';
|
||||
|
||||
export interface UserPreferences {
|
||||
id: string;
|
||||
userId: string;
|
||||
unitSystem: UnitSystem;
|
||||
currencyCode: string;
|
||||
timeZone: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateUserPreferencesRequest {
|
||||
userId: string;
|
||||
unitSystem?: UnitSystem;
|
||||
}
|
||||
|
||||
export interface UpdateUserPreferencesRequest {
|
||||
unitSystem?: UnitSystem;
|
||||
currencyCode?: string;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export interface UserPreferencesResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
unitSystem: UnitSystem;
|
||||
currencyCode: string;
|
||||
timeZone: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
Reference in New Issue
Block a user