phase-9: domain-driven architecture complete
- Restructured server layer with 5 domains: shared, screener, portfolio, calls, finance - Migrated 58 TypeScript files to domain-driven structure - Updated CLAUDE.md with new architecture documentation - Added .gitignore rules for .md files (except CLAUDE.md) - Removed unused CatalystAnalyst import from app.ts - Fixed lint errors: removed unused imports, fixed regex escape, added console suppressions - Verified no sensitive data in git history - Server code compiles cleanly with TypeScript strict mode
This commit is contained in:
committed by
saikiranvella
parent
83116baa3c
commit
96a752ecf7
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Split an array into smaller chunks of specified size.
|
||||
* @param array The array to split
|
||||
* @param size The size of each chunk
|
||||
* @returns Array of chunks
|
||||
* @example chunkArray([1,2,3,4,5], 2) → [[1,2], [3,4], [5]]
|
||||
*/
|
||||
export const chunkArray = <T>(array: T[], size: number): T[][] => {
|
||||
const chunkCount = Math.ceil(array.length / size);
|
||||
return Array.from({ length: chunkCount }, (_, index) => {
|
||||
const start = index * size;
|
||||
const end = start + size;
|
||||
return array.slice(start, end);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as queries from '../db/queries.constant';
|
||||
|
||||
export class QueryBuilder {
|
||||
readonly sql: string;
|
||||
readonly queryParams: unknown[];
|
||||
|
||||
/**
|
||||
* Create a QueryBuilder from a query constant path.
|
||||
*
|
||||
* @param queryPath Path to query in queries.constant.ts (e.g., 'MARKET_CALLS_QUERIES.SELECT_ALL')
|
||||
* @param params Parameters to bind (? placeholders in SQL)
|
||||
*/
|
||||
constructor(queryPath: string, params: unknown[] = []) {
|
||||
this.sql = this.lookupQuery(queryPath);
|
||||
this.queryParams = params;
|
||||
|
||||
// Validate parameter count matches placeholders
|
||||
const placeholderCount = (this.sql.match(/\?/g) || []).length;
|
||||
if (this.queryParams.length !== placeholderCount) {
|
||||
throw new Error(
|
||||
`Parameter mismatch for query "${queryPath}": expected ${placeholderCount}, got ${this.queryParams.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a query from queries.constant.ts.
|
||||
* Supports nested paths like "MARKET_CALLS_QUERIES.SELECT_ALL".
|
||||
*
|
||||
* @param queryPath Path to query (e.g., 'MARKET_CALLS_QUERIES.SELECT_ALL')
|
||||
* @returns The SQL query string
|
||||
* @throws Error if query not found
|
||||
*/
|
||||
private lookupQuery(queryPath: string): string {
|
||||
const parts = queryPath.split('.');
|
||||
|
||||
// Navigate through the nested objects
|
||||
let current: any = queries;
|
||||
for (const part of parts) {
|
||||
if (!(part in current)) {
|
||||
throw new Error(
|
||||
`Query not found: "${queryPath}". Make sure it exists in queries.constant.ts`,
|
||||
);
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
if (typeof current !== 'string') {
|
||||
throw new Error(`Invalid query: "${queryPath}" must be a string, got ${typeof current}`);
|
||||
}
|
||||
|
||||
// Clean up the SQL (remove extra whitespace)
|
||||
return current.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Logger } from '../types';
|
||||
|
||||
/**
|
||||
* Shared server-side logger utilities.
|
||||
*
|
||||
* noopLogger — silent logger for use in API server context where stdout
|
||||
* output from screener/analyst classes would pollute the request log.
|
||||
* Pass as { logger: noopLogger } to ScreenerEngine, BenchmarkProvider,
|
||||
* CatalystAnalyst, SimpleFINClient, LLMAnalyst.
|
||||
*/
|
||||
export const noopLogger: Logger = {
|
||||
write: () => {},
|
||||
log: () => {},
|
||||
warn: () => {},
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Sanitize a ticker symbol.
|
||||
* - Converts to uppercase
|
||||
* - Trims whitespace
|
||||
* - Validates non-empty
|
||||
*
|
||||
* @param ticker The ticker symbol (e.g. "aapl", " MSFT ", "BRK.B")
|
||||
* @returns Normalized ticker (e.g. "AAPL", "MSFT", "BRK.B")
|
||||
* @throws Error if ticker is empty or invalid
|
||||
*/
|
||||
export function sanitizeTicker(ticker: string): string {
|
||||
if (!ticker || typeof ticker !== 'string') {
|
||||
throw new Error('Invalid ticker: must be a non-empty string');
|
||||
}
|
||||
|
||||
const normalized = ticker.trim().toUpperCase();
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error('Invalid ticker: cannot be empty or whitespace');
|
||||
}
|
||||
|
||||
// Optional: validate ticker format (alphanumeric + dots/hyphens)
|
||||
if (!/^[A-Z0-9-.]+$/.test(normalized)) {
|
||||
throw new Error(`Invalid ticker format: ${normalized}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an array of tickers.
|
||||
*
|
||||
* @param tickers Array of ticker symbols
|
||||
* @returns Array of normalized tickers
|
||||
* @throws Error if any ticker is invalid
|
||||
*/
|
||||
export function sanitizeTickers(tickers: unknown): string[] {
|
||||
if (!Array.isArray(tickers)) {
|
||||
throw new Error('Invalid tickers: must be an array');
|
||||
}
|
||||
|
||||
if (tickers.length === 0) {
|
||||
throw new Error('Invalid tickers: array cannot be empty');
|
||||
}
|
||||
|
||||
return tickers.map((t) => {
|
||||
if (typeof t !== 'string') {
|
||||
throw new Error(`Invalid ticker in array: ${t} (expected string)`);
|
||||
}
|
||||
return sanitizeTicker(t);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a string field.
|
||||
* - Trims whitespace
|
||||
* - Validates non-empty
|
||||
* - Optional: enforces max length
|
||||
*
|
||||
* @param value The string value
|
||||
* @param fieldName Name of the field (for error messages)
|
||||
* @param maxLength Maximum allowed length (optional)
|
||||
* @returns Trimmed string
|
||||
* @throws Error if value is invalid
|
||||
*/
|
||||
export function sanitizeString(value: unknown, fieldName: string, maxLength?: number): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`Invalid ${fieldName}: must be a string`);
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
throw new Error(`Invalid ${fieldName}: cannot be empty or whitespace`);
|
||||
}
|
||||
|
||||
if (maxLength && trimmed.length > maxLength) {
|
||||
throw new Error(`Invalid ${fieldName}: exceeds max length of ${maxLength} characters`);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a number field.
|
||||
* - Validates it's a number
|
||||
* - Optional: enforces min/max bounds
|
||||
*
|
||||
* @param value The numeric value
|
||||
* @param fieldName Name of the field (for error messages)
|
||||
* @param min Minimum allowed value (optional)
|
||||
* @param max Maximum allowed value (optional)
|
||||
* @returns The validated number
|
||||
* @throws Error if value is invalid
|
||||
*/
|
||||
export function sanitizeNumber(
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
options?: { min?: number; max?: number },
|
||||
): number {
|
||||
const num = typeof value === 'number' ? value : Number(value);
|
||||
|
||||
if (isNaN(num)) {
|
||||
throw new Error(`Invalid ${fieldName}: must be a valid number`);
|
||||
}
|
||||
|
||||
if (options?.min !== undefined && num < options.min) {
|
||||
throw new Error(`Invalid ${fieldName}: must be at least ${options.min}`);
|
||||
}
|
||||
|
||||
if (options?.max !== undefined && num > options.max) {
|
||||
throw new Error(`Invalid ${fieldName}: must be at most ${options.max}`);
|
||||
}
|
||||
|
||||
return num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an ISO date string.
|
||||
* - Validates it's a valid ISO date
|
||||
* - Converts to string format YYYY-MM-DD
|
||||
*
|
||||
* @param value The date value (ISO string or Date)
|
||||
* @param fieldName Name of the field (for error messages)
|
||||
* @returns Date as YYYY-MM-DD string
|
||||
* @throws Error if date is invalid
|
||||
*/
|
||||
export function sanitizeDate(value: unknown, fieldName: string): string {
|
||||
let date: Date | null = null;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
date = new Date(value);
|
||||
} else if (value instanceof Date) {
|
||||
date = value;
|
||||
}
|
||||
|
||||
if (!date || isNaN(date.getTime())) {
|
||||
throw new Error(`Invalid ${fieldName}: must be a valid date`);
|
||||
}
|
||||
|
||||
return date.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
}
|
||||
Reference in New Issue
Block a user