diff --git a/src/adapters/postgres/cohort-adapter.ts b/src/adapters/postgres/cohort-adapter.ts index e4ef54f5..e1516417 100644 --- a/src/adapters/postgres/cohort-adapter.ts +++ b/src/adapters/postgres/cohort-adapter.ts @@ -43,7 +43,7 @@ import { FieldValueConverter } from 'src/utils/field-value-converter'; export class PostgresCohortService { // Cache for repository column names (static data) private cachedCohortColumnNames: string[] | null = null; - + // Cache for custom fields metadata (with TTL) private customFieldsCache: { data: any[]; @@ -333,8 +333,10 @@ export class PostgresCohortService { return new Map(); } + // Optimized query: Filter FieldValues first, then apply DISTINCT ON + // This avoids scanning the entire FieldValues table const query = ` - SELECT DISTINCT + SELECT fv."itemId", f."fieldId", f."label", @@ -354,17 +356,29 @@ export class PostgresCohortService { f."type", f."fieldParams", f."sourceDetails" - FROM public."Cohort" c - LEFT JOIN ( - SELECT DISTINCT ON (fv."fieldId", fv."itemId") fv.* + FROM ( + SELECT DISTINCT ON (fv."fieldId", fv."itemId") + fv."fieldId", + fv."itemId", + fv."textValue", + fv."numberValue", + fv."calendarValue", + fv."dropdownValue", + fv."radioValue", + fv."checkboxValue", + fv."textareaValue", + fv."fileValue", + fv."value" FROM public."FieldValues" fv - ) fv ON fv."itemId" = c."cohortId" + WHERE fv."itemId" = ANY($1) + ORDER BY fv."fieldId", fv."itemId", fv."createdAt" DESC, fv."fieldValuesId" DESC + ) fv INNER JOIN public."Fields" f ON fv."fieldId" = f."fieldId" - WHERE c."cohortId" = ANY($1); + ORDER BY fv."itemId", f."fieldId"; `; let results = await this.cohortMembersRepository.query(query, [cohortIds]); - + // Process results for dynamic options results = await Promise.all( results.map(async (data) => { @@ -1471,12 +1485,13 @@ export class PostgresCohortService { whereClause['cohortId'] = In(cohortIds); } - const [cohortData, totalCount] = await this.cohortRepository.findAndCount({ - where: whereClause, - order, - skip: offset, - take: limit, - }); + const [cohortData, totalCount] = + await this.cohortRepository.findAndCount({ + where: whereClause, + order, + skip: offset, + take: limit, + }); count = totalCount; diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index d199f9c3..d7ce8db5 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -30,7 +30,7 @@ import { AuthService } from "./auth.service"; import { JwtAuthGuard } from "src/common/guards/keycloak.guard"; import { APIID } from "src/common/utils/api-id.config"; import { AllExceptionsFilter } from "src/common/filters/exception.filter"; -import { Response } from "express"; +import { Response, Request } from "express"; @ApiTags("Auth") @Controller("auth") @@ -43,8 +43,12 @@ export class AuthController { @UsePipes(ValidationPipe) @HttpCode(HttpStatus.OK) @ApiForbiddenResponse({ description: "Forbidden" }) - public async login(@Body() authDto: AuthDto, @Res() response: Response) { - return this.authService.login(authDto, response); + public async login( + @Body() authDto: AuthDto, + @Req() request: Request, + @Res() response: Response + ) { + return this.authService.login(authDto, request, response); } @UseFilters(new AllExceptionsFilter(APIID.USER_AUTH)) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index d3aa71a1..ef979178 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -10,7 +10,9 @@ import jwt_decode from 'jwt-decode'; import APIResponse from 'src/common/responses/response'; import { KeycloakService } from 'src/common/utils/keycloak.service'; import { APIID } from 'src/common/utils/api-id.config'; -import { Response } from 'express'; +import { Response, Request } from 'express'; +import { LoggerUtil } from 'src/common/logger/LoggerUtil'; +import { AuthDto } from './dto/auth-dto'; type LoginResponse = { access_token: string; @@ -25,9 +27,21 @@ export class AuthService { private readonly keycloakService: KeycloakService ) {} - async login(authDto, response: Response) { + async login(authDto: AuthDto, request: Request, response: Response) { const apiId = APIID.LOGIN; const { username, password } = authDto; + + // Extract request information for logging + const userAgent = request.headers['user-agent'] || 'Unknown'; + + // Log login attempt start (username and IP excluded for legal compliance) + LoggerUtil.log( + `Login attempt initiated - User-Agent: ${userAgent}`, + 'AuthService', + undefined, + 'info' + ); + try { // Fetch user details by username const userData = await this.useradapter @@ -40,6 +54,15 @@ export class AuthService { ? 'User details not found for user' : 'User is inactive, please verify your email'; + const failureReason = userData ? 'USER_INACTIVE' : 'USER_NOT_FOUND'; + + // Log failed login attempt with reason and status code (username and IP excluded for legal compliance) + LoggerUtil.error( + `Login failed - StatusCode: ${HttpStatus.BAD_REQUEST}, Reason: ${failureReason}, Message: ${errorMessage}, IssueType: CLIENT_ERROR`, + errorMessage, + 'AuthService' + ); + return APIResponse.error( response, apiId, @@ -66,6 +89,14 @@ export class AuthService { token_type, }; + // Log successful login with status code (username and IP excluded for legal compliance) + LoggerUtil.log( + `Login successful - User-Agent: ${userAgent}, StatusCode: ${HttpStatus.OK}`, + 'AuthService', + undefined, + 'info' + ); + return APIResponse.success( response, apiId, @@ -75,9 +106,52 @@ export class AuthService { ); } catch (error) { if (error.response && error.response.status === 401) { + // Log invalid credentials with status code (username and IP excluded for legal compliance) + LoggerUtil.error( + `Login failed - StatusCode: ${HttpStatus.UNAUTHORIZED}, Reason: INVALID_CREDENTIALS, Message: Invalid username or password, IssueType: CLIENT_ERROR`, + 'Invalid username or password', + 'AuthService' + ); throw new NotFoundException('Invalid username or password'); } else { const errorMessage = error?.message || 'Something went wrong'; + const errorStack = error?.stack || 'No stack trace available'; + const httpStatus = + error?.response?.status || HttpStatus.INTERNAL_SERVER_ERROR; + const issueType = httpStatus >= 500 ? 'SERVER_ERROR' : 'CLIENT_ERROR'; + + // Determine failure reason based on httpStatus + let failureReason = 'INTERNAL_SERVER_ERROR'; + if (httpStatus >= 400 && httpStatus < 500) { + if (httpStatus === 400) { + failureReason = 'BAD_REQUEST'; + } else if (httpStatus === 403) { + failureReason = 'FORBIDDEN'; + } else if (httpStatus === 404) { + failureReason = 'NOT_FOUND'; + } else if (httpStatus === 429) { + failureReason = 'RATE_LIMIT_EXCEEDED'; + } else { + failureReason = 'CLIENT_ERROR'; + } + } else if (httpStatus >= 500) { + if (httpStatus === 502) { + failureReason = 'BAD_GATEWAY'; + } else if (httpStatus === 503) { + failureReason = 'SERVICE_UNAVAILABLE'; + } else if (httpStatus === 504) { + failureReason = 'GATEWAY_TIMEOUT'; + } + // failureReason already defaults to 'INTERNAL_SERVER_ERROR' for other 5xx errors + } + + // Log error with status code and issue type (username and IP excluded for legal compliance) + LoggerUtil.error( + `Login failed - StatusCode: ${httpStatus}, Reason: ${failureReason}, Message: ${errorMessage}, IssueType: ${issueType}`, + errorStack, + 'AuthService' + ); + return APIResponse.error( response, apiId, @@ -91,13 +165,45 @@ export class AuthService { public async getUserByAuth(request: any, tenantId, response: Response) { const apiId = APIID.USER_AUTH; + + // Extract request information for logging + const userAgent = request.headers['user-agent'] || 'Unknown'; + try { + // Log API call attempt (username and IP excluded for legal compliance) + LoggerUtil.log( + `GetUserByAuth attempt - User-Agent: ${userAgent}, TenantId: ${ + tenantId || 'Not provided' + }`, + 'AuthService', + undefined, + 'info' + ); + + // Decode JWT token to get username const decoded: any = jwt_decode(request.headers.authorization); - const username = decoded.preferred_username; + const username = decoded.preferred_username || 'Unknown'; + + // Log with username after decoding (username, userId, and IP excluded for legal compliance) + LoggerUtil.log( + `GetUserByAuth processing - TenantId: ${tenantId || 'Not provided'}`, + 'AuthService', + undefined, + 'info' + ); + const data = await this.useradapter .buildUserAdapter() .findUserDetails(null, username, tenantId); + // Log successful response (username, userId, and IP excluded for legal compliance) + LoggerUtil.log( + `GetUserByAuth successful - StatusCode: ${HttpStatus.OK}`, + 'AuthService', + undefined, + 'info' + ); + return APIResponse.success( response, apiId, @@ -107,6 +213,47 @@ export class AuthService { ); } catch (e) { const errorMessage = e?.message || 'Something went wrong'; + const errorStack = e?.stack || 'No stack trace available'; + + // Determine error type for logging purposes (but keep API response consistent) + let detectedStatus = HttpStatus.INTERNAL_SERVER_ERROR; + let failureReason = 'INTERNAL_SERVER_ERROR'; + let issueType = 'SERVER_ERROR'; + + if ( + e.name === 'JsonWebTokenError' || + e.message?.includes('token') || + e.message?.includes('jwt') + ) { + detectedStatus = HttpStatus.UNAUTHORIZED; + failureReason = 'INVALID_TOKEN'; + issueType = 'CLIENT_ERROR'; + } else if ( + e.message?.includes('not found') || + e.message?.includes('does not exist') + ) { + detectedStatus = HttpStatus.NOT_FOUND; + failureReason = 'USER_NOT_FOUND'; + issueType = 'CLIENT_ERROR'; + } else if ( + e.message?.includes('unauthorized') || + e.message?.includes('forbidden') + ) { + detectedStatus = HttpStatus.FORBIDDEN; + failureReason = 'UNAUTHORIZED'; + issueType = 'CLIENT_ERROR'; + } + + // Log failed attempt with comprehensive details (username, userId, and IP excluded for legal compliance) + LoggerUtil.error( + `GetUserByAuth failed - DetectedStatusCode: ${detectedStatus}, Reason: ${failureReason}, Message: ${errorMessage}, IssueType: ${issueType}, TenantId: ${ + tenantId || 'Not provided' + }`, + errorStack, + 'AuthService' + ); + + // Keep original API response behavior - always return INTERNAL_SERVER_ERROR return APIResponse.error( response, apiId, diff --git a/src/common/logger/LoggerUtil.ts b/src/common/logger/LoggerUtil.ts index 1d1bbbd1..17b57d40 100644 --- a/src/common/logger/LoggerUtil.ts +++ b/src/common/logger/LoggerUtil.ts @@ -22,57 +22,123 @@ export class LoggerUtil { level: 'info', format: winston.format.combine(winston.format.timestamp(), customFormat), transports: [ - new winston.transports.Console(), - new winston.transports.File({ filename: 'error.log', level: 'error' }), - new winston.transports.File({ filename: 'combined.log' }), + new winston.transports.Console({ + // Console logging is fast and non-blocking + handleExceptions: false, + handleRejections: false, + }), + new winston.transports.File({ + filename: 'error.log', + level: 'error', + // Log rotation configuration for performance and disk space management + // When log file reaches 5MB, it will be rotated (renamed) and a new file created + maxsize: 5242880, // 5MB - maximum size before rotation + maxFiles: 5, // Keep maximum 5 rotated log files (error.log, error.log.1, error.log.2, etc.) + tailable: true, // Oldest logs are deleted when maxFiles is reached + }), + new winston.transports.File({ + filename: 'combined.log', + // Log rotation configuration for performance and disk space management + // When log file reaches 5MB, it will be rotated (renamed) and a new file created + maxsize: 5242880, // 5MB - maximum size before rotation + maxFiles: 5, // Keep maximum 5 rotated log files (combined.log, combined.log.1, combined.log.2, etc.) + tailable: true, // Oldest logs are deleted when maxFiles is reached + }), ], + // Prevent Winston from exiting the process when logging errors occur + // This ensures logging failures don't crash the application + exitOnError: false, }); } return this.logger; } + + /** + * Non-blocking log method - uses process.nextTick to offload logging + * This ensures API responses are not delayed by logging operations + */ static log( message: string, context?: string, user?: string, level: string = 'info', ) { - this.getLogger().log({ - level: level, - message: message, - context: context, - user: user, - timestamp: new Date().toISOString(), + // Use process.nextTick to make logging non-blocking + // This ensures the API response is sent before logging completes + process.nextTick(() => { + try { + this.getLogger().log({ + level: level, + message: message, + context: context, + user: user, + timestamp: new Date().toISOString(), + }); + } catch (err) { + // Silently fail - don't let logging errors affect API responses + // Only log to console as last resort + console.error('Logger error:', err); + } }); } + /** + * Non-blocking error log method + */ static error( message: string, error?: string, context?: string, user?: string, ) { - this.getLogger().error({ - message: message, - error: error, - context: context, - user: user, - timestamp: new Date().toISOString(), + // Use process.nextTick to make logging non-blocking + process.nextTick(() => { + try { + this.getLogger().error({ + message: message, + error: error, + context: context, + user: user, + timestamp: new Date().toISOString(), + }); + } catch (err) { + // Silently fail - don't let logging errors affect API responses + console.error('Logger error:', err); + } }); } + /** + * Non-blocking warn log method + */ static warn(message: string, context?: string) { - this.getLogger().warn({ - message: message, - context: context, - timestamp: new Date().toISOString(), + process.nextTick(() => { + try { + this.getLogger().warn({ + message: message, + context: context, + timestamp: new Date().toISOString(), + }); + } catch (err) { + console.error('Logger error:', err); + } }); } + /** + * Non-blocking debug log method + */ static debug(message: string, context?: string) { - this.getLogger().debug({ - message: message, - context: context, - timestamp: new Date().toISOString(), + process.nextTick(() => { + try { + this.getLogger().debug({ + message: message, + context: context, + timestamp: new Date().toISOString(), + }); + } catch (err) { + console.error('Logger error:', err); + } }); } } \ No newline at end of file diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 1fbfb656..23a93a26 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -18,6 +18,7 @@ import { UseFilters, BadRequestException, UnauthorizedException, + HttpStatus, } from '@nestjs/common'; import { @@ -96,15 +97,29 @@ export class UserController { @Query('fieldvalue') fieldvalue: string | null = null ) { const tenantId = headers['tenantid']; + + // Log API call attempt (username, userId, and IP excluded for legal compliance) + LoggerUtil.log( + `GetUser attempt - TenantId: ${tenantId || 'Not provided'}, FieldValue: ${ + fieldvalue || 'false' + }`, + 'UserController', + undefined, + 'info' + ); + if (!tenantId) { - LoggerUtil.warn( - `${API_RESPONSES.BAD_REQUEST}`, - `Error: Missing tenantId in request headers for user ${userId}` + // Log missing tenantId error (username, userId, and IP excluded for legal compliance) + LoggerUtil.error( + `GetUser failed - StatusCode: 400, Reason: MISSING_TENANT_ID, Message: Missing tenantId in request headers, IssueType: CLIENT_ERROR`, + 'Missing tenantId in request headers', + 'UserController' ); return response .status(400) .json({ statusCode: 400, error: 'Please provide a tenantId.' }); } + const fieldValueBoolean = fieldvalue === 'true'; // Context and ContextType can be taken from .env later const userData: UserData = { @@ -113,11 +128,72 @@ export class UserController { userId: userId, fieldValue: fieldValueBoolean, }; - const result = await this.userAdapter - .buildUserAdapter() - .getUsersDetailsById(userData, response); - return response.status(result.statusCode).json(result); + try { + const result = await this.userAdapter + .buildUserAdapter() + .getUsersDetailsById(userData, response); + + const statusCode = result.statusCode || 200; + + // Determine if successful or failed based on status code + if (statusCode >= 200 && statusCode < 300) { + // Log successful response (username, userId, and IP excluded for legal compliance) + LoggerUtil.log( + `GetUser successful - StatusCode: ${statusCode}, TenantId: ${tenantId}`, + 'UserController', + undefined, // Username excluded for legal compliance + 'info' + ); + } else { + // Log failed response with reason + let failureReason = 'UNKNOWN_ERROR'; + let issueType = 'SERVER_ERROR'; + + if (statusCode === 400) { + failureReason = 'BAD_REQUEST'; + issueType = 'CLIENT_ERROR'; + } else if (statusCode === 404) { + failureReason = 'USER_NOT_FOUND'; + issueType = 'CLIENT_ERROR'; + } else if (statusCode === 401 || statusCode === 403) { + failureReason = 'UNAUTHORIZED'; + issueType = 'CLIENT_ERROR'; + } else if (statusCode >= 500) { + failureReason = 'INTERNAL_SERVER_ERROR'; + // issueType already defaults to 'SERVER_ERROR' for 5xx errors + } + + // Log failed response (username, userId, and IP excluded for legal compliance) + LoggerUtil.error( + `GetUser failed - StatusCode: ${statusCode}, Reason: ${failureReason}, Message: ${ + result.message || result.error || 'Unknown error' + }, IssueType: ${issueType}, TenantId: ${tenantId}`, + result.error || result.message || 'Unknown error', + 'UserController' + ); + } + + return response.status(statusCode).json(result); + } catch (error) { + const errorMessage = error?.message || 'Something went wrong'; + const errorStack = error?.stack || 'No stack trace available'; + const httpStatus = error?.status || HttpStatus.INTERNAL_SERVER_ERROR; + const issueType = httpStatus >= 500 ? 'SERVER_ERROR' : 'CLIENT_ERROR'; + + // Log exception with comprehensive details (username, userId, and IP excluded for legal compliance) + LoggerUtil.error( + `GetUser exception - StatusCode: ${httpStatus}, Reason: EXCEPTION, Message: ${errorMessage}, IssueType: ${issueType}, TenantId: ${tenantId}`, + errorStack, + 'UserController' + ); + + return response.status(httpStatus).json({ + statusCode: httpStatus, + error: errorMessage, + message: 'An error occurred while fetching user details', + }); + } } @UseFilters(new AllExceptionsFilter(APIID.USER_CREATE))