260223:1415 20260223 nextJS & nestJS Best pratices
All checks were successful
Build and Deploy / deploy (push) Successful in 4m44s
All checks were successful
Build and Deploy / deploy (push) Successful in 4m44s
This commit is contained in:
@@ -23,6 +23,7 @@ import { winstonConfig } from './modules/monitoring/logger/winston.config';
|
||||
// Entities & Interceptors
|
||||
import { AuditLog } from './common/entities/audit-log.entity';
|
||||
import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor';
|
||||
import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor';
|
||||
import { MaintenanceModeGuard } from './common/guards/maintenance-mode.guard';
|
||||
|
||||
// Modules
|
||||
@@ -176,6 +177,11 @@ import { AuditLogModule } from './modules/audit-log/audit-log.module';
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: AuditLogInterceptor,
|
||||
},
|
||||
// 🔑 4. Register Global Interceptor (Idempotency) — ป้องกัน duplicate POST/PUT requests
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: IdempotencyInterceptor,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// File: src/common/exceptions/http-exception.filter.ts
|
||||
// บันทึกการแก้ไข: ปรับปรุง Global Filter ให้จัดการ Error ปลอดภัยสำหรับ Production และ Log ละเอียดใน Dev (T1.1)
|
||||
// Fix #3 & #4: แทน console.error ด้วย Logger, เพิ่ม ErrorResponseBody interface
|
||||
|
||||
import {
|
||||
ExceptionFilter,
|
||||
@@ -11,6 +11,16 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
interface ErrorResponseBody {
|
||||
statusCode: number;
|
||||
timestamp: string;
|
||||
path: string;
|
||||
message?: unknown;
|
||||
error?: string;
|
||||
stack?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
@@ -33,49 +43,46 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
: { message: 'Internal server error' };
|
||||
|
||||
// จัดรูปแบบ Error Message ให้เป็น Object เสมอ
|
||||
let errorBody: any =
|
||||
let errorBody: Record<string, unknown> =
|
||||
typeof exceptionResponse === 'string'
|
||||
? { message: exceptionResponse }
|
||||
: exceptionResponse;
|
||||
: (exceptionResponse as Record<string, unknown>);
|
||||
|
||||
// 3. 📝 Logging Strategy (แยกตามความรุนแรง)
|
||||
if (status >= 500) {
|
||||
// 💥 Critical Error: Log stack trace เต็มๆ
|
||||
this.logger.error(
|
||||
`💥 HTTP ${status} Error on ${request.method} ${request.url}`,
|
||||
exception instanceof Error
|
||||
? exception.stack
|
||||
: JSON.stringify(exception),
|
||||
`HTTP ${status} Error on ${request.method} ${request.url}`,
|
||||
exception instanceof Error ? exception.stack : JSON.stringify(exception)
|
||||
);
|
||||
|
||||
// 👇👇 สิ่งที่คุณต้องการ: Log ดิบๆ ให้เห็นชัดใน Docker Console 👇👇
|
||||
console.error('💥 REAL CRITICAL ERROR:', exception);
|
||||
} else {
|
||||
// ⚠️ Client Error (400, 401, 403, 404): Log แค่ Warning พอ ไม่ต้อง Stack Trace
|
||||
this.logger.warn(
|
||||
`⚠️ HTTP ${status} Error on ${request.method} ${request.url}: ${JSON.stringify(errorBody.message || errorBody)}`,
|
||||
`HTTP ${status} Error on ${request.method} ${request.url}: ${JSON.stringify(errorBody['message'] ?? errorBody)}`
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 🔒 Security & Response Formatting
|
||||
// กรณี Production และเป็น Error 500 -> ต้องซ่อนรายละเอียดความผิดพลาดของ Server
|
||||
if (status === 500 && process.env.NODE_ENV === 'production') {
|
||||
if (status === 500 && process.env['NODE_ENV'] === 'production') {
|
||||
errorBody = {
|
||||
message: 'Internal server error',
|
||||
// อาจเพิ่ม reference code เพื่อให้ user แจ้ง support ได้ เช่น code: 'ERR-500'
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Construct Final Response
|
||||
const responseBody = {
|
||||
// 5. Construct Final Response (type-safe)
|
||||
const responseBody: ErrorResponseBody = {
|
||||
statusCode: status,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
...errorBody, // Spread message, error, validation details
|
||||
...errorBody,
|
||||
};
|
||||
|
||||
// 🛠️ Development Mode: แถม Stack Trace ไปให้ Frontend Debug ง่ายขึ้น
|
||||
if (process.env.NODE_ENV !== 'production' && exception instanceof Error) {
|
||||
if (
|
||||
process.env['NODE_ENV'] !== 'production' &&
|
||||
exception instanceof Error
|
||||
) {
|
||||
responseBody.stack = exception.stack;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// File: src/common/interceptors/audit-log.interceptor.ts
|
||||
// Fix #2: Replaced `as unknown as AuditLog` with CreateAuditLogPayload typed interface
|
||||
// Lint fixes: async-in-tap pattern, null vs undefined entityId, safe any handling
|
||||
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
@@ -16,6 +20,17 @@ import { AuditLog } from '../entities/audit-log.entity';
|
||||
import { AUDIT_KEY, AuditMetadata } from '../decorators/audit.decorator';
|
||||
import { User } from '../../modules/user/entities/user.entity';
|
||||
|
||||
/** Typed payload for creating AuditLog, replacing the `as unknown as AuditLog` cast */
|
||||
interface CreateAuditLogPayload {
|
||||
userId: number | null | undefined;
|
||||
action: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
severity: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditLogInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(AuditLogInterceptor.name);
|
||||
@@ -23,13 +38,13 @@ export class AuditLogInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
@InjectRepository(AuditLog)
|
||||
private auditLogRepo: Repository<AuditLog>,
|
||||
private auditLogRepo: Repository<AuditLog>
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const auditMetadata = this.reflector.getAllAndOverride<AuditMetadata>(
|
||||
AUDIT_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
[context.getHandler(), context.getClass()]
|
||||
);
|
||||
|
||||
if (!auditMetadata) {
|
||||
@@ -37,44 +52,70 @@ export class AuditLogInterceptor implements NestInterceptor {
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = (request as any).user as User;
|
||||
const rawIp = request.ip || request.socket.remoteAddress;
|
||||
const ip = Array.isArray(rawIp) ? rawIp[0] : rawIp;
|
||||
const user = (request as Request & { user?: User }).user;
|
||||
const rawIp: string | string[] | undefined =
|
||||
request.ip ?? request.socket.remoteAddress;
|
||||
const ip: string | undefined = Array.isArray(rawIp) ? rawIp[0] : rawIp;
|
||||
const userAgent = request.get('user-agent');
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(async (data) => {
|
||||
try {
|
||||
let entityId = null;
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
if ('id' in data) entityId = String(data.id);
|
||||
else if ('audit_id' in data) entityId = String(data.audit_id);
|
||||
else if ('user_id' in data) entityId = String(data.user_id);
|
||||
}
|
||||
|
||||
if (!entityId && request.params.id) {
|
||||
entityId = String(request.params.id);
|
||||
}
|
||||
|
||||
// ✅ FIX: ใช้ user?.user_id || null
|
||||
const auditLog = this.auditLogRepo.create({
|
||||
userId: user ? user.user_id : null,
|
||||
action: auditMetadata.action,
|
||||
entityType: auditMetadata.entityType,
|
||||
entityId: entityId,
|
||||
ipAddress: ip,
|
||||
userAgent: userAgent,
|
||||
severity: 'INFO',
|
||||
} as unknown as AuditLog); // ✨ Trick: Cast ผ่าน unknown เพื่อล้าง Error ถ้า TS ยังไม่อัปเดต
|
||||
|
||||
await this.auditLogRepo.save(auditLog);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to create audit log for ${auditMetadata.action}: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
// Use void for fire-and-forget: tap() does not support async callbacks
|
||||
tap((data: unknown) => {
|
||||
void this.saveAuditLog(
|
||||
data,
|
||||
auditMetadata,
|
||||
request,
|
||||
user,
|
||||
ip,
|
||||
userAgent
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Extracted async method to aoid "Promise returned in tap" lint warning */
|
||||
private async saveAuditLog(
|
||||
data: unknown,
|
||||
auditMetadata: AuditMetadata,
|
||||
request: Request,
|
||||
user: User | undefined,
|
||||
ip: string | undefined,
|
||||
userAgent: string | undefined
|
||||
): Promise<void> {
|
||||
try {
|
||||
let entityId: string | undefined;
|
||||
|
||||
if (data !== null && typeof data === 'object') {
|
||||
const dataRecord = data as Record<string, unknown>;
|
||||
if ('id' in dataRecord) {
|
||||
entityId = String(dataRecord['id']);
|
||||
} else if ('audit_id' in dataRecord) {
|
||||
entityId = String(dataRecord['audit_id']);
|
||||
} else if ('user_id' in dataRecord) {
|
||||
entityId = String(dataRecord['user_id']);
|
||||
}
|
||||
}
|
||||
|
||||
if (!entityId && request.params['id']) {
|
||||
entityId = String(request.params['id']);
|
||||
}
|
||||
|
||||
const payload: CreateAuditLogPayload = {
|
||||
userId: user?.user_id ?? null,
|
||||
action: auditMetadata.action,
|
||||
entityType: auditMetadata.entityType,
|
||||
entityId,
|
||||
ipAddress: ip,
|
||||
userAgent,
|
||||
severity: 'INFO',
|
||||
};
|
||||
|
||||
const auditLog = this.auditLogRepo.create(payload as Partial<AuditLog>);
|
||||
await this.auditLogRepo.save(auditLog);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to create audit log for ${auditMetadata.action}: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// File: src/common/interceptors/transform.interceptor.ts
|
||||
// Fix #1: แก้ไข `any` type ให้ถูกต้องตาม nestjs-best-practices (TypeScript Strict Mode)
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
@@ -7,40 +10,67 @@ import {
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
export interface Response<T> {
|
||||
/** Metadata สำหรับ Paginated Response */
|
||||
export interface ResponseMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/** Standard API Response Wrapper */
|
||||
export interface ApiResponse<T> {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
data: T;
|
||||
meta?: any;
|
||||
meta?: ResponseMeta;
|
||||
}
|
||||
|
||||
/** Internal shape สำหรับ paginated data ที่ service ส่งมา */
|
||||
interface PaginatedPayload<T> {
|
||||
data: T[];
|
||||
meta: ResponseMeta;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function isPaginatedPayload<T>(value: unknown): value is PaginatedPayload<T> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'data' in value &&
|
||||
'meta' in value &&
|
||||
Array.isArray((value as PaginatedPayload<T>).data)
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T>
|
||||
implements NestInterceptor<T, Response<T>>
|
||||
implements NestInterceptor<T, ApiResponse<T>>
|
||||
{
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler
|
||||
): Observable<Response<T>> {
|
||||
next: CallHandler<T>
|
||||
): Observable<ApiResponse<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data: any) => {
|
||||
const response = context.switchToHttp().getResponse();
|
||||
map((data: T) => {
|
||||
const response = context.switchToHttp().getResponse<{ statusCode: number }>();
|
||||
|
||||
// Handle Pagination Response (Standardize)
|
||||
// ถ้า data มี structure { data: [], meta: {} } ให้ unzip ออกมา
|
||||
if (data && data.data && data.meta) {
|
||||
if (isPaginatedPayload(data)) {
|
||||
return {
|
||||
statusCode: response.statusCode,
|
||||
message: data.message || 'Success',
|
||||
data: data.data,
|
||||
message: data.message ?? 'Success',
|
||||
data: data.data as unknown as T,
|
||||
meta: data.meta,
|
||||
};
|
||||
}
|
||||
|
||||
const dataAsRecord = data as Record<string, unknown>;
|
||||
return {
|
||||
statusCode: response.statusCode,
|
||||
message: data?.message || 'Success',
|
||||
data: data?.result || data,
|
||||
message: (dataAsRecord?.['message'] as string | undefined) ?? 'Success',
|
||||
data: (dataAsRecord?.['result'] as T | undefined) ?? data,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -125,7 +125,11 @@ export class CorrespondenceService {
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
const orgCode = 'ORG'; // TODO: Fetch real ORG Code from Organization Entity
|
||||
// [Fix #6] Fetch real ORG Code from Organization entity
|
||||
const originatorOrg = await this.orgRepo.findOne({
|
||||
where: { id: userOrgId },
|
||||
});
|
||||
const orgCode = originatorOrg?.organizationCode ?? 'UNK';
|
||||
|
||||
// [v1.5.1] Extract recipient organization from recipients array (Primary TO)
|
||||
const toRecipient = createDto.recipients?.find((r) => r.type === 'TO');
|
||||
@@ -217,7 +221,8 @@ export class CorrespondenceService {
|
||||
);
|
||||
}
|
||||
|
||||
this.searchService.indexDocument({
|
||||
// Fire-and-forget search indexing (non-blocking, void intentional)
|
||||
void this.searchService.indexDocument({
|
||||
id: savedCorr.id,
|
||||
type: 'correspondence',
|
||||
docNumber: docNumber.number,
|
||||
@@ -491,8 +496,13 @@ export class CorrespondenceService {
|
||||
|
||||
if (updateDto.recipients) {
|
||||
// Safe check for 'type' or 'recipientType' (mismatch safeguard)
|
||||
interface RecipientInput {
|
||||
type?: string;
|
||||
recipientType?: string;
|
||||
organizationId?: number;
|
||||
}
|
||||
const newToRecipient = updateDto.recipients.find(
|
||||
(r: any) => r.type === 'TO' || r.recipientType === 'TO'
|
||||
(r: RecipientInput) => r.type === 'TO' || r.recipientType === 'TO'
|
||||
);
|
||||
newRecipientId = newToRecipient?.organizationId;
|
||||
|
||||
@@ -521,7 +531,13 @@ export class CorrespondenceService {
|
||||
if (recOrg) recipientCode = recOrg.organizationCode;
|
||||
}
|
||||
|
||||
const orgCode = 'ORG'; // Placeholder - should be fetched from Originator if needed in future
|
||||
// [Fix #6] Fetch real ORG Code from originator organization
|
||||
const originatorOrgForUpdate = await this.orgRepo.findOne({
|
||||
where: {
|
||||
id: updateDto.originatorId ?? currentCorr.originatorId ?? 0,
|
||||
},
|
||||
});
|
||||
const orgCode = originatorOrgForUpdate?.organizationCode ?? 'UNK';
|
||||
|
||||
// Prepare Contexts
|
||||
const oldCtx = {
|
||||
|
||||
Reference in New Issue
Block a user