260223:1415 20260223 nextJS & nestJS Best pratices
All checks were successful
Build and Deploy / deploy (push) Successful in 4m44s

This commit is contained in:
admin
2026-02-23 14:15:06 +07:00
parent c90a664f53
commit ef16817f38
164 changed files with 24815 additions and 311 deletions

View File

@@ -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 {}

View File

@@ -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;
}

View File

@@ -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}`
);
}
}
}

View File

@@ -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,
};
})
);

View File

@@ -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 = {