diff --git a/src/configurations/common/queue-names.ts b/src/configurations/common/queue-names.ts index 9fbfcea..ae98158 100644 --- a/src/configurations/common/queue-names.ts +++ b/src/configurations/common/queue-names.ts @@ -6,6 +6,7 @@ export const QueueName = { MOODLE_SYNC: 'moodle-sync', ANALYTICS_REFRESH: 'analytics-refresh', AUDIT: 'audit', + ERROR_LOG: 'error-log', REPORT_GENERATION: 'report-generation', } as const; diff --git a/src/entities/CLAUDE.md b/src/entities/CLAUDE.md index 766419c..72aca8d 100644 --- a/src/entities/CLAUDE.md +++ b/src/entities/CLAUDE.md @@ -28,6 +28,7 @@ MikroORM entity definitions. All domain state lives here. - Adding a new entity requires registering it in `index.entity.ts` AND creating a migration (`npx mikro-orm migration:create`). Forgetting the migration silently diverges the dev DB from production. - Relation decorators (`@ManyToOne`, `@OneToMany`) need matching inverse sides or MikroORM will silently drop joins. - When writing new repos, prefer `em.fork()` + query builder inside the repo — don't leak query building into services. +- **`uq_analysis_pipeline_active_scope` is a recurring CLI suggestion trap.** This partial unique index (FAC-132, see `Migration20260414155236_fac-132-pipeline-scope-unique-index.ts`) uses `COALESCE(...,'NONE')` over nullable scope FKs and a `WHERE status NOT IN (...)` clause. MikroORM decorators can't represent it, so `migration:create` will propose dropping it on every run. **Never accept that drop** — it reopens the bug FAC-132 fixed (duplicate active pipelines for the same scope). Always strip the `drop index "uq_analysis_pipeline_active_scope"` line from generated migrations before committing. ## Pointers diff --git a/src/entities/error-log.entity.ts b/src/entities/error-log.entity.ts new file mode 100644 index 0000000..b130306 --- /dev/null +++ b/src/entities/error-log.entity.ts @@ -0,0 +1,65 @@ +import { Entity, Index, Opt, PrimaryKey, Property } from '@mikro-orm/core'; +import { v4 } from 'uuid'; + +// Error log rows capture unhandled 5xx exceptions for admin-side diagnostics. +// Never soft-deleted — queries must use `filters: { softDelete: false }`. +// Matches the SyncLog/AuditLog precedent of opting out of the global filter. +@Entity() +export class ErrorLog { + @PrimaryKey() + id: string & Opt = v4(); + + @Index() + @Property() + statusCode!: number; + + @Property() + method!: string; + + @Index() + @Property() + path!: string; + + @Index() + @Property({ nullable: true }) + userId?: string; + + @Property({ nullable: true }) + userName?: string; + + @Index() + @Property() + errorName!: string; + + @Property({ type: 'text' }) + message!: string; + + @Property({ type: 'text', nullable: true }) + stack?: string; + + @Property({ type: 'jsonb', nullable: true }) + requestBody?: Record; + + @Property({ type: 'jsonb', nullable: true }) + requestQuery?: Record; + + @Property({ nullable: true }) + browserName?: string; + + @Property({ nullable: true }) + os?: string; + + @Property({ nullable: true }) + ipAddress?: string; + + @Index() + @Property({ nullable: true }) + acknowledgedAt?: Date; + + @Property({ nullable: true }) + acknowledgedBy?: string; + + @Index() + @Property({ defaultRaw: 'now()', length: 6 }) + occurredAt: Date & Opt = new Date(); +} diff --git a/src/entities/index.entity.ts b/src/entities/index.entity.ts index 33e5319..fddc3cf 100644 --- a/src/entities/index.entity.ts +++ b/src/entities/index.entity.ts @@ -31,6 +31,7 @@ import { Section } from './section.entity'; import { TopicModelRun } from './topic-model-run.entity'; import { SyncLog } from './sync-log.entity'; import { AuditLog } from './audit-log.entity'; +import { ErrorLog } from './error-log.entity'; import { ReportJob } from './report-job.entity'; export { @@ -67,6 +68,7 @@ export { TopicModelRun, SyncLog, AuditLog, + ErrorLog, ReportJob, }; @@ -104,5 +106,6 @@ export const entities = [ TopicModelRun, SyncLog, AuditLog, + ErrorLog, ReportJob, ]; diff --git a/src/migrations/.snapshot-faculytics_db.json b/src/migrations/.snapshot-faculytics_db.json index 4a81964..5be8bb7 100644 --- a/src/migrations/.snapshot-faculytics_db.json +++ b/src/migrations/.snapshot-faculytics_db.json @@ -397,8 +397,11 @@ "scale": null, "default": "'USER'", "comment": null, - "enumItems": [], - "mappedType": "text" + "enumItems": [ + "USER", + "SCHEDULER" + ], + "mappedType": "enum" } }, "name": "analysis_pipeline", @@ -2133,6 +2136,360 @@ "nativeEnums": {}, "comment": null }, + { + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "string" + }, + "status_code": { + "name": "status_code", + "type": "int4", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": 32, + "scale": 0, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "integer" + }, + "method": { + "name": "method", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "string" + }, + "path": { + "name": "path", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "string" + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "string" + }, + "user_name": { + "name": "user_name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "string" + }, + "error_name": { + "name": "error_name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "string" + }, + "message": { + "name": "message", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "stack": { + "name": "stack", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "request_body": { + "name": "request_body", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "json" + }, + "request_query": { + "name": "request_query", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "json" + }, + "browser_name": { + "name": "browser_name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "string" + }, + "os": { + "name": "os", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "string" + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "string" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "datetime" + }, + "acknowledged_by": { + "name": "acknowledged_by", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 255, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "string" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": "now()", + "comment": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "name": "error_log", + "schema": "public", + "indexes": [ + { + "columnNames": [ + "acknowledged_at" + ], + "composite": false, + "constraint": false, + "keyName": "error_log_acknowledged_at_index", + "unique": false, + "primary": false + }, + { + "columnNames": [ + "error_name" + ], + "composite": false, + "constraint": false, + "keyName": "error_log_error_name_index", + "unique": false, + "primary": false + }, + { + "columnNames": [ + "occurred_at" + ], + "composite": false, + "constraint": false, + "keyName": "error_log_occurred_at_index", + "unique": false, + "primary": false + }, + { + "columnNames": [ + "path" + ], + "composite": false, + "constraint": false, + "keyName": "error_log_path_index", + "unique": false, + "primary": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": false, + "keyName": "error_log_pkey", + "unique": true, + "primary": true + }, + { + "columnNames": [ + "status_code" + ], + "composite": false, + "constraint": false, + "keyName": "error_log_status_code_index", + "unique": false, + "primary": false + }, + { + "columnNames": [ + "user_id" + ], + "composite": false, + "constraint": false, + "keyName": "error_log_user_id_index", + "unique": false, + "primary": false + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": {}, + "comment": null + }, { "columns": { "id": { diff --git a/src/migrations/Migration20260512023035.ts b/src/migrations/Migration20260512023035.ts new file mode 100644 index 0000000..1a0e961 --- /dev/null +++ b/src/migrations/Migration20260512023035.ts @@ -0,0 +1,39 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260512023035 extends Migration { + + // This migration does two things: + // + // 1. Creates the `error_log` table for the new admin diagnostics page that + // persists unhandled 5xx exceptions for inspection. + // + // 2. Adds the `analysis_pipeline_trigger_check` CHECK constraint declared by + // `@Enum(() => PipelineTrigger)` on the entity but missing from the DB. + // Verified safe on staging: all existing rows have `trigger = 'USER'` and + // no constraint of that name exists yet, so the ADD succeeds without + // scanning past row values. + // + // The CLI also wanted to drop `uq_analysis_pipeline_active_scope` here — + // that's the FAC-132 partial unique index, intentionally created via raw SQL + // because MikroORM decorators can't represent it. It is INTENTIONALLY NOT + // included; dropping it reintroduces the bug FAC-132 fixed. See + // `src/entities/CLAUDE.md` for the recurring-drift trap. + + override async up(): Promise { + this.addSql(`create table "error_log" ("id" varchar(255) not null, "status_code" int not null, "method" varchar(255) not null, "path" varchar(255) not null, "user_id" varchar(255) null, "user_name" varchar(255) null, "error_name" varchar(255) not null, "message" text not null, "stack" text null, "request_body" jsonb null, "request_query" jsonb null, "browser_name" varchar(255) null, "os" varchar(255) null, "ip_address" varchar(255) null, "acknowledged_at" timestamptz null, "acknowledged_by" varchar(255) null, "occurred_at" timestamptz(6) not null default now(), constraint "error_log_pkey" primary key ("id"));`); + this.addSql(`create index "error_log_status_code_index" on "error_log" ("status_code");`); + this.addSql(`create index "error_log_path_index" on "error_log" ("path");`); + this.addSql(`create index "error_log_user_id_index" on "error_log" ("user_id");`); + this.addSql(`create index "error_log_error_name_index" on "error_log" ("error_name");`); + this.addSql(`create index "error_log_acknowledged_at_index" on "error_log" ("acknowledged_at");`); + this.addSql(`create index "error_log_occurred_at_index" on "error_log" ("occurred_at");`); + + this.addSql(`alter table "analysis_pipeline" add constraint "analysis_pipeline_trigger_check" check("trigger" in ('USER', 'SCHEDULER'));`); + } + + override async down(): Promise { + this.addSql(`alter table "analysis_pipeline" drop constraint if exists "analysis_pipeline_trigger_check";`); + this.addSql(`drop table if exists "error_log" cascade;`); + } + +} diff --git a/src/modules/index.module.ts b/src/modules/index.module.ts index e6f0382..8906d8f 100644 --- a/src/modules/index.module.ts +++ b/src/modules/index.module.ts @@ -22,6 +22,7 @@ import { FacultyModule } from './faculty/faculty.module'; import { CurriculumModule } from './curriculum/curriculum.module'; import { AdminModule } from './admin/admin.module'; import { AuditModule } from './audit/audit.module'; +import { SystemErrorsModule } from './system-errors/system-errors.module'; import { SemestersModule } from './semesters/semesters.module'; import { ReportsModule } from './reports/reports.module'; import { ThrottlerModule } from '@nestjs/throttler'; @@ -47,6 +48,7 @@ export const ApplicationModules = [ CurriculumModule, AdminModule, AuditModule, + SystemErrorsModule, SemestersModule, ReportsModule, ]; diff --git a/src/modules/moodle/services/moodle-user-hydration.service.spec.ts b/src/modules/moodle/services/moodle-user-hydration.service.spec.ts index 5182c1f..3db9da6 100644 --- a/src/modules/moodle/services/moodle-user-hydration.service.spec.ts +++ b/src/modules/moodle/services/moodle-user-hydration.service.spec.ts @@ -210,3 +210,120 @@ describe('MoodleUserHydrationService scope derivation', () => { expect(user.campus).toBe(initialCampus); }); }); + +describe('MoodleUserHydrationService institutional role cleanup', () => { + // Regression: multi-role user (faculty + chairperson + dean) hits 500 on + // login because resolveInstitutionalRoles dereferences `ir.moodleCategory` + // without a null guard. populate('moodleCategory') can return null when the + // referenced category was soft-deleted or drifted out of the local mirror. + // Single-role users skip the (DEAN ∧ CHAIRPERSON) cleanup branch entirely, + // which is why the issue only manifests for the intersection. + const setupWithInstRoles = async (instRoles: UserInstitutionalRole[]) => { + const user = { + id: 'u1', + moodleUserId: 42, + userName: 'jdoe', + departmentSource: InstitutionalRoleSource.AUTO, + programSource: InstitutionalRoleSource.AUTO, + roles: [], + updateRolesFromEnrollments: jest.fn(), + } as unknown as User; + + const dept = { id: 'd1', name: 'dept-d1' } as Department; + const program = { + id: 'p1', + moodleCategoryId: 100, + department: dept, + } as unknown as Program; + const course = { id: 'course-1', program } as unknown as Course; + + const tx: FakeTx = { + findOneOrFail: jest.fn((entity: unknown) => { + if (entity === User) return Promise.resolve(user); + return Promise.reject( + new Error(`unexpected findOneOrFail for ${String(entity)}`), + ); + }), + findOne: jest.fn((entity: unknown) => { + if (entity === Program) return Promise.resolve(program); + return Promise.resolve(null); + }), + find: jest.fn((entity: unknown, _filter: unknown) => { + if (entity === Enrollment) return Promise.resolve([]); + if (entity === UserInstitutionalRole) return Promise.resolve(instRoles); + return Promise.resolve([]); + }), + upsert: jest.fn((entity: unknown, data: unknown) => { + if (entity === Course) return Promise.resolve(course); + if (entity === Section) return Promise.resolve(data); + if (entity === Enrollment) return Promise.resolve(data); + return Promise.resolve(data); + }), + populate: jest.fn(() => Promise.resolve(undefined)), + persist: jest.fn(), + flush: jest.fn(() => Promise.resolve(undefined)), + remove: jest.fn(), + create: jest.fn((_entity: unknown, data: unknown) => data), + }; + + const unitOfWork = { + runInTransaction: jest.fn((work: (em: unknown) => Promise) => + work(tx), + ), + } as unknown as UnitOfWork; + + const moodleService = buildMoodleService(course); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MoodleUserHydrationService, + { provide: MoodleService, useValue: moodleService }, + { provide: UnitOfWork, useValue: unitOfWork }, + ], + }).compile(); + + const service = module.get(MoodleUserHydrationService); + return { service, user, tx }; + }; + + it('does not throw when a CHAIRPERSON role has a null moodleCategory (orphaned by drift)', async () => { + // ucmn-t-67092 scenario: user has DEAN + CHAIRPERSON, and the chairperson's + // related moodle_category was either soft-deleted or filtered out by the + // global soft-delete filter, so populate returns null for that relation. + const deanRole = { + role: 'DEAN', + source: InstitutionalRoleSource.MANUAL, + moodleCategory: { moodleCategoryId: 200, parentMoodleCategoryId: 150 }, + } as unknown as UserInstitutionalRole; + const chairpersonOrphan = { + role: 'CHAIRPERSON', + source: InstitutionalRoleSource.MANUAL, + moodleCategory: null, + } as unknown as UserInstitutionalRole; + + const { service } = await setupWithInstRoles([deanRole, chairpersonOrphan]); + + await expect( + service.hydrateUserCourses(42, 'token'), + ).resolves.not.toThrow(); + }); + + it('does not throw when a DEAN role has a null moodleCategory (drifted manual assignment)', async () => { + const deanOrphan = { + role: 'DEAN', + source: InstitutionalRoleSource.MANUAL, + moodleCategory: null, + } as unknown as UserInstitutionalRole; + const chairperson = { + role: 'CHAIRPERSON', + source: InstitutionalRoleSource.AUTO, + moodleCategory: { moodleCategoryId: 100, parentMoodleCategoryId: 200 }, + } as unknown as UserInstitutionalRole; + + const { service } = await setupWithInstRoles([deanOrphan, chairperson]); + + await expect( + service.hydrateUserCourses(42, 'token'), + ).resolves.not.toThrow(); + }); +}); diff --git a/src/modules/moodle/services/moodle-user-hydration.service.ts b/src/modules/moodle/services/moodle-user-hydration.service.ts index 811be08..da1a252 100644 --- a/src/modules/moodle/services/moodle-user-hydration.service.ts +++ b/src/modules/moodle/services/moodle-user-hydration.service.ts @@ -342,7 +342,11 @@ export class MoodleUserHydrationService { } } - // Step 3: Clean up redundant CHAIRPERSON roles where a manual DEAN covers the parent dept + // Step 3: Clean up redundant CHAIRPERSON roles where a manual DEAN covers the parent dept. + // `moodleCategory` is technically a NOT NULL FK, but populate can resolve to null when the + // related row is hidden by the global soft-delete filter or has drifted out of the local + // mirror after a Moodle restructure. Guard every access — losing such a stale row to a + // null deref is exactly the (DEAN ∧ CHAIRPERSON) login-500 path. const allExistingRoles = await tx.find( UserInstitutionalRole, { user }, @@ -356,19 +360,21 @@ export class MoodleUserHydrationService { ir.role === (UserRole.DEAN as string) && ir.source === (InstitutionalRoleSource.MANUAL as string), ) - .map((ir) => ir.moodleCategory.moodleCategoryId), + .map((ir) => ir.moodleCategory?.moodleCategoryId) + .filter((id): id is number => id != null), ); for (const existing of allExistingRoles) { if (existing.role !== (UserRole.CHAIRPERSON as string)) continue; - const parentDeptId = programToDeptMap.get( - existing.moodleCategory.moodleCategoryId, - ); + const existingCatId = existing.moodleCategory?.moodleCategoryId; + if (existingCatId == null) continue; + + const parentDeptId = programToDeptMap.get(existingCatId); const deptId = parentDeptId ?? ( await tx.findOne(MoodleCategory, { - moodleCategoryId: existing.moodleCategory.moodleCategoryId, + moodleCategoryId: existingCatId, }) )?.parentMoodleCategoryId; @@ -387,9 +393,9 @@ export class MoodleUserHydrationService { ); const existingAutoKeys = new Set( - existingAutoRoles.map( - (ir) => `${ir.role}:${ir.moodleCategory.moodleCategoryId}`, - ), + existingAutoRoles + .filter((ir) => ir.moodleCategory?.moodleCategoryId != null) + .map((ir) => `${ir.role}:${ir.moodleCategory.moodleCategoryId}`), ); const detectedKeys = new Set( @@ -398,7 +404,13 @@ export class MoodleUserHydrationService { // Remove stale auto roles for (const existing of existingAutoRoles) { - const key = `${existing.role}:${existing.moodleCategory.moodleCategoryId}`; + const existingCatId = existing.moodleCategory?.moodleCategoryId; + if (existingCatId == null) { + // Orphaned auto row pointing at a category that's been pruned — drop it. + tx.remove(existing); + continue; + } + const key = `${existing.role}:${existingCatId}`; if (!detectedKeys.has(key)) { tx.remove(existing); } @@ -412,13 +424,16 @@ export class MoodleUserHydrationService { ); // Categories with any manual role (program-level) const manualCategoryIds = new Set( - existingManualRoles.map((ir) => ir.moodleCategory.moodleCategoryId), + existingManualRoles + .map((ir) => ir.moodleCategory?.moodleCategoryId) + .filter((id): id is number => id != null), ); // Department categories with a manual DEAN (parent-level) const manualDeanDeptIds = new Set( existingManualRoles .filter((ir) => ir.role === (UserRole.DEAN as string)) - .map((ir) => ir.moodleCategory.moodleCategoryId), + .map((ir) => ir.moodleCategory?.moodleCategoryId) + .filter((id): id is number => id != null), ); for (const programCatId of detectedPrograms) { diff --git a/src/modules/system-errors/dto/emit-error-params.dto.ts b/src/modules/system-errors/dto/emit-error-params.dto.ts new file mode 100644 index 0000000..bd2ffac --- /dev/null +++ b/src/modules/system-errors/dto/emit-error-params.dto.ts @@ -0,0 +1,15 @@ +export interface EmitErrorParams { + statusCode: number; + method: string; + path: string; + userId?: string; + userName?: string; + errorName: string; + message: string; + stack?: string; + requestBody?: Record; + requestQuery?: Record; + browserName?: string; + os?: string; + ipAddress?: string; +} diff --git a/src/modules/system-errors/dto/error-log-job-message.dto.ts b/src/modules/system-errors/dto/error-log-job-message.dto.ts new file mode 100644 index 0000000..14d1cde --- /dev/null +++ b/src/modules/system-errors/dto/error-log-job-message.dto.ts @@ -0,0 +1,16 @@ +export interface ErrorLogJobMessage { + statusCode: number; + method: string; + path: string; + userId?: string; + userName?: string; + errorName: string; + message: string; + stack?: string; + requestBody?: Record; + requestQuery?: Record; + browserName?: string; + os?: string; + ipAddress?: string; + occurredAt: string; // ISO timestamp +} diff --git a/src/modules/system-errors/dto/requests/list-error-logs-query.dto.ts b/src/modules/system-errors/dto/requests/list-error-logs-query.dto.ts new file mode 100644 index 0000000..dbc5cd1 --- /dev/null +++ b/src/modules/system-errors/dto/requests/list-error-logs-query.dto.ts @@ -0,0 +1,100 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsDateString, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; +import { PaginationQueryDto } from 'src/modules/common/dto/pagination-query.dto'; + +export class ListErrorLogsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ + description: 'Filter by exact HTTP status code', + example: 500, + }) + @IsInt() + @Min(100) + @IsOptional() + @Type(() => Number) + statusCode?: number; + + @ApiPropertyOptional({ + description: 'Filter by HTTP method', + example: 'POST', + }) + @IsString() + @IsOptional() + @MaxLength(10) + method?: string; + + @ApiPropertyOptional({ + description: 'Filter by request path (partial match)', + example: '/api/v1/auth/login', + }) + @IsString() + @IsOptional() + @MaxLength(255) + pathSearch?: string; + + @ApiPropertyOptional({ + description: + 'Filter by error class name (e.g. TypeError, QueryFailedError)', + example: 'TypeError', + }) + @IsString() + @IsOptional() + @MaxLength(100) + errorName?: string; + + @ApiPropertyOptional({ + description: 'Filter by user name (partial match)', + example: 'ucmn-t-67092', + }) + @IsString() + @IsOptional() + @MaxLength(100) + userName?: string; + + @ApiPropertyOptional({ + description: 'Filter by acknowledged status. Omit to include both states.', + example: false, + }) + @IsBoolean() + @IsOptional() + @Transform(({ value }: { value: unknown }) => { + if (value === 'true' || value === true) return true; + if (value === 'false' || value === false) return false; + return value; + }) + acknowledged?: boolean; + + @ApiPropertyOptional({ + description: 'Lower bound (inclusive) on occurredAt (ISO 8601)', + example: '2026-05-01T00:00:00.000Z', + }) + @IsDateString() + @IsOptional() + from?: string; + + @ApiPropertyOptional({ + description: 'Upper bound (inclusive) on occurredAt (ISO 8601)', + example: '2026-05-31T23:59:59.999Z', + }) + @IsDateString() + @IsOptional() + to?: string; + + @ApiPropertyOptional({ + description: + 'General text search across path, errorName, message, and userName', + example: 'login', + }) + @IsString() + @IsOptional() + @MaxLength(200) + search?: string; +} diff --git a/src/modules/system-errors/dto/responses/error-log-detail.response.dto.ts b/src/modules/system-errors/dto/responses/error-log-detail.response.dto.ts new file mode 100644 index 0000000..adbe7b5 --- /dev/null +++ b/src/modules/system-errors/dto/responses/error-log-detail.response.dto.ts @@ -0,0 +1,79 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ErrorLog } from 'src/entities/error-log.entity'; + +export class ErrorLogDetailResponseDto { + @ApiProperty() + id: string; + + @ApiProperty({ example: 500 }) + statusCode: number; + + @ApiProperty({ example: 'POST' }) + method: string; + + @ApiProperty({ example: '/api/v1/auth/login' }) + path: string; + + @ApiPropertyOptional() + userId?: string; + + @ApiPropertyOptional() + userName?: string; + + @ApiProperty({ example: 'TypeError' }) + errorName: string; + + @ApiProperty() + message: string; + + @ApiPropertyOptional({ description: 'Full stack trace (may be large)' }) + stack?: string; + + @ApiPropertyOptional({ + description: 'Captured request body with sensitive fields redacted', + }) + requestBody?: Record; + + @ApiPropertyOptional() + requestQuery?: Record; + + @ApiPropertyOptional() + browserName?: string; + + @ApiPropertyOptional() + os?: string; + + @ApiPropertyOptional() + ipAddress?: string; + + @ApiPropertyOptional() + acknowledgedAt?: Date; + + @ApiPropertyOptional() + acknowledgedBy?: string; + + @ApiProperty() + occurredAt: Date; + + static Map(entity: ErrorLog): ErrorLogDetailResponseDto { + return { + id: entity.id, + statusCode: entity.statusCode, + method: entity.method, + path: entity.path, + userId: entity.userId, + userName: entity.userName, + errorName: entity.errorName, + message: entity.message, + stack: entity.stack, + requestBody: entity.requestBody, + requestQuery: entity.requestQuery, + browserName: entity.browserName, + os: entity.os, + ipAddress: entity.ipAddress, + acknowledgedAt: entity.acknowledgedAt, + acknowledgedBy: entity.acknowledgedBy, + occurredAt: entity.occurredAt, + }; + } +} diff --git a/src/modules/system-errors/dto/responses/error-log-item.response.dto.ts b/src/modules/system-errors/dto/responses/error-log-item.response.dto.ts new file mode 100644 index 0000000..4a26baa --- /dev/null +++ b/src/modules/system-errors/dto/responses/error-log-item.response.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ErrorLog } from 'src/entities/error-log.entity'; + +export class ErrorLogItemResponseDto { + @ApiProperty() + id: string; + + @ApiProperty({ example: 500 }) + statusCode: number; + + @ApiProperty({ example: 'POST' }) + method: string; + + @ApiProperty({ example: '/api/v1/auth/login' }) + path: string; + + @ApiPropertyOptional() + userId?: string; + + @ApiPropertyOptional() + userName?: string; + + @ApiProperty({ example: 'TypeError' }) + errorName: string; + + @ApiProperty() + message: string; + + @ApiPropertyOptional() + acknowledgedAt?: Date; + + @ApiProperty() + occurredAt: Date; + + static Map(entity: ErrorLog): ErrorLogItemResponseDto { + return { + id: entity.id, + statusCode: entity.statusCode, + method: entity.method, + path: entity.path, + userId: entity.userId, + userName: entity.userName, + errorName: entity.errorName, + message: entity.message, + acknowledgedAt: entity.acknowledgedAt, + occurredAt: entity.occurredAt, + }; + } +} diff --git a/src/modules/system-errors/dto/responses/error-log-list.response.dto.ts b/src/modules/system-errors/dto/responses/error-log-list.response.dto.ts new file mode 100644 index 0000000..8fa9e46 --- /dev/null +++ b/src/modules/system-errors/dto/responses/error-log-list.response.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginationMeta } from 'src/modules/common/dto/pagination.dto'; +import { ErrorLogItemResponseDto } from './error-log-item.response.dto'; + +export class ErrorLogListResponseDto { + @ApiProperty({ type: [ErrorLogItemResponseDto] }) + data: ErrorLogItemResponseDto[]; + + @ApiProperty({ type: PaginationMeta }) + meta: PaginationMeta; +} diff --git a/src/modules/system-errors/error-log-query.service.ts b/src/modules/system-errors/error-log-query.service.ts new file mode 100644 index 0000000..55d12a8 --- /dev/null +++ b/src/modules/system-errors/error-log-query.service.ts @@ -0,0 +1,156 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { FilterQuery } from '@mikro-orm/core'; +import { ErrorLog } from 'src/entities/error-log.entity'; +import { CurrentUserService } from '../common/cls/current-user.service'; +import { ListErrorLogsQueryDto } from './dto/requests/list-error-logs-query.dto'; +import { ErrorLogItemResponseDto } from './dto/responses/error-log-item.response.dto'; +import { ErrorLogListResponseDto } from './dto/responses/error-log-list.response.dto'; +import { ErrorLogDetailResponseDto } from './dto/responses/error-log-detail.response.dto'; + +@Injectable() +export class ErrorLogQueryService { + constructor( + private readonly em: EntityManager, + private readonly currentUserService: CurrentUserService, + ) {} + + async ListErrorLogs( + query: ListErrorLogsQueryDto, + ): Promise { + const page = query.page ?? 1; + const limit = query.limit ?? 10; + const offset = (page - 1) * limit; + + const [logs, totalItems] = await this.em.findAndCount( + ErrorLog, + this.BuildFilter(query), + { + limit, + offset, + orderBy: { occurredAt: 'DESC', id: 'DESC' }, + filters: { softDelete: false }, + }, + ); + + return { + data: logs.map((log) => ErrorLogItemResponseDto.Map(log)), + meta: { + totalItems, + itemCount: logs.length, + itemsPerPage: limit, + totalPages: Math.ceil(totalItems / limit), + currentPage: page, + }, + }; + } + + async GetErrorLog(id: string): Promise { + const log = await this.em.findOneOrFail( + ErrorLog, + { id }, + { + filters: { softDelete: false }, + failHandler: () => new NotFoundException('Error log not found'), + }, + ); + + return ErrorLogDetailResponseDto.Map(log); + } + + async Acknowledge(id: string): Promise { + const log = await this.em.findOneOrFail( + ErrorLog, + { id }, + { + filters: { softDelete: false }, + failHandler: () => new NotFoundException('Error log not found'), + }, + ); + + if (!log.acknowledgedAt) { + const actor = this.currentUserService.get(); + log.acknowledgedAt = new Date(); + log.acknowledgedBy = actor?.userName ?? actor?.id ?? 'unknown'; + await this.em.flush(); + } + + return ErrorLogDetailResponseDto.Map(log); + } + + async Unacknowledge(id: string): Promise { + const log = await this.em.findOneOrFail( + ErrorLog, + { id }, + { + filters: { softDelete: false }, + failHandler: () => new NotFoundException('Error log not found'), + }, + ); + + if (log.acknowledgedAt) { + log.acknowledgedAt = undefined; + log.acknowledgedBy = undefined; + await this.em.flush(); + } + + return ErrorLogDetailResponseDto.Map(log); + } + + private BuildFilter(query: ListErrorLogsQueryDto): FilterQuery { + const filter: FilterQuery = {}; + + if (query.statusCode !== undefined) { + filter.statusCode = query.statusCode; + } + + if (query.method) { + filter.method = query.method.toUpperCase(); + } + + if (query.errorName) { + filter.errorName = query.errorName; + } + + if (query.pathSearch) { + filter.path = { + $ilike: `%${this.EscapeLikePattern(query.pathSearch.trim())}%`, + }; + } + + if (query.userName) { + filter.userName = { + $ilike: `%${this.EscapeLikePattern(query.userName.trim())}%`, + }; + } + + if (query.acknowledged === true) { + filter.acknowledgedAt = { $ne: null } as never; + } else if (query.acknowledged === false) { + filter.acknowledgedAt = null as never; + } + + if (query.from || query.to) { + const occurredAtFilter: Record = {}; + if (query.from) occurredAtFilter.$gte = new Date(query.from); + if (query.to) occurredAtFilter.$lte = new Date(query.to); + filter.occurredAt = occurredAtFilter as never; + } + + if (query.search) { + const search = `%${this.EscapeLikePattern(query.search.trim())}%`; + filter.$or = [ + { path: { $ilike: search } }, + { errorName: { $ilike: search } }, + { message: { $ilike: search } }, + { userName: { $ilike: search } }, + ]; + } + + return filter; + } + + private EscapeLikePattern(value: string): string { + return value.replace(/[%_\\]/g, '\\$&'); + } +} diff --git a/src/modules/system-errors/error-log.controller.ts b/src/modules/system-errors/error-log.controller.ts new file mode 100644 index 0000000..b478f9a --- /dev/null +++ b/src/modules/system-errors/error-log.controller.ts @@ -0,0 +1,76 @@ +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { UseJwtGuard } from 'src/security/decorators'; +import { UserRole } from 'src/modules/auth/roles.enum'; +import { ErrorLogQueryService } from './error-log-query.service'; +import { ListErrorLogsQueryDto } from './dto/requests/list-error-logs-query.dto'; +import { ErrorLogListResponseDto } from './dto/responses/error-log-list.response.dto'; +import { ErrorLogDetailResponseDto } from './dto/responses/error-log-detail.response.dto'; + +@ApiTags('Error Logs') +@Controller('error-logs') +@UseJwtGuard(UserRole.SUPER_ADMIN) +@ApiBearerAuth() +export class ErrorLogController { + constructor(private readonly errorLogQueryService: ErrorLogQueryService) {} + + @Get() + @ApiOperation({ + summary: 'List captured 5xx errors with filters and pagination', + }) + @ApiResponse({ status: 200, type: ErrorLogListResponseDto }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden — superadmin only' }) + async ListErrorLogs( + @Query() query: ListErrorLogsQueryDto, + ): Promise { + return this.errorLogQueryService.ListErrorLogs(query); + } + + @Get(':id') + @ApiOperation({ + summary: 'Get a single error log entry with full stack + sanitized body', + }) + @ApiParam({ name: 'id', type: String, description: 'Error log UUID' }) + @ApiResponse({ status: 200, type: ErrorLogDetailResponseDto }) + @ApiResponse({ status: 400, description: 'Invalid UUID format' }) + @ApiResponse({ status: 404, description: 'Error log not found' }) + async GetErrorLog( + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.errorLogQueryService.GetErrorLog(id); + } + + @Post(':id/acknowledge') + @ApiOperation({ summary: 'Mark an error log entry as acknowledged' }) + @ApiParam({ name: 'id', type: String, description: 'Error log UUID' }) + @ApiResponse({ status: 200, type: ErrorLogDetailResponseDto }) + async Acknowledge( + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.errorLogQueryService.Acknowledge(id); + } + + @Post(':id/unacknowledge') + @ApiOperation({ summary: 'Reset an error log entry to unacknowledged' }) + @ApiParam({ name: 'id', type: String, description: 'Error log UUID' }) + @ApiResponse({ status: 200, type: ErrorLogDetailResponseDto }) + async Unacknowledge( + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.errorLogQueryService.Unacknowledge(id); + } +} diff --git a/src/modules/system-errors/error-log.processor.ts b/src/modules/system-errors/error-log.processor.ts new file mode 100644 index 0000000..d5625ed --- /dev/null +++ b/src/modules/system-errors/error-log.processor.ts @@ -0,0 +1,68 @@ +import { Logger } from '@nestjs/common'; +import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { QueueName } from 'src/configurations/common/queue-names'; +import { ErrorLog } from 'src/entities/error-log.entity'; +import type { ErrorLogJobMessage } from './dto/error-log-job-message.dto'; + +@Processor(QueueName.ERROR_LOG, { concurrency: 1 }) +export class ErrorLogProcessor extends WorkerHost { + private readonly logger = new Logger(ErrorLogProcessor.name); + + constructor(private readonly em: EntityManager) { + super(); + } + + async process(job: Job): Promise { + const { + statusCode, + method, + path, + userId, + userName, + errorName, + message, + stack, + requestBody, + requestQuery, + browserName, + os, + ipAddress, + occurredAt, + } = job.data; + + const fork = this.em.fork(); + fork.create(ErrorLog, { + statusCode, + method, + path, + userId, + userName, + errorName, + message, + stack, + requestBody, + requestQuery, + browserName, + os, + ipAddress, + occurredAt: new Date(occurredAt), + }); + await fork.flush(); + + this.logger.log( + `Persisted error log: ${statusCode} ${method} ${path} — ${errorName}`, + ); + } + + @OnWorkerEvent('failed') + onFailed(job: Job, error: Error) { + this.logger.error( + `Error log job ${job.id} failed (attempt ${job.attemptsMade}): ` + + `statusCode=${job.data.statusCode}, path=${job.data.path}, ` + + `errorName=${job.data.errorName}, occurredAt=${job.data.occurredAt} — ` + + `${error.message}`, + ); + } +} diff --git a/src/modules/system-errors/error-log.service.ts b/src/modules/system-errors/error-log.service.ts new file mode 100644 index 0000000..56ecf3b --- /dev/null +++ b/src/modules/system-errors/error-log.service.ts @@ -0,0 +1,39 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { QueueName } from 'src/configurations/common/queue-names'; +import type { EmitErrorParams } from './dto/emit-error-params.dto'; +import type { ErrorLogJobMessage } from './dto/error-log-job-message.dto'; + +@Injectable() +export class ErrorLogService { + private readonly logger = new Logger(ErrorLogService.name); + + constructor( + @InjectQueue(QueueName.ERROR_LOG) private readonly errorLogQueue: Queue, + ) {} + + async Emit(params: EmitErrorParams): Promise { + const envelope: ErrorLogJobMessage = { + ...params, + occurredAt: new Date().toISOString(), + }; + + try { + await this.errorLogQueue.add('error-log', envelope, { + attempts: 1, + removeOnComplete: true, + removeOnFail: 100, + }); + } catch (error) { + // We can't surface this as another 500 — we'd recurse through the + // exception filter. Best effort: write a structured warning so it + // shows up in stdout/pino even if the queue is unreachable. + this.logger.warn( + `Failed to enqueue error log: statusCode=${params.statusCode}, ` + + `path=${params.path}, errorName=${params.errorName} — ` + + `${(error as Error).message}`, + ); + } + } +} diff --git a/src/modules/system-errors/filters/error-capture.filter.spec.ts b/src/modules/system-errors/filters/error-capture.filter.spec.ts new file mode 100644 index 0000000..7a94e71 --- /dev/null +++ b/src/modules/system-errors/filters/error-capture.filter.spec.ts @@ -0,0 +1,152 @@ +import { ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; +import { BaseExceptionFilter, HttpAdapterHost } from '@nestjs/core'; +import { ErrorCaptureFilter } from './error-capture.filter'; +import { ErrorLogService } from '../error-log.service'; +import { RequestMetadataService } from 'src/modules/common/cls/request-metadata.service'; +import { CurrentUserService } from 'src/modules/common/cls/current-user.service'; +import type { EmitErrorParams } from '../dto/emit-error-params.dto'; + +describe('ErrorCaptureFilter', () => { + let filter: ErrorCaptureFilter; + let errorLogService: { Emit: jest.Mock }; + let requestMetadataService: { get: jest.Mock }; + let currentUserService: { get: jest.Mock }; + let httpAdapterHost: HttpAdapterHost; + // Spy on super.catch to skip BaseExceptionFilter's response-writing logic — + // we're testing the capture path, not the wire-response path. + let superCatchSpy: jest.SpyInstance; + + const buildHost = ( + request: Record, + type: 'http' | 'rpc' = 'http', + ): ArgumentsHost => + ({ + getType: () => type, + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => ({}), + }), + getArgs: () => [], + getArgByIndex: () => undefined, + switchToRpc: () => ({}) as never, + switchToWs: () => ({}) as never, + }) as unknown as ArgumentsHost; + + beforeEach(() => { + errorLogService = { Emit: jest.fn().mockResolvedValue(undefined) }; + requestMetadataService = { + get: jest.fn().mockReturnValue({ + browserName: 'Chrome', + os: 'Linux', + ipAddress: '127.0.0.1', + }), + }; + currentUserService = { + get: jest.fn().mockReturnValue({ id: 'u1', userName: 'tester' }), + }; + httpAdapterHost = { + httpAdapter: {}, + } as unknown as HttpAdapterHost; + + superCatchSpy = jest + .spyOn(BaseExceptionFilter.prototype, 'catch') + .mockImplementation(() => undefined); + + filter = new ErrorCaptureFilter( + httpAdapterHost, + errorLogService as unknown as ErrorLogService, + requestMetadataService as unknown as RequestMetadataService, + currentUserService as unknown as CurrentUserService, + ); + }); + + afterEach(() => { + superCatchSpy.mockRestore(); + }); + + it('captures unhandled 500-equivalent errors with sanitized request body', () => { + const host = buildHost({ + method: 'POST', + originalUrl: '/api/v1/auth/login', + body: { username: 'ucmn-t-67092', password: 'Password789#' }, + query: {}, + }); + + filter.catch(new TypeError('Cannot read properties of null'), host); + + expect(errorLogService.Emit).toHaveBeenCalledTimes(1); + const params = ( + errorLogService.Emit.mock.calls as EmitErrorParams[][] + )[0][0]; + expect(params.statusCode).toBe(500); + expect(params.method).toBe('POST'); + expect(params.path).toBe('/api/v1/auth/login'); + expect(params.errorName).toBe('TypeError'); + expect(params.message).toBe('Cannot read properties of null'); + expect(params.userId).toBe('u1'); + expect(params.userName).toBe('tester'); + expect(params.requestBody).toEqual({ + username: 'ucmn-t-67092', + password: '[REDACTED]', + }); + expect(params.browserName).toBe('Chrome'); + }); + + it('captures explicit 5xx HttpExceptions', () => { + const host = buildHost({ + method: 'GET', + originalUrl: '/api/v1/foo', + body: {}, + query: {}, + }); + + filter.catch( + new HttpException('boom', HttpStatus.SERVICE_UNAVAILABLE), + host, + ); + + expect(errorLogService.Emit).toHaveBeenCalledTimes(1); + const params = ( + errorLogService.Emit.mock.calls as EmitErrorParams[][] + )[0][0]; + expect(params.statusCode).toBe(503); + }); + + it('does NOT capture 4xx HttpExceptions', () => { + const host = buildHost({ + method: 'POST', + originalUrl: '/api/v1/auth/login', + body: { username: 'x', password: 'y' }, + query: {}, + }); + + filter.catch( + new HttpException('Invalid credentials', HttpStatus.UNAUTHORIZED), + host, + ); + + expect(errorLogService.Emit).not.toHaveBeenCalled(); + }); + + it('does NOT capture for non-http hosts (e.g. BullMQ worker errors)', () => { + const host = buildHost({ method: 'POST', originalUrl: '/' }, 'rpc'); + + filter.catch(new Error('worker failure'), host); + + expect(errorLogService.Emit).not.toHaveBeenCalled(); + }); + + it('swallows capture failures so the response is never blocked', () => { + errorLogService.Emit.mockImplementation(() => { + throw new Error('queue down'); + }); + const host = buildHost({ + method: 'POST', + originalUrl: '/api/v1/foo', + body: {}, + query: {}, + }); + + expect(() => filter.catch(new Error('original'), host)).not.toThrow(); + }); +}); diff --git a/src/modules/system-errors/filters/error-capture.filter.ts b/src/modules/system-errors/filters/error-capture.filter.ts new file mode 100644 index 0000000..288af7d --- /dev/null +++ b/src/modules/system-errors/filters/error-capture.filter.ts @@ -0,0 +1,102 @@ +import { + ArgumentsHost, + Catch, + HttpException, + HttpStatus, + Injectable, + Logger, +} from '@nestjs/common'; +import { BaseExceptionFilter, HttpAdapterHost } from '@nestjs/core'; +import { Request } from 'express'; +import { RequestMetadataService } from 'src/modules/common/cls/request-metadata.service'; +import { CurrentUserService } from 'src/modules/common/cls/current-user.service'; +import { ErrorLogService } from '../error-log.service'; +import { sanitizeRequestPayload } from '../lib/sanitize-request'; + +/** + * Global exception filter that persists every unhandled 5xx into `error_log` + * for admin diagnostics, then defers to NestJS's BaseExceptionFilter so the + * wire response (status, body) is byte-identical to today's behaviour. + * + * Skips: + * - 4xx HttpExceptions (validation, auth, not-found — these are user-driven) + * - Any failure inside this filter itself (logged + swallowed so we never + * recurse or mask the original error) + */ +@Injectable() +@Catch() +export class ErrorCaptureFilter extends BaseExceptionFilter { + private readonly captureLogger = new Logger(ErrorCaptureFilter.name); + + constructor( + httpAdapterHost: HttpAdapterHost, + private readonly errorLogService: ErrorLogService, + private readonly requestMetadataService: RequestMetadataService, + private readonly currentUserService: CurrentUserService, + ) { + super(httpAdapterHost.httpAdapter); + } + + override catch(exception: unknown, host: ArgumentsHost): void { + const statusCode = this.resolveStatusCode(exception); + + if (statusCode >= 500) { + try { + this.captureError(exception, statusCode, host); + } catch (captureError) { + this.captureLogger.warn( + `Failed to capture error log: ${(captureError as Error).message}`, + ); + } + } + + super.catch(exception, host); + } + + private resolveStatusCode(exception: unknown): number { + if (exception instanceof HttpException) { + return exception.getStatus(); + } + return HttpStatus.INTERNAL_SERVER_ERROR; + } + + private captureError( + exception: unknown, + statusCode: number, + host: ArgumentsHost, + ): void { + // The filter is registered globally, but a non-HTTP request (e.g. a BullMQ + // worker failure routed here) would not have a `getRequest` payload — + // bail out cleanly in that case. + if (host.getType() !== 'http') return; + + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + + const errorObj = exception instanceof Error ? exception : undefined; + const errorName = errorObj?.name ?? 'UnknownError'; + const message = + errorObj?.message ?? + (typeof exception === 'string' ? exception : String(exception)); + const stack = errorObj?.stack; + + const meta = this.requestMetadataService.get(); + const currentUser = this.currentUserService.get(); + + void this.errorLogService.Emit({ + statusCode, + method: request.method, + path: request.originalUrl ?? request.url, + userId: currentUser?.id, + userName: currentUser?.userName, + errorName, + message, + stack, + requestBody: sanitizeRequestPayload(request.body), + requestQuery: sanitizeRequestPayload(request.query), + browserName: meta?.browserName, + os: meta?.os, + ipAddress: meta?.ipAddress, + }); + } +} diff --git a/src/modules/system-errors/jobs/error-log-cleanup.job.ts b/src/modules/system-errors/jobs/error-log-cleanup.job.ts new file mode 100644 index 0000000..d7a9de8 --- /dev/null +++ b/src/modules/system-errors/jobs/error-log-cleanup.job.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, SchedulerRegistry } from '@nestjs/schedule'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { BaseJob } from 'src/crons/base.job'; +import { JobRecordType } from 'src/crons/startup-job-registry'; +import { ErrorLog } from 'src/entities/error-log.entity'; + +const RETENTION_DAYS = 90; + +@Injectable() +export class ErrorLogCleanupJob extends BaseJob { + private isRunning = false; + + constructor( + private readonly em: EntityManager, + schedulerRegistry: SchedulerRegistry, + ) { + super(schedulerRegistry, ErrorLogCleanupJob.name); + } + + protected runStartupTask(): Promise { + return Promise.resolve({ + status: 'skipped', + details: 'Cleanup runs on schedule only', + }); + } + + // Daily at 04:00 UTC — runs after ReportCleanupJob (03:00) so they don't + // pile up on the same connection at the same minute. + @Cron('0 4 * * *', { name: 'ErrorLogCleanupJob' }) + async handleCleanup(): Promise { + await this.safeRun(); + } + + private async safeRun(): Promise { + if (this.isRunning) { + this.logger.log('ErrorLogCleanupJob is already running'); + return { status: 'skipped', details: 'Job is already running' }; + } + + this.isRunning = true; + + try { + const cutoff = new Date( + Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000, + ); + + const deleted = await this.em.nativeDelete(ErrorLog, { + occurredAt: { $lt: cutoff }, + }); + + this.logger.log( + `Cleaned up ${deleted} error log rows older than ${RETENTION_DAYS} days`, + ); + + return { + status: 'executed', + details: `Deleted ${deleted} expired error logs`, + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error('Error during error-log cleanup:', message); + return { status: 'failed', details: message }; + } finally { + this.isRunning = false; + } + } +} diff --git a/src/modules/system-errors/lib/sanitize-request.spec.ts b/src/modules/system-errors/lib/sanitize-request.spec.ts new file mode 100644 index 0000000..fa64d2e --- /dev/null +++ b/src/modules/system-errors/lib/sanitize-request.spec.ts @@ -0,0 +1,86 @@ +import { sanitizeRequestPayload } from './sanitize-request'; + +describe('sanitizeRequestPayload', () => { + it('redacts top-level sensitive keys', () => { + const result = sanitizeRequestPayload({ + username: 'ucmn-t-67092', + password: 'Password789#', + }); + expect(result).toEqual({ + username: 'ucmn-t-67092', + password: '[REDACTED]', + }); + }); + + it('redacts nested sensitive keys', () => { + const result = sanitizeRequestPayload({ + user: { username: 'a', currentPassword: 'old', newPassword: 'new' }, + tokens: { refreshToken: 'abc', accessToken: 'xyz' }, + }); + expect(result).toEqual({ + user: { + username: 'a', + currentPassword: '[REDACTED]', + newPassword: '[REDACTED]', + }, + tokens: { refreshToken: '[REDACTED]', accessToken: '[REDACTED]' }, + }); + }); + + it('treats sensitive-key match as case-insensitive', () => { + const result = sanitizeRequestPayload({ + Password: 'a', + PASSWORD: 'b', + Authorization: 'Bearer abc', + }); + expect(result).toEqual({ + Password: '[REDACTED]', + PASSWORD: '[REDACTED]', + Authorization: '[REDACTED]', + }); + }); + + it('returns undefined for non-object payloads', () => { + expect(sanitizeRequestPayload(null)).toBeUndefined(); + expect(sanitizeRequestPayload('string')).toBeUndefined(); + expect(sanitizeRequestPayload(42)).toBeUndefined(); + expect(sanitizeRequestPayload([1, 2])).toBeUndefined(); + }); + + it('truncates very long strings', () => { + const long = 'a'.repeat(5000); + const result = sanitizeRequestPayload({ note: long }); + expect((result?.note as string).length).toBeLessThanOrEqual(4100); + expect(result?.note).toMatch(/\[truncated\]$/); + }); + + it('caps recursion depth to avoid pathological payloads', () => { + const deep: Record = {}; + let cursor = deep; + for (let i = 0; i < 20; i++) { + cursor.next = {}; + cursor = cursor.next as Record; + } + const result = sanitizeRequestPayload(deep); + // Walk down until we see the truncation marker. + let node: unknown = result; + let saw = false; + for (let i = 0; i < 20 && typeof node === 'object' && node !== null; i++) { + if (node === '[TRUNCATED_DEPTH]') { + saw = true; + break; + } + node = (node as Record).next; + if (node === '[TRUNCATED_DEPTH]') { + saw = true; + break; + } + } + expect(saw).toBe(true); + }); + + it('preserves arrays of primitives', () => { + const result = sanitizeRequestPayload({ tags: ['a', 'b', 'c'] }); + expect(result).toEqual({ tags: ['a', 'b', 'c'] }); + }); +}); diff --git a/src/modules/system-errors/lib/sanitize-request.ts b/src/modules/system-errors/lib/sanitize-request.ts new file mode 100644 index 0000000..7c44642 --- /dev/null +++ b/src/modules/system-errors/lib/sanitize-request.ts @@ -0,0 +1,80 @@ +// Fields whose values are stripped from captured request bodies/queries before +// they're persisted. Match by lowercase key to catch casing variants. +const SENSITIVE_KEYS = new Set([ + 'password', + 'currentpassword', + 'newpassword', + 'oldpassword', + 'confirmpassword', + 'token', + 'accesstoken', + 'refreshtoken', + 'authorization', + 'cookie', + 'set-cookie', + 'apikey', + 'api_key', + 'secret', + 'clientsecret', +]); + +const REDACTION = '[REDACTED]'; +const MAX_DEPTH = 6; +const MAX_KEYS_PER_OBJECT = 200; +const MAX_STRING_LENGTH = 4000; + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +function sanitizeValue(value: unknown, depth: number): unknown { + if (depth >= MAX_DEPTH) return '[TRUNCATED_DEPTH]'; + + if (typeof value === 'string') { + return value.length > MAX_STRING_LENGTH + ? `${value.slice(0, MAX_STRING_LENGTH)}…[truncated]` + : value; + } + + if (Array.isArray(value)) { + return value.map((item) => sanitizeValue(item, depth + 1)); + } + + if (isPlainObject(value)) { + const result: Record = {}; + let count = 0; + for (const [key, val] of Object.entries(value)) { + if (count >= MAX_KEYS_PER_OBJECT) { + result['[TRUNCATED_KEYS]'] = true; + break; + } + count++; + if (SENSITIVE_KEYS.has(key.toLowerCase())) { + result[key] = REDACTION; + } else { + result[key] = sanitizeValue(val, depth + 1); + } + } + return result; + } + + return value; +} + +/** + * Walks an arbitrary JSON-like payload and replaces any value under a known + * sensitive key with `[REDACTED]`. Also caps depth/breadth/string length so a + * pathological payload can't blow up the error_log table. + */ +export function sanitizeRequestPayload( + payload: unknown, +): Record | undefined { + if (!isPlainObject(payload)) return undefined; + const sanitized = sanitizeValue(payload, 0); + return isPlainObject(sanitized) ? sanitized : undefined; +} diff --git a/src/modules/system-errors/system-errors.module.ts b/src/modules/system-errors/system-errors.module.ts new file mode 100644 index 0000000..ce90794 --- /dev/null +++ b/src/modules/system-errors/system-errors.module.ts @@ -0,0 +1,39 @@ +import { Global, Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { BullModule } from '@nestjs/bullmq'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { QueueName } from 'src/configurations/common/queue-names'; +import { ErrorLog } from 'src/entities/error-log.entity'; +import { User } from 'src/entities/user.entity'; +import { AppClsModule } from '../common/cls/cls.module'; +import { ErrorLogService } from './error-log.service'; +import { ErrorLogProcessor } from './error-log.processor'; +import { ErrorLogQueryService } from './error-log-query.service'; +import { ErrorLogController } from './error-log.controller'; +import { ErrorCaptureFilter } from './filters/error-capture.filter'; +import { ErrorLogCleanupJob } from './jobs/error-log-cleanup.job'; + +@Global() +@Module({ + imports: [ + BullModule.registerQueue({ name: QueueName.ERROR_LOG }), + // User is registered alongside ErrorLog so the RolesGuard injected by + // `@UseJwtGuard(SUPER_ADMIN)` on ErrorLogController can resolve + // UserRepository inside this module's scope. + MikroOrmModule.forFeature([ErrorLog, User]), + AppClsModule, + ], + controllers: [ErrorLogController], + providers: [ + ErrorLogService, + ErrorLogProcessor, + ErrorLogQueryService, + ErrorLogCleanupJob, + { + provide: APP_FILTER, + useClass: ErrorCaptureFilter, + }, + ], + exports: [ErrorLogService], +}) +export class SystemErrorsModule {}