From d94a84593f993f861fb3e55a0cc99fa66004f114 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 08:02:18 -0600 Subject: [PATCH] [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 --- package-lock.json | 222 ++++++++++- package.json | 4 + src/app.ts | 9 + src/modules/audit/audit.controller.ts | 72 ++++ src/modules/audit/audit.routes.ts | 16 + .../audit/middleware/audit.middleware.ts | 74 ++++ src/modules/audit/services/audit.instance.ts | 21 + src/modules/auth/auth.service.ts | 25 +- src/modules/auth/entities/device.entity.ts | 2 +- .../auth/entities/profile-module.entity.ts | 2 +- .../auth/entities/profile-tool.entity.ts | 8 +- .../user-profile-assignment.entity.ts | 2 +- .../auth/entities/user-profile.entity.ts | 2 +- src/modules/auth/entities/user.entity.ts | 8 +- src/modules/auth/mfa.controller.ts | 115 ++++++ src/modules/auth/mfa.routes.ts | 19 + src/modules/auth/services/mfa.service.ts | 322 +++++++++++++++ src/modules/feature-flags/README.md | 87 +++++ .../controllers/feature-flags.controller.ts | 367 ++++++++++++++++++ .../feature-flags/controllers/index.ts | 1 + .../feature-flags/dto/feature-flag.dto.ts | 53 +++ src/modules/feature-flags/dto/index.ts | 1 + .../entities/flag-evaluation.entity.ts | 19 +- .../feature-flags/entities/flag.entity.ts | 8 - src/modules/feature-flags/entities/index.ts | 4 - .../entities/tenant-override.entity.ts | 8 - .../feature-flags/feature-flags.controller.ts | 68 ++++ .../feature-flags/feature-flags.module.ts | 44 +++ .../feature-flags/feature-flags.routes.ts | 16 + src/modules/feature-flags/index.ts | 5 + .../middleware/feature-flags.middleware.ts | 23 ++ .../services/feature-flags.instance.ts | 8 + .../services/feature-flags.service.ts | 345 ++++++++++++++++ src/modules/feature-flags/services/index.ts | 1 + .../entities/fiscal-period.entity.ts | 7 +- .../financial/entities/fiscal-year.entity.ts | 7 +- src/modules/financial/entities/index.ts | 4 +- src/modules/inventory/entities/enums.ts | 16 + .../inventory/entities/picking.entity.ts | 16 +- .../inventory/entities/stock-move.entity.ts | 3 +- 40 files changed, 1959 insertions(+), 75 deletions(-) create mode 100644 src/modules/audit/audit.controller.ts create mode 100644 src/modules/audit/audit.routes.ts create mode 100644 src/modules/audit/middleware/audit.middleware.ts create mode 100644 src/modules/audit/services/audit.instance.ts create mode 100644 src/modules/auth/mfa.controller.ts create mode 100644 src/modules/auth/mfa.routes.ts create mode 100644 src/modules/auth/services/mfa.service.ts create mode 100644 src/modules/feature-flags/README.md create mode 100644 src/modules/feature-flags/controllers/feature-flags.controller.ts create mode 100644 src/modules/feature-flags/controllers/index.ts create mode 100644 src/modules/feature-flags/dto/feature-flag.dto.ts create mode 100644 src/modules/feature-flags/dto/index.ts create mode 100644 src/modules/feature-flags/feature-flags.controller.ts create mode 100644 src/modules/feature-flags/feature-flags.module.ts create mode 100644 src/modules/feature-flags/feature-flags.routes.ts create mode 100644 src/modules/feature-flags/index.ts create mode 100644 src/modules/feature-flags/middleware/feature-flags.middleware.ts create mode 100644 src/modules/feature-flags/services/feature-flags.instance.ts create mode 100644 src/modules/feature-flags/services/feature-flags.service.ts create mode 100644 src/modules/feature-flags/services/index.ts create mode 100644 src/modules/inventory/entities/enums.ts diff --git a/package-lock.json b/package-lock.json index 8c747a0..e2082af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,9 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.11.3", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", + "speakeasy": "^2.0.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.28", @@ -42,6 +44,8 @@ "@types/morgan": "^1.9.9", "@types/node": "^20.10.4", "@types/pg": "^8.10.9", + "@types/qrcode": "^1.5.6", + "@types/speakeasy": "^2.0.10", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "@types/uuid": "^9.0.7", @@ -2151,6 +2155,16 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -2205,6 +2219,16 @@ "@types/node": "*" } }, + "node_modules/@types/speakeasy": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz", + "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -2803,6 +2827,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base32.js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", + "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3088,7 +3118,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -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": { "version": "1.7.1", "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_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -6420,7 +6464,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6477,7 +6520,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6734,6 +6776,15 @@ "node": ">=8" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6874,6 +6925,141 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -6991,6 +7177,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -7227,6 +7419,12 @@ "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": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7408,6 +7606,18 @@ "source-map": "^0.6.0" } }, + "node_modules/speakeasy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", + "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==", + "license": "MIT", + "dependencies": { + "base32.js": "0.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -8315,6 +8525,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", diff --git a/package.json b/package.json index f7403c0..16374db 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.11.3", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", + "speakeasy": "^2.0.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.28", @@ -48,6 +50,8 @@ "@types/morgan": "^1.9.9", "@types/node": "^20.10.4", "@types/pg": "^8.10.9", + "@types/qrcode": "^1.5.6", + "@types/speakeasy": "^2.0.10", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "@types/uuid": "^9.0.7", diff --git a/src/app.ts b/src/app.ts index 86a3dd7..0b68fc1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,8 +8,12 @@ import { logger } from './shared/utils/logger.js'; import { AppError, ApiResponse } from './shared/types/index.js'; import { setupSwagger } from './config/swagger.config.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 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 companiesRoutes from './modules/companies/companies.routes.js'; import coreRoutes from './modules/core/core.routes.js'; @@ -56,8 +60,13 @@ app.get('/health', (_req: Request, res: Response) => { // API routes - Core app.use(`${apiPrefix}/auth`, authRoutes); +app.use(`${apiPrefix}/auth/mfa`, mfaRoutes); app.use(`${apiPrefix}/auth/api-keys`, apiKeysRoutes); 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}/companies`, companiesRoutes); app.use(`${apiPrefix}/core`, coreRoutes); diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts new file mode 100644 index 0000000..e06168f --- /dev/null +++ b/src/modules/audit/audit.controller.ts @@ -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); + } + } +}; diff --git a/src/modules/audit/audit.routes.ts b/src/modules/audit/audit.routes.ts new file mode 100644 index 0000000..fd353dc --- /dev/null +++ b/src/modules/audit/audit.routes.ts @@ -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; diff --git a/src/modules/audit/middleware/audit.middleware.ts b/src/modules/audit/middleware/audit.middleware.ts new file mode 100644 index 0000000..dc71703 --- /dev/null +++ b/src/modules/audit/middleware/audit.middleware.ts @@ -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(); + }; +}; diff --git a/src/modules/audit/services/audit.instance.ts b/src/modules/audit/services/audit.instance.ts new file mode 100644 index 0000000..21d482f --- /dev/null +++ b/src/modules/audit/services/audit.instance.ts @@ -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) +); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 43efe10..9785915 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -5,10 +5,12 @@ import { User, UserStatus, Role } from './entities/index.js'; import { tokenService, TokenPair, RequestMetadata } from './services/token.service.js'; import { UnauthorizedError, ValidationError, NotFoundError } from '../../shared/types/index.js'; import { logger } from '../../shared/utils/logger.js'; +import { MfaService } from './services/mfa.service.js'; export interface LoginDto { email: string; password: string; + mfaCode?: string; metadata?: RequestMetadata; // IP and user agent for session tracking } @@ -70,16 +72,27 @@ class AuthService { // Verify password const isValidPassword = await bcrypt.compare(dto.password, user.passwordHash || ''); 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 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 const metadata: RequestMetadata = dto.metadata || { diff --git a/src/modules/auth/entities/device.entity.ts b/src/modules/auth/entities/device.entity.ts index 16eaeec..6115bf3 100644 --- a/src/modules/auth/entities/device.entity.ts +++ b/src/modules/auth/entities/device.entity.ts @@ -45,7 +45,7 @@ export class Device { @Column({ type: 'text', nullable: true, name: 'push_token' }) pushToken: string; - @Column({ name: 'is_trusted', default: false }) + @Column({ type: 'boolean', name: 'is_trusted', default: false }) isTrusted: boolean; @Column({ type: 'timestamptz', nullable: true, name: 'last_active_at' }) diff --git a/src/modules/auth/entities/profile-module.entity.ts b/src/modules/auth/entities/profile-module.entity.ts index c2a9fc7..bf19a51 100644 --- a/src/modules/auth/entities/profile-module.entity.ts +++ b/src/modules/auth/entities/profile-module.entity.ts @@ -18,7 +18,7 @@ export class ProfileModule { @Column({ type: 'varchar', length: 50, nullable: false, name: 'module_code' }) moduleCode: string; - @Column({ name: 'is_enabled', default: true }) + @Column({ type: 'boolean', name: 'is_enabled', default: true }) isEnabled: boolean; @ManyToOne(() => UserProfile, (p) => p.modules, { onDelete: 'CASCADE' }) diff --git a/src/modules/auth/entities/profile-tool.entity.ts b/src/modules/auth/entities/profile-tool.entity.ts index aa3197b..9f585cb 100644 --- a/src/modules/auth/entities/profile-tool.entity.ts +++ b/src/modules/auth/entities/profile-tool.entity.ts @@ -18,16 +18,16 @@ export class ProfileTool { @Column({ type: 'varchar', length: 50, nullable: false, name: 'tool_code' }) toolCode: string; - @Column({ name: 'can_view', default: false }) + @Column({ type: 'boolean', name: 'can_view', default: false }) canView: boolean; - @Column({ name: 'can_create', default: false }) + @Column({ type: 'boolean', name: 'can_create', default: false }) canCreate: boolean; - @Column({ name: 'can_edit', default: false }) + @Column({ type: 'boolean', name: 'can_edit', default: false }) canEdit: boolean; - @Column({ name: 'can_delete', default: false }) + @Column({ type: 'boolean', name: 'can_delete', default: false }) canDelete: boolean; @ManyToOne(() => UserProfile, (p) => p.tools, { onDelete: 'CASCADE' }) diff --git a/src/modules/auth/entities/user-profile-assignment.entity.ts b/src/modules/auth/entities/user-profile-assignment.entity.ts index 5bbe58b..4e0e978 100644 --- a/src/modules/auth/entities/user-profile-assignment.entity.ts +++ b/src/modules/auth/entities/user-profile-assignment.entity.ts @@ -20,7 +20,7 @@ export class UserProfileAssignment { @Column({ type: 'uuid', nullable: false, name: 'profile_id' }) profileId: string; - @Column({ name: 'is_default', default: false }) + @Column({ type: 'boolean', name: 'is_default', default: false }) isDefault: boolean; @CreateDateColumn({ name: 'assigned_at', type: 'timestamp' }) diff --git a/src/modules/auth/entities/user-profile.entity.ts b/src/modules/auth/entities/user-profile.entity.ts index 400b28f..699c384 100644 --- a/src/modules/auth/entities/user-profile.entity.ts +++ b/src/modules/auth/entities/user-profile.entity.ts @@ -31,7 +31,7 @@ export class UserProfile { @Column({ type: 'text', nullable: true }) description: string; - @Column({ name: 'is_active', default: true }) + @Column({ type: 'boolean', name: 'is_active', default: true }) isActive: boolean; @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) diff --git a/src/modules/auth/entities/user.entity.ts b/src/modules/auth/entities/user.entity.ts index f141dd4..e902d55 100644 --- a/src/modules/auth/entities/user.entity.ts +++ b/src/modules/auth/entities/user.entity.ts @@ -60,10 +60,10 @@ export class User { @Column({ type: 'boolean', default: false, nullable: false, name: 'is_superuser' }) isSuperuser: boolean; - @Column({ name: 'is_superadmin', default: false }) + @Column({ type: 'boolean', name: 'is_superadmin', default: false }) isSuperadmin: boolean; - @Column({ name: 'mfa_enabled', default: false }) + @Column({ type: 'boolean', name: 'mfa_enabled', default: false }) mfaEnabled: boolean; @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 }) mfaBackupCodes: string[]; - @Column({ name: 'oauth_provider', length: 50, nullable: true }) + @Column({ type: 'varchar', name: 'oauth_provider', length: 50, nullable: true }) oauthProvider: string; - @Column({ name: 'oauth_provider_id', length: 255, nullable: true }) + @Column({ type: 'varchar', name: 'oauth_provider_id', length: 255, nullable: true }) oauthProviderId: string; @Column({ diff --git a/src/modules/auth/mfa.controller.ts b/src/modules/auth/mfa.controller.ts new file mode 100644 index 0000000..d84c939 --- /dev/null +++ b/src/modules/auth/mfa.controller.ts @@ -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); + } + }, +}; diff --git a/src/modules/auth/mfa.routes.ts b/src/modules/auth/mfa.routes.ts new file mode 100644 index 0000000..34b2787 --- /dev/null +++ b/src/modules/auth/mfa.routes.ts @@ -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; diff --git a/src/modules/auth/services/mfa.service.ts b/src/modules/auth/services/mfa.service.ts new file mode 100644 index 0000000..2b07150 --- /dev/null +++ b/src/modules/auth/services/mfa.service.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/modules/feature-flags/README.md b/src/modules/feature-flags/README.md new file mode 100644 index 0000000..6a22fd9 --- /dev/null +++ b/src/modules/feature-flags/README.md @@ -0,0 +1,87 @@ +# Feature Flags Module + +## Descripcion + +Sistema de feature flags/toggles para control de funcionalidades. Permite habilitar/deshabilitar features globalmente o por tenant, con soporte para rollout gradual basado en porcentajes y overrides temporales con expiracion. + +## Entidades + +| Entidad | Schema | Descripcion | +|---------|--------|-------------| +| `Flag` | feature_flags.flags | Definicion de feature flags con estado global y porcentaje de rollout | +| `TenantOverride` | feature_flags.tenant_overrides | Overrides por tenant con razon y fecha de expiracion | +| `FlagEvaluation` | feature_flags.flag_evaluations | Log de evaluaciones para auditoria | + +## Servicios + +| Servicio | Responsabilidades | +|----------|-------------------| +| `FeatureFlagsService` | CRUD de flags y overrides; evaluacion de flags; rollout deterministico; limpieza de expirados | + +## Endpoints + +| Method | Path | Descripcion | +|--------|------|-------------| +| GET | `/flags` | Lista flags activos | +| GET | `/flags/all` | Lista todos los flags (incluyendo inactivos) | +| GET | `/flags/tags/:tags` | Lista flags por tags | +| GET | `/flags/:id` | Obtiene flag por ID | +| GET | `/flags/code/:code` | Obtiene flag por codigo | +| POST | `/flags` | Crea nuevo flag | +| PATCH | `/flags/:id` | Actualiza flag | +| DELETE | `/flags/:id` | Elimina flag (soft/hard) | +| PATCH | `/flags/:id/toggle` | Activa/desactiva flag | +| GET | `/flags/:id/stats` | Estadisticas de overrides del flag | +| GET | `/flags/:flagId/overrides` | Lista overrides de un flag | +| GET | `/tenants/:tenantId/overrides` | Lista overrides de un tenant | +| GET | `/overrides/:id` | Obtiene override por ID | +| POST | `/overrides` | Crea override | +| PATCH | `/overrides/:id` | Actualiza override | +| DELETE | `/overrides/:id` | Elimina override | +| GET | `/evaluate/:code` | Evalua un flag para tenant | +| POST | `/evaluate` | Evalua multiples flags | +| GET | `/is-enabled/:code` | Check rapido de flag habilitado | +| POST | `/maintenance/cleanup` | Limpia overrides expirados | + +## Dependencias + +- `common` - Utilidades compartidas (crypto para hash) + +## Configuracion + +No requiere configuracion de ambiente. + +## Prioridad de Evaluacion + +1. **Tenant Override** (si existe y no ha expirado) +2. **Estado Global** del flag (enabled/disabled) +3. **Rollout Percentage** (si el flag esta habilitado) +4. **Default** (false si el flag no existe) + +## Rollout Deterministico + +El rollout por porcentaje usa un hash MD5 de `flagCode:tenantId` para asegurar que: +- El mismo tenant siempre obtiene el mismo resultado +- La distribucion es uniforme entre tenants +- No hay necesidad de almacenar el resultado + +```typescript +// Ejemplo: 30% rollout +// Tenants con bucket 0-29 = habilitado +// Tenants con bucket 30-99 = deshabilitado +bucket = MD5(flagCode + tenantId) % 100 +enabled = bucket < rolloutPercentage +``` + +## Fuentes de Evaluacion + +| Fuente | Descripcion | +|--------|-------------| +| `default` | Flag no existe, retorna false | +| `global` | Valor global del flag | +| `override` | Override especifico del tenant | +| `rollout` | Resultado del calculo de rollout | + +## Mantenimiento + +Se recomienda ejecutar `POST /maintenance/cleanup` periodicamente (cron diario) para eliminar overrides expirados. diff --git a/src/modules/feature-flags/controllers/feature-flags.controller.ts b/src/modules/feature-flags/controllers/feature-flags.controller.ts new file mode 100644 index 0000000..13c0e0c --- /dev/null +++ b/src/modules/feature-flags/controllers/feature-flags.controller.ts @@ -0,0 +1,367 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { FeatureFlagsService } from '../services/feature-flags.service'; + +export class FeatureFlagsController { + public router: Router; + + constructor(private readonly featureFlagsService: FeatureFlagsService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Flag CRUD + this.router.get('/flags', this.findAllFlags.bind(this)); + this.router.get('/flags/all', this.findAllFlagsIncludingInactive.bind(this)); + this.router.get('/flags/tags/:tags', this.findFlagsByTags.bind(this)); + this.router.get('/flags/:id', this.findFlagById.bind(this)); + this.router.get('/flags/code/:code', this.findFlagByCode.bind(this)); + this.router.post('/flags', this.createFlag.bind(this)); + this.router.patch('/flags/:id', this.updateFlag.bind(this)); + this.router.delete('/flags/:id', this.deleteFlag.bind(this)); + this.router.patch('/flags/:id/toggle', this.toggleFlag.bind(this)); + this.router.get('/flags/:id/stats', this.getFlagStats.bind(this)); + + // Tenant Overrides + this.router.get('/flags/:flagId/overrides', this.findOverridesForFlag.bind(this)); + this.router.get('/tenants/:tenantId/overrides', this.findOverridesForTenant.bind(this)); + this.router.get('/overrides/:id', this.findOverrideById.bind(this)); + this.router.post('/overrides', this.createOverride.bind(this)); + this.router.patch('/overrides/:id', this.updateOverride.bind(this)); + this.router.delete('/overrides/:id', this.deleteOverride.bind(this)); + + // Evaluation + this.router.get('/evaluate/:code', this.evaluateFlag.bind(this)); + this.router.post('/evaluate', this.evaluateFlags.bind(this)); + this.router.get('/is-enabled/:code', this.isEnabled.bind(this)); + + // Maintenance + this.router.post('/maintenance/cleanup', this.cleanupExpiredOverrides.bind(this)); + } + + // ============================================ + // FLAGS + // ============================================ + + private async findAllFlags(req: Request, res: Response, next: NextFunction): Promise { + try { + const flags = await this.featureFlagsService.findAllFlags(); + res.json({ data: flags, total: flags.length }); + } catch (error) { + next(error); + } + } + + private async findAllFlagsIncludingInactive( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const flags = await this.featureFlagsService.findAllFlagsIncludingInactive(); + res.json({ data: flags, total: flags.length }); + } catch (error) { + next(error); + } + } + + private async findFlagById(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const flag = await this.featureFlagsService.findFlagById(id); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async findFlagByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const flag = await this.featureFlagsService.findFlagByCode(code); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async findFlagsByTags(req: Request, res: Response, next: NextFunction): Promise { + try { + const { tags } = req.params; + const tagList = tags.split(','); + const flags = await this.featureFlagsService.findFlagsByTags(tagList); + res.json({ data: flags, total: flags.length }); + } catch (error) { + next(error); + } + } + + private async createFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = req.headers['x-user-id'] as string; + const flag = await this.featureFlagsService.createFlag(req.body, userId); + res.status(201).json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async updateFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const userId = req.headers['x-user-id'] as string; + const flag = await this.featureFlagsService.updateFlag(id, req.body, userId); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async deleteFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { soft } = req.query; + const userId = req.headers['x-user-id'] as string; + + let result: boolean; + if (soft === 'true') { + const flag = await this.featureFlagsService.softDeleteFlag(id, userId); + result = flag !== null; + } else { + result = await this.featureFlagsService.deleteFlag(id); + } + + if (!result) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async toggleFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { enabled } = req.body; + const userId = req.headers['x-user-id'] as string; + + const flag = await this.featureFlagsService.toggleFlag(id, enabled, userId); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async getFlagStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const stats = await this.featureFlagsService.getFlagStats(id); + + if (!stats.flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + // ============================================ + // OVERRIDES + // ============================================ + + private async findOverridesForFlag( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { flagId } = req.params; + const overrides = await this.featureFlagsService.findOverridesForFlag(flagId); + res.json({ data: overrides, total: overrides.length }); + } catch (error) { + next(error); + } + } + + private async findOverridesForTenant( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { tenantId } = req.params; + const overrides = await this.featureFlagsService.findOverridesForTenant(tenantId); + res.json({ data: overrides, total: overrides.length }); + } catch (error) { + next(error); + } + } + + private async findOverrideById( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { id } = req.params; + const override = await this.featureFlagsService.findOverrideById(id); + + if (!override) { + res.status(404).json({ error: 'Override not found' }); + return; + } + + res.json({ data: override }); + } catch (error) { + next(error); + } + } + + private async createOverride(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = req.headers['x-user-id'] as string; + const override = await this.featureFlagsService.createOverride(req.body, userId); + res.status(201).json({ data: override }); + } catch (error) { + next(error); + } + } + + private async updateOverride(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const override = await this.featureFlagsService.updateOverride(id, req.body); + + if (!override) { + res.status(404).json({ error: 'Override not found' }); + return; + } + + res.json({ data: override }); + } catch (error) { + next(error); + } + } + + private async deleteOverride(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.featureFlagsService.deleteOverride(id); + + if (!deleted) { + res.status(404).json({ error: 'Override not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // EVALUATION + // ============================================ + + private async evaluateFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const tenantId = req.headers['x-tenant-id'] as string || req.query.tenantId as string; + + if (!tenantId) { + res.status(400).json({ error: 'tenantId is required (header x-tenant-id or query param)' }); + return; + } + + const result = await this.featureFlagsService.evaluateFlag(code, tenantId); + res.json({ data: result }); + } catch (error) { + next(error); + } + } + + private async evaluateFlags(req: Request, res: Response, next: NextFunction): Promise { + try { + const { flagCodes, tenantId } = req.body; + + if (!flagCodes || !Array.isArray(flagCodes)) { + res.status(400).json({ error: 'flagCodes array is required' }); + return; + } + + if (!tenantId) { + res.status(400).json({ error: 'tenantId is required' }); + return; + } + + const results = await this.featureFlagsService.evaluateFlags(flagCodes, tenantId); + res.json({ data: results, total: results.length }); + } catch (error) { + next(error); + } + } + + private async isEnabled(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const tenantId = req.headers['x-tenant-id'] as string || req.query.tenantId as string; + + if (!tenantId) { + res.status(400).json({ error: 'tenantId is required (header x-tenant-id or query param)' }); + return; + } + + const enabled = await this.featureFlagsService.isEnabled(code, tenantId); + res.json({ data: { code, enabled } }); + } catch (error) { + next(error); + } + } + + // ============================================ + // MAINTENANCE + // ============================================ + + private async cleanupExpiredOverrides( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const count = await this.featureFlagsService.cleanupExpiredOverrides(); + res.json({ data: { cleanedUp: count } }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/feature-flags/controllers/index.ts b/src/modules/feature-flags/controllers/index.ts new file mode 100644 index 0000000..56046b6 --- /dev/null +++ b/src/modules/feature-flags/controllers/index.ts @@ -0,0 +1 @@ +export { FeatureFlagsController } from './feature-flags.controller'; diff --git a/src/modules/feature-flags/dto/feature-flag.dto.ts b/src/modules/feature-flags/dto/feature-flag.dto.ts new file mode 100644 index 0000000..cc395f5 --- /dev/null +++ b/src/modules/feature-flags/dto/feature-flag.dto.ts @@ -0,0 +1,53 @@ +// ===================================================== +// DTOs: Feature Flags +// Modulo: MGN-019 +// Version: 1.0.0 +// ===================================================== + +export interface CreateFlagDto { + code: string; + name: string; + description?: string; + enabled?: boolean; + rolloutPercentage?: number; + tags?: string[]; +} + +export interface UpdateFlagDto { + name?: string; + description?: string; + enabled?: boolean; + rolloutPercentage?: number; + tags?: string[]; + isActive?: boolean; +} + +export interface CreateTenantOverrideDto { + flagId: string; + tenantId: string; + enabled: boolean; + reason?: string; + expiresAt?: Date; +} + +export interface UpdateTenantOverrideDto { + enabled?: boolean; + reason?: string; + expiresAt?: Date | null; +} + +export interface EvaluateFlagDto { + flagCode: string; + tenantId: string; +} + +export interface EvaluateFlagsDto { + flagCodes: string[]; + tenantId: string; +} + +export interface FlagEvaluationResult { + code: string; + enabled: boolean; + source: 'override' | 'global' | 'rollout' | 'default'; +} diff --git a/src/modules/feature-flags/dto/index.ts b/src/modules/feature-flags/dto/index.ts new file mode 100644 index 0000000..8cba2ff --- /dev/null +++ b/src/modules/feature-flags/dto/index.ts @@ -0,0 +1 @@ +export * from './feature-flag.dto'; diff --git a/src/modules/feature-flags/entities/flag-evaluation.entity.ts b/src/modules/feature-flags/entities/flag-evaluation.entity.ts index 3f8e1b8..27a0218 100644 --- a/src/modules/feature-flags/entities/flag-evaluation.entity.ts +++ b/src/modules/feature-flags/entities/flag-evaluation.entity.ts @@ -1,11 +1,3 @@ -/** - * FlagEvaluation Entity - * Feature flag evaluation history for analytics - * Compatible with erp-core flag-evaluation.entity - * - * @module FeatureFlags - */ - import { Entity, PrimaryGeneratedColumn, @@ -14,9 +6,15 @@ import { ManyToOne, JoinColumn, } 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_tenant', ['tenantId']) @Index('idx_flag_evaluations_date', ['evaluatedAt']) @@ -48,6 +46,7 @@ export class FlagEvaluation { @Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP', name: 'evaluated_at' }) evaluatedAt: Date; + // Relaciones @ManyToOne(() => Flag, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'flag_id' }) flag: Flag; diff --git a/src/modules/feature-flags/entities/flag.entity.ts b/src/modules/feature-flags/entities/flag.entity.ts index 69579de..779b16f 100644 --- a/src/modules/feature-flags/entities/flag.entity.ts +++ b/src/modules/feature-flags/entities/flag.entity.ts @@ -1,11 +1,3 @@ -/** - * Flag Entity - * Feature flag definition with rollout control - * Compatible with erp-core flag.entity - * - * @module FeatureFlags - */ - import { Entity, PrimaryGeneratedColumn, diff --git a/src/modules/feature-flags/entities/index.ts b/src/modules/feature-flags/entities/index.ts index 8cf3637..c76811f 100644 --- a/src/modules/feature-flags/entities/index.ts +++ b/src/modules/feature-flags/entities/index.ts @@ -1,7 +1,3 @@ -/** - * Feature Flags Entities - Export - */ - export { Flag } from './flag.entity'; export { TenantOverride } from './tenant-override.entity'; export { FlagEvaluation } from './flag-evaluation.entity'; diff --git a/src/modules/feature-flags/entities/tenant-override.entity.ts b/src/modules/feature-flags/entities/tenant-override.entity.ts index d4dee9b..eb65066 100644 --- a/src/modules/feature-flags/entities/tenant-override.entity.ts +++ b/src/modules/feature-flags/entities/tenant-override.entity.ts @@ -1,11 +1,3 @@ -/** - * TenantOverride Entity - * Per-tenant feature flag override - * Compatible with erp-core tenant-override.entity - * - * @module FeatureFlags - */ - import { Entity, PrimaryGeneratedColumn, diff --git a/src/modules/feature-flags/feature-flags.controller.ts b/src/modules/feature-flags/feature-flags.controller.ts new file mode 100644 index 0000000..b36e28d --- /dev/null +++ b/src/modules/feature-flags/feature-flags.controller.ts @@ -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); + } + } +}; diff --git a/src/modules/feature-flags/feature-flags.module.ts b/src/modules/feature-flags/feature-flags.module.ts new file mode 100644 index 0000000..7d3e09f --- /dev/null +++ b/src/modules/feature-flags/feature-flags.module.ts @@ -0,0 +1,44 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { FeatureFlagsService } from './services'; +import { FeatureFlagsController } from './controllers'; +import { Flag, TenantOverride, FlagEvaluation } from './entities'; + +export interface FeatureFlagsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class FeatureFlagsModule { + public router: Router; + public featureFlagsService: FeatureFlagsService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: FeatureFlagsModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const flagRepository = this.dataSource.getRepository(Flag); + const overrideRepository = this.dataSource.getRepository(TenantOverride); + + this.featureFlagsService = new FeatureFlagsService( + flagRepository, + overrideRepository + ); + } + + private initializeRoutes(): void { + const featureFlagsController = new FeatureFlagsController(this.featureFlagsService); + this.router.use(`${this.basePath}/feature-flags`, featureFlagsController.router); + } + + static getEntities(): Function[] { + return [Flag, TenantOverride, FlagEvaluation]; + } +} diff --git a/src/modules/feature-flags/feature-flags.routes.ts b/src/modules/feature-flags/feature-flags.routes.ts new file mode 100644 index 0000000..d6332b6 --- /dev/null +++ b/src/modules/feature-flags/feature-flags.routes.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { featureFlagsController } from './feature-flags.controller.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; diff --git a/src/modules/feature-flags/index.ts b/src/modules/feature-flags/index.ts new file mode 100644 index 0000000..2423724 --- /dev/null +++ b/src/modules/feature-flags/index.ts @@ -0,0 +1,5 @@ +export { FeatureFlagsModule, FeatureFlagsModuleOptions } from './feature-flags.module'; +export { FeatureFlagsService } from './services'; +export { FeatureFlagsController } from './controllers'; +export { Flag, TenantOverride } from './entities'; +export * from './dto'; diff --git a/src/modules/feature-flags/middleware/feature-flags.middleware.ts b/src/modules/feature-flags/middleware/feature-flags.middleware.ts new file mode 100644 index 0000000..dc1c9ea --- /dev/null +++ b/src/modules/feature-flags/middleware/feature-flags.middleware.ts @@ -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(); +}; diff --git a/src/modules/feature-flags/services/feature-flags.instance.ts b/src/modules/feature-flags/services/feature-flags.instance.ts new file mode 100644 index 0000000..3117759 --- /dev/null +++ b/src/modules/feature-flags/services/feature-flags.instance.ts @@ -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) +); diff --git a/src/modules/feature-flags/services/feature-flags.service.ts b/src/modules/feature-flags/services/feature-flags.service.ts new file mode 100644 index 0000000..5c8a86d --- /dev/null +++ b/src/modules/feature-flags/services/feature-flags.service.ts @@ -0,0 +1,345 @@ +import { Repository, In } from 'typeorm'; +import { createHash } from 'crypto'; +import { Flag, TenantOverride } from '../entities'; +import { + CreateFlagDto, + UpdateFlagDto, + CreateTenantOverrideDto, + UpdateTenantOverrideDto, + FlagEvaluationResult, +} from '../dto'; + +export class FeatureFlagsService { + constructor( + private readonly flagRepository: Repository, + private readonly overrideRepository: Repository + ) {} + + // ============================================ + // FLAGS - CRUD + // ============================================ + + async findAllFlags(): Promise { + return this.flagRepository.find({ + where: { isActive: true }, + order: { code: 'ASC' }, + }); + } + + async findAllFlagsIncludingInactive(): Promise { + return this.flagRepository.find({ + order: { code: 'ASC' }, + }); + } + + async findFlagById(id: string): Promise { + return this.flagRepository.findOne({ + where: { id }, + relations: ['overrides'], + }); + } + + async findFlagByCode(code: string): Promise { + return this.flagRepository.findOne({ + where: { code }, + relations: ['overrides'], + }); + } + + async findFlagsByTags(tags: string[]): Promise { + return this.flagRepository + .createQueryBuilder('flag') + .where('flag.is_active = true') + .andWhere('flag.tags && :tags', { tags }) + .orderBy('flag.code', 'ASC') + .getMany(); + } + + async createFlag(data: CreateFlagDto, createdBy?: string): Promise { + const flag = this.flagRepository.create({ + ...data, + createdBy, + }); + return this.flagRepository.save(flag); + } + + async updateFlag( + id: string, + data: UpdateFlagDto, + updatedBy?: string + ): Promise { + const flag = await this.flagRepository.findOne({ where: { id } }); + if (!flag) return null; + + Object.assign(flag, data, { updatedBy }); + return this.flagRepository.save(flag); + } + + async deleteFlag(id: string): Promise { + const result = await this.flagRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async softDeleteFlag(id: string, updatedBy?: string): Promise { + return this.updateFlag(id, { isActive: false }, updatedBy); + } + + async toggleFlag(id: string, enabled: boolean, updatedBy?: string): Promise { + return this.updateFlag(id, { enabled }, updatedBy); + } + + // ============================================ + // TENANT OVERRIDES - CRUD + // ============================================ + + async findOverridesForFlag(flagId: string): Promise { + return this.overrideRepository.find({ + where: { flagId }, + order: { createdAt: 'DESC' }, + }); + } + + async findOverridesForTenant(tenantId: string): Promise { + return this.overrideRepository.find({ + where: { tenantId }, + relations: ['flag'], + order: { createdAt: 'DESC' }, + }); + } + + async findOverride(flagId: string, tenantId: string): Promise { + return this.overrideRepository.findOne({ + where: { flagId, tenantId }, + relations: ['flag'], + }); + } + + async findOverrideById(id: string): Promise { + return this.overrideRepository.findOne({ + where: { id }, + relations: ['flag'], + }); + } + + async createOverride( + data: CreateTenantOverrideDto, + createdBy?: string + ): Promise { + const override = this.overrideRepository.create({ + ...data, + createdBy, + }); + return this.overrideRepository.save(override); + } + + async updateOverride( + id: string, + data: UpdateTenantOverrideDto + ): Promise { + const override = await this.overrideRepository.findOne({ where: { id } }); + if (!override) return null; + + Object.assign(override, data); + return this.overrideRepository.save(override); + } + + async deleteOverride(id: string): Promise { + const result = await this.overrideRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async deleteOverrideByFlagAndTenant(flagId: string, tenantId: string): Promise { + const result = await this.overrideRepository.delete({ flagId, tenantId }); + return (result.affected ?? 0) > 0; + } + + // ============================================ + // FLAG EVALUATION + // ============================================ + + /** + * Evaluates a single flag for a tenant. + * Priority: tenant override > global flag > rollout > default (false) + */ + async evaluateFlag(flagCode: string, tenantId: string): Promise { + // 1. Find the flag + const flag = await this.flagRepository.findOne({ + where: { code: flagCode, isActive: true }, + }); + + if (!flag) { + return { code: flagCode, enabled: false, source: 'default' }; + } + + // 2. Check tenant override + const override = await this.overrideRepository.findOne({ + where: { flagId: flag.id, tenantId }, + }); + + if (override) { + // Check if override is expired + if (!override.expiresAt || override.expiresAt > new Date()) { + return { code: flagCode, enabled: override.enabled, source: 'override' }; + } + } + + // 3. Check global flag state + if (!flag.enabled) { + return { code: flagCode, enabled: false, source: 'global' }; + } + + // 4. Evaluate rollout percentage + if (flag.rolloutPercentage >= 100) { + return { code: flagCode, enabled: true, source: 'global' }; + } + + if (flag.rolloutPercentage <= 0) { + return { code: flagCode, enabled: false, source: 'rollout' }; + } + + // 5. Deterministic hash-based rollout + const bucket = this.calculateBucket(flagCode, tenantId); + const enabled = bucket < flag.rolloutPercentage; + + return { code: flagCode, enabled, source: 'rollout' }; + } + + /** + * Evaluates multiple flags for a tenant in a single call. + * More efficient than calling evaluateFlag multiple times. + */ + async evaluateFlags( + flagCodes: string[], + tenantId: string + ): Promise { + // Get all requested flags in one query + const flags = await this.flagRepository.find({ + where: { code: In(flagCodes), isActive: true }, + }); + + const flagMap = new Map(flags.map((f) => [f.code, f])); + + // Get all overrides for this tenant and these flags in one query + const flagIds = flags.map((f) => f.id); + const overrides = await this.overrideRepository.find({ + where: { flagId: In(flagIds), tenantId }, + }); + + const overrideMap = new Map(overrides.map((o) => [o.flagId, o])); + const now = new Date(); + + // Evaluate each flag + return flagCodes.map((code) => { + const flag = flagMap.get(code); + + if (!flag) { + return { code, enabled: false, source: 'default' as const }; + } + + const override = overrideMap.get(flag.id); + + if (override && (!override.expiresAt || override.expiresAt > now)) { + return { code, enabled: override.enabled, source: 'override' as const }; + } + + if (!flag.enabled) { + return { code, enabled: false, source: 'global' as const }; + } + + if (flag.rolloutPercentage >= 100) { + return { code, enabled: true, source: 'global' as const }; + } + + if (flag.rolloutPercentage <= 0) { + return { code, enabled: false, source: 'rollout' as const }; + } + + const bucket = this.calculateBucket(code, tenantId); + const enabled = bucket < flag.rolloutPercentage; + + return { code, enabled, source: 'rollout' as const }; + }); + } + + /** + * Quick boolean check for a single flag. + */ + async isEnabled(flagCode: string, tenantId: string): Promise { + const result = await this.evaluateFlag(flagCode, tenantId); + return result.enabled; + } + + // ============================================ + // MAINTENANCE + // ============================================ + + /** + * Removes expired overrides from the database. + * Should be called periodically via cron job. + */ + async cleanupExpiredOverrides(): Promise { + const now = new Date(); + const result = await this.overrideRepository + .createQueryBuilder() + .delete() + .where('expires_at IS NOT NULL') + .andWhere('expires_at < :now', { now }) + .execute(); + + return result.affected ?? 0; + } + + /** + * Gets statistics for a flag including override counts. + */ + async getFlagStats(flagId: string): Promise<{ + flag: Flag | null; + overrideCount: number; + enabledOverrides: number; + disabledOverrides: number; + }> { + const flag = await this.flagRepository.findOne({ where: { id: flagId } }); + + if (!flag) { + return { + flag: null, + overrideCount: 0, + enabledOverrides: 0, + disabledOverrides: 0, + }; + } + + const now = new Date(); + const overrides = await this.overrideRepository + .createQueryBuilder('o') + .where('o.flag_id = :flagId', { flagId }) + .andWhere('(o.expires_at IS NULL OR o.expires_at > :now)', { now }) + .getMany(); + + const enabledOverrides = overrides.filter((o) => o.enabled).length; + const disabledOverrides = overrides.filter((o) => !o.enabled).length; + + return { + flag, + overrideCount: overrides.length, + enabledOverrides, + disabledOverrides, + }; + } + + // ============================================ + // PRIVATE HELPERS + // ============================================ + + /** + * Calculates a deterministic bucket (0-99) for rollout evaluation. + * Uses MD5 hash of flag code + tenant ID for consistent results. + */ + private calculateBucket(flagCode: string, tenantId: string): number { + const input = `${flagCode}:${tenantId}`; + const hash = createHash('md5').update(input).digest('hex'); + // Take first 8 chars of hash and convert to number + const num = parseInt(hash.substring(0, 8), 16); + return Math.abs(num % 100); + } +} diff --git a/src/modules/feature-flags/services/index.ts b/src/modules/feature-flags/services/index.ts new file mode 100644 index 0000000..4415dc0 --- /dev/null +++ b/src/modules/feature-flags/services/index.ts @@ -0,0 +1 @@ +export { FeatureFlagsService } from './feature-flags.service'; diff --git a/src/modules/financial/entities/fiscal-period.entity.ts b/src/modules/financial/entities/fiscal-period.entity.ts index b3f92a3..7330341 100644 --- a/src/modules/financial/entities/fiscal-period.entity.ts +++ b/src/modules/financial/entities/fiscal-period.entity.ts @@ -7,7 +7,12 @@ import { ManyToOne, JoinColumn, } 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' }) @Index('idx_fiscal_periods_tenant_id', ['tenantId']) diff --git a/src/modules/financial/entities/fiscal-year.entity.ts b/src/modules/financial/entities/fiscal-year.entity.ts index 7a7866e..524401a 100644 --- a/src/modules/financial/entities/fiscal-year.entity.ts +++ b/src/modules/financial/entities/fiscal-year.entity.ts @@ -9,12 +9,7 @@ import { OneToMany, } from 'typeorm'; import { Company } from '../../auth/entities/company.entity.js'; -import { FiscalPeriod } from './fiscal-period.entity.js'; - -export enum FiscalPeriodStatus { - OPEN = 'open', - CLOSED = 'closed', -} +import { FiscalPeriod, FiscalPeriodStatus } from './fiscal-period.entity.js'; @Entity({ schema: 'financial', name: 'fiscal_years' }) @Index('idx_fiscal_years_tenant_id', ['tenantId']) diff --git a/src/modules/financial/entities/index.ts b/src/modules/financial/entities/index.ts index 97af730..85ddedf 100644 --- a/src/modules/financial/entities/index.ts +++ b/src/modules/financial/entities/index.ts @@ -19,5 +19,5 @@ export { Payment, PaymentType, PaymentMethod, PaymentStatus } from './payment.en export { Tax, TaxType } from './tax.entity.js'; // Fiscal period entities -export { FiscalYear, FiscalPeriodStatus } from './fiscal-year.entity.js'; -export { FiscalPeriod } from './fiscal-period.entity.js'; +export { FiscalYear } from './fiscal-year.entity.js'; +export { FiscalPeriod, FiscalPeriodStatus } from './fiscal-period.entity.js'; diff --git a/src/modules/inventory/entities/enums.ts b/src/modules/inventory/entities/enums.ts new file mode 100644 index 0000000..fdabe3d --- /dev/null +++ b/src/modules/inventory/entities/enums.ts @@ -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', +} diff --git a/src/modules/inventory/entities/picking.entity.ts b/src/modules/inventory/entities/picking.entity.ts index 9254b6a..5503ee8 100644 --- a/src/modules/inventory/entities/picking.entity.ts +++ b/src/modules/inventory/entities/picking.entity.ts @@ -12,21 +12,9 @@ import { import { Company } from '../../auth/entities/company.entity.js'; import { Location } from './location.entity.js'; import { StockMove } from './stock-move.entity.js'; +import { PickingType, MoveStatus } from './enums.js'; -export enum PickingType { - INCOMING = 'incoming', - OUTGOING = 'outgoing', - INTERNAL = 'internal', -} - -export enum MoveStatus { - DRAFT = 'draft', - WAITING = 'waiting', - CONFIRMED = 'confirmed', - ASSIGNED = 'assigned', - DONE = 'done', - CANCELLED = 'cancelled', -} +export { PickingType, MoveStatus }; @Entity({ schema: 'inventory', name: 'pickings' }) @Index('idx_pickings_tenant_id', ['tenantId']) diff --git a/src/modules/inventory/entities/stock-move.entity.ts b/src/modules/inventory/entities/stock-move.entity.ts index c6c8988..e548a61 100644 --- a/src/modules/inventory/entities/stock-move.entity.ts +++ b/src/modules/inventory/entities/stock-move.entity.ts @@ -8,10 +8,11 @@ import { ManyToOne, JoinColumn, } from 'typeorm'; -import { Picking, MoveStatus } from './picking.entity.js'; +import { Picking } from './picking.entity.js'; import { Product } from './product.entity.js'; import { Location } from './location.entity.js'; import { Lot } from './lot.entity.js'; +import { MoveStatus } from './enums.js'; @Entity({ schema: 'inventory', name: 'stock_moves' }) @Index('idx_stock_moves_tenant_id', ['tenantId'])