[SYNC] feat: Add audit, MFA, and feature flags modules

- Add audit controller, routes, middleware and services
- Add MFA controller, routes and services
- Add feature flags module with controllers, DTOs, middleware and services
- Update auth, financial, inventory entities
- Update package.json dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 08:02:18 -06:00
parent a4b1b2fd34
commit d94a84593f
40 changed files with 1959 additions and 75 deletions

222
package-lock.json generated
View File

@ -22,7 +22,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.2", "reflect-metadata": "^0.2.2",
"speakeasy": "^2.0.0",
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.28", "typeorm": "^0.3.28",
@ -42,6 +44,8 @@
"@types/morgan": "^1.9.9", "@types/morgan": "^1.9.9",
"@types/node": "^20.10.4", "@types/node": "^20.10.4",
"@types/pg": "^8.10.9", "@types/pg": "^8.10.9",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"@types/swagger-jsdoc": "^6.0.4", "@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8", "@types/swagger-ui-express": "^4.1.8",
"@types/uuid": "^9.0.7", "@types/uuid": "^9.0.7",
@ -2151,6 +2155,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",
@ -2205,6 +2219,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",
@ -2803,6 +2827,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",
@ -3088,7 +3118,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"
@ -3491,6 +3520,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.1", "version": "1.7.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
@ -3587,6 +3625,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",
@ -6420,7 +6464,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"
@ -6477,7 +6520,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"
@ -6734,6 +6776,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",
@ -6874,6 +6925,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.1", "version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
@ -6991,6 +7177,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",
@ -7227,6 +7419,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"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",
@ -7408,6 +7606,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",
@ -8315,6 +8525,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.20", "version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",

View File

@ -28,7 +28,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.2", "reflect-metadata": "^0.2.2",
"speakeasy": "^2.0.0",
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.28", "typeorm": "^0.3.28",
@ -48,6 +50,8 @@
"@types/morgan": "^1.9.9", "@types/morgan": "^1.9.9",
"@types/node": "^20.10.4", "@types/node": "^20.10.4",
"@types/pg": "^8.10.9", "@types/pg": "^8.10.9",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"@types/swagger-jsdoc": "^6.0.4", "@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8", "@types/swagger-ui-express": "^4.1.8",
"@types/uuid": "^9.0.7", "@types/uuid": "^9.0.7",

View File

@ -8,8 +8,12 @@ import { logger } from './shared/utils/logger.js';
import { AppError, ApiResponse } from './shared/types/index.js'; import { AppError, ApiResponse } from './shared/types/index.js';
import { setupSwagger } from './config/swagger.config.js'; import { setupSwagger } from './config/swagger.config.js';
import authRoutes from './modules/auth/auth.routes.js'; import authRoutes from './modules/auth/auth.routes.js';
import mfaRoutes from './modules/auth/mfa.routes.js';
import apiKeysRoutes from './modules/auth/apiKeys.routes.js'; import apiKeysRoutes from './modules/auth/apiKeys.routes.js';
import usersRoutes from './modules/users/users.routes.js'; import usersRoutes from './modules/users/users.routes.js';
import auditRoutes from './modules/audit/audit.routes.js';
import featureFlagsRoutes from './modules/feature-flags/feature-flags.routes.js';
import { featureFlagsMiddleware } from './modules/feature-flags/middleware/feature-flags.middleware.js';
import { tenantsRoutes } from './modules/tenants/index.js'; import { tenantsRoutes } from './modules/tenants/index.js';
import companiesRoutes from './modules/companies/companies.routes.js'; import companiesRoutes from './modules/companies/companies.routes.js';
import coreRoutes from './modules/core/core.routes.js'; import coreRoutes from './modules/core/core.routes.js';
@ -56,8 +60,13 @@ app.get('/health', (_req: Request, res: Response) => {
// API routes - Core // API routes - Core
app.use(`${apiPrefix}/auth`, authRoutes); app.use(`${apiPrefix}/auth`, authRoutes);
app.use(`${apiPrefix}/auth/mfa`, mfaRoutes);
app.use(`${apiPrefix}/auth/api-keys`, apiKeysRoutes); app.use(`${apiPrefix}/auth/api-keys`, apiKeysRoutes);
app.use(`${apiPrefix}/users`, usersRoutes); app.use(`${apiPrefix}/users`, usersRoutes);
app.use(`${apiPrefix}/audit`, auditRoutes);
app.use(`${apiPrefix}/feature-flags`, featureFlagsRoutes);
app.use(featureFlagsMiddleware);
app.use(`${apiPrefix}/tenants`, tenantsRoutes); app.use(`${apiPrefix}/tenants`, tenantsRoutes);
app.use(`${apiPrefix}/companies`, companiesRoutes); app.use(`${apiPrefix}/companies`, companiesRoutes);
app.use(`${apiPrefix}/core`, coreRoutes); app.use(`${apiPrefix}/core`, coreRoutes);

View File

@ -0,0 +1,72 @@
import { Response, NextFunction } from 'express';
import { AuthenticatedRequest, ApiResponse } from '../../shared/types/index.js';
import { auditService } from './services/audit.instance.js';
export const auditController = {
/**
* Get audit logs with filters
*/
async getLogs(req: AuthenticatedRequest, 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) }
);
const response: ApiResponse = {
success: true,
data: result.data,
meta: {
total: result.total,
page: Number(page),
limit: Number(limit),
totalPages: Math.ceil(result.total / Number(limit))
}
};
res.json(response);
} catch (error) {
next(error);
}
},
/**
* Get logs for a specific entity
*/
async getEntityLogs(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const { entityType, entityId } = req.params;
const result = await auditService.findAuditLogsByEntity(
req.user!.tenantId,
entityType,
entityId
);
const response: ApiResponse = {
success: true,
data: result
};
res.json(response);
} catch (error) {
next(error);
}
}
};

View File

@ -0,0 +1,16 @@
import { Router } from 'express';
import { auditController } from './audit.controller.js';
import { authenticate } from '../../shared/middleware/auth.middleware.js';
// import { requirePermission } from '../../shared/middleware/rbac.middleware.js'; // To implement later
const router = Router();
router.use(authenticate);
// 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;

View File

@ -0,0 +1,74 @@
import { Response, NextFunction } from 'express';
import { AuthenticatedRequest } from '../../../shared/types/index.js';
import { auditService } from '../services/audit.instance.js';
import { AuditAction } from '../entities/audit-log.entity.js'; // Asumo que existe el enum
export const auditMiddleware = (action?: string, resourceType?: string) => {
return async (req: AuthenticatedRequest, 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';
}
// Evitar loguear READs masivos si no es necesario (opcional, por ahora logueamos todo lo que pase por este middleware)
// Si el middleware se usa globalmente, filtrar GETs.
// Si se usa por ruta, asumimos que queremos auditar.
// Solo loguear operaciones exitosas o errores críticos?
// Logueamos todo.
if (res.statusCode >= 400) {
// Podríamos loguear fallos con otra acción o flag
}
try {
auditService.createAuditLog(req.user!.tenantId, {
userId: req.user!.userId,
action: derivedAction as any, // Cast to specific enum if needed
resourceType: derivedResource,
resourceId: req.params.id, // Common pattern
ipAddress: req.ip,
userAgent: req.get('User-Agent'),
oldValues: undefined, // Dificil de obtener automaticamente sin hacer query previo
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();
};
};

View File

@ -0,0 +1,21 @@
import { AppDataSource } from '../../../config/typeorm.js';
import {
AuditLog,
EntityChange,
LoginHistory,
SensitiveDataAccess,
DataExport,
PermissionChange,
ConfigChange,
} from '../entities/index.js'; // Ajustar path de entities
import { AuditService } from './audit.service.js';
export const auditService = new AuditService(
AppDataSource.getRepository(AuditLog),
AppDataSource.getRepository(EntityChange),
AppDataSource.getRepository(LoginHistory),
AppDataSource.getRepository(SensitiveDataAccess),
AppDataSource.getRepository(DataExport),
AppDataSource.getRepository(PermissionChange),
AppDataSource.getRepository(ConfigChange)
);

View File

@ -5,10 +5,12 @@ import { User, UserStatus, Role } from './entities/index.js';
import { tokenService, TokenPair, RequestMetadata } from './services/token.service.js'; import { tokenService, TokenPair, RequestMetadata } from './services/token.service.js';
import { UnauthorizedError, ValidationError, NotFoundError } from '../../shared/types/index.js'; import { UnauthorizedError, ValidationError, NotFoundError } from '../../shared/types/index.js';
import { logger } from '../../shared/utils/logger.js'; import { logger } from '../../shared/utils/logger.js';
import { MfaService } from './services/mfa.service.js';
export interface LoginDto { export interface LoginDto {
email: string; email: string;
password: string; password: string;
mfaCode?: string;
metadata?: RequestMetadata; // IP and user agent for session tracking metadata?: RequestMetadata; // IP and user agent for session tracking
} }
@ -70,16 +72,27 @@ class AuthService {
// Verify password // Verify password
const isValidPassword = await bcrypt.compare(dto.password, user.passwordHash || ''); const isValidPassword = await bcrypt.compare(dto.password, user.passwordHash || '');
if (!isValidPassword) { if (!isValidPassword) {
throw new UnauthorizedError('Credenciales inválidas'); throw new UnauthorizedError('Credenciales inválidas');
}
// MFA Verification
if (user.mfaEnabled) {
if (!dto.mfaCode) {
throw new ValidationError('MFA Code Required', [{
code: 'mfa_required',
message: 'Multi-factor authentication code is required',
path: ['mfaCode']
}]);
}
const isMfaValid = await MfaService.verifyMfaCode(user.id, dto.mfaCode);
if (!isMfaValid) {
throw new UnauthorizedError('Invalid MFA code');
}
} }
// Update last login // Update last login
user.lastLoginAt = new Date(); user.lastLoginAt = new Date();
user.loginCount += 1;
if (dto.metadata?.ipAddress) {
user.lastLoginIp = dto.metadata.ipAddress;
}
await this.userRepository.save(user);
// Generate token pair using TokenService // Generate token pair using TokenService
const metadata: RequestMetadata = dto.metadata || { const metadata: RequestMetadata = dto.metadata || {

View File

@ -45,7 +45,7 @@ export class Device {
@Column({ type: 'text', nullable: true, name: 'push_token' }) @Column({ type: 'text', nullable: true, name: 'push_token' })
pushToken: string; pushToken: string;
@Column({ name: 'is_trusted', default: false }) @Column({ type: 'boolean', name: 'is_trusted', default: false })
isTrusted: boolean; isTrusted: boolean;
@Column({ type: 'timestamptz', nullable: true, name: 'last_active_at' }) @Column({ type: 'timestamptz', nullable: true, name: 'last_active_at' })

View File

@ -18,7 +18,7 @@ export class ProfileModule {
@Column({ type: 'varchar', length: 50, nullable: false, name: 'module_code' }) @Column({ type: 'varchar', length: 50, nullable: false, name: 'module_code' })
moduleCode: string; moduleCode: string;
@Column({ name: 'is_enabled', default: true }) @Column({ type: 'boolean', name: 'is_enabled', default: true })
isEnabled: boolean; isEnabled: boolean;
@ManyToOne(() => UserProfile, (p) => p.modules, { onDelete: 'CASCADE' }) @ManyToOne(() => UserProfile, (p) => p.modules, { onDelete: 'CASCADE' })

View File

@ -18,16 +18,16 @@ export class ProfileTool {
@Column({ type: 'varchar', length: 50, nullable: false, name: 'tool_code' }) @Column({ type: 'varchar', length: 50, nullable: false, name: 'tool_code' })
toolCode: string; toolCode: string;
@Column({ name: 'can_view', default: false }) @Column({ type: 'boolean', name: 'can_view', default: false })
canView: boolean; canView: boolean;
@Column({ name: 'can_create', default: false }) @Column({ type: 'boolean', name: 'can_create', default: false })
canCreate: boolean; canCreate: boolean;
@Column({ name: 'can_edit', default: false }) @Column({ type: 'boolean', name: 'can_edit', default: false })
canEdit: boolean; canEdit: boolean;
@Column({ name: 'can_delete', default: false }) @Column({ type: 'boolean', name: 'can_delete', default: false })
canDelete: boolean; canDelete: boolean;
@ManyToOne(() => UserProfile, (p) => p.tools, { onDelete: 'CASCADE' }) @ManyToOne(() => UserProfile, (p) => p.tools, { onDelete: 'CASCADE' })

View File

@ -20,7 +20,7 @@ export class UserProfileAssignment {
@Column({ type: 'uuid', nullable: false, name: 'profile_id' }) @Column({ type: 'uuid', nullable: false, name: 'profile_id' })
profileId: string; profileId: string;
@Column({ name: 'is_default', default: false }) @Column({ type: 'boolean', name: 'is_default', default: false })
isDefault: boolean; isDefault: boolean;
@CreateDateColumn({ name: 'assigned_at', type: 'timestamp' }) @CreateDateColumn({ name: 'assigned_at', type: 'timestamp' })

View File

@ -31,7 +31,7 @@ export class UserProfile {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
description: string; description: string;
@Column({ name: 'is_active', default: true }) @Column({ type: 'boolean', name: 'is_active', default: true })
isActive: boolean; isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp' })

View File

@ -60,10 +60,10 @@ export class User {
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_superuser' }) @Column({ type: 'boolean', default: false, nullable: false, name: 'is_superuser' })
isSuperuser: boolean; isSuperuser: boolean;
@Column({ name: 'is_superadmin', default: false }) @Column({ type: 'boolean', name: 'is_superadmin', default: false })
isSuperadmin: boolean; isSuperadmin: boolean;
@Column({ name: 'mfa_enabled', default: false }) @Column({ type: 'boolean', name: 'mfa_enabled', default: false })
mfaEnabled: boolean; mfaEnabled: boolean;
@Column({ name: 'mfa_secret_encrypted', type: 'text', nullable: true }) @Column({ name: 'mfa_secret_encrypted', type: 'text', nullable: true })
@ -72,10 +72,10 @@ export class User {
@Column({ name: 'mfa_backup_codes', type: 'text', array: true, nullable: true }) @Column({ name: 'mfa_backup_codes', type: 'text', array: true, nullable: true })
mfaBackupCodes: string[]; mfaBackupCodes: string[];
@Column({ name: 'oauth_provider', length: 50, nullable: true }) @Column({ type: 'varchar', name: 'oauth_provider', length: 50, nullable: true })
oauthProvider: string; oauthProvider: string;
@Column({ name: 'oauth_provider_id', length: 255, nullable: true }) @Column({ type: 'varchar', name: 'oauth_provider_id', length: 255, nullable: true })
oauthProviderId: string; oauthProviderId: string;
@Column({ @Column({

View File

@ -0,0 +1,115 @@
import { Request, Response, NextFunction } from 'express';
import { MfaService } from './services/mfa.service.js';
import { ApiResponse, AuthenticatedRequest } from '../../shared/types/index.js';
export const mfaController = {
/**
* Initialize MFA setup
*/
async setup(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId; // Assuming req.user is populated by auth middleware
const result = await MfaService.setupMfa(userId);
const response: ApiResponse = {
success: true,
data: result,
};
res.json(response);
} catch (error) {
next(error);
}
},
/**
* Verify MFA setup and enable
*/
async verifySetup(req: AuthenticatedRequest, 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'); // Should use Zod/Validation middleware ideally
}
const result = await MfaService.verifyMfaSetup(userId, secret, code);
const response: ApiResponse = {
success: true,
message: result.message,
data: { backupCodes: result.backupCodes },
};
res.json(response);
} catch (error) {
next(error);
}
},
/**
* Disable MFA
*/
async disable(req: AuthenticatedRequest, 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);
const response: ApiResponse = {
success: true,
message: result.message,
};
res.json(response);
} catch (error) {
next(error);
}
},
/**
* Get MFA status
*/
async getStatus(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const result = await MfaService.getMfaStatus(userId);
const response: ApiResponse = {
success: true,
data: result,
};
res.json(response);
} catch (error) {
next(error);
}
},
/**
* Regenerate backup codes
*/
async regenerateBackupCodes(req: AuthenticatedRequest, 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);
const response: ApiResponse = {
success: true,
message: result.message,
data: { backupCodes: result.backupCodes },
};
res.json(response);
} catch (error) {
next(error);
}
},
};

View File

@ -0,0 +1,19 @@
import { Router } from 'express';
import { mfaController } from './mfa.controller.js';
import { authenticate } from '../../shared/middleware/auth.middleware.js';
const router = Router();
// All MFA routes require authentication
router.use(authenticate);
// 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;

View File

@ -0,0 +1,322 @@
import speakeasy from 'speakeasy';
import QRCode from 'qrcode';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { AppDataSource } from '../../../config/typeorm.js';
import { User } from '../entities/user.entity';
import { config } from '../../../config';
import { AppError } from '../../../shared/types';
export class MfaService {
private static get userRepository() {
return AppDataSource.getRepository(User);
}
private static get appName() {
return 'ERP Core'; // Or config.appName if available
}
/**
* Initialize MFA setup - generate secret and QR code
*/
static async setupMfa(userId: string) {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new AppError('User not found', 404);
}
if (user.mfaEnabled) {
throw new AppError('MFA is already enabled', 400);
}
// 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) {
throw new AppError('User not found', 404);
}
if (user.mfaEnabled) {
throw new AppError('MFA is already enabled', 400);
}
// 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) {
throw new AppError('Invalid verification code', 400);
}
// 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;
// user.mfaEnabledAt = new Date(); // Field not in entity yet, skipping
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) {
throw new AppError('User not found', 404);
}
if (!user.mfaEnabled || !user.mfaSecretEncrypted) {
throw new AppError('MFA is not enabled for this user', 400);
}
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) {
throw new AppError('User not found', 404);
}
if (!user.mfaEnabled) {
throw new AppError('MFA is not enabled', 400);
}
// Optional: Verify password if provided
if (password) {
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
throw new AppError('Invalid password', 401);
}
}
// Verify MFA code
const isMfaValid = await this.verifyMfaCode(userId, code, code.length > 6);
if (!isMfaValid) {
throw new AppError('Invalid verification code', 400);
}
// Disable MFA
user.mfaEnabled = false;
user.mfaSecretEncrypted = null as any; // TypeORM nullable handling
user.mfaBackupCodes = null as any;
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) {
throw new AppError('User not found', 404);
}
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) {
throw new AppError('User not found', 404);
}
if (!user.mfaEnabled) {
throw new AppError('MFA is not enabled', 400);
}
// Optional: Verify password if provided
if (password) {
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
throw new AppError('Invalid password', 401);
}
}
// Verify MFA code
const isMfaValid = await this.verifyMfaCode(userId, code);
if (!isMfaValid) {
throw new AppError('Invalid verification code', 400);
}
// 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 = config.jwt.secret; // Using JWT secret as fallback
// 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 = config.jwt.secret;
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;
}
}

View 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.

View File

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

View File

@ -0,0 +1 @@
export { FeatureFlagsController } from './feature-flags.controller';

View 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';
}

View File

@ -0,0 +1 @@
export * from './feature-flag.dto';

View File

@ -1,11 +1,3 @@
/**
* FlagEvaluation Entity
* Feature flag evaluation history for analytics
* Compatible with erp-core flag-evaluation.entity
*
* @module FeatureFlags
*/
import { import {
Entity, Entity,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
@ -14,9 +6,15 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { Flag } from './flag.entity'; import { Flag } from './flag.entity.js';
@Entity({ schema: 'feature_flags', name: 'flag_evaluations' }) /**
* 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_flag', ['flagId'])
@Index('idx_flag_evaluations_tenant', ['tenantId']) @Index('idx_flag_evaluations_tenant', ['tenantId'])
@Index('idx_flag_evaluations_date', ['evaluatedAt']) @Index('idx_flag_evaluations_date', ['evaluatedAt'])
@ -48,6 +46,7 @@ export class FlagEvaluation {
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP', name: 'evaluated_at' }) @Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP', name: 'evaluated_at' })
evaluatedAt: Date; evaluatedAt: Date;
// Relaciones
@ManyToOne(() => Flag, { onDelete: 'CASCADE' }) @ManyToOne(() => Flag, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'flag_id' }) @JoinColumn({ name: 'flag_id' })
flag: Flag; flag: Flag;

View File

@ -1,11 +1,3 @@
/**
* Flag Entity
* Feature flag definition with rollout control
* Compatible with erp-core flag.entity
*
* @module FeatureFlags
*/
import { import {
Entity, Entity,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,

View File

@ -1,7 +1,3 @@
/**
* Feature Flags Entities - Export
*/
export { Flag } from './flag.entity'; export { Flag } from './flag.entity';
export { TenantOverride } from './tenant-override.entity'; export { TenantOverride } from './tenant-override.entity';
export { FlagEvaluation } from './flag-evaluation.entity'; export { FlagEvaluation } from './flag-evaluation.entity';

View File

@ -1,11 +1,3 @@
/**
* TenantOverride Entity
* Per-tenant feature flag override
* Compatible with erp-core tenant-override.entity
*
* @module FeatureFlags
*/
import { import {
Entity, Entity,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,

View File

@ -0,0 +1,68 @@
import { Response, NextFunction } from 'express';
import { AuthenticatedRequest, ApiResponse } from '../../shared/types/index.js';
import { featureFlagsService } from './services/feature-flags.instance.js';
export const featureFlagsController = {
/**
* Get all flags (Admin only ideally)
*/
async getAllFlags(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const flags = await featureFlagsService.findAllFlagsIncludingInactive();
const response: ApiResponse = {
success: true,
data: flags,
};
res.json(response);
} catch (error) {
next(error);
}
},
/**
* Evaluate flags for current tenant
*/
async evaluateFlags(req: AuthenticatedRequest, 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);
const response: ApiResponse = {
success: true,
data: results,
};
res.json(response);
} catch (error) {
next(error);
}
},
/**
* Create or Update an override for a tenant
*/
async upsertOverride(req: AuthenticatedRequest, 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);
}
const response: ApiResponse = {
success: true,
data: result,
};
res.json(response);
} catch (error) {
next(error);
}
}
};

View 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];
}
}

View File

@ -0,0 +1,16 @@
import { Router } from 'express';
import { featureFlagsController } from './feature-flags.controller.js';
import { authenticate } from '../../shared/middleware/auth.middleware.js';
const router = Router();
router.use(authenticate);
// 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;

View 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';

View File

@ -0,0 +1,23 @@
import { Response, NextFunction } from 'express';
import { AuthenticatedRequest } from '../../../shared/types/index.js';
import { featureFlagsService } from '../services/feature-flags.instance.js';
export const featureFlagsMiddleware = async (req: AuthenticatedRequest, 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();
};

View File

@ -0,0 +1,8 @@
import { AppDataSource } from '../../../config/typeorm.js';
import { Flag, TenantOverride } from '../entities/index.js';
import { FeatureFlagsService } from './feature-flags.service.js';
export const featureFlagsService = new FeatureFlagsService(
AppDataSource.getRepository(Flag),
AppDataSource.getRepository(TenantOverride)
);

View 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);
}
}

View File

@ -0,0 +1 @@
export { FeatureFlagsService } from './feature-flags.service';

View File

@ -7,7 +7,12 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { FiscalYear, FiscalPeriodStatus } from './fiscal-year.entity.js'; import { FiscalYear } from './fiscal-year.entity.js';
export enum FiscalPeriodStatus {
OPEN = 'open',
CLOSED = 'closed',
}
@Entity({ schema: 'financial', name: 'fiscal_periods' }) @Entity({ schema: 'financial', name: 'fiscal_periods' })
@Index('idx_fiscal_periods_tenant_id', ['tenantId']) @Index('idx_fiscal_periods_tenant_id', ['tenantId'])

View File

@ -9,12 +9,7 @@ import {
OneToMany, OneToMany,
} from 'typeorm'; } from 'typeorm';
import { Company } from '../../auth/entities/company.entity.js'; import { Company } from '../../auth/entities/company.entity.js';
import { FiscalPeriod } from './fiscal-period.entity.js'; import { FiscalPeriod, FiscalPeriodStatus } from './fiscal-period.entity.js';
export enum FiscalPeriodStatus {
OPEN = 'open',
CLOSED = 'closed',
}
@Entity({ schema: 'financial', name: 'fiscal_years' }) @Entity({ schema: 'financial', name: 'fiscal_years' })
@Index('idx_fiscal_years_tenant_id', ['tenantId']) @Index('idx_fiscal_years_tenant_id', ['tenantId'])

View File

@ -19,5 +19,5 @@ export { Payment, PaymentType, PaymentMethod, PaymentStatus } from './payment.en
export { Tax, TaxType } from './tax.entity.js'; export { Tax, TaxType } from './tax.entity.js';
// Fiscal period entities // Fiscal period entities
export { FiscalYear, FiscalPeriodStatus } from './fiscal-year.entity.js'; export { FiscalYear } from './fiscal-year.entity.js';
export { FiscalPeriod } from './fiscal-period.entity.js'; export { FiscalPeriod, FiscalPeriodStatus } from './fiscal-period.entity.js';

View File

@ -0,0 +1,16 @@
// Inventory module enums - extracted to avoid circular dependencies
export enum PickingType {
INCOMING = 'incoming',
OUTGOING = 'outgoing',
INTERNAL = 'internal',
}
export enum MoveStatus {
DRAFT = 'draft',
WAITING = 'waiting',
CONFIRMED = 'confirmed',
ASSIGNED = 'assigned',
DONE = 'done',
CANCELLED = 'cancelled',
}

View File

@ -12,21 +12,9 @@ import {
import { Company } from '../../auth/entities/company.entity.js'; import { Company } from '../../auth/entities/company.entity.js';
import { Location } from './location.entity.js'; import { Location } from './location.entity.js';
import { StockMove } from './stock-move.entity.js'; import { StockMove } from './stock-move.entity.js';
import { PickingType, MoveStatus } from './enums.js';
export enum PickingType { export { PickingType, MoveStatus };
INCOMING = 'incoming',
OUTGOING = 'outgoing',
INTERNAL = 'internal',
}
export enum MoveStatus {
DRAFT = 'draft',
WAITING = 'waiting',
CONFIRMED = 'confirmed',
ASSIGNED = 'assigned',
DONE = 'done',
CANCELLED = 'cancelled',
}
@Entity({ schema: 'inventory', name: 'pickings' }) @Entity({ schema: 'inventory', name: 'pickings' })
@Index('idx_pickings_tenant_id', ['tenantId']) @Index('idx_pickings_tenant_id', ['tenantId'])

View File

@ -8,10 +8,11 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { Picking, MoveStatus } from './picking.entity.js'; import { Picking } from './picking.entity.js';
import { Product } from './product.entity.js'; import { Product } from './product.entity.js';
import { Location } from './location.entity.js'; import { Location } from './location.entity.js';
import { Lot } from './lot.entity.js'; import { Lot } from './lot.entity.js';
import { MoveStatus } from './enums.js';
@Entity({ schema: 'inventory', name: 'stock_moves' }) @Entity({ schema: 'inventory', name: 'stock_moves' })
@Index('idx_stock_moves_tenant_id', ['tenantId']) @Index('idx_stock_moves_tenant_id', ['tenantId'])