[SYNC] feat: Add audit, MFA, and feature flags modules
- Add audit module with controllers, DTOs, middleware and services - Add MFA controller, routes and services - Add feature flags module with controllers, DTOs and services - Update audit entities with proper TypeORM decorators - Update auth service and DTOs - Update main.ts configuration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e38d3c3864
commit
f9ec80b037
262
package-lock.json
generated
262
package-lock.json
generated
@ -10,6 +10,8 @@
|
|||||||
"license": "PROPRIETARY",
|
"license": "PROPRIETARY",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.3",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
@ -18,7 +20,9 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.1",
|
"reflect-metadata": "^0.2.1",
|
||||||
|
"speakeasy": "^2.0.0",
|
||||||
"typeorm": "^0.3.17",
|
"typeorm": "^0.3.17",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
@ -34,6 +38,8 @@
|
|||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/speakeasy": "^2.0.10",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
@ -1669,6 +1675,16 @@
|
|||||||
"pg-types": "^2.2.0"
|
"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": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||||
@ -1723,6 +1739,16 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/stack-utils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||||
@ -1757,6 +1783,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
@ -2322,6 +2354,12 @@
|
|||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@ -2614,7 +2652,6 @@
|
|||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@ -2729,6 +2766,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cliui": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"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": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz",
|
"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": "^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": {
|
"node_modules/dir-glob": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||||
@ -5310,6 +5379,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@ -5856,7 +5931,6 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@ -5913,7 +5987,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@ -6171,6 +6244,15 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@ -6311,6 +6393,141 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||||
@ -6420,6 +6637,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@ -6735,6 +6958,12 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@ -6916,6 +7145,18 @@
|
|||||||
"source-map": "^0.6.0"
|
"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": {
|
"node_modules/split2": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
@ -7798,6 +8039,15 @@
|
|||||||
"node": ">=10.12.0"
|
"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": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
@ -7832,6 +8082,12 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.19",
|
"version": "1.1.19",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
||||||
|
|||||||
@ -16,6 +16,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.3",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
@ -24,7 +26,9 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.1",
|
"reflect-metadata": "^0.2.1",
|
||||||
|
"speakeasy": "^2.0.0",
|
||||||
"typeorm": "^0.3.17",
|
"typeorm": "^0.3.17",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
@ -40,6 +44,8 @@
|
|||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/speakeasy": "^2.0.10",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
|
|||||||
57
src/main.ts
57
src/main.ts
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Main Entry Point
|
* Main Entry Point
|
||||||
* Mecánicas Diesel Backend - ERP Suite
|
* Mecánicas Diesel Backend - ERP Suite
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
@ -49,6 +49,14 @@ import { createDiagnosisController as createFieldDiagnosisController } from './m
|
|||||||
import { createSyncController } from './modules/field-service/controllers/sync.controller';
|
import { createSyncController } from './modules/field-service/controllers/sync.controller';
|
||||||
import { createCheckinController } from './modules/field-service/controllers/checkin.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
|
// Payment Terminals Module
|
||||||
import { PaymentTerminalsModule } from './modules/payment-terminals';
|
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 { WorkBay } from './modules/service-management/entities/work-bay.entity';
|
||||||
import { Service } from './modules/service-management/entities/service.entity';
|
import { Service } from './modules/service-management/entities/service.entity';
|
||||||
import { Vehicle } from './modules/vehicle-management/entities/vehicle.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 { Fleet } from './modules/vehicle-management/entities/fleet.entity';
|
||||||
import { VehicleEngine } from './modules/vehicle-management/entities/vehicle-engine.entity';
|
import { VehicleEngine } from './modules/vehicle-management/entities/vehicle-engine.entity';
|
||||||
import { EngineCatalog } from './modules/vehicle-management/entities/engine-catalog.entity';
|
import { EngineCatalog } from './modules/vehicle-management/entities/engine-catalog.entity';
|
||||||
@ -148,6 +157,7 @@ const AppDataSource = new DataSource({
|
|||||||
Service,
|
Service,
|
||||||
// Vehicle Management
|
// Vehicle Management
|
||||||
Vehicle,
|
Vehicle,
|
||||||
|
VehicleDocument,
|
||||||
Fleet,
|
Fleet,
|
||||||
VehicleEngine,
|
VehicleEngine,
|
||||||
EngineCatalog,
|
EngineCatalog,
|
||||||
@ -196,8 +206,19 @@ const AppDataSource = new DataSource({
|
|||||||
FieldEvidence,
|
FieldEvidence,
|
||||||
FieldCheckin,
|
FieldCheckin,
|
||||||
OfflineQueueItem,
|
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',
|
logging: process.env.NODE_ENV === 'development',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -228,10 +249,16 @@ async function bootstrap() {
|
|||||||
try {
|
try {
|
||||||
// Initialize database connection
|
// Initialize database connection
|
||||||
await AppDataSource.initialize();
|
await AppDataSource.initialize();
|
||||||
console.log('📦 Database connection established');
|
console.log('📦 Database connection established');
|
||||||
|
|
||||||
// Register API routes
|
// Register API routes
|
||||||
app.use('/api/v1/auth', createAuthController(AppDataSource));
|
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/users', createUsersController(AppDataSource));
|
||||||
app.use('/api/v1/service-orders', createServiceOrderController(AppDataSource));
|
app.use('/api/v1/service-orders', createServiceOrderController(AppDataSource));
|
||||||
app.use('/api/v1/quotes', createQuoteController(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/positions', createGpsPositionController(AppDataSource));
|
||||||
app.use('/api/v1/gps/geofences', createGeofenceController(AppDataSource));
|
app.use('/api/v1/gps/geofences', createGeofenceController(AppDataSource));
|
||||||
app.use('/api/v1/gps/routes', createRouteSegmentController(AppDataSource));
|
app.use('/api/v1/gps/routes', createRouteSegmentController(AppDataSource));
|
||||||
console.log('📡 GPS module initialized');
|
console.log('📡 GPS module initialized');
|
||||||
|
|
||||||
// Assets Module Routes
|
// Assets Module Routes
|
||||||
app.use('/api/v1/assets', createAssetController(AppDataSource));
|
app.use('/api/v1/assets', createAssetController(AppDataSource));
|
||||||
app.use('/api/v1/assets/assignments', createAssetAssignmentController(AppDataSource));
|
app.use('/api/v1/assets/assignments', createAssetAssignmentController(AppDataSource));
|
||||||
app.use('/api/v1/assets/audits', createAssetAuditController(AppDataSource));
|
app.use('/api/v1/assets/audits', createAssetAuditController(AppDataSource));
|
||||||
app.use('/api/v1/assets/maintenance', createAssetMaintenanceController(AppDataSource));
|
app.use('/api/v1/assets/maintenance', createAssetMaintenanceController(AppDataSource));
|
||||||
console.log('📦 Assets module initialized');
|
console.log('📦 Assets module initialized');
|
||||||
|
|
||||||
// Dispatch Module Routes
|
// Dispatch Module Routes
|
||||||
app.use('/api/v1/dispatch', createDispatchController(AppDataSource));
|
app.use('/api/v1/dispatch', createDispatchController(AppDataSource));
|
||||||
app.use('/api/v1/dispatch/skills', createSkillController(AppDataSource));
|
app.use('/api/v1/dispatch/skills', createSkillController(AppDataSource));
|
||||||
app.use('/api/v1/dispatch/shifts', createShiftController(AppDataSource));
|
app.use('/api/v1/dispatch/shifts', createShiftController(AppDataSource));
|
||||||
app.use('/api/v1/dispatch/rules', createRuleController(AppDataSource));
|
app.use('/api/v1/dispatch/rules', createRuleController(AppDataSource));
|
||||||
console.log('📋 Dispatch module initialized');
|
console.log('📋 Dispatch module initialized');
|
||||||
|
|
||||||
// Field Service Module Routes
|
// Field Service Module Routes
|
||||||
app.use('/api/v1/field/checklists', createChecklistController(AppDataSource));
|
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/diagnosis', createFieldDiagnosisController(AppDataSource));
|
||||||
app.use('/api/v1/field/sync', createSyncController(AppDataSource));
|
app.use('/api/v1/field/sync', createSyncController(AppDataSource));
|
||||||
app.use('/api/v1/field/checkins', createCheckinController(AppDataSource));
|
app.use('/api/v1/field/checkins', createCheckinController(AppDataSource));
|
||||||
console.log('📱 Field Service module initialized');
|
console.log('📱 Field Service module initialized');
|
||||||
|
|
||||||
// Payment Terminals Module
|
// Payment Terminals Module
|
||||||
const paymentTerminals = new PaymentTerminalsModule({ dataSource: AppDataSource });
|
const paymentTerminals = new PaymentTerminalsModule({ dataSource: AppDataSource });
|
||||||
app.use('/api/v1', paymentTerminals.router);
|
app.use('/api/v1', paymentTerminals.router);
|
||||||
app.use('/webhooks', paymentTerminals.webhookRouter);
|
app.use('/webhooks', paymentTerminals.webhookRouter);
|
||||||
console.log('💳 Payment Terminals module initialized');
|
console.log('💳 Payment Terminals module initialized');
|
||||||
|
|
||||||
// API documentation endpoint
|
// API documentation endpoint
|
||||||
app.get('/api/v1', (_req, res) => {
|
app.get('/api/v1', (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
name: 'Mecánicas Diesel API',
|
name: 'Mecánicas Diesel API',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
endpoints: {
|
endpoints: {
|
||||||
auth: '/api/v1/auth',
|
auth: '/api/v1/auth',
|
||||||
@ -342,10 +369,10 @@ async function bootstrap() {
|
|||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`🔧 Mecánicas Diesel Backend running on port ${PORT}`);
|
console.log(`🔧 Mecánicas Diesel Backend running on port ${PORT}`);
|
||||||
console.log(`📊 Environment: ${process.env.NODE_ENV || 'development'}`);
|
console.log(`📊 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||||
console.log(`🏥 Health check: http://localhost:${PORT}/health`);
|
console.log(`🥠Health check: http://localhost:${PORT}/health`);
|
||||||
console.log(`📚 API Root: http://localhost:${PORT}/api/v1`);
|
console.log(`📚 API Root: http://localhost:${PORT}/api/v1`);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start server:', error);
|
console.error('Failed to start server:', error);
|
||||||
@ -354,3 +381,5 @@ async function bootstrap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|
||||||
|
export { AppDataSource };
|
||||||
|
|||||||
67
src/modules/audit/audit.controller.ts
Normal file
67
src/modules/audit/audit.controller.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
70
src/modules/audit/audit.module.ts
Normal file
70
src/modules/audit/audit.module.ts
Normal file
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/modules/audit/audit.routes.ts
Normal file
15
src/modules/audit/audit.routes.ts
Normal file
@ -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;
|
||||||
335
src/modules/audit/controllers/audit.controller.ts
Normal file
335
src/modules/audit/controllers/audit.controller.ts
Normal file
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/audit/controllers/index.ts
Normal file
1
src/modules/audit/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AuditController } from './audit.controller';
|
||||||
346
src/modules/audit/dto/audit.dto.ts
Normal file
346
src/modules/audit/dto/audit.dto.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
newValues?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
newData?: Record<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
newValue?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
changeReason?: string;
|
||||||
|
}
|
||||||
10
src/modules/audit/dto/index.ts
Normal file
10
src/modules/audit/dto/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export {
|
||||||
|
CreateAuditLogDto,
|
||||||
|
CreateEntityChangeDto,
|
||||||
|
CreateLoginHistoryDto,
|
||||||
|
CreateSensitiveDataAccessDto,
|
||||||
|
CreateDataExportDto,
|
||||||
|
UpdateDataExportStatusDto,
|
||||||
|
CreatePermissionChangeDto,
|
||||||
|
CreateConfigChangeDto,
|
||||||
|
} from './audit.dto';
|
||||||
@ -1,11 +1,3 @@
|
|||||||
/**
|
|
||||||
* AuditLog Entity
|
|
||||||
* General activity tracking with full request context
|
|
||||||
* Compatible with erp-core audit-log.entity
|
|
||||||
*
|
|
||||||
* @module Audit
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
|
|||||||
@ -1,11 +1,3 @@
|
|||||||
/**
|
|
||||||
* ConfigChange Entity
|
|
||||||
* System configuration change auditing
|
|
||||||
* Compatible with erp-core config-change.entity
|
|
||||||
*
|
|
||||||
* @module Audit
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
|
|||||||
@ -1,11 +1,3 @@
|
|||||||
/**
|
|
||||||
* DataExport Entity
|
|
||||||
* GDPR/reporting data export request management
|
|
||||||
* Compatible with erp-core data-export.entity
|
|
||||||
*
|
|
||||||
* @module Audit
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
|
|||||||
@ -1,11 +1,3 @@
|
|||||||
/**
|
|
||||||
* EntityChange Entity
|
|
||||||
* Data modification versioning and change history
|
|
||||||
* Compatible with erp-core entity-change.entity
|
|
||||||
*
|
|
||||||
* @module Audit
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
/**
|
|
||||||
* Audit Entities - Export
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { AuditLog, AuditAction, AuditCategory, AuditStatus } from './audit-log.entity';
|
export { AuditLog, AuditAction, AuditCategory, AuditStatus } from './audit-log.entity';
|
||||||
export { EntityChange, ChangeType } from './entity-change.entity';
|
export { EntityChange, ChangeType } from './entity-change.entity';
|
||||||
export { LoginHistory, LoginStatus, AuthMethod, MfaMethod } from './login-history.entity';
|
export { LoginHistory, LoginStatus, AuthMethod, MfaMethod } from './login-history.entity';
|
||||||
|
|||||||
@ -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 {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
|
|||||||
@ -1,11 +1,3 @@
|
|||||||
/**
|
|
||||||
* PermissionChange Entity
|
|
||||||
* Access control change auditing
|
|
||||||
* Compatible with erp-core permission-change.entity
|
|
||||||
*
|
|
||||||
* @module Audit
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
|
|||||||
@ -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 {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
|
|||||||
5
src/modules/audit/index.ts
Normal file
5
src/modules/audit/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { AuditModule, AuditModuleOptions } from './audit.module';
|
||||||
|
export * from './entities';
|
||||||
|
export * from './services';
|
||||||
|
export * from './controllers';
|
||||||
|
export * from './dto';
|
||||||
61
src/modules/audit/middleware/audit.middleware.ts
Normal file
61
src/modules/audit/middleware/audit.middleware.ts
Normal file
@ -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();
|
||||||
|
};
|
||||||
|
};
|
||||||
33
src/modules/audit/services/audit.instance.ts
Normal file
33
src/modules/audit/services/audit.instance.ts
Normal file
@ -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['createAuditLog']>) => auditService.instance.createAuditLog(...args),
|
||||||
|
findAuditLogs: (...args: Parameters<AuditService['findAuditLogs']>) => auditService.instance.findAuditLogs(...args),
|
||||||
|
findAuditLogsByEntity: (...args: Parameters<AuditService['findAuditLogsByEntity']>) => auditService.instance.findAuditLogsByEntity(...args),
|
||||||
|
};
|
||||||
303
src/modules/audit/services/audit.service.ts
Normal file
303
src/modules/audit/services/audit.service.ts
Normal file
@ -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<AuditLog>,
|
||||||
|
private readonly entityChangeRepository: Repository<EntityChange>,
|
||||||
|
private readonly loginHistoryRepository: Repository<LoginHistory>,
|
||||||
|
private readonly sensitiveDataAccessRepository: Repository<SensitiveDataAccess>,
|
||||||
|
private readonly dataExportRepository: Repository<DataExport>,
|
||||||
|
private readonly permissionChangeRepository: Repository<PermissionChange>,
|
||||||
|
private readonly configChangeRepository: Repository<ConfigChange>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AUDIT LOGS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async createAuditLog(tenantId: string, data: Partial<AuditLog>): Promise<AuditLog> {
|
||||||
|
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<AuditLog> = { 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<AuditLog[]> {
|
||||||
|
return this.auditLogRepository.find({
|
||||||
|
where: { tenantId, resourceType: entityType, resourceId: entityId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ENTITY CHANGES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async createEntityChange(tenantId: string, data: Partial<EntityChange>): Promise<EntityChange> {
|
||||||
|
const change = this.entityChangeRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
return this.entityChangeRepository.save(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findEntityChanges(
|
||||||
|
tenantId: string,
|
||||||
|
entityType: string,
|
||||||
|
entityId: string
|
||||||
|
): Promise<EntityChange[]> {
|
||||||
|
return this.entityChangeRepository.find({
|
||||||
|
where: { tenantId, entityType, entityId },
|
||||||
|
order: { changedAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEntityVersion(
|
||||||
|
tenantId: string,
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
version: number
|
||||||
|
): Promise<EntityChange | null> {
|
||||||
|
return this.entityChangeRepository.findOne({
|
||||||
|
where: { tenantId, entityType, entityId, version },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LOGIN HISTORY
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async createLoginHistory(data: Partial<LoginHistory>): Promise<LoginHistory> {
|
||||||
|
const login = this.loginHistoryRepository.create(data);
|
||||||
|
return this.loginHistoryRepository.save(login);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLoginHistory(
|
||||||
|
userId: string,
|
||||||
|
tenantId?: string,
|
||||||
|
limit: number = 20
|
||||||
|
): Promise<LoginHistory[]> {
|
||||||
|
const where: FindOptionsWhere<LoginHistory> = { userId };
|
||||||
|
if (tenantId) where.tenantId = tenantId;
|
||||||
|
|
||||||
|
return this.loginHistoryRepository.find({
|
||||||
|
where,
|
||||||
|
order: { attemptedAt: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveSessionsCount(userId: string): Promise<number> {
|
||||||
|
// 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<SensitiveDataAccess>
|
||||||
|
): Promise<SensitiveDataAccess> {
|
||||||
|
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<SensitiveDataAccess[]> {
|
||||||
|
const where: FindOptionsWhere<SensitiveDataAccess> = { 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<DataExport>): Promise<DataExport> {
|
||||||
|
const exportRecord = this.dataExportRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
return this.dataExportRepository.save(exportRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDataExport(id: string): Promise<DataExport | null> {
|
||||||
|
return this.dataExportRepository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserDataExports(tenantId: string, userId: string): Promise<DataExport[]> {
|
||||||
|
return this.dataExportRepository.find({
|
||||||
|
where: { tenantId, userId },
|
||||||
|
order: { requestedAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDataExportStatus(
|
||||||
|
id: string,
|
||||||
|
status: string,
|
||||||
|
updates: Partial<DataExport> = {}
|
||||||
|
): Promise<DataExport | null> {
|
||||||
|
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<PermissionChange>
|
||||||
|
): Promise<PermissionChange> {
|
||||||
|
const change = this.permissionChangeRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
return this.permissionChangeRepository.save(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPermissionChanges(
|
||||||
|
tenantId: string,
|
||||||
|
targetUserId?: string
|
||||||
|
): Promise<PermissionChange[]> {
|
||||||
|
const where: FindOptionsWhere<PermissionChange> = { tenantId };
|
||||||
|
if (targetUserId) where.targetUserId = targetUserId;
|
||||||
|
|
||||||
|
return this.permissionChangeRepository.find({
|
||||||
|
where,
|
||||||
|
order: { changedAt: 'DESC' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONFIG CHANGES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async logConfigChange(tenantId: string, data: Partial<ConfigChange>): Promise<ConfigChange> {
|
||||||
|
const change = this.configChangeRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
return this.configChangeRepository.save(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findConfigChanges(tenantId: string, configType?: string): Promise<ConfigChange[]> {
|
||||||
|
const where: FindOptionsWhere<ConfigChange> = { 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<ConfigChange | null> {
|
||||||
|
return this.configChangeRepository.findOne({
|
||||||
|
where: { tenantId, configKey },
|
||||||
|
order: { changedAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/audit/services/index.ts
Normal file
1
src/modules/audit/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AuditService, AuditLogFilters, PaginationOptions } from './audit.service';
|
||||||
@ -7,8 +7,9 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
// Login Schema
|
// Login Schema
|
||||||
export const loginSchema = z.object({
|
export const loginSchema = z.object({
|
||||||
email: z.string().email('Email inválido'),
|
email: z.string().email('Email inválido'),
|
||||||
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
|
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
|
||||||
|
mfaCode: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LoginDto = z.infer<typeof loginSchema>;
|
export type LoginDto = z.infer<typeof loginSchema>;
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { RefreshToken } from './entities/refresh-token.entity';
|
|||||||
import { Workshop } from './entities/workshop.entity';
|
import { Workshop } from './entities/workshop.entity';
|
||||||
import { LoginDto, RegisterDto, ChangePasswordDto } from './auth.dto';
|
import { LoginDto, RegisterDto, ChangePasswordDto } from './auth.dto';
|
||||||
import { generateAccessToken, generateRefreshToken as generateRefreshJwt, verifyToken } from '../../shared/utils/jwt.utils';
|
import { generateAccessToken, generateRefreshToken as generateRefreshJwt, verifyToken } from '../../shared/utils/jwt.utils';
|
||||||
|
import { MfaService } from './services/mfa.service';
|
||||||
|
|
||||||
const REFRESH_TOKEN_EXPIRES_DAYS = 30;
|
const REFRESH_TOKEN_EXPIRES_DAYS = 30;
|
||||||
|
|
||||||
@ -49,11 +50,22 @@ export class AuthService {
|
|||||||
|
|
||||||
const isValidPassword = await bcrypt.compare(dto.password, user.passwordHash);
|
const isValidPassword = await bcrypt.compare(dto.password, user.passwordHash);
|
||||||
if (!isValidPassword) {
|
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();
|
user.lastLoginAt = new Date();
|
||||||
await this.userRepository.save(user);
|
|
||||||
|
|
||||||
const tokenPayload = {
|
const tokenPayload = {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
@ -51,6 +51,15 @@ export class User {
|
|||||||
@Column({ name: 'email_verified', type: 'boolean', default: false })
|
@Column({ name: 'email_verified', type: 'boolean', default: false })
|
||||||
emailVerified: boolean;
|
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 })
|
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||||
lastLoginAt?: Date;
|
lastLoginAt?: Date;
|
||||||
|
|
||||||
|
|||||||
110
src/modules/auth/mfa.controller.ts
Normal file
110
src/modules/auth/mfa.controller.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
19
src/modules/auth/mfa.routes.ts
Normal file
19
src/modules/auth/mfa.routes.ts
Normal file
@ -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;
|
||||||
354
src/modules/auth/services/mfa.service.ts
Normal file
354
src/modules/auth/services/mfa.service.ts
Normal file
@ -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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/modules/feature-flags/README.md
Normal file
87
src/modules/feature-flags/README.md
Normal file
@ -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.
|
||||||
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const count = await this.featureFlagsService.cleanupExpiredOverrides();
|
||||||
|
res.json({ data: { cleanedUp: count } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/feature-flags/controllers/index.ts
Normal file
1
src/modules/feature-flags/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { FeatureFlagsController } from './feature-flags.controller';
|
||||||
53
src/modules/feature-flags/dto/feature-flag.dto.ts
Normal file
53
src/modules/feature-flags/dto/feature-flag.dto.ts
Normal file
@ -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';
|
||||||
|
}
|
||||||
1
src/modules/feature-flags/dto/index.ts
Normal file
1
src/modules/feature-flags/dto/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './feature-flag.dto';
|
||||||
53
src/modules/feature-flags/entities/flag-evaluation.entity.ts
Normal file
53
src/modules/feature-flags/entities/flag-evaluation.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
57
src/modules/feature-flags/entities/flag.entity.ts
Normal file
57
src/modules/feature-flags/entities/flag.entity.ts
Normal file
@ -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[];
|
||||||
|
}
|
||||||
3
src/modules/feature-flags/entities/index.ts
Normal file
3
src/modules/feature-flags/entities/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { Flag } from './flag.entity';
|
||||||
|
export { TenantOverride } from './tenant-override.entity';
|
||||||
|
export { FlagEvaluation } from './flag-evaluation.entity';
|
||||||
50
src/modules/feature-flags/entities/tenant-override.entity.ts
Normal file
50
src/modules/feature-flags/entities/tenant-override.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
65
src/modules/feature-flags/feature-flags.controller.ts
Normal file
65
src/modules/feature-flags/feature-flags.controller.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
44
src/modules/feature-flags/feature-flags.module.ts
Normal file
44
src/modules/feature-flags/feature-flags.module.ts
Normal file
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/modules/feature-flags/feature-flags.routes.ts
Normal file
16
src/modules/feature-flags/feature-flags.routes.ts
Normal file
@ -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;
|
||||||
5
src/modules/feature-flags/index.ts
Normal file
5
src/modules/feature-flags/index.ts
Normal file
@ -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';
|
||||||
@ -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();
|
||||||
|
};
|
||||||
29
src/modules/feature-flags/services/feature-flags.instance.ts
Normal file
29
src/modules/feature-flags/services/feature-flags.instance.ts
Normal file
@ -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<FeatureFlagsService['createOverride']>) => getService().createOverride(...args),
|
||||||
|
updateOverride: (...args: Parameters<FeatureFlagsService['updateOverride']>) => getService().updateOverride(...args),
|
||||||
|
};
|
||||||
345
src/modules/feature-flags/services/feature-flags.service.ts
Normal file
345
src/modules/feature-flags/services/feature-flags.service.ts
Normal file
@ -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<Flag>,
|
||||||
|
private readonly overrideRepository: Repository<TenantOverride>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// FLAGS - CRUD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async findAllFlags(): Promise<Flag[]> {
|
||||||
|
return this.flagRepository.find({
|
||||||
|
where: { isActive: true },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllFlagsIncludingInactive(): Promise<Flag[]> {
|
||||||
|
return this.flagRepository.find({
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findFlagById(id: string): Promise<Flag | null> {
|
||||||
|
return this.flagRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['overrides'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findFlagByCode(code: string): Promise<Flag | null> {
|
||||||
|
return this.flagRepository.findOne({
|
||||||
|
where: { code },
|
||||||
|
relations: ['overrides'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findFlagsByTags(tags: string[]): Promise<Flag[]> {
|
||||||
|
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<Flag> {
|
||||||
|
const flag = this.flagRepository.create({
|
||||||
|
...data,
|
||||||
|
createdBy,
|
||||||
|
});
|
||||||
|
return this.flagRepository.save(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFlag(
|
||||||
|
id: string,
|
||||||
|
data: UpdateFlagDto,
|
||||||
|
updatedBy?: string
|
||||||
|
): Promise<Flag | null> {
|
||||||
|
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<boolean> {
|
||||||
|
const result = await this.flagRepository.delete(id);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDeleteFlag(id: string, updatedBy?: string): Promise<Flag | null> {
|
||||||
|
return this.updateFlag(id, { isActive: false }, updatedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleFlag(id: string, enabled: boolean, updatedBy?: string): Promise<Flag | null> {
|
||||||
|
return this.updateFlag(id, { enabled }, updatedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TENANT OVERRIDES - CRUD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async findOverridesForFlag(flagId: string): Promise<TenantOverride[]> {
|
||||||
|
return this.overrideRepository.find({
|
||||||
|
where: { flagId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOverridesForTenant(tenantId: string): Promise<TenantOverride[]> {
|
||||||
|
return this.overrideRepository.find({
|
||||||
|
where: { tenantId },
|
||||||
|
relations: ['flag'],
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOverride(flagId: string, tenantId: string): Promise<TenantOverride | null> {
|
||||||
|
return this.overrideRepository.findOne({
|
||||||
|
where: { flagId, tenantId },
|
||||||
|
relations: ['flag'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOverrideById(id: string): Promise<TenantOverride | null> {
|
||||||
|
return this.overrideRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['flag'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOverride(
|
||||||
|
data: CreateTenantOverrideDto,
|
||||||
|
createdBy?: string
|
||||||
|
): Promise<TenantOverride> {
|
||||||
|
const override = this.overrideRepository.create({
|
||||||
|
...data,
|
||||||
|
createdBy,
|
||||||
|
});
|
||||||
|
return this.overrideRepository.save(override);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOverride(
|
||||||
|
id: string,
|
||||||
|
data: UpdateTenantOverrideDto
|
||||||
|
): Promise<TenantOverride | null> {
|
||||||
|
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<boolean> {
|
||||||
|
const result = await this.overrideRepository.delete(id);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOverrideByFlagAndTenant(flagId: string, tenantId: string): Promise<boolean> {
|
||||||
|
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<FlagEvaluationResult> {
|
||||||
|
// 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<FlagEvaluationResult[]> {
|
||||||
|
// 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<boolean> {
|
||||||
|
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<number> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/feature-flags/services/index.ts
Normal file
1
src/modules/feature-flags/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { FeatureFlagsService } from './feature-flags.service';
|
||||||
Loading…
Reference in New Issue
Block a user