diff --git a/package-lock.json b/package-lock.json index 141cc4a..9709ccc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "license": "PROPRIETARY", "dependencies": { "bcryptjs": "^2.4.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.3", "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^16.3.1", @@ -18,7 +20,9 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.11.3", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.1", + "speakeasy": "^2.0.0", "typeorm": "^0.3.17", "uuid": "^9.0.1", "winston": "^3.11.0", @@ -34,6 +38,8 @@ "@types/morgan": "^1.9.9", "@types/node": "^20.10.0", "@types/pg": "^8.16.0", + "@types/qrcode": "^1.5.6", + "@types/speakeasy": "^2.0.10", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", @@ -1669,6 +1675,16 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -1723,6 +1739,16 @@ "@types/node": "*" } }, + "node_modules/@types/speakeasy": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz", + "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -1757,6 +1783,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -2322,6 +2354,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base32.js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", + "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2614,7 +2652,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2729,6 +2766,23 @@ "dev": true, "license": "MIT" }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", + "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.20" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3008,6 +3062,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dedent": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", @@ -3105,6 +3168,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -5310,6 +5379,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.36", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.36.tgz", + "integrity": "sha512-woWhKMAVx1fzzUnMCyOzglgSgf6/AFHLASdOBcchYCyvWSGWt12imw3iu2hdI5d4dGZRsNWAmWiz37sDKUPaRQ==", + "license": "MIT" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -5856,7 +5931,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5913,7 +5987,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6171,6 +6244,15 @@ "node": ">=8" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6311,6 +6393,141 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -6420,6 +6637,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6735,6 +6958,12 @@ "node": ">= 0.8" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6916,6 +7145,18 @@ "source-map": "^0.6.0" } }, + "node_modules/speakeasy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", + "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==", + "license": "MIT", + "dependencies": { + "base32.js": "0.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -7798,6 +8039,15 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -7832,6 +8082,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", diff --git a/package.json b/package.json index 69cb134..14d7313 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ }, "dependencies": { "bcryptjs": "^2.4.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.3", "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^16.3.1", @@ -24,7 +26,9 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.11.3", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.1", + "speakeasy": "^2.0.0", "typeorm": "^0.3.17", "uuid": "^9.0.1", "winston": "^3.11.0", @@ -40,6 +44,8 @@ "@types/morgan": "^1.9.9", "@types/node": "^20.10.0", "@types/pg": "^8.16.0", + "@types/qrcode": "^1.5.6", + "@types/speakeasy": "^2.0.10", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", diff --git a/src/main.ts b/src/main.ts index 7cca810..6085ff6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ -/** +/** * Main Entry Point - * Mecánicas Diesel Backend - ERP Suite + * Mecánicas Diesel Backend - ERP Suite */ import 'reflect-metadata'; @@ -49,6 +49,14 @@ import { createDiagnosisController as createFieldDiagnosisController } from './m import { createSyncController } from './modules/field-service/controllers/sync.controller'; import { createCheckinController } from './modules/field-service/controllers/checkin.controller'; +// Audit & Feature Flags +import { AuditLog, EntityChange, LoginHistory, SensitiveDataAccess, DataExport, PermissionChange, ConfigChange } from './modules/audit/entities/index'; +import { Flag, TenantOverride } from './modules/feature-flags/entities/index'; +import auditRoutes from './modules/audit/audit.routes'; +import featureFlagsRoutes from './modules/feature-flags/feature-flags.routes'; +import mfaRoutes from './modules/auth/mfa.routes'; +import { featureFlagsMiddleware } from './modules/feature-flags/middleware/feature-flags.middleware'; + // Payment Terminals Module import { PaymentTerminalsModule } from './modules/payment-terminals'; @@ -65,6 +73,7 @@ import { Quote } from './modules/service-management/entities/quote.entity'; import { WorkBay } from './modules/service-management/entities/work-bay.entity'; import { Service } from './modules/service-management/entities/service.entity'; import { Vehicle } from './modules/vehicle-management/entities/vehicle.entity'; +import { VehicleDocument } from './modules/vehicle-management/entities/vehicle-document.entity'; import { Fleet } from './modules/vehicle-management/entities/fleet.entity'; import { VehicleEngine } from './modules/vehicle-management/entities/vehicle-engine.entity'; import { EngineCatalog } from './modules/vehicle-management/entities/engine-catalog.entity'; @@ -148,6 +157,7 @@ const AppDataSource = new DataSource({ Service, // Vehicle Management Vehicle, + VehicleDocument, Fleet, VehicleEngine, EngineCatalog, @@ -196,8 +206,19 @@ const AppDataSource = new DataSource({ FieldEvidence, FieldCheckin, OfflineQueueItem, + // Audit + AuditLog, + EntityChange, + LoginHistory, + SensitiveDataAccess, + DataExport, + PermissionChange, + ConfigChange, + // Feature Flags + Flag, + TenantOverride, ], - synchronize: process.env.NODE_ENV === 'development', + synchronize: process.env.DB_SYNCHRONIZE === 'true', logging: process.env.NODE_ENV === 'development', }); @@ -228,10 +249,16 @@ async function bootstrap() { try { // Initialize database connection await AppDataSource.initialize(); - console.log('📦 Database connection established'); + console.log('📦 Database connection established'); // Register API routes app.use('/api/v1/auth', createAuthController(AppDataSource)); + app.use('/api/v1/auth/mfa', mfaRoutes); + app.use('/api/v1/audit', auditRoutes); + app.use('/api/v1/feature-flags', featureFlagsRoutes); + + app.use(featureFlagsMiddleware); + app.use('/api/v1/users', createUsersController(AppDataSource)); app.use('/api/v1/service-orders', createServiceOrderController(AppDataSource)); app.use('/api/v1/quotes', createQuoteController(AppDataSource)); @@ -247,21 +274,21 @@ async function bootstrap() { app.use('/api/v1/gps/positions', createGpsPositionController(AppDataSource)); app.use('/api/v1/gps/geofences', createGeofenceController(AppDataSource)); app.use('/api/v1/gps/routes', createRouteSegmentController(AppDataSource)); - console.log('📡 GPS module initialized'); + console.log('📡 GPS module initialized'); // Assets Module Routes app.use('/api/v1/assets', createAssetController(AppDataSource)); app.use('/api/v1/assets/assignments', createAssetAssignmentController(AppDataSource)); app.use('/api/v1/assets/audits', createAssetAuditController(AppDataSource)); app.use('/api/v1/assets/maintenance', createAssetMaintenanceController(AppDataSource)); - console.log('📦 Assets module initialized'); + console.log('📦 Assets module initialized'); // Dispatch Module Routes app.use('/api/v1/dispatch', createDispatchController(AppDataSource)); app.use('/api/v1/dispatch/skills', createSkillController(AppDataSource)); app.use('/api/v1/dispatch/shifts', createShiftController(AppDataSource)); app.use('/api/v1/dispatch/rules', createRuleController(AppDataSource)); - console.log('📋 Dispatch module initialized'); + console.log('📋 Dispatch module initialized'); // Field Service Module Routes app.use('/api/v1/field/checklists', createChecklistController(AppDataSource)); @@ -269,18 +296,18 @@ async function bootstrap() { app.use('/api/v1/field/diagnosis', createFieldDiagnosisController(AppDataSource)); app.use('/api/v1/field/sync', createSyncController(AppDataSource)); app.use('/api/v1/field/checkins', createCheckinController(AppDataSource)); - console.log('📱 Field Service module initialized'); + console.log('📱 Field Service module initialized'); // Payment Terminals Module const paymentTerminals = new PaymentTerminalsModule({ dataSource: AppDataSource }); app.use('/api/v1', paymentTerminals.router); app.use('/webhooks', paymentTerminals.webhookRouter); - console.log('💳 Payment Terminals module initialized'); + console.log('💳 Payment Terminals module initialized'); // API documentation endpoint app.get('/api/v1', (_req, res) => { res.json({ - name: 'Mecánicas Diesel API', + name: 'Mecánicas Diesel API', version: '1.0.0', endpoints: { auth: '/api/v1/auth', @@ -342,10 +369,10 @@ async function bootstrap() { // Start server app.listen(PORT, () => { - console.log(`🔧 Mecánicas Diesel Backend running on port ${PORT}`); - console.log(`📊 Environment: ${process.env.NODE_ENV || 'development'}`); - console.log(`🏥 Health check: http://localhost:${PORT}/health`); - console.log(`📚 API Root: http://localhost:${PORT}/api/v1`); + console.log(`🔧 Mecánicas Diesel Backend running on port ${PORT}`); + console.log(`📊 Environment: ${process.env.NODE_ENV || 'development'}`); + console.log(`🏥 Health check: http://localhost:${PORT}/health`); + console.log(`📚 API Root: http://localhost:${PORT}/api/v1`); }); } catch (error) { console.error('Failed to start server:', error); @@ -354,3 +381,5 @@ async function bootstrap() { } bootstrap(); + +export { AppDataSource }; diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts new file mode 100644 index 0000000..efbea84 --- /dev/null +++ b/src/modules/audit/audit.controller.ts @@ -0,0 +1,67 @@ +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../../shared/types/index'; +import { auditService } from './services/audit.instance'; + +export const auditController = { + /** + * Get audit logs with filters + */ + async getLogs(req: AuthRequest, res: Response, next: NextFunction) { + try { + const { + userId, + entityType, + action, + startDate, + endDate, + page = 1, + limit = 20 + } = req.query; + + const filters = { + userId: userId as string, + entityType: entityType as string, + action: action as string, + startDate: startDate ? new Date(startDate as string) : undefined, + endDate: endDate ? new Date(endDate as string) : undefined, + }; + + const result = await auditService.findAuditLogs( + req.user!.tenantId, + filters, + { page: Number(page), limit: Number(limit) } + ); + + res.json({ + success: true, + data: result.data, + total: result.total, + page: Number(page), + limit: Number(limit) + }); + } catch (error) { + next(error); + } + }, + + /** + * Get logs for a specific entity + */ + async getEntityLogs(req: AuthRequest, res: Response, next: NextFunction) { + try { + const { entityType, entityId } = req.params; + const result = await auditService.findAuditLogsByEntity( + req.user!.tenantId, + entityType, + entityId + ); + + res.json({ + success: true, + data: result + }); + } catch (error) { + next(error); + } + } +}; diff --git a/src/modules/audit/audit.module.ts b/src/modules/audit/audit.module.ts new file mode 100644 index 0000000..6686fc8 --- /dev/null +++ b/src/modules/audit/audit.module.ts @@ -0,0 +1,70 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { AuditService } from './services'; +import { AuditController } from './controllers'; +import { + AuditLog, + EntityChange, + LoginHistory, + SensitiveDataAccess, + DataExport, + PermissionChange, + ConfigChange, +} from './entities'; + +export interface AuditModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class AuditModule { + public router: Router; + public auditService: AuditService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: AuditModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const auditLogRepository = this.dataSource.getRepository(AuditLog); + const entityChangeRepository = this.dataSource.getRepository(EntityChange); + const loginHistoryRepository = this.dataSource.getRepository(LoginHistory); + const sensitiveDataAccessRepository = this.dataSource.getRepository(SensitiveDataAccess); + const dataExportRepository = this.dataSource.getRepository(DataExport); + const permissionChangeRepository = this.dataSource.getRepository(PermissionChange); + const configChangeRepository = this.dataSource.getRepository(ConfigChange); + + this.auditService = new AuditService( + auditLogRepository, + entityChangeRepository, + loginHistoryRepository, + sensitiveDataAccessRepository, + dataExportRepository, + permissionChangeRepository, + configChangeRepository + ); + } + + private initializeRoutes(): void { + const auditController = new AuditController(this.auditService); + this.router.use(`${this.basePath}/audit`, auditController.router); + } + + static getEntities(): Function[] { + return [ + AuditLog, + EntityChange, + LoginHistory, + SensitiveDataAccess, + DataExport, + PermissionChange, + ConfigChange, + ]; + } +} diff --git a/src/modules/audit/audit.routes.ts b/src/modules/audit/audit.routes.ts new file mode 100644 index 0000000..ddd5b6e --- /dev/null +++ b/src/modules/audit/audit.routes.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { auditController } from './audit.controller'; +import { authMiddleware } from '../../shared/middleware/auth.middleware'; + +const router = Router(); + +router.use(authMiddleware); + +// List logs (add permission check later, e.g. 'audit.read') +router.get('/', (req, res, next) => auditController.getLogs(req, res, next)); + +// Entity logs +router.get('/:entityType/:entityId', (req, res, next) => auditController.getEntityLogs(req, res, next)); + +export default router; diff --git a/src/modules/audit/controllers/audit.controller.ts b/src/modules/audit/controllers/audit.controller.ts new file mode 100644 index 0000000..041ffdd --- /dev/null +++ b/src/modules/audit/controllers/audit.controller.ts @@ -0,0 +1,335 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { AuditService, AuditLogFilters } from '../services/audit.service'; + +export class AuditController { + public router: Router; + + constructor(private readonly auditService: AuditService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Audit Logs + this.router.get('/logs', this.findAuditLogs.bind(this)); + this.router.get('/logs/entity/:entityType/:entityId', this.findAuditLogsByEntity.bind(this)); + this.router.post('/logs', this.createAuditLog.bind(this)); + + // Entity Changes + this.router.get('/changes/:entityType/:entityId', this.findEntityChanges.bind(this)); + this.router.get('/changes/:entityType/:entityId/version/:version', this.getEntityVersion.bind(this)); + this.router.post('/changes', this.createEntityChange.bind(this)); + + // Login History + this.router.get('/logins/user/:userId', this.findLoginHistory.bind(this)); + this.router.get('/logins/user/:userId/active-sessions', this.getActiveSessionsCount.bind(this)); + this.router.post('/logins', this.createLoginHistory.bind(this)); + this.router.post('/logins/:sessionId/logout', this.markSessionLogout.bind(this)); + + // Sensitive Data Access + this.router.get('/sensitive-access', this.findSensitiveDataAccess.bind(this)); + this.router.post('/sensitive-access', this.logSensitiveDataAccess.bind(this)); + + // Data Exports + this.router.get('/exports', this.findUserDataExports.bind(this)); + this.router.get('/exports/:id', this.findDataExport.bind(this)); + this.router.post('/exports', this.createDataExport.bind(this)); + this.router.patch('/exports/:id/status', this.updateDataExportStatus.bind(this)); + + // Permission Changes + this.router.get('/permission-changes', this.findPermissionChanges.bind(this)); + this.router.post('/permission-changes', this.logPermissionChange.bind(this)); + + // Config Changes + this.router.get('/config-changes', this.findConfigChanges.bind(this)); + this.router.post('/config-changes', this.logConfigChange.bind(this)); + } + + // ============================================ + // AUDIT LOGS + // ============================================ + + private async findAuditLogs(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const filters: AuditLogFilters = { + userId: req.query.userId as string, + entityType: req.query.entityType as string, + action: req.query.action as string, + category: req.query.category as string, + ipAddress: req.query.ipAddress as string, + }; + + if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string); + if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string); + + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 50; + + const result = await this.auditService.findAuditLogs(tenantId, filters, { page, limit }); + res.json({ data: result.data, total: result.total, page, limit }); + } catch (error) { + next(error); + } + } + + private async findAuditLogsByEntity(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { entityType, entityId } = req.params; + + const logs = await this.auditService.findAuditLogsByEntity(tenantId, entityType, entityId); + res.json({ data: logs, total: logs.length }); + } catch (error) { + next(error); + } + } + + private async createAuditLog(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const log = await this.auditService.createAuditLog(tenantId, req.body); + res.status(201).json({ data: log }); + } catch (error) { + next(error); + } + } + + // ============================================ + // ENTITY CHANGES + // ============================================ + + private async findEntityChanges(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { entityType, entityId } = req.params; + + const changes = await this.auditService.findEntityChanges(tenantId, entityType, entityId); + res.json({ data: changes, total: changes.length }); + } catch (error) { + next(error); + } + } + + private async getEntityVersion(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { entityType, entityId, version } = req.params; + + const change = await this.auditService.getEntityVersion( + tenantId, + entityType, + entityId, + parseInt(version) + ); + + if (!change) { + res.status(404).json({ error: 'Version not found' }); + return; + } + + res.json({ data: change }); + } catch (error) { + next(error); + } + } + + private async createEntityChange(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const change = await this.auditService.createEntityChange(tenantId, req.body); + res.status(201).json({ data: change }); + } catch (error) { + next(error); + } + } + + // ============================================ + // LOGIN HISTORY + // ============================================ + + private async findLoginHistory(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { userId } = req.params; + const limit = parseInt(req.query.limit as string) || 20; + + const history = await this.auditService.findLoginHistory(userId, tenantId, limit); + res.json({ data: history, total: history.length }); + } catch (error) { + next(error); + } + } + + private async getActiveSessionsCount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId } = req.params; + const count = await this.auditService.getActiveSessionsCount(userId); + res.json({ data: { activeSessions: count } }); + } catch (error) { + next(error); + } + } + + private async createLoginHistory(req: Request, res: Response, next: NextFunction): Promise { + try { + const login = await this.auditService.createLoginHistory(req.body); + res.status(201).json({ data: login }); + } catch (error) { + next(error); + } + } + + private async markSessionLogout(_req: Request, res: Response, _next: NextFunction): Promise { + // Note: Session logout tracking requires a separate Session entity + // LoginHistory only tracks login attempts, not active sessions + res.status(501).json({ + error: 'Session logout tracking not implemented', + message: 'Use the Auth module session endpoints for logout tracking', + }); + } + + // ============================================ + // SENSITIVE DATA ACCESS + // ============================================ + + private async findSensitiveDataAccess(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const filters: { userId?: string; dataType?: string; startDate?: Date; endDate?: Date } = { + userId: req.query.userId as string, + dataType: req.query.dataType as string, + }; + + if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string); + if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string); + + const access = await this.auditService.findSensitiveDataAccess(tenantId, filters); + res.json({ data: access, total: access.length }); + } catch (error) { + next(error); + } + } + + private async logSensitiveDataAccess(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const access = await this.auditService.logSensitiveDataAccess(tenantId, req.body); + res.status(201).json({ data: access }); + } catch (error) { + next(error); + } + } + + // ============================================ + // DATA EXPORTS + // ============================================ + + private async findUserDataExports(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const exports = await this.auditService.findUserDataExports(tenantId, userId); + res.json({ data: exports, total: exports.length }); + } catch (error) { + next(error); + } + } + + private async findDataExport(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const exportRecord = await this.auditService.findDataExport(id); + + if (!exportRecord) { + res.status(404).json({ error: 'Export not found' }); + return; + } + + res.json({ data: exportRecord }); + } catch (error) { + next(error); + } + } + + private async createDataExport(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const exportRecord = await this.auditService.createDataExport(tenantId, req.body); + res.status(201).json({ data: exportRecord }); + } catch (error) { + next(error); + } + } + + private async updateDataExportStatus(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { status, ...updates } = req.body; + + const exportRecord = await this.auditService.updateDataExportStatus(id, status, updates); + + if (!exportRecord) { + res.status(404).json({ error: 'Export not found' }); + return; + } + + res.json({ data: exportRecord }); + } catch (error) { + next(error); + } + } + + // ============================================ + // PERMISSION CHANGES + // ============================================ + + private async findPermissionChanges(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const targetUserId = req.query.targetUserId as string; + + const changes = await this.auditService.findPermissionChanges(tenantId, targetUserId); + res.json({ data: changes, total: changes.length }); + } catch (error) { + next(error); + } + } + + private async logPermissionChange(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const change = await this.auditService.logPermissionChange(tenantId, req.body); + res.status(201).json({ data: change }); + } catch (error) { + next(error); + } + } + + // ============================================ + // CONFIG CHANGES + // ============================================ + + private async findConfigChanges(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const configType = req.query.configType as string; + + const changes = await this.auditService.findConfigChanges(tenantId, configType); + res.json({ data: changes, total: changes.length }); + } catch (error) { + next(error); + } + } + + private async logConfigChange(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const change = await this.auditService.logConfigChange(tenantId, req.body); + res.status(201).json({ data: change }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/audit/controllers/index.ts b/src/modules/audit/controllers/index.ts new file mode 100644 index 0000000..668948b --- /dev/null +++ b/src/modules/audit/controllers/index.ts @@ -0,0 +1 @@ +export { AuditController } from './audit.controller'; diff --git a/src/modules/audit/dto/audit.dto.ts b/src/modules/audit/dto/audit.dto.ts new file mode 100644 index 0000000..f646e6a --- /dev/null +++ b/src/modules/audit/dto/audit.dto.ts @@ -0,0 +1,346 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsObject, + IsUUID, + IsEnum, + IsIP, + MaxLength, + MinLength, +} from 'class-validator'; + +// ============================================ +// AUDIT LOG DTOs +// ============================================ + +export class CreateAuditLogDto { + @IsOptional() + @IsUUID() + userId?: string; + + @IsString() + @MaxLength(20) + action: string; + + @IsOptional() + @IsString() + @MaxLength(30) + category?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + entityType?: string; + + @IsOptional() + @IsUUID() + entityId?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsObject() + oldValues?: Record; + + @IsOptional() + @IsObject() + newValues?: Record; + + @IsOptional() + @IsObject() + metadata?: Record; + + @IsOptional() + @IsString() + @MaxLength(45) + ipAddress?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + userAgent?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + requestId?: string; +} + +// ============================================ +// ENTITY CHANGE DTOs +// ============================================ + +export class CreateEntityChangeDto { + @IsString() + @MaxLength(100) + entityType: string; + + @IsUUID() + entityId: string; + + @IsString() + @MaxLength(20) + changeType: string; + + @IsOptional() + @IsUUID() + changedBy?: string; + + @IsNumber() + version: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + changedFields?: string[]; + + @IsOptional() + @IsObject() + previousData?: Record; + + @IsOptional() + @IsObject() + newData?: Record; + + @IsOptional() + @IsString() + changeReason?: string; +} + +// ============================================ +// LOGIN HISTORY DTOs +// ============================================ + +export class CreateLoginHistoryDto { + @IsUUID() + userId: string; + + @IsOptional() + @IsUUID() + tenantId?: string; + + @IsString() + @MaxLength(20) + status: string; + + @IsOptional() + @IsString() + @MaxLength(30) + authMethod?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + mfaMethod?: string; + + @IsOptional() + @IsBoolean() + mfaUsed?: boolean; + + @IsOptional() + @IsString() + @MaxLength(45) + ipAddress?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + userAgent?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + deviceFingerprint?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + location?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + sessionId?: string; + + @IsOptional() + @IsString() + failureReason?: string; +} + +// ============================================ +// SENSITIVE DATA ACCESS DTOs +// ============================================ + +export class CreateSensitiveDataAccessDto { + @IsUUID() + userId: string; + + @IsString() + @MaxLength(50) + dataType: string; + + @IsString() + @MaxLength(20) + accessType: string; + + @IsOptional() + @IsString() + @MaxLength(100) + entityType?: string; + + @IsOptional() + @IsUUID() + entityId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + fieldsAccessed?: string[]; + + @IsOptional() + @IsString() + accessReason?: string; + + @IsOptional() + @IsBoolean() + wasExported?: boolean; + + @IsOptional() + @IsString() + @MaxLength(45) + ipAddress?: string; +} + +// ============================================ +// DATA EXPORT DTOs +// ============================================ + +export class CreateDataExportDto { + @IsString() + @MaxLength(30) + exportType: string; + + @IsString() + @MaxLength(20) + format: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + entities?: string[]; + + @IsOptional() + @IsObject() + filters?: Record; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + fields?: string[]; + + @IsOptional() + @IsString() + exportReason?: string; +} + +export class UpdateDataExportStatusDto { + @IsString() + @MaxLength(20) + status: string; + + @IsOptional() + @IsString() + filePath?: string; + + @IsOptional() + @IsNumber() + fileSize?: number; + + @IsOptional() + @IsNumber() + recordCount?: number; + + @IsOptional() + @IsString() + errorMessage?: string; +} + +// ============================================ +// PERMISSION CHANGE DTOs +// ============================================ + +export class CreatePermissionChangeDto { + @IsUUID() + targetUserId: string; + + @IsUUID() + changedBy: string; + + @IsString() + @MaxLength(20) + changeType: string; + + @IsString() + @MaxLength(30) + scope: string; + + @IsOptional() + @IsString() + @MaxLength(100) + resourceType?: string; + + @IsOptional() + @IsUUID() + resourceId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + previousPermissions?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + newPermissions?: string[]; + + @IsOptional() + @IsString() + changeReason?: string; +} + +// ============================================ +// CONFIG CHANGE DTOs +// ============================================ + +export class CreateConfigChangeDto { + @IsString() + @MaxLength(30) + configType: string; + + @IsString() + @MaxLength(200) + configKey: string; + + @IsUUID() + changedBy: string; + + @IsNumber() + version: number; + + @IsOptional() + @IsObject() + previousValue?: Record; + + @IsOptional() + @IsObject() + newValue?: Record; + + @IsOptional() + @IsString() + changeReason?: string; +} diff --git a/src/modules/audit/dto/index.ts b/src/modules/audit/dto/index.ts new file mode 100644 index 0000000..51a4ace --- /dev/null +++ b/src/modules/audit/dto/index.ts @@ -0,0 +1,10 @@ +export { + CreateAuditLogDto, + CreateEntityChangeDto, + CreateLoginHistoryDto, + CreateSensitiveDataAccessDto, + CreateDataExportDto, + UpdateDataExportStatusDto, + CreatePermissionChangeDto, + CreateConfigChangeDto, +} from './audit.dto'; diff --git a/src/modules/audit/entities/audit-log.entity.ts b/src/modules/audit/entities/audit-log.entity.ts index 7ff4c9a..6fd98e7 100644 --- a/src/modules/audit/entities/audit-log.entity.ts +++ b/src/modules/audit/entities/audit-log.entity.ts @@ -1,11 +1,3 @@ -/** - * AuditLog Entity - * General activity tracking with full request context - * Compatible with erp-core audit-log.entity - * - * @module Audit - */ - import { Entity, PrimaryGeneratedColumn, diff --git a/src/modules/audit/entities/config-change.entity.ts b/src/modules/audit/entities/config-change.entity.ts index ecb212d..f9b3a69 100644 --- a/src/modules/audit/entities/config-change.entity.ts +++ b/src/modules/audit/entities/config-change.entity.ts @@ -1,11 +1,3 @@ -/** - * ConfigChange Entity - * System configuration change auditing - * Compatible with erp-core config-change.entity - * - * @module Audit - */ - import { Entity, PrimaryGeneratedColumn, diff --git a/src/modules/audit/entities/data-export.entity.ts b/src/modules/audit/entities/data-export.entity.ts index 54f38a8..727bf36 100644 --- a/src/modules/audit/entities/data-export.entity.ts +++ b/src/modules/audit/entities/data-export.entity.ts @@ -1,11 +1,3 @@ -/** - * DataExport Entity - * GDPR/reporting data export request management - * Compatible with erp-core data-export.entity - * - * @module Audit - */ - import { Entity, PrimaryGeneratedColumn, diff --git a/src/modules/audit/entities/entity-change.entity.ts b/src/modules/audit/entities/entity-change.entity.ts index 6b73ce6..b2e208e 100644 --- a/src/modules/audit/entities/entity-change.entity.ts +++ b/src/modules/audit/entities/entity-change.entity.ts @@ -1,11 +1,3 @@ -/** - * EntityChange Entity - * Data modification versioning and change history - * Compatible with erp-core entity-change.entity - * - * @module Audit - */ - import { Entity, PrimaryGeneratedColumn, diff --git a/src/modules/audit/entities/index.ts b/src/modules/audit/entities/index.ts index feda39f..e0f3abd 100644 --- a/src/modules/audit/entities/index.ts +++ b/src/modules/audit/entities/index.ts @@ -1,7 +1,3 @@ -/** - * Audit Entities - Export - */ - export { AuditLog, AuditAction, AuditCategory, AuditStatus } from './audit-log.entity'; export { EntityChange, ChangeType } from './entity-change.entity'; export { LoginHistory, LoginStatus, AuthMethod, MfaMethod } from './login-history.entity'; diff --git a/src/modules/audit/entities/login-history.entity.ts b/src/modules/audit/entities/login-history.entity.ts index 8fc339e..d90123d 100644 --- a/src/modules/audit/entities/login-history.entity.ts +++ b/src/modules/audit/entities/login-history.entity.ts @@ -1,11 +1,3 @@ -/** - * LoginHistory Entity - * Authentication event tracking with device, location and risk scoring - * Compatible with erp-core login-history.entity - * - * @module Audit - */ - import { Entity, PrimaryGeneratedColumn, diff --git a/src/modules/audit/entities/permission-change.entity.ts b/src/modules/audit/entities/permission-change.entity.ts index 6f075d3..b673a6a 100644 --- a/src/modules/audit/entities/permission-change.entity.ts +++ b/src/modules/audit/entities/permission-change.entity.ts @@ -1,11 +1,3 @@ -/** - * PermissionChange Entity - * Access control change auditing - * Compatible with erp-core permission-change.entity - * - * @module Audit - */ - import { Entity, PrimaryGeneratedColumn, diff --git a/src/modules/audit/entities/sensitive-data-access.entity.ts b/src/modules/audit/entities/sensitive-data-access.entity.ts index 5dbba5a..140c0eb 100644 --- a/src/modules/audit/entities/sensitive-data-access.entity.ts +++ b/src/modules/audit/entities/sensitive-data-access.entity.ts @@ -1,11 +1,3 @@ -/** - * SensitiveDataAccess Entity - * Security/compliance logging for PII, financial, medical and credential access - * Compatible with erp-core sensitive-data-access.entity - * - * @module Audit - */ - import { Entity, PrimaryGeneratedColumn, diff --git a/src/modules/audit/index.ts b/src/modules/audit/index.ts new file mode 100644 index 0000000..c9df41c --- /dev/null +++ b/src/modules/audit/index.ts @@ -0,0 +1,5 @@ +export { AuditModule, AuditModuleOptions } from './audit.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/audit/middleware/audit.middleware.ts b/src/modules/audit/middleware/audit.middleware.ts new file mode 100644 index 0000000..1372f8b --- /dev/null +++ b/src/modules/audit/middleware/audit.middleware.ts @@ -0,0 +1,61 @@ +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../../../shared/types/index'; +import { auditService } from '../services/audit.instance'; + +export const auditMiddleware = (action?: string, resourceType?: string) => { + return async (req: AuthRequest, res: Response, next: NextFunction) => { + // Solo auditamos si hay usuario y tenant (request autenticado) + if (!req.user) { + return next(); + } + + const originalSend = res.send; + let responseBody: any; + + // Intercept response to log status and body (optional) + res.send = function (body) { + responseBody = body; + return originalSend.apply(res, arguments as any); + }; + + res.on('finish', () => { + // Logic to determine action if not provided + let derivedAction = action; + if (!derivedAction) { + if (req.method === 'POST') derivedAction = 'CREATE'; + else if (req.method === 'PUT' || req.method === 'PATCH') derivedAction = 'UPDATE'; + else if (req.method === 'DELETE') derivedAction = 'DELETE'; + else derivedAction = 'READ'; + } + + // Logic to determine resourceType if not provided + let derivedResource = resourceType; + if (!derivedResource) { + // Try to guess from URL: /api/v1/users -> users + const parts = req.baseUrl.split('/'); + derivedResource = parts[parts.length - 1] || 'UNKNOWN'; + } + + try { + auditService.createAuditLog(req.user!.tenantId, { + userId: req.user!.userId, + action: derivedAction as any, + resourceType: derivedResource, + resourceId: req.params.id, + ipAddress: req.ip, + userAgent: req.get('User-Agent'), + newValues: req.method !== 'GET' ? req.body : undefined, + metadata: { + method: req.method, + url: req.originalUrl, + statusCode: res.statusCode + } + }).catch(err => console.error('Audit log error', err)); + } catch (err) { + console.error('Audit middleware error', err); + } + }); + + next(); + }; +}; diff --git a/src/modules/audit/services/audit.instance.ts b/src/modules/audit/services/audit.instance.ts new file mode 100644 index 0000000..ca9d78a --- /dev/null +++ b/src/modules/audit/services/audit.instance.ts @@ -0,0 +1,33 @@ +import { AppDataSource } from '../../../main'; +import { + AuditLog, + EntityChange, + LoginHistory, + SensitiveDataAccess, + DataExport, + PermissionChange, + ConfigChange, +} from '../entities/index'; +import { AuditService } from './audit.service'; + +let _auditService: AuditService | null = null; + +export const auditService = { + get instance(): AuditService { + if (!_auditService) { + _auditService = new AuditService( + AppDataSource.getRepository(AuditLog), + AppDataSource.getRepository(EntityChange), + AppDataSource.getRepository(LoginHistory), + AppDataSource.getRepository(SensitiveDataAccess), + AppDataSource.getRepository(DataExport), + AppDataSource.getRepository(PermissionChange), + AppDataSource.getRepository(ConfigChange) + ); + } + return _auditService; + }, + createAuditLog: (...args: Parameters) => auditService.instance.createAuditLog(...args), + findAuditLogs: (...args: Parameters) => auditService.instance.findAuditLogs(...args), + findAuditLogsByEntity: (...args: Parameters) => auditService.instance.findAuditLogsByEntity(...args), +}; diff --git a/src/modules/audit/services/audit.service.ts b/src/modules/audit/services/audit.service.ts new file mode 100644 index 0000000..a4e8e4c --- /dev/null +++ b/src/modules/audit/services/audit.service.ts @@ -0,0 +1,303 @@ +import { Repository, FindOptionsWhere, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { + AuditLog, + EntityChange, + LoginHistory, + SensitiveDataAccess, + DataExport, + PermissionChange, + ConfigChange, +} from '../entities'; + +export interface AuditLogFilters { + userId?: string; + entityType?: string; + action?: string; + category?: string; + startDate?: Date; + endDate?: Date; + ipAddress?: string; +} + +export interface PaginationOptions { + page?: number; + limit?: number; +} + +export class AuditService { + constructor( + private readonly auditLogRepository: Repository, + private readonly entityChangeRepository: Repository, + private readonly loginHistoryRepository: Repository, + private readonly sensitiveDataAccessRepository: Repository, + private readonly dataExportRepository: Repository, + private readonly permissionChangeRepository: Repository, + private readonly configChangeRepository: Repository + ) {} + + // ============================================ + // AUDIT LOGS + // ============================================ + + async createAuditLog(tenantId: string, data: Partial): Promise { + const log = this.auditLogRepository.create({ + ...data, + tenantId, + }); + return this.auditLogRepository.save(log); + } + + async findAuditLogs( + tenantId: string, + filters: AuditLogFilters = {}, + pagination: PaginationOptions = {} + ): Promise<{ data: AuditLog[]; total: number }> { + const { page = 1, limit = 50 } = pagination; + const where: FindOptionsWhere = { tenantId }; + + if (filters.userId) where.userId = filters.userId; + if (filters.entityType) where.resourceType = filters.entityType; + if (filters.action) where.action = filters.action as any; + if (filters.category) where.actionCategory = filters.category as any; + if (filters.ipAddress) where.ipAddress = filters.ipAddress; + + if (filters.startDate && filters.endDate) { + where.createdAt = Between(filters.startDate, filters.endDate); + } else if (filters.startDate) { + where.createdAt = MoreThanOrEqual(filters.startDate); + } else if (filters.endDate) { + where.createdAt = LessThanOrEqual(filters.endDate); + } + + const [data, total] = await this.auditLogRepository.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { data, total }; + } + + async findAuditLogsByEntity( + tenantId: string, + entityType: string, + entityId: string + ): Promise { + return this.auditLogRepository.find({ + where: { tenantId, resourceType: entityType, resourceId: entityId }, + order: { createdAt: 'DESC' }, + }); + } + + // ============================================ + // ENTITY CHANGES + // ============================================ + + async createEntityChange(tenantId: string, data: Partial): Promise { + const change = this.entityChangeRepository.create({ + ...data, + tenantId, + }); + return this.entityChangeRepository.save(change); + } + + async findEntityChanges( + tenantId: string, + entityType: string, + entityId: string + ): Promise { + return this.entityChangeRepository.find({ + where: { tenantId, entityType, entityId }, + order: { changedAt: 'DESC' }, + }); + } + + async getEntityVersion( + tenantId: string, + entityType: string, + entityId: string, + version: number + ): Promise { + return this.entityChangeRepository.findOne({ + where: { tenantId, entityType, entityId, version }, + }); + } + + // ============================================ + // LOGIN HISTORY + // ============================================ + + async createLoginHistory(data: Partial): Promise { + const login = this.loginHistoryRepository.create(data); + return this.loginHistoryRepository.save(login); + } + + async findLoginHistory( + userId: string, + tenantId?: string, + limit: number = 20 + ): Promise { + const where: FindOptionsWhere = { userId }; + if (tenantId) where.tenantId = tenantId; + + return this.loginHistoryRepository.find({ + where, + order: { attemptedAt: 'DESC' }, + take: limit, + }); + } + + async getActiveSessionsCount(userId: string): Promise { + // Note: LoginHistory tracks login attempts, not sessions + // This counts successful login attempts (not truly active sessions) + return this.loginHistoryRepository.count({ + where: { userId, status: 'success' }, + }); + } + + // Note: Session logout tracking requires a separate Session entity + // LoginHistory only tracks login attempts + + // ============================================ + // SENSITIVE DATA ACCESS + // ============================================ + + async logSensitiveDataAccess( + tenantId: string, + data: Partial + ): Promise { + const access = this.sensitiveDataAccessRepository.create({ + ...data, + tenantId, + }); + return this.sensitiveDataAccessRepository.save(access); + } + + async findSensitiveDataAccess( + tenantId: string, + filters: { userId?: string; dataType?: string; startDate?: Date; endDate?: Date } = {} + ): Promise { + const where: FindOptionsWhere = { tenantId }; + + if (filters.userId) where.userId = filters.userId; + if (filters.dataType) where.dataType = filters.dataType as any; + + if (filters.startDate && filters.endDate) { + where.accessedAt = Between(filters.startDate, filters.endDate); + } + + return this.sensitiveDataAccessRepository.find({ + where, + order: { accessedAt: 'DESC' }, + take: 100, + }); + } + + // ============================================ + // DATA EXPORTS + // ============================================ + + async createDataExport(tenantId: string, data: Partial): Promise { + const exportRecord = this.dataExportRepository.create({ + ...data, + tenantId, + status: 'pending', + }); + return this.dataExportRepository.save(exportRecord); + } + + async findDataExport(id: string): Promise { + return this.dataExportRepository.findOne({ where: { id } }); + } + + async findUserDataExports(tenantId: string, userId: string): Promise { + return this.dataExportRepository.find({ + where: { tenantId, userId }, + order: { requestedAt: 'DESC' }, + }); + } + + async updateDataExportStatus( + id: string, + status: string, + updates: Partial = {} + ): Promise { + const exportRecord = await this.findDataExport(id); + if (!exportRecord) return null; + + exportRecord.status = status as any; + Object.assign(exportRecord, updates); + + if (status === 'completed') { + exportRecord.completedAt = new Date(); + } + + return this.dataExportRepository.save(exportRecord); + } + + // ============================================ + // PERMISSION CHANGES + // ============================================ + + async logPermissionChange( + tenantId: string, + data: Partial + ): Promise { + const change = this.permissionChangeRepository.create({ + ...data, + tenantId, + }); + return this.permissionChangeRepository.save(change); + } + + async findPermissionChanges( + tenantId: string, + targetUserId?: string + ): Promise { + const where: FindOptionsWhere = { tenantId }; + if (targetUserId) where.targetUserId = targetUserId; + + return this.permissionChangeRepository.find({ + where, + order: { changedAt: 'DESC' }, + take: 100, + }); + } + + // ============================================ + // CONFIG CHANGES + // ============================================ + + async logConfigChange(tenantId: string, data: Partial): Promise { + const change = this.configChangeRepository.create({ + ...data, + tenantId, + }); + return this.configChangeRepository.save(change); + } + + async findConfigChanges(tenantId: string, configType?: string): Promise { + const where: FindOptionsWhere = { tenantId }; + if (configType) where.configType = configType as any; + + return this.configChangeRepository.find({ + where, + order: { changedAt: 'DESC' }, + take: 100, + }); + } + + // Note: ConfigChange entity doesn't track versions + // Use changedAt timestamp to get specific config snapshots + async getConfigChangeByDate( + tenantId: string, + configKey: string, + date: Date + ): Promise { + return this.configChangeRepository.findOne({ + where: { tenantId, configKey }, + order: { changedAt: 'DESC' }, + }); + } +} diff --git a/src/modules/audit/services/index.ts b/src/modules/audit/services/index.ts new file mode 100644 index 0000000..4e17eb0 --- /dev/null +++ b/src/modules/audit/services/index.ts @@ -0,0 +1 @@ +export { AuditService, AuditLogFilters, PaginationOptions } from './audit.service'; diff --git a/src/modules/auth/auth.dto.ts b/src/modules/auth/auth.dto.ts index 8c90628..a8e0ada 100644 --- a/src/modules/auth/auth.dto.ts +++ b/src/modules/auth/auth.dto.ts @@ -7,8 +7,9 @@ import { z } from 'zod'; // Login Schema export const loginSchema = z.object({ - email: z.string().email('Email inválido'), - password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'), + email: z.string().email('Email inválido'), + password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'), + mfaCode: z.string().optional(), }); export type LoginDto = z.infer; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index c0c3bfc..9cf8db1 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -11,6 +11,7 @@ import { RefreshToken } from './entities/refresh-token.entity'; import { Workshop } from './entities/workshop.entity'; import { LoginDto, RegisterDto, ChangePasswordDto } from './auth.dto'; import { generateAccessToken, generateRefreshToken as generateRefreshJwt, verifyToken } from '../../shared/utils/jwt.utils'; +import { MfaService } from './services/mfa.service'; const REFRESH_TOKEN_EXPIRES_DAYS = 30; @@ -49,11 +50,22 @@ export class AuthService { const isValidPassword = await bcrypt.compare(dto.password, user.passwordHash); if (!isValidPassword) { - throw new Error('Credenciales inválidas'); + throw new Error('Credenciales inválidas'); + } + + // MFA Verification + if (user.mfaEnabled) { + if (!dto.mfaCode) { + throw new Error('MFA_REQUIRED'); // Standardized error for frontend to catch + } + + const isMfaValid = await MfaService.verifyMfaCode(user.id, dto.mfaCode); + if (!isMfaValid) { + throw new Error('Código MFA inválido'); + } } user.lastLoginAt = new Date(); - await this.userRepository.save(user); const tokenPayload = { userId: user.id, diff --git a/src/modules/auth/entities/user.entity.ts b/src/modules/auth/entities/user.entity.ts index 5eaf21c..4060122 100644 --- a/src/modules/auth/entities/user.entity.ts +++ b/src/modules/auth/entities/user.entity.ts @@ -51,6 +51,15 @@ export class User { @Column({ name: 'email_verified', type: 'boolean', default: false }) emailVerified: boolean; + @Column({ name: 'mfa_enabled', type: 'boolean', default: false }) + mfaEnabled: boolean; + + @Column({ name: 'mfa_secret_encrypted', type: 'text', nullable: true }) + mfaSecretEncrypted?: string; + + @Column({ name: 'mfa_backup_codes', type: 'text', array: true, nullable: true }) + mfaBackupCodes?: string[]; + @Column({ name: 'last_login_at', type: 'timestamptz', nullable: true }) lastLoginAt?: Date; diff --git a/src/modules/auth/mfa.controller.ts b/src/modules/auth/mfa.controller.ts new file mode 100644 index 0000000..5d3b4d2 --- /dev/null +++ b/src/modules/auth/mfa.controller.ts @@ -0,0 +1,110 @@ +import { Request, Response, NextFunction } from 'express'; +import { MfaService } from './services/mfa.service'; +import { AuthRequest } from '../../shared/types/index'; + +export const mfaController = { + /** + * Initialize MFA setup + */ + async setup(req: AuthRequest, res: Response, next: NextFunction) { + try { + const userId = req.user!.userId; // Assuming req.user is populated by auth middleware + const result = await MfaService.setupMfa(userId); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + next(error); + } + }, + + /** + * Verify MFA setup and enable + */ + async verifySetup(req: AuthRequest, res: Response, next: NextFunction) { + try { + const userId = req.user!.userId; + const { secret, code } = req.body; + + if (!secret || !code) { + throw new Error('Secret and code are required'); + } + + const result = await MfaService.verifyMfaSetup(userId, secret, code); + + res.json({ + success: true, + message: result.message, + data: { backupCodes: result.backupCodes }, + }); + } catch (error) { + next(error); + } + }, + + /** + * Disable MFA + */ + async disable(req: AuthRequest, res: Response, next: NextFunction) { + try { + const userId = req.user!.userId; + const { code, password } = req.body; + + if (!code) { + throw new Error('Verification code is required'); + } + + const result = await MfaService.disableMfa(userId, code, password); + + res.json({ + success: true, + message: result.message, + }); + } catch (error) { + next(error); + } + }, + + /** + * Get MFA status + */ + async getStatus(req: AuthRequest, res: Response, next: NextFunction) { + try { + const userId = req.user!.userId; + const result = await MfaService.getMfaStatus(userId); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + next(error); + } + }, + + /** + * Regenerate backup codes + */ + async regenerateBackupCodes(req: AuthRequest, res: Response, next: NextFunction) { + try { + const userId = req.user!.userId; + const { code, password } = req.body; + + if (!code) { + throw new Error('Verification code is required'); + } + + const result = await MfaService.regenerateBackupCodes(userId, code, password); + + res.json({ + success: true, + message: result.message, + data: { backupCodes: result.backupCodes }, + }); + } catch (error) { + next(error); + } + }, +}; diff --git a/src/modules/auth/mfa.routes.ts b/src/modules/auth/mfa.routes.ts new file mode 100644 index 0000000..6f92ad4 --- /dev/null +++ b/src/modules/auth/mfa.routes.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { mfaController } from './mfa.controller'; +import { authMiddleware } from '../../shared/middleware/auth.middleware'; + +const router = Router(); + +// All MFA routes require authentication +router.use(authMiddleware); + +// Setup +router.post('/setup', (req, res, next) => mfaController.setup(req, res, next)); +router.post('/verify-setup', (req, res, next) => mfaController.verifySetup(req, res, next)); + +// Management +router.get('/status', (req, res, next) => mfaController.getStatus(req, res, next)); +router.post('/disable', (req, res, next) => mfaController.disable(req, res, next)); +router.post('/regenerate-codes', (req, res, next) => mfaController.regenerateBackupCodes(req, res, next)); + +export default router; diff --git a/src/modules/auth/services/mfa.service.ts b/src/modules/auth/services/mfa.service.ts new file mode 100644 index 0000000..17f5d17 --- /dev/null +++ b/src/modules/auth/services/mfa.service.ts @@ -0,0 +1,354 @@ +import speakeasy from 'speakeasy'; +import QRCode from 'qrcode'; +import bcrypt from 'bcryptjs'; +import crypto from 'crypto'; +import { AppDataSource } from '../../../main'; +import { User } from '../entities/user.entity'; + +// Minimal config or just use process.env directly if config import fails +const encryptionSecret = process.env.JWT_SECRET || 'fallback-secret'; + +export class MfaService { + private static get userRepository() { + return AppDataSource.getRepository(User); + } + + private static get appName() { + return 'ERP Mecánicas Diesel'; + } + + /** + * Initialize MFA setup - generate secret and QR code + */ + static async setupMfa(userId: string) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + const error = new Error('User not found'); + (error as any).statusCode = 404; + throw error; + } + + if (user.mfaEnabled) { + const error = new Error('MFA is already enabled'); + (error as any).statusCode = 400; + throw error; + } + + // Generate TOTP secret + const secret = speakeasy.generateSecret({ + name: `${this.appName} (${user.email})`, + length: 20, + }); + + // Generate QR code + const qrCodeDataUrl = await QRCode.toDataURL(secret.otpauth_url!); + + // Generate backup codes + const backupCodes = this.generateBackupCodes(); + + return { + secret: secret.base32, + qrCodeDataUrl, + backupCodes, + }; + } + + /** + * Verify MFA setup and enable + */ + static async verifyMfaSetup( + userId: string, + secretToken: string, + code: string + ) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + const error = new Error('User not found'); + (error as any).statusCode = 404; + throw error; + } + + if (user.mfaEnabled) { + const error = new Error('MFA is already enabled'); + (error as any).statusCode = 400; + throw error; + } + + // Verify the TOTP code + const isValid = speakeasy.totp.verify({ + secret: secretToken, + encoding: 'base32', + token: code, + window: 1, // Allow 1 step (30 seconds) tolerance + }); + + if (!isValid) { + const error = new Error('Invalid verification code'); + (error as any).statusCode = 400; + throw error; + } + + // Generate and hash backup codes + const backupCodes = this.generateBackupCodes(); + const hashedBackupCodes = await Promise.all( + backupCodes.map((c) => bcrypt.hash(c, 10)) + ); + + // Enable MFA and store secret + user.mfaEnabled = true; + user.mfaSecretEncrypted = this.encryptSecret(secretToken); + user.mfaBackupCodes = hashedBackupCodes; + + await this.userRepository.save(user); + + return { + success: true, + message: 'MFA enabled successfully. Please save your backup codes.', + backupCodes, // Return unhashed backup codes one last time + }; + } + + /** + * Verify TOTP code during login + */ + static async verifyMfaCode( + userId: string, + code: string, + isBackupCode: boolean = false + ): Promise { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + const error = new Error('User not found'); + (error as any).statusCode = 404; + throw error; + } + + if (!user.mfaEnabled || !user.mfaSecretEncrypted) { + const error = new Error('MFA is not enabled for this user'); + (error as any).statusCode = 400; + throw error; + } + + if (isBackupCode) { + return this.verifyBackupCode(user, code); + } + + // Decrypt secret and verify TOTP + const secret = this.decryptSecret(user.mfaSecretEncrypted); + + const isValid = speakeasy.totp.verify({ + secret, + encoding: 'base32', + token: code, + window: 1, + }); + + return isValid; + } + + /** + * Disable MFA for user + */ + static async disableMfa(userId: string, code: string, password?: string) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + const error = new Error('User not found'); + (error as any).statusCode = 404; + throw error; + } + + if (!user.mfaEnabled) { + const error = new Error('MFA is not enabled'); + (error as any).statusCode = 400; + throw error; + } + + // Optional: Verify password if provided + if (password) { + const isPasswordValid = await bcrypt.compare(password, user.passwordHash); + if (!isPasswordValid) { + const error = new Error('Invalid password'); + (error as any).statusCode = 401; + throw error; + } + } + + // Verify MFA code + const isMfaValid = await this.verifyMfaCode(userId, code, code.length > 6); + if (!isMfaValid) { + const error = new Error('Invalid verification code'); + (error as any).statusCode = 400; + throw error; + } + + // Disable MFA + user.mfaEnabled = false; + user.mfaSecretEncrypted = undefined; + user.mfaBackupCodes = undefined; + + await this.userRepository.save(user); + + return { + success: true, + message: 'MFA disabled successfully', + }; + } + + /** + * Get MFA status for user + */ + static async getMfaStatus(userId: string) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + const error = new Error('User not found'); + (error as any).statusCode = 404; + throw error; + } + + const backupCodesRemaining = user.mfaBackupCodes?.length || 0; + + return { + enabled: user.mfaEnabled || false, + backupCodesRemaining, + }; + } + + /** + * Regenerate backup codes + */ + static async regenerateBackupCodes( + userId: string, + code: string, + password?: string + ) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + const error = new Error('User not found'); + (error as any).statusCode = 404; + throw error; + } + + if (!user.mfaEnabled) { + const error = new Error('MFA is not enabled'); + (error as any).statusCode = 400; + throw error; + } + + // Optional: Verify password if provided + if (password) { + const isPasswordValid = await bcrypt.compare(password, user.passwordHash); + if (!isPasswordValid) { + const error = new Error('Invalid password'); + (error as any).statusCode = 401; + throw error; + } + } + + // Verify MFA code + const isMfaValid = await this.verifyMfaCode(userId, code); + if (!isMfaValid) { + const error = new Error('Invalid verification code'); + (error as any).statusCode = 400; + throw error; + } + + // Generate new backup codes + const backupCodes = this.generateBackupCodes(); + const hashedBackupCodes = await Promise.all( + backupCodes.map((c) => bcrypt.hash(c, 10)) + ); + + // Update user + user.mfaBackupCodes = hashedBackupCodes; + await this.userRepository.save(user); + + return { + backupCodes, + message: 'New backup codes generated. Please save them securely.', + }; + } + + // ==================== Private Methods ==================== + + /** + * Generate 10 random backup codes + */ + private static generateBackupCodes(): string[] { + const codes: string[] = []; + for (let i = 0; i < 10; i++) { + const code = crypto.randomBytes(4).toString('hex').toUpperCase(); + // Format as XXXX-XXXX for readability + codes.push(`${code.slice(0, 4)}-${code.slice(4)}`); + } + return codes; + } + + /** + * Verify a backup code + */ + private static async verifyBackupCode(user: User, code: string): Promise { + if (!user.mfaBackupCodes || user.mfaBackupCodes.length === 0) { + return false; + } + + // Normalize code (remove dashes, uppercase) + const normalizedCode = code.replace(/-/g, '').toUpperCase(); + const formattedCode = `${normalizedCode.slice(0, 4)}-${normalizedCode.slice(4)}`; + + // Check each hashed backup code + for (let i = 0; i < user.mfaBackupCodes.length; i++) { + const isMatch = await bcrypt.compare(formattedCode, user.mfaBackupCodes[i]); + if (isMatch) { + // Remove the used backup code + const updatedCodes = [...user.mfaBackupCodes]; + updatedCodes.splice(i, 1); + user.mfaBackupCodes = updatedCodes; + await this.userRepository.save(user); + return true; + } + } + + return false; + } + + /** + * Encrypt MFA secret for storage + */ + private static encryptSecret(secret: string): string { + const encryptionKey = encryptionSecret; + + // Use first 32 bytes of key for AES-256 + const key = crypto.createHash('sha256').update(encryptionKey).digest(); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + + let encrypted = cipher.update(secret, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + // Return IV + encrypted data + return iv.toString('hex') + ':' + encrypted; + } + + /** + * Decrypt MFA secret from storage + */ + private static decryptSecret(encryptedSecret: string): string { + const encryptionKey = encryptionSecret; + + const key = crypto.createHash('sha256').update(encryptionKey).digest(); + const [ivHex, encrypted] = encryptedSecret.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } +} \ No newline at end of file diff --git a/src/modules/feature-flags/README.md b/src/modules/feature-flags/README.md new file mode 100644 index 0000000..6a22fd9 --- /dev/null +++ b/src/modules/feature-flags/README.md @@ -0,0 +1,87 @@ +# Feature Flags Module + +## Descripcion + +Sistema de feature flags/toggles para control de funcionalidades. Permite habilitar/deshabilitar features globalmente o por tenant, con soporte para rollout gradual basado en porcentajes y overrides temporales con expiracion. + +## Entidades + +| Entidad | Schema | Descripcion | +|---------|--------|-------------| +| `Flag` | feature_flags.flags | Definicion de feature flags con estado global y porcentaje de rollout | +| `TenantOverride` | feature_flags.tenant_overrides | Overrides por tenant con razon y fecha de expiracion | +| `FlagEvaluation` | feature_flags.flag_evaluations | Log de evaluaciones para auditoria | + +## Servicios + +| Servicio | Responsabilidades | +|----------|-------------------| +| `FeatureFlagsService` | CRUD de flags y overrides; evaluacion de flags; rollout deterministico; limpieza de expirados | + +## Endpoints + +| Method | Path | Descripcion | +|--------|------|-------------| +| GET | `/flags` | Lista flags activos | +| GET | `/flags/all` | Lista todos los flags (incluyendo inactivos) | +| GET | `/flags/tags/:tags` | Lista flags por tags | +| GET | `/flags/:id` | Obtiene flag por ID | +| GET | `/flags/code/:code` | Obtiene flag por codigo | +| POST | `/flags` | Crea nuevo flag | +| PATCH | `/flags/:id` | Actualiza flag | +| DELETE | `/flags/:id` | Elimina flag (soft/hard) | +| PATCH | `/flags/:id/toggle` | Activa/desactiva flag | +| GET | `/flags/:id/stats` | Estadisticas de overrides del flag | +| GET | `/flags/:flagId/overrides` | Lista overrides de un flag | +| GET | `/tenants/:tenantId/overrides` | Lista overrides de un tenant | +| GET | `/overrides/:id` | Obtiene override por ID | +| POST | `/overrides` | Crea override | +| PATCH | `/overrides/:id` | Actualiza override | +| DELETE | `/overrides/:id` | Elimina override | +| GET | `/evaluate/:code` | Evalua un flag para tenant | +| POST | `/evaluate` | Evalua multiples flags | +| GET | `/is-enabled/:code` | Check rapido de flag habilitado | +| POST | `/maintenance/cleanup` | Limpia overrides expirados | + +## Dependencias + +- `common` - Utilidades compartidas (crypto para hash) + +## Configuracion + +No requiere configuracion de ambiente. + +## Prioridad de Evaluacion + +1. **Tenant Override** (si existe y no ha expirado) +2. **Estado Global** del flag (enabled/disabled) +3. **Rollout Percentage** (si el flag esta habilitado) +4. **Default** (false si el flag no existe) + +## Rollout Deterministico + +El rollout por porcentaje usa un hash MD5 de `flagCode:tenantId` para asegurar que: +- El mismo tenant siempre obtiene el mismo resultado +- La distribucion es uniforme entre tenants +- No hay necesidad de almacenar el resultado + +```typescript +// Ejemplo: 30% rollout +// Tenants con bucket 0-29 = habilitado +// Tenants con bucket 30-99 = deshabilitado +bucket = MD5(flagCode + tenantId) % 100 +enabled = bucket < rolloutPercentage +``` + +## Fuentes de Evaluacion + +| Fuente | Descripcion | +|--------|-------------| +| `default` | Flag no existe, retorna false | +| `global` | Valor global del flag | +| `override` | Override especifico del tenant | +| `rollout` | Resultado del calculo de rollout | + +## Mantenimiento + +Se recomienda ejecutar `POST /maintenance/cleanup` periodicamente (cron diario) para eliminar overrides expirados. diff --git a/src/modules/feature-flags/controllers/feature-flags.controller.ts b/src/modules/feature-flags/controllers/feature-flags.controller.ts new file mode 100644 index 0000000..13c0e0c --- /dev/null +++ b/src/modules/feature-flags/controllers/feature-flags.controller.ts @@ -0,0 +1,367 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { FeatureFlagsService } from '../services/feature-flags.service'; + +export class FeatureFlagsController { + public router: Router; + + constructor(private readonly featureFlagsService: FeatureFlagsService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Flag CRUD + this.router.get('/flags', this.findAllFlags.bind(this)); + this.router.get('/flags/all', this.findAllFlagsIncludingInactive.bind(this)); + this.router.get('/flags/tags/:tags', this.findFlagsByTags.bind(this)); + this.router.get('/flags/:id', this.findFlagById.bind(this)); + this.router.get('/flags/code/:code', this.findFlagByCode.bind(this)); + this.router.post('/flags', this.createFlag.bind(this)); + this.router.patch('/flags/:id', this.updateFlag.bind(this)); + this.router.delete('/flags/:id', this.deleteFlag.bind(this)); + this.router.patch('/flags/:id/toggle', this.toggleFlag.bind(this)); + this.router.get('/flags/:id/stats', this.getFlagStats.bind(this)); + + // Tenant Overrides + this.router.get('/flags/:flagId/overrides', this.findOverridesForFlag.bind(this)); + this.router.get('/tenants/:tenantId/overrides', this.findOverridesForTenant.bind(this)); + this.router.get('/overrides/:id', this.findOverrideById.bind(this)); + this.router.post('/overrides', this.createOverride.bind(this)); + this.router.patch('/overrides/:id', this.updateOverride.bind(this)); + this.router.delete('/overrides/:id', this.deleteOverride.bind(this)); + + // Evaluation + this.router.get('/evaluate/:code', this.evaluateFlag.bind(this)); + this.router.post('/evaluate', this.evaluateFlags.bind(this)); + this.router.get('/is-enabled/:code', this.isEnabled.bind(this)); + + // Maintenance + this.router.post('/maintenance/cleanup', this.cleanupExpiredOverrides.bind(this)); + } + + // ============================================ + // FLAGS + // ============================================ + + private async findAllFlags(req: Request, res: Response, next: NextFunction): Promise { + try { + const flags = await this.featureFlagsService.findAllFlags(); + res.json({ data: flags, total: flags.length }); + } catch (error) { + next(error); + } + } + + private async findAllFlagsIncludingInactive( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const flags = await this.featureFlagsService.findAllFlagsIncludingInactive(); + res.json({ data: flags, total: flags.length }); + } catch (error) { + next(error); + } + } + + private async findFlagById(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const flag = await this.featureFlagsService.findFlagById(id); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async findFlagByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const flag = await this.featureFlagsService.findFlagByCode(code); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async findFlagsByTags(req: Request, res: Response, next: NextFunction): Promise { + try { + const { tags } = req.params; + const tagList = tags.split(','); + const flags = await this.featureFlagsService.findFlagsByTags(tagList); + res.json({ data: flags, total: flags.length }); + } catch (error) { + next(error); + } + } + + private async createFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = req.headers['x-user-id'] as string; + const flag = await this.featureFlagsService.createFlag(req.body, userId); + res.status(201).json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async updateFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const userId = req.headers['x-user-id'] as string; + const flag = await this.featureFlagsService.updateFlag(id, req.body, userId); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async deleteFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { soft } = req.query; + const userId = req.headers['x-user-id'] as string; + + let result: boolean; + if (soft === 'true') { + const flag = await this.featureFlagsService.softDeleteFlag(id, userId); + result = flag !== null; + } else { + result = await this.featureFlagsService.deleteFlag(id); + } + + if (!result) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async toggleFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { enabled } = req.body; + const userId = req.headers['x-user-id'] as string; + + const flag = await this.featureFlagsService.toggleFlag(id, enabled, userId); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async getFlagStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const stats = await this.featureFlagsService.getFlagStats(id); + + if (!stats.flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + // ============================================ + // OVERRIDES + // ============================================ + + private async findOverridesForFlag( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { flagId } = req.params; + const overrides = await this.featureFlagsService.findOverridesForFlag(flagId); + res.json({ data: overrides, total: overrides.length }); + } catch (error) { + next(error); + } + } + + private async findOverridesForTenant( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { tenantId } = req.params; + const overrides = await this.featureFlagsService.findOverridesForTenant(tenantId); + res.json({ data: overrides, total: overrides.length }); + } catch (error) { + next(error); + } + } + + private async findOverrideById( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { id } = req.params; + const override = await this.featureFlagsService.findOverrideById(id); + + if (!override) { + res.status(404).json({ error: 'Override not found' }); + return; + } + + res.json({ data: override }); + } catch (error) { + next(error); + } + } + + private async createOverride(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = req.headers['x-user-id'] as string; + const override = await this.featureFlagsService.createOverride(req.body, userId); + res.status(201).json({ data: override }); + } catch (error) { + next(error); + } + } + + private async updateOverride(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const override = await this.featureFlagsService.updateOverride(id, req.body); + + if (!override) { + res.status(404).json({ error: 'Override not found' }); + return; + } + + res.json({ data: override }); + } catch (error) { + next(error); + } + } + + private async deleteOverride(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.featureFlagsService.deleteOverride(id); + + if (!deleted) { + res.status(404).json({ error: 'Override not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // EVALUATION + // ============================================ + + private async evaluateFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const tenantId = req.headers['x-tenant-id'] as string || req.query.tenantId as string; + + if (!tenantId) { + res.status(400).json({ error: 'tenantId is required (header x-tenant-id or query param)' }); + return; + } + + const result = await this.featureFlagsService.evaluateFlag(code, tenantId); + res.json({ data: result }); + } catch (error) { + next(error); + } + } + + private async evaluateFlags(req: Request, res: Response, next: NextFunction): Promise { + try { + const { flagCodes, tenantId } = req.body; + + if (!flagCodes || !Array.isArray(flagCodes)) { + res.status(400).json({ error: 'flagCodes array is required' }); + return; + } + + if (!tenantId) { + res.status(400).json({ error: 'tenantId is required' }); + return; + } + + const results = await this.featureFlagsService.evaluateFlags(flagCodes, tenantId); + res.json({ data: results, total: results.length }); + } catch (error) { + next(error); + } + } + + private async isEnabled(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const tenantId = req.headers['x-tenant-id'] as string || req.query.tenantId as string; + + if (!tenantId) { + res.status(400).json({ error: 'tenantId is required (header x-tenant-id or query param)' }); + return; + } + + const enabled = await this.featureFlagsService.isEnabled(code, tenantId); + res.json({ data: { code, enabled } }); + } catch (error) { + next(error); + } + } + + // ============================================ + // MAINTENANCE + // ============================================ + + private async cleanupExpiredOverrides( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const count = await this.featureFlagsService.cleanupExpiredOverrides(); + res.json({ data: { cleanedUp: count } }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/feature-flags/controllers/index.ts b/src/modules/feature-flags/controllers/index.ts new file mode 100644 index 0000000..56046b6 --- /dev/null +++ b/src/modules/feature-flags/controllers/index.ts @@ -0,0 +1 @@ +export { FeatureFlagsController } from './feature-flags.controller'; diff --git a/src/modules/feature-flags/dto/feature-flag.dto.ts b/src/modules/feature-flags/dto/feature-flag.dto.ts new file mode 100644 index 0000000..cc395f5 --- /dev/null +++ b/src/modules/feature-flags/dto/feature-flag.dto.ts @@ -0,0 +1,53 @@ +// ===================================================== +// DTOs: Feature Flags +// Modulo: MGN-019 +// Version: 1.0.0 +// ===================================================== + +export interface CreateFlagDto { + code: string; + name: string; + description?: string; + enabled?: boolean; + rolloutPercentage?: number; + tags?: string[]; +} + +export interface UpdateFlagDto { + name?: string; + description?: string; + enabled?: boolean; + rolloutPercentage?: number; + tags?: string[]; + isActive?: boolean; +} + +export interface CreateTenantOverrideDto { + flagId: string; + tenantId: string; + enabled: boolean; + reason?: string; + expiresAt?: Date; +} + +export interface UpdateTenantOverrideDto { + enabled?: boolean; + reason?: string; + expiresAt?: Date | null; +} + +export interface EvaluateFlagDto { + flagCode: string; + tenantId: string; +} + +export interface EvaluateFlagsDto { + flagCodes: string[]; + tenantId: string; +} + +export interface FlagEvaluationResult { + code: string; + enabled: boolean; + source: 'override' | 'global' | 'rollout' | 'default'; +} diff --git a/src/modules/feature-flags/dto/index.ts b/src/modules/feature-flags/dto/index.ts new file mode 100644 index 0000000..8cba2ff --- /dev/null +++ b/src/modules/feature-flags/dto/index.ts @@ -0,0 +1 @@ +export * from './feature-flag.dto'; diff --git a/src/modules/feature-flags/entities/flag-evaluation.entity.ts b/src/modules/feature-flags/entities/flag-evaluation.entity.ts new file mode 100644 index 0000000..e404138 --- /dev/null +++ b/src/modules/feature-flags/entities/flag-evaluation.entity.ts @@ -0,0 +1,53 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Flag } from './flag.entity'; + +/** + * FlagEvaluation Entity + * Maps to flags.flag_evaluations DDL table + * Historial de evaluaciones de feature flags para analytics + * Propagated from template-saas HU-REFACT-005 + */ +@Entity({ schema: 'flags', name: 'flag_evaluations' }) +@Index('idx_flag_evaluations_flag', ['flagId']) +@Index('idx_flag_evaluations_tenant', ['tenantId']) +@Index('idx_flag_evaluations_date', ['evaluatedAt']) +export class FlagEvaluation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'flag_id' }) + flagId: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: true, name: 'user_id' }) + userId: string | null; + + @Column({ type: 'boolean', nullable: false }) + result: boolean; + + @Column({ type: 'varchar', length: 100, nullable: true }) + variant: string | null; + + @Column({ type: 'jsonb', default: {}, name: 'evaluation_context' }) + evaluationContext: Record; + + @Column({ type: 'varchar', length: 100, nullable: true, name: 'evaluation_reason' }) + evaluationReason: string | null; + + @Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP', name: 'evaluated_at' }) + evaluatedAt: Date; + + // Relaciones + @ManyToOne(() => Flag, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'flag_id' }) + flag: Flag; +} diff --git a/src/modules/feature-flags/entities/flag.entity.ts b/src/modules/feature-flags/entities/flag.entity.ts new file mode 100644 index 0000000..779b16f --- /dev/null +++ b/src/modules/feature-flags/entities/flag.entity.ts @@ -0,0 +1,57 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + OneToMany, +} from 'typeorm'; +import { TenantOverride } from './tenant-override.entity'; + +@Entity({ name: 'flags', schema: 'feature_flags' }) +@Unique(['code']) +export class Flag { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'code', type: 'varchar', length: 50 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'enabled', type: 'boolean', default: false }) + enabled: boolean; + + @Column({ name: 'rollout_percentage', type: 'int', default: 100 }) + rolloutPercentage: number; + + @Column({ name: 'tags', type: 'text', array: true, nullable: true }) + tags: string[]; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @OneToMany(() => TenantOverride, (override) => override.flag) + overrides: TenantOverride[]; +} diff --git a/src/modules/feature-flags/entities/index.ts b/src/modules/feature-flags/entities/index.ts new file mode 100644 index 0000000..c76811f --- /dev/null +++ b/src/modules/feature-flags/entities/index.ts @@ -0,0 +1,3 @@ +export { Flag } from './flag.entity'; +export { TenantOverride } from './tenant-override.entity'; +export { FlagEvaluation } from './flag-evaluation.entity'; diff --git a/src/modules/feature-flags/entities/tenant-override.entity.ts b/src/modules/feature-flags/entities/tenant-override.entity.ts new file mode 100644 index 0000000..eb65066 --- /dev/null +++ b/src/modules/feature-flags/entities/tenant-override.entity.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Flag } from './flag.entity'; + +@Entity({ name: 'tenant_overrides', schema: 'feature_flags' }) +@Unique(['flagId', 'tenantId']) +export class TenantOverride { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'flag_id', type: 'uuid' }) + flagId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'enabled', type: 'boolean' }) + enabled: boolean; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason: string; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => Flag, (flag) => flag.overrides, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'flag_id' }) + flag: Flag; +} diff --git a/src/modules/feature-flags/feature-flags.controller.ts b/src/modules/feature-flags/feature-flags.controller.ts new file mode 100644 index 0000000..15cab88 --- /dev/null +++ b/src/modules/feature-flags/feature-flags.controller.ts @@ -0,0 +1,65 @@ +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../../shared/types/index'; +import { featureFlagsService } from './services/feature-flags.instance'; + +export const featureFlagsController = { + /** + * Get all flags (Admin only ideally) + */ + async getAllFlags(req: AuthRequest, res: Response, next: NextFunction) { + try { + const flags = await featureFlagsService.findAllFlagsIncludingInactive(); + res.json({ + success: true, + data: flags, + }); + } catch (error) { + next(error); + } + }, + + /** + * Evaluate flags for current tenant + */ + async evaluateFlags(req: AuthRequest, res: Response, next: NextFunction) { + try { + const { codes } = req.body; + if (!Array.isArray(codes)) { + throw new Error('Codes must be an array of strings'); + } + + const results = await featureFlagsService.evaluateFlags(codes, req.user!.tenantId); + res.json({ + success: true, + data: results, + }); + } catch (error) { + next(error); + } + }, + + /** + * Create or Update an override for a tenant + */ + async upsertOverride(req: AuthRequest, res: Response, next: NextFunction) { + try { + const { flagId, tenantId, enabled, expiresAt } = req.body; + + const existing = await featureFlagsService.findOverride(flagId, tenantId); + let result; + + if (existing) { + result = await featureFlagsService.updateOverride(existing.id, { enabled, expiresAt }); + } else { + result = await featureFlagsService.createOverride({ flagId, tenantId, enabled, expiresAt }, req.user!.userId); + } + + res.json({ + success: true, + data: result, + }); + } catch (error) { + next(error); + } + } +}; diff --git a/src/modules/feature-flags/feature-flags.module.ts b/src/modules/feature-flags/feature-flags.module.ts new file mode 100644 index 0000000..7d3e09f --- /dev/null +++ b/src/modules/feature-flags/feature-flags.module.ts @@ -0,0 +1,44 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { FeatureFlagsService } from './services'; +import { FeatureFlagsController } from './controllers'; +import { Flag, TenantOverride, FlagEvaluation } from './entities'; + +export interface FeatureFlagsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class FeatureFlagsModule { + public router: Router; + public featureFlagsService: FeatureFlagsService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: FeatureFlagsModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const flagRepository = this.dataSource.getRepository(Flag); + const overrideRepository = this.dataSource.getRepository(TenantOverride); + + this.featureFlagsService = new FeatureFlagsService( + flagRepository, + overrideRepository + ); + } + + private initializeRoutes(): void { + const featureFlagsController = new FeatureFlagsController(this.featureFlagsService); + this.router.use(`${this.basePath}/feature-flags`, featureFlagsController.router); + } + + static getEntities(): Function[] { + return [Flag, TenantOverride, FlagEvaluation]; + } +} diff --git a/src/modules/feature-flags/feature-flags.routes.ts b/src/modules/feature-flags/feature-flags.routes.ts new file mode 100644 index 0000000..e178c2d --- /dev/null +++ b/src/modules/feature-flags/feature-flags.routes.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { featureFlagsController } from './feature-flags.controller'; +import { authMiddleware } from '../../shared/middleware/auth.middleware'; + +const router = Router(); + +router.use(authMiddleware); + +// Public evaluation (for current tenant) +router.post('/evaluate', (req, res, next) => featureFlagsController.evaluateFlags(req, res, next)); + +// Admin routes (should add isSuperuser or permission check) +router.get('/', (req, res, next) => featureFlagsController.getAllFlags(req, res, next)); +router.post('/overrides', (req, res, next) => featureFlagsController.upsertOverride(req, res, next)); + +export default router; diff --git a/src/modules/feature-flags/index.ts b/src/modules/feature-flags/index.ts new file mode 100644 index 0000000..2423724 --- /dev/null +++ b/src/modules/feature-flags/index.ts @@ -0,0 +1,5 @@ +export { FeatureFlagsModule, FeatureFlagsModuleOptions } from './feature-flags.module'; +export { FeatureFlagsService } from './services'; +export { FeatureFlagsController } from './controllers'; +export { Flag, TenantOverride } from './entities'; +export * from './dto'; diff --git a/src/modules/feature-flags/middleware/feature-flags.middleware.ts b/src/modules/feature-flags/middleware/feature-flags.middleware.ts new file mode 100644 index 0000000..dd94ea8 --- /dev/null +++ b/src/modules/feature-flags/middleware/feature-flags.middleware.ts @@ -0,0 +1,23 @@ +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../../../shared/types/index'; +import { featureFlagsService } from '../services/feature-flags.instance'; + +export const featureFlagsMiddleware = async (req: AuthRequest, res: Response, next: NextFunction) => { + // Solo evaluamos si hay tenant (request autenticado) + if (!req.user || !req.user.tenantId) { + return next(); + } + + try { + // Inyectamos un objeto helper en el request para chequear flags facilmente en controladores/servicios + (req as any).flags = { + isEnabled: async (code: string) => featureFlagsService.isEnabled(code, req.user!.tenantId), + evaluate: async (code: string) => featureFlagsService.evaluateFlag(code, req.user!.tenantId), + evaluateMany: async (codes: string[]) => featureFlagsService.evaluateFlags(codes, req.user!.tenantId) + }; + } catch (err) { + console.error('Feature flags middleware error', err); + } + + next(); +}; diff --git a/src/modules/feature-flags/services/feature-flags.instance.ts b/src/modules/feature-flags/services/feature-flags.instance.ts new file mode 100644 index 0000000..b33ab90 --- /dev/null +++ b/src/modules/feature-flags/services/feature-flags.instance.ts @@ -0,0 +1,29 @@ +import { AppDataSource } from '../../../main'; +import { Flag, TenantOverride } from '../entities/index'; +import { FeatureFlagsService } from './feature-flags.service'; + +let _featureFlagsService: FeatureFlagsService | null = null; + +function getService(): FeatureFlagsService { + if (!_featureFlagsService) { + _featureFlagsService = new FeatureFlagsService( + AppDataSource.getRepository(Flag), + AppDataSource.getRepository(TenantOverride) + ); + } + return _featureFlagsService; +} + +export const featureFlagsService = { + get instance(): FeatureFlagsService { return getService(); }, + findAllFlags: () => getService().findAllFlags(), + findAllFlagsIncludingInactive: () => getService().findAllFlagsIncludingInactive(), + findFlagById: (id: string) => getService().findFlagById(id), + findFlagByCode: (code: string) => getService().findFlagByCode(code), + evaluateFlag: (code: string, tenantId: string) => getService().evaluateFlag(code, tenantId), + evaluateFlags: (codes: string[], tenantId: string) => getService().evaluateFlags(codes, tenantId), + isEnabled: (code: string, tenantId: string) => getService().isEnabled(code, tenantId), + findOverride: (flagId: string, tenantId: string) => getService().findOverride(flagId, tenantId), + createOverride: (...args: Parameters) => getService().createOverride(...args), + updateOverride: (...args: Parameters) => getService().updateOverride(...args), +}; diff --git a/src/modules/feature-flags/services/feature-flags.service.ts b/src/modules/feature-flags/services/feature-flags.service.ts new file mode 100644 index 0000000..5c8a86d --- /dev/null +++ b/src/modules/feature-flags/services/feature-flags.service.ts @@ -0,0 +1,345 @@ +import { Repository, In } from 'typeorm'; +import { createHash } from 'crypto'; +import { Flag, TenantOverride } from '../entities'; +import { + CreateFlagDto, + UpdateFlagDto, + CreateTenantOverrideDto, + UpdateTenantOverrideDto, + FlagEvaluationResult, +} from '../dto'; + +export class FeatureFlagsService { + constructor( + private readonly flagRepository: Repository, + private readonly overrideRepository: Repository + ) {} + + // ============================================ + // FLAGS - CRUD + // ============================================ + + async findAllFlags(): Promise { + return this.flagRepository.find({ + where: { isActive: true }, + order: { code: 'ASC' }, + }); + } + + async findAllFlagsIncludingInactive(): Promise { + return this.flagRepository.find({ + order: { code: 'ASC' }, + }); + } + + async findFlagById(id: string): Promise { + return this.flagRepository.findOne({ + where: { id }, + relations: ['overrides'], + }); + } + + async findFlagByCode(code: string): Promise { + return this.flagRepository.findOne({ + where: { code }, + relations: ['overrides'], + }); + } + + async findFlagsByTags(tags: string[]): Promise { + return this.flagRepository + .createQueryBuilder('flag') + .where('flag.is_active = true') + .andWhere('flag.tags && :tags', { tags }) + .orderBy('flag.code', 'ASC') + .getMany(); + } + + async createFlag(data: CreateFlagDto, createdBy?: string): Promise { + const flag = this.flagRepository.create({ + ...data, + createdBy, + }); + return this.flagRepository.save(flag); + } + + async updateFlag( + id: string, + data: UpdateFlagDto, + updatedBy?: string + ): Promise { + const flag = await this.flagRepository.findOne({ where: { id } }); + if (!flag) return null; + + Object.assign(flag, data, { updatedBy }); + return this.flagRepository.save(flag); + } + + async deleteFlag(id: string): Promise { + const result = await this.flagRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async softDeleteFlag(id: string, updatedBy?: string): Promise { + return this.updateFlag(id, { isActive: false }, updatedBy); + } + + async toggleFlag(id: string, enabled: boolean, updatedBy?: string): Promise { + return this.updateFlag(id, { enabled }, updatedBy); + } + + // ============================================ + // TENANT OVERRIDES - CRUD + // ============================================ + + async findOverridesForFlag(flagId: string): Promise { + return this.overrideRepository.find({ + where: { flagId }, + order: { createdAt: 'DESC' }, + }); + } + + async findOverridesForTenant(tenantId: string): Promise { + return this.overrideRepository.find({ + where: { tenantId }, + relations: ['flag'], + order: { createdAt: 'DESC' }, + }); + } + + async findOverride(flagId: string, tenantId: string): Promise { + return this.overrideRepository.findOne({ + where: { flagId, tenantId }, + relations: ['flag'], + }); + } + + async findOverrideById(id: string): Promise { + return this.overrideRepository.findOne({ + where: { id }, + relations: ['flag'], + }); + } + + async createOverride( + data: CreateTenantOverrideDto, + createdBy?: string + ): Promise { + const override = this.overrideRepository.create({ + ...data, + createdBy, + }); + return this.overrideRepository.save(override); + } + + async updateOverride( + id: string, + data: UpdateTenantOverrideDto + ): Promise { + const override = await this.overrideRepository.findOne({ where: { id } }); + if (!override) return null; + + Object.assign(override, data); + return this.overrideRepository.save(override); + } + + async deleteOverride(id: string): Promise { + const result = await this.overrideRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async deleteOverrideByFlagAndTenant(flagId: string, tenantId: string): Promise { + const result = await this.overrideRepository.delete({ flagId, tenantId }); + return (result.affected ?? 0) > 0; + } + + // ============================================ + // FLAG EVALUATION + // ============================================ + + /** + * Evaluates a single flag for a tenant. + * Priority: tenant override > global flag > rollout > default (false) + */ + async evaluateFlag(flagCode: string, tenantId: string): Promise { + // 1. Find the flag + const flag = await this.flagRepository.findOne({ + where: { code: flagCode, isActive: true }, + }); + + if (!flag) { + return { code: flagCode, enabled: false, source: 'default' }; + } + + // 2. Check tenant override + const override = await this.overrideRepository.findOne({ + where: { flagId: flag.id, tenantId }, + }); + + if (override) { + // Check if override is expired + if (!override.expiresAt || override.expiresAt > new Date()) { + return { code: flagCode, enabled: override.enabled, source: 'override' }; + } + } + + // 3. Check global flag state + if (!flag.enabled) { + return { code: flagCode, enabled: false, source: 'global' }; + } + + // 4. Evaluate rollout percentage + if (flag.rolloutPercentage >= 100) { + return { code: flagCode, enabled: true, source: 'global' }; + } + + if (flag.rolloutPercentage <= 0) { + return { code: flagCode, enabled: false, source: 'rollout' }; + } + + // 5. Deterministic hash-based rollout + const bucket = this.calculateBucket(flagCode, tenantId); + const enabled = bucket < flag.rolloutPercentage; + + return { code: flagCode, enabled, source: 'rollout' }; + } + + /** + * Evaluates multiple flags for a tenant in a single call. + * More efficient than calling evaluateFlag multiple times. + */ + async evaluateFlags( + flagCodes: string[], + tenantId: string + ): Promise { + // Get all requested flags in one query + const flags = await this.flagRepository.find({ + where: { code: In(flagCodes), isActive: true }, + }); + + const flagMap = new Map(flags.map((f) => [f.code, f])); + + // Get all overrides for this tenant and these flags in one query + const flagIds = flags.map((f) => f.id); + const overrides = await this.overrideRepository.find({ + where: { flagId: In(flagIds), tenantId }, + }); + + const overrideMap = new Map(overrides.map((o) => [o.flagId, o])); + const now = new Date(); + + // Evaluate each flag + return flagCodes.map((code) => { + const flag = flagMap.get(code); + + if (!flag) { + return { code, enabled: false, source: 'default' as const }; + } + + const override = overrideMap.get(flag.id); + + if (override && (!override.expiresAt || override.expiresAt > now)) { + return { code, enabled: override.enabled, source: 'override' as const }; + } + + if (!flag.enabled) { + return { code, enabled: false, source: 'global' as const }; + } + + if (flag.rolloutPercentage >= 100) { + return { code, enabled: true, source: 'global' as const }; + } + + if (flag.rolloutPercentage <= 0) { + return { code, enabled: false, source: 'rollout' as const }; + } + + const bucket = this.calculateBucket(code, tenantId); + const enabled = bucket < flag.rolloutPercentage; + + return { code, enabled, source: 'rollout' as const }; + }); + } + + /** + * Quick boolean check for a single flag. + */ + async isEnabled(flagCode: string, tenantId: string): Promise { + const result = await this.evaluateFlag(flagCode, tenantId); + return result.enabled; + } + + // ============================================ + // MAINTENANCE + // ============================================ + + /** + * Removes expired overrides from the database. + * Should be called periodically via cron job. + */ + async cleanupExpiredOverrides(): Promise { + const now = new Date(); + const result = await this.overrideRepository + .createQueryBuilder() + .delete() + .where('expires_at IS NOT NULL') + .andWhere('expires_at < :now', { now }) + .execute(); + + return result.affected ?? 0; + } + + /** + * Gets statistics for a flag including override counts. + */ + async getFlagStats(flagId: string): Promise<{ + flag: Flag | null; + overrideCount: number; + enabledOverrides: number; + disabledOverrides: number; + }> { + const flag = await this.flagRepository.findOne({ where: { id: flagId } }); + + if (!flag) { + return { + flag: null, + overrideCount: 0, + enabledOverrides: 0, + disabledOverrides: 0, + }; + } + + const now = new Date(); + const overrides = await this.overrideRepository + .createQueryBuilder('o') + .where('o.flag_id = :flagId', { flagId }) + .andWhere('(o.expires_at IS NULL OR o.expires_at > :now)', { now }) + .getMany(); + + const enabledOverrides = overrides.filter((o) => o.enabled).length; + const disabledOverrides = overrides.filter((o) => !o.enabled).length; + + return { + flag, + overrideCount: overrides.length, + enabledOverrides, + disabledOverrides, + }; + } + + // ============================================ + // PRIVATE HELPERS + // ============================================ + + /** + * Calculates a deterministic bucket (0-99) for rollout evaluation. + * Uses MD5 hash of flag code + tenant ID for consistent results. + */ + private calculateBucket(flagCode: string, tenantId: string): number { + const input = `${flagCode}:${tenantId}`; + const hash = createHash('md5').update(input).digest('hex'); + // Take first 8 chars of hash and convert to number + const num = parseInt(hash.substring(0, 8), 16); + return Math.abs(num % 100); + } +} diff --git a/src/modules/feature-flags/services/index.ts b/src/modules/feature-flags/services/index.ts new file mode 100644 index 0000000..4415dc0 --- /dev/null +++ b/src/modules/feature-flags/services/index.ts @@ -0,0 +1 @@ +export { FeatureFlagsService } from './feature-flags.service';