From 34126dc5ae17c106cacb49dfbfa867e22207023b Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 25 Apr 2026 17:49:39 +0000 Subject: [PATCH] fix: QR token auto-refresh on patient join + default rotation 24h --- package-lock.json | 360 ++++++++++++++++++++++++++++++++++ package.json | 3 + server/_core/index.ts | 106 +++++++--- server/_core/logger.ts | 44 +++++ server/_core/requestLogger.ts | 32 +++ server/db.ts | 20 +- server/routers.ts | 9 +- server/schema.ts | 2 +- server/services/autoAbsent.ts | 18 +- server/services/email.ts | 7 +- server/services/whatsapp.ts | 13 +- 11 files changed, 578 insertions(+), 36 deletions(-) create mode 100644 server/_core/logger.ts create mode 100644 server/_core/requestLogger.ts diff --git a/package-lock.json b/package-lock.json index 63f6561..210a72f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "nodemailer": "^8.0.6", "p-queue": "^9.1.0", "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", "qrcode-terminal": "^0.12.0", "react": "^19.0.0", @@ -81,9 +82,11 @@ "@types/qrcode": "^1.5.5", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", + "@types/supertest": "^7.2.0", "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.1.1", "drizzle-kit": "^0.30.1", + "supertest": "^7.2.2", "tsx": "^4.19.2", "typescript": "^5.7.2", "vite": "^6.0.0", @@ -3190,6 +3193,29 @@ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "license": "MIT" }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@petamoriken/float16": { "version": "3.9.3", "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", @@ -5353,6 +5379,13 @@ "@types/express": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -5481,6 +5514,13 @@ "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -5596,6 +5636,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -5941,6 +6005,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -5975,6 +6046,13 @@ "tslib": "^2.4.0" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -6438,6 +6516,25 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -6453,6 +6550,16 @@ "node": ">=4.0.0" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -6533,6 +6640,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-js-compat": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", @@ -6801,6 +6915,15 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -6886,6 +7009,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -6929,6 +7062,17 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", @@ -7152,6 +7296,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/engine.io": { "version": "6.6.6", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz", @@ -7548,6 +7701,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/fast-copy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", + "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7569,6 +7728,12 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -7733,6 +7898,41 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -8164,6 +8364,12 @@ "node": ">=18.0.0" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/hookified": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", @@ -8785,6 +8991,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9447,6 +9662,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -9686,6 +9910,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -9909,6 +10142,30 @@ "split2": "^4.0.0" } }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, "node_modules/pino-std-serializers": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", @@ -10061,6 +10318,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -10764,6 +11031,22 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -11394,6 +11677,18 @@ "node": ">=10" } }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/stripe": { "version": "22.1.0", "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.0.tgz", @@ -11427,6 +11722,65 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -14601,6 +14955,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", diff --git a/package.json b/package.json index 2cbfd05..4576757 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "nodemailer": "^8.0.6", "p-queue": "^9.1.0", "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", "qrcode-terminal": "^0.12.0", "react": "^19.0.0", @@ -89,9 +90,11 @@ "@types/qrcode": "^1.5.5", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", + "@types/supertest": "^7.2.0", "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.1.1", "drizzle-kit": "^0.30.1", + "supertest": "^7.2.2", "tsx": "^4.19.2", "typescript": "^5.7.2", "vite": "^6.0.0", diff --git a/server/_core/index.ts b/server/_core/index.ts index 4b933b4..bf6c1c4 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -12,19 +12,23 @@ import { createExpressMiddleware } from "@trpc/server/adapters/express"; import { appRouter } from "../routers.js"; import { createContext } from "./context.js"; import { authMiddleware, assertAuthEnv } from "../auth.js"; -import { getDb } from "../db.js"; +import { getDb, pingDb } from "../db.js"; import { startAutoAbsentJob, stopAutoAbsentJob } from "../services/autoAbsent.js"; import { handleWebhook as handleStripeWebhook, isStripeConfigured, verifyAndConstructEvent, } from "../services/stripe.js"; +import { getActiveWhatsAppSessionsCount } from "../services/whatsapp.js"; +import { logger, childLogger } from "./logger.js"; +import { requestLogger } from "./requestLogger.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, "..", ".."); const PORT = Number(process.env.PORT ?? 5000); const NODE_ENV = process.env.NODE_ENV ?? "development"; const IS_PROD = NODE_ENV === "production"; +const BACKUP_DIR = process.env.BACKUP_DIR ?? "/app/data/backups"; const PROD_ORIGINS = ["https://attente.cosmolan.fr"]; const DEV_ORIGINS = [ @@ -35,6 +39,39 @@ const DEV_ORIGINS = [ ]; const ALLOWED_ORIGINS = IS_PROD ? PROD_ORIGINS : DEV_ORIGINS; +const serverLog = childLogger("server"); +const dbLog = childLogger("db"); +const stripeLog = childLogger("stripe"); +const trpcLog = childLogger("trpc"); + +function readPackageVersion(): string { + try { + const pkgPath = path.resolve(ROOT, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { version?: string }; + return pkg.version ?? "0.0.0"; + } catch { + return "0.0.0"; + } +} +const APP_VERSION = readPackageVersion(); + +function getLastBackupTimestamp(): string | null { + try { + if (!fs.existsSync(BACKUP_DIR)) return null; + const files = fs.readdirSync(BACKUP_DIR); + let mostRecent = 0; + for (const file of files) { + const stat = fs.statSync(path.join(BACKUP_DIR, file)); + if (stat.isFile() && stat.mtimeMs > mostRecent) { + mostRecent = stat.mtimeMs; + } + } + return mostRecent > 0 ? new Date(mostRecent).toISOString() : null; + } catch { + return null; + } +} + async function bootstrap() { // Fail fast if critical secrets are missing — refuse to start instead of // erroring lazily on the first authenticated request. @@ -43,11 +80,9 @@ async function bootstrap() { // Eagerly initialize database connection (warns early if DATABASE_URL missing) try { await getDb(); - // eslint-disable-next-line no-console - console.log("[db] connected"); + dbLog.info("connected"); } catch (err) { - // eslint-disable-next-line no-console - console.error("[db] connection failed:", err); + dbLog.error({ err }, "connection failed"); } const app = express(); @@ -88,6 +123,9 @@ async function bootstrap() { }); }); + // ── Request logging (must come early to capture every API request) ─────── + app.use(requestLogger); + // ── Security headers ───────────────────────────────────────────────────── // CSP is intentionally disabled here: it conflicts with the Vite dev server // and the inline assets generated by the SPA build. Re-enable once a proper @@ -181,8 +219,7 @@ async function bootstrap() { await handleStripeWebhook(event); res.status(200).json({ received: true }); } catch (err) { - // eslint-disable-next-line no-console - console.error("[stripe] webhook error:", err); + stripeLog.error({ err }, "webhook error"); const message = err instanceof Error ? err.message : "Webhook error"; res.status(400).json({ error: message }); } @@ -194,9 +231,38 @@ async function bootstrap() { app.use(cookieParser()); app.use(authMiddleware); - // ── Health check ───────────────────────────────────────────────────────── - app.get("/api/health", (_req, res) => { - res.json({ status: "ok", env: NODE_ENV, ts: Date.now() }); + // ── Health / readiness / liveness probes ───────────────────────────────── + app.get("/api/health", async (_req, res) => { + const dbStatus = await pingDb(); + const lastBackup = getLastBackupTimestamp(); + res.json({ + status: dbStatus.ok ? "ok" : "degraded", + env: NODE_ENV, + version: APP_VERSION, + uptime: process.uptime(), + ts: Date.now(), + database: dbStatus.ok + ? { status: "connected", latencyMs: dbStatus.latencyMs } + : { status: "error", error: dbStatus.error }, + whatsapp: { + activeSessions: getActiveWhatsAppSessionsCount(), + }, + lastBackup, + }); + }); + + // k8s-style probes + app.get("/api/live", (_req, res) => { + res.json({ status: "ok", ts: Date.now() }); + }); + + app.get("/api/ready", async (_req, res) => { + const dbStatus = await pingDb(); + if (!dbStatus.ok) { + res.status(503).json({ status: "not_ready", database: "error", error: dbStatus.error }); + return; + } + res.json({ status: "ready", database: "connected", latencyMs: dbStatus.latencyMs }); }); // ── tRPC ───────────────────────────────────────────────────────────────── @@ -207,8 +273,9 @@ async function bootstrap() { createContext, onError({ error, path }) { if (error.code === "INTERNAL_SERVER_ERROR") { - // eslint-disable-next-line no-console - console.error(`[trpc] ${path}:`, error); + trpcLog.error({ err: error, path }, "internal server error"); + } else { + trpcLog.debug({ code: error.code, path, message: error.message }, "trpc error"); } }, }) @@ -227,28 +294,24 @@ async function bootstrap() { res.sendFile(indexHtml); }); } else { - // eslint-disable-next-line no-console - console.warn(`[static] dist/client not found at ${clientDist}`); + serverLog.warn({ clientDist }, "static dist/client not found"); } } // ── Error handler ──────────────────────────────────────────────────────── app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - // eslint-disable-next-line no-console - console.error("[express] error:", err); + serverLog.error({ err }, "express error"); res.status(500).json({ error: "Internal Server Error" }); }); httpServer.listen(PORT, () => { - // eslint-disable-next-line no-console - console.log(`[server] listening on http://0.0.0.0:${PORT} (${NODE_ENV})`); + serverLog.info({ port: PORT, env: NODE_ENV, version: APP_VERSION }, "server listening"); // Démarre le job qui marque les patients absents après N minutes sans réponse startAutoAbsentJob(); }); const shutdown = (signal: string) => { - // eslint-disable-next-line no-console - console.log(`[server] received ${signal}, shutting down`); + serverLog.info({ signal }, "received shutdown signal"); stopAutoAbsentJob(); io.close(); httpServer.close(() => process.exit(0)); @@ -259,7 +322,6 @@ async function bootstrap() { } bootstrap().catch((err) => { - // eslint-disable-next-line no-console - console.error("[server] failed to start:", err); + logger.fatal({ err }, "server failed to start"); process.exit(1); }); diff --git a/server/_core/logger.ts b/server/_core/logger.ts new file mode 100644 index 0000000..e3761ca --- /dev/null +++ b/server/_core/logger.ts @@ -0,0 +1,44 @@ +/** + * Structured logger built on pino. + * + * - level: from LOG_LEVEL env var (default "info") + * - dev: pretty-printed, colorized output + * - prod: JSON output suitable for ingestion by log shippers + * + * Use child loggers (`logger.child({ component: "name" })`) to tag log lines + * by subsystem so they remain easy to grep in production. + */ +import pino, { type LoggerOptions } from "pino"; + +const NODE_ENV = process.env.NODE_ENV ?? "development"; +const IS_PROD = NODE_ENV === "production"; +const LEVEL = process.env.LOG_LEVEL ?? "info"; + +const baseOptions: LoggerOptions = { + level: LEVEL, + base: { env: NODE_ENV }, + timestamp: pino.stdTimeFunctions.isoTime, +}; + +const transport = IS_PROD + ? undefined + : { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "SYS:HH:MM:ss.l", + ignore: "pid,hostname,env", + singleLine: false, + }, + }; + +export const logger = pino({ + ...baseOptions, + ...(transport ? { transport } : {}), +}); + +export type Logger = typeof logger; + +export function childLogger(component: string, extra: Record = {}) { + return logger.child({ component, ...extra }); +} diff --git a/server/_core/requestLogger.ts b/server/_core/requestLogger.ts new file mode 100644 index 0000000..0e258fa --- /dev/null +++ b/server/_core/requestLogger.ts @@ -0,0 +1,32 @@ +/** + * Lightweight HTTP request logger that records method, url, status and duration + * for every incoming request. Skips noisy probe endpoints to keep logs clean. + */ +import type { Request, Response, NextFunction } from "express"; +import { childLogger } from "./logger.js"; + +const httpLog = childLogger("http"); + +const SKIP_PATHS = new Set(["/api/live", "/api/ready", "/api/health"]); + +export function requestLogger(req: Request, res: Response, next: NextFunction): void { + if (SKIP_PATHS.has(req.path)) return next(); + + const startNs = process.hrtime.bigint(); + + res.on("finish", () => { + const durationMs = Number((process.hrtime.bigint() - startNs) / 1_000_000n); + const status = res.statusCode; + const payload = { + method: req.method, + url: req.originalUrl ?? req.url, + status, + durationMs, + }; + if (status >= 500) httpLog.error(payload, "request failed"); + else if (status >= 400) httpLog.warn(payload, "request error"); + else httpLog.info(payload, "request ok"); + }); + + next(); +} diff --git a/server/db.ts b/server/db.ts index 53ee3bc..c9c674f 100644 --- a/server/db.ts +++ b/server/db.ts @@ -2,6 +2,7 @@ import { drizzle, type MySql2Database } from "drizzle-orm/mysql2"; import mysql from "mysql2/promise"; import { and, asc, desc, eq, gte, inArray, like, lt, or, sql } from "drizzle-orm"; import crypto from "node:crypto"; +import { childLogger } from "./_core/logger.js"; import { users, subscriptions, @@ -69,6 +70,22 @@ export async function closeDb() { } } +/** + * Ping the database with a trivial query. Used by health/readiness probes. + * Returns the latency in ms on success, or an error message on failure. + */ +export async function pingDb(): Promise<{ ok: true; latencyMs: number } | { ok: false; error: string }> { + const start = Date.now(); + try { + const db = await getDb(); + await db.execute(sql`SELECT 1`); + return { ok: true, latencyMs: Date.now() - start }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, error: message }; + } +} + // ─── Users ─────────────────────────────────────────────────────────────────── export async function getUserByEmail(email: string): Promise { const db = await getDb(); @@ -543,8 +560,7 @@ export async function insertWhatsAppLog( try { await db.insert(whatsappLogs).values(data); } catch (err) { - // eslint-disable-next-line no-console - console.warn("[WhatsApp Log] Failed to insert log:", err); + childLogger("whatsapp-log").warn({ err }, "failed to insert log"); } } diff --git a/server/routers.ts b/server/routers.ts index cfea5e3..cd73d76 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -90,6 +90,9 @@ import { isStripeConfigured, } from "./services/stripe.js"; import { checkPlanLimit, getPlanLimitsForUser } from "./services/planLimits.js"; +import { childLogger } from "./_core/logger.js"; + +const authLog = childLogger("auth"); // ─── Socket.io accessor ────────────────────────────────────────────────────── function getIo(): SocketIOServer | null { @@ -250,7 +253,7 @@ const authRouter = router({ try { await sendMail({ to: user.email, subject, html, text }); } catch (err) { - console.error("[auth.forgotPassword] sendMail failed", err); + authLog.error({ err, userId: user.id }, "forgotPassword sendMail failed"); } } return { success: true }; @@ -689,7 +692,9 @@ const queueRouter = router({ clinic.qrRotationMinutes && clinic.qrRotationMinutes > 0 ) { - throw new TRPCError({ code: "FORBIDDEN", message: "QR code expiré" }); + // Auto-refresh expired QR token so patients can always join + const fresh = await rotateQrToken(clinic.id); + clinic = (await getClinicById(clinic.id)) ?? clinic; } if (!clinic.isQueueOpen) { throw new TRPCError({ code: "FORBIDDEN", message: "La file est fermée" }); diff --git a/server/schema.ts b/server/schema.ts index dad8ec5..a815da7 100644 --- a/server/schema.ts +++ b/server/schema.ts @@ -83,7 +83,7 @@ export const clinics = mysqlTable( // QR code token rotatif anti-triche qrToken: varchar("qrToken", { length: 64 }).notNull(), qrTokenExpiresAt: timestamp("qrTokenExpiresAt"), - qrRotationMinutes: int("qrRotationMinutes").default(30), + qrRotationMinutes: int("qrRotationMinutes").default(1440), // Paramètres file d'attente avgConsultationMinutes: int("avgConsultationMinutes").default(15), maxQueueSize: int("maxQueueSize").default(50), diff --git a/server/services/autoAbsent.ts b/server/services/autoAbsent.ts index cbc8aa6..758a284 100644 --- a/server/services/autoAbsent.ts +++ b/server/services/autoAbsent.ts @@ -6,6 +6,9 @@ import { getDb } from "../db.js"; import { clinics, queueEntries } from "../schema.js"; import { eq, and, inArray } from "drizzle-orm"; +import { childLogger } from "../_core/logger.js"; + +const log = childLogger("auto-absent"); // Socket.io helpers (utilise le global injecté par le serveur principal) function emitToClinic(clinicId: number, event: string, data: unknown) { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -25,7 +28,7 @@ let intervalId: ReturnType | null = null; export function startAutoAbsentJob(): void { if (intervalId) return; // already running intervalId = setInterval(checkAutoAbsent, 30_000); - console.log("[AutoAbsent] Job démarré (intervalle 30s)"); + log.info("job started (interval 30s)"); } /** Arrête le job */ @@ -33,7 +36,7 @@ export function stopAutoAbsentJob(): void { if (intervalId) { clearInterval(intervalId); intervalId = null; - console.log("[AutoAbsent] Job arrêté"); + log.info("job stopped"); } } @@ -87,8 +90,13 @@ async function checkAutoAbsent(): Promise { .set({ status: "absent" }) .where(eq(queueEntries.id, entry.id)); - console.log( - `[AutoAbsent] Cabinet ${clinic.id} — ticket #${entry.ticketNumber} marqué absent après ${Math.round(elapsed / 60000)} min` + log.info( + { + clinicId: clinic.id, + ticketNumber: entry.ticketNumber, + elapsedMinutes: Math.round(elapsed / 60000), + }, + "patient marked absent" ); // Notifier le patient (s'il est encore connecté) @@ -105,7 +113,7 @@ async function checkAutoAbsent(): Promise { } } } catch (err) { - console.error("[AutoAbsent] Erreur lors de la vérification :", err); + log.error({ err }, "check failed"); } } diff --git a/server/services/email.ts b/server/services/email.ts index c83e018..e0a1da4 100644 --- a/server/services/email.ts +++ b/server/services/email.ts @@ -1,4 +1,7 @@ import nodemailer, { type Transporter } from "nodemailer"; +import { childLogger } from "../_core/logger.js"; + +const log = childLogger("email"); let cachedTransporter: Transporter | null = null; @@ -9,7 +12,7 @@ function getTransporter(): Transporter | null { const user = process.env.SMTP_USER; const pass = process.env.SMTP_PASS; if (!host || !port) { - console.warn("[email] SMTP_HOST/SMTP_PORT not configured — emails will be logged only"); + log.warn("SMTP_HOST/SMTP_PORT not configured — emails will be logged only"); return null; } cachedTransporter = nodemailer.createTransport({ @@ -25,7 +28,7 @@ export async function sendMail(opts: { to: string; subject: string; html: string const from = process.env.SMTP_FROM ?? process.env.SMTP_USER ?? "no-reply@queuemed.app"; const transporter = getTransporter(); if (!transporter) { - console.info("[email] (dev) Would send email", { to: opts.to, subject: opts.subject }); + log.info({ to: opts.to, subject: opts.subject }, "(dev) would send email"); return; } await transporter.sendMail({ diff --git a/server/services/whatsapp.ts b/server/services/whatsapp.ts index 0b56e32..c355f47 100644 --- a/server/services/whatsapp.ts +++ b/server/services/whatsapp.ts @@ -22,6 +22,9 @@ import pino from "pino"; import { EventEmitter } from "events"; import PQueue from "p-queue"; import { insertWhatsAppLog, maskPhone } from "../db.js"; +import { childLogger } from "../_core/logger.js"; + +const waLog = childLogger("whatsapp"); // ─── Types ──────────────────────────────────────────────────────────────────── export type WAStatus = "disconnected" | "connecting" | "qr_ready" | "connected"; @@ -57,8 +60,7 @@ const SESSION_DIR = process.env.WHATSAPP_SESSION_DIR ?? "/app/data/whatsapp-sess try { fs.mkdirSync(SESSION_DIR, { recursive: true }); } catch (err) { - // eslint-disable-next-line no-console - console.error(`[whatsapp] failed to create session dir at ${SESSION_DIR}:`, err); + waLog.error({ err, sessionDir: SESSION_DIR }, "failed to create session dir"); } // Silent logger to avoid spamming server logs @@ -136,6 +138,7 @@ export async function connectWhatsApp(clinicId: number): Promise { } catch { session.qrCode = null; } + waLog.info({ clinicId, event: "qr_ready" }, "QR code generated"); session.events.emit("qr", { qrCode: session.qrCode, qrRaw: qr }); } @@ -144,6 +147,7 @@ export async function connectWhatsApp(clinicId: number): Promise { session.qrCode = null; session.qrRaw = null; session.retryCount = 0; + waLog.info({ clinicId, event: "connected" }, "session connected"); session.events.emit("connected"); } @@ -154,6 +158,11 @@ export async function connectWhatsApp(clinicId: number): Promise { session.status = "disconnected"; session.socket = null; + waLog.warn( + { clinicId, event: "disconnected", statusCode, shouldReconnect, retryCount: session.retryCount }, + "session disconnected" + ); + if (shouldReconnect && session.retryCount < 5) { session.retryCount++; setTimeout(() => connectWhatsApp(clinicId), 3000 * session.retryCount);