diff --git a/notification-sender/jest.config.cjs b/notification-sender/jest.config.cjs new file mode 100644 index 0000000..c3e754b --- /dev/null +++ b/notification-sender/jest.config.cjs @@ -0,0 +1,20 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"], + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + useESM: false, + tsconfig: { + module: "CommonJS", + moduleResolution: "node", + }, + }, + ], + }, +}; diff --git a/notification-sender/package-lock.json b/notification-sender/package-lock.json index 83325b5..f7a9ea0 100644 --- a/notification-sender/package-lock.json +++ b/notification-sender/package-lock.json @@ -17,12 +17,522 @@ }, "devDependencies": { "@types/axios": "^0.9.36", + "@types/jest": "^30.0.0", "@types/mongoose": "^5.11.96", "@types/node": "^24.3.3", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", "ts-node": "^10.9.2", "typescript": "^5.9.2" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -56,6 +566,503 @@ "kuler": "^2.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.4.1", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jest/test-result": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -93,6 +1100,76 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -121,6 +1198,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/axios": { "version": "0.9.36", "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.9.36.tgz", @@ -128,6 +1216,89 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, "node_modules/@types/mongoose": { "version": "5.11.96", "resolved": "https://registry.npmjs.org/@types/mongoose/-/mongoose-5.11.96.tgz", @@ -148,6 +1319,13 @@ "undici-types": "~7.10.0" } }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -169,6 +1347,343 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -195,6 +1710,98 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansi-styles/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ansi-styles/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -202,6 +1809,16 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -225,6 +1842,192 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/babel-jest": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.4.1", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.4.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.4.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.0.tgz", + "integrity": "sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, "node_modules/bson": { "version": "6.10.4", "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", @@ -234,6 +2037,13 @@ "node": ">=16.20.1" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -247,6 +2057,193 @@ "node": ">= 0.4" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "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" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, "node_modules/color": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", @@ -304,6 +2301,20 @@ "node": ">= 0.8" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -311,6 +2322,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/dayjs": { "version": "1.11.18", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", @@ -334,6 +2360,31 @@ } } }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -343,6 +2394,16 @@ "node": ">=0.4.0" } }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -379,12 +2440,63 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.394", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.394.tgz", + "integrity": "sha512-Wmt2Gm0o8JWBuGgmc4XZ0u9s1RaCRqhxP47phplmfg04+qypTUurpeJGP45A7Fhv7jdrrVH44PLlR9qXo37cVQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/enabled": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", "license": "MIT" }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-ex/node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -430,12 +2542,136 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fn.name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", @@ -462,6 +2698,23 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -478,6 +2731,28 @@ "node": ">= 6" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -487,6 +2762,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -511,6 +2806,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -524,6 +2829,41 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -536,6 +2876,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -575,6 +2954,65 @@ "node": ">= 0.4" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -587,6 +3025,26 @@ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "license": "MIT" }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -599,6 +3057,771 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", + "import-local": "^3.2.0", + "jest-cli": "30.4.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0", + "pretty-format": "30.4.1", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "parse-json": "^5.2.0", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "jest-util": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.4.1", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.4.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/kareem": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", @@ -614,6 +3837,43 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", @@ -631,6 +3891,45 @@ "node": ">= 12.0.0" } }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -638,6 +3937,16 @@ "dev": true, "license": "ISC" }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -653,6 +3962,13 @@ "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", "license": "MIT" }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -674,6 +3990,52 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "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==", + "dev": true, + "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", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mongodb": { "version": "6.18.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz", @@ -779,6 +4141,86 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=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==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/one-time": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", @@ -788,6 +4230,229 @@ "fn.name": "1.x.x" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "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" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "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" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -803,6 +4468,39 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -817,6 +4515,39 @@ "node": ">= 6" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -846,12 +4577,58 @@ "node": ">=10" } }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/sift": { "version": "17.1.3", "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", "license": "MIT" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-swizzle": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", @@ -861,6 +4638,37 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -870,6 +4678,13 @@ "memory-pager": "^1.0.2" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -879,6 +4694,19 @@ "node": "*" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -888,12 +4716,283 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", @@ -915,6 +5014,85 @@ "node": ">= 14.0.0" } }, + "node_modules/ts-jest": { + "version": "29.4.11", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", + "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.0", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -959,6 +5137,37 @@ } } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", @@ -973,6 +5182,20 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", @@ -980,6 +5203,75 @@ "dev": true, "license": "MIT" }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -993,6 +5285,42 @@ "dev": true, "license": "MIT" }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -1015,6 +5343,22 @@ "node": ">=18" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/winston": { "version": "3.17.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", @@ -1051,6 +5395,220 @@ "node": ">= 12.0.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?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==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -1060,6 +5618,19 @@ "engines": { "node": ">=6" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/notification-sender/package.json b/notification-sender/package.json index 3f1ab8f..e9d6b76 100644 --- a/notification-sender/package.json +++ b/notification-sender/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "jest --config jest.config.cjs" }, "keywords": [], "author": "", @@ -14,8 +15,11 @@ "description": "", "devDependencies": { "@types/axios": "^0.9.36", + "@types/jest": "^30.0.0", "@types/mongoose": "^5.11.96", "@types/node": "^24.3.3", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", "ts-node": "^10.9.2", "typescript": "^5.9.2" }, diff --git a/notification-sender/src/Models/Jobs/ChannelDispatchers.ts b/notification-sender/src/Models/Jobs/ChannelDispatchers.ts new file mode 100644 index 0000000..4892de7 --- /dev/null +++ b/notification-sender/src/Models/Jobs/ChannelDispatchers.ts @@ -0,0 +1,185 @@ +import { logDone, logError, logIntent } from "../../config/logger.js"; + +export interface DispatchResult { + success: boolean; + providerResponse?: string; + error?: string; +} + +function formatResponseFailure(endpoint: string, data: unknown): string { + const serialized = JSON.stringify(data); + return `${endpoint} returned success=false${serialized && serialized !== "{}" ? ` data=${serialized}` : " without error details"}`; +} + +function getApiBaseUrl(): string { + const raw = process.env.API_URL || ""; + + if (!raw.trim()) { + return ""; + } + + try { + return new URL(raw).origin; + } catch { + return raw.replace(/\/$/, ""); + } +} + +function buildApiUrl(path: string): string { + return `${getApiBaseUrl()}${path}`; +} + +function formatDispatchError(error: unknown): string { + if (typeof error === "object" && error !== null && "isAxiosError" in error) { + const axiosError = error as { + message?: string; + response?: { status?: number; data?: unknown }; + }; + const details = [ + axiosError.response?.status ? `status=${axiosError.response.status}` : undefined, + axiosError.response?.data !== undefined ? `data=${JSON.stringify(axiosError.response.data)}` : undefined, + axiosError.message ? `message=${axiosError.message}` : undefined, + ].filter(Boolean); + + return details.join(" ") || "Axios request failed without details"; + } + + return error instanceof Error ? error.message : String(error); +} + +export async function dispatchWhatsApp(params: { + companyId: string; + appointmentId: string; + type: string; + systemToken: string; + phoneNumber: string; + message: string; + companyOwnerId: string; +}): Promise { + try { + if (!params.phoneNumber.trim() || !params.message.trim() || !params.companyOwnerId.trim()) { + return { success: false, error: "WhatsApp dispatch requires phoneNumber, message, and companyOwnerId" }; + } + + // Send worker-rendered content directly so cancellation jobs survive appointment deletion. + const axios = (await import("axios")).default; + const response = await axios.post( + buildApiUrl("/notifications/send-wap"), + { + systemToken: params.systemToken, + phoneNumber: params.phoneNumber, + message: params.message, + companyId: params.companyId, + sessionUser: params.companyOwnerId, + }, + { + headers: { "Content-Type": "application/json" }, + } + ); + + logDone(`WhatsApp dispatched for company ${params.companyId}`); + return { success: true, providerResponse: JSON.stringify(response.data) }; + } catch (error) { + const msg = formatDispatchError(error); + logError(`WhatsApp dispatch failed for company ${params.companyId}:`, msg); + return { success: false, error: msg }; + } +} + +export async function dispatchEmail(params: { + appointmentId: string; + systemToken: string; + type: string; + email: string; + subject: string; + message: string; +}): Promise { + try { + if (!params.email.trim() || !params.subject.trim() || !params.message.trim()) { + return { success: false, error: "Email dispatch requires email, subject, and message" }; + } + + // Send worker-rendered content directly so cancellation jobs survive appointment deletion. + const axios = (await import("axios")).default; + const response = await axios.post( + buildApiUrl("/notifications/send-email"), + { + systemToken: params.systemToken, + email: params.email, + subject: params.subject, + message: params.message, + }, + { + headers: { "Content-Type": "application/json" }, + } + ); + + const responseData = response.data as { success?: boolean }; + if (responseData.success === false) { + return { + success: false, + error: formatResponseFailure("notifications/send-email", response.data), + }; + } + + logDone(`Email dispatched to ${params.email}`); + return { success: true, providerResponse: JSON.stringify(response.data) }; + } catch (error) { + const msg = formatDispatchError(error); + logError(`Email dispatch failed for ${params.email}:`, msg); + return { success: false, error: msg }; + } +} + +export async function dispatchSystem(params: { + systemToken: string; + userId: string; + subject: string; + message: string; + companyId?: string; + serviceId?: number; + conversationId?: string; + type?: string; + code?: string; +}): Promise { + try { + if (!params.userId.trim() || !params.subject.trim() || !params.message.trim()) { + return { success: false, error: "System dispatch requires userId, subject, and message" }; + } + + // Delegate to the server API for system notification + const axios = (await import("axios")).default; + const response = await axios.post( + buildApiUrl("/systemnotifications/create"), + { + userId: params.userId, + subject: params.subject, + message: params.message, + companyId: params.companyId, + serviceId: params.serviceId, + conversationId: params.conversationId, + type: params.type, + code: params.code, + systemToken: params.systemToken, + }, + { + headers: { "Content-Type": "application/json" }, + } + ); + + const responseData = response.data as { success?: boolean }; + if (responseData.success === false) { + return { + success: false, + error: formatResponseFailure("systemnotifications/create", response.data), + }; + } + + logDone(`System notification dispatched to user ${params.userId}`); + return { success: true, providerResponse: JSON.stringify(response.data) }; + } catch (error) { + const msg = formatDispatchError(error); + logError(`System notification dispatch failed for user ${params.userId}:`, msg); + return { success: false, error: msg }; + } +} diff --git a/notification-sender/src/Models/Jobs/JobProcessor.ts b/notification-sender/src/Models/Jobs/JobProcessor.ts new file mode 100644 index 0000000..cba2984 --- /dev/null +++ b/notification-sender/src/Models/Jobs/JobProcessor.ts @@ -0,0 +1,228 @@ +import { logDone, logError, logIntent } from "../../config/logger.js"; +import { NotificationJobModel, INotificationJob } from "./NotificationJob.Adapter.Mongoose.js"; +import { WhatsAppThrottleClass } from "./WhatsAppThrottle.js"; +import { dispatchWhatsApp, dispatchEmail, dispatchSystem } from "./ChannelDispatchers.js"; +import { resolveNotificationContent } from "./NotificationContentResolver.js"; + +const POLL_INTERVAL_MS = 5000; +const BASE_RETRY_DELAY_MS = 5000; +const MAX_JITTER_MS = 3000; +const THROTTLE_RETRY_DELAY_MS = 8000; +const SUPPORTED_APPOINTMENT_NOTIFICATION_TYPES = new Set(["creation", "reminder", "cancellation"]); + +function resolveAppointmentNotificationType(type: unknown): string { + return typeof type === "string" && SUPPORTED_APPOINTMENT_NOTIFICATION_TYPES.has(type) ? type : "reminder"; +} + +export function buildPendingJobQuery(now: Date) { + return { + status: "pending", + scheduledAt: { $lte: now }, + $or: [{ nextRetryAt: { $exists: false } }, { nextRetryAt: null }, { nextRetryAt: { $lte: now } }], + }; +} + +export function calculateNextRetryAt(attempt: number, now: Date): Date { + const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt - 1); + const jitter = Math.floor(Math.random() * MAX_JITTER_MS); + return new Date(now.getTime() + delay + jitter); +} + +function getJobId(job: { id?: unknown; _id?: unknown; appointmentId?: unknown; channel?: unknown; get?: (path: string) => unknown }): string { + const value = job.id || job._id || job.get?.("_id"); + if (value) return String(value); + + const appointmentId = job.appointmentId || job.get?.("appointmentId"); + const channel = job.channel || job.get?.("channel"); + return appointmentId || channel ? `unknown appointment=${String(appointmentId || "n/a")} channel=${String(channel || "n/a")}` : "unknown"; +} + +export class JobProcessor { + private jobModel: NotificationJobModel; + private throttle: WhatsAppThrottleClass; + private polling = false; + + constructor() { + this.jobModel = new NotificationJobModel(); + this.throttle = new WhatsAppThrottleClass(); + } + + public async startPolling(): Promise { + if (this.polling) return; + this.polling = true; + logIntent("JobProcessor: starting poll loop"); + this.poll(); + } + + public stopPolling(): void { + this.polling = false; + logIntent("JobProcessor: stopping poll loop"); + } + + private async poll(): Promise { + if (!this.polling) return; + + try { + const now = new Date(); + const pendingJobs = await this.jobModel.notificationJobList + .find(buildPendingJobQuery(now)) + .sort({ scheduledAt: 1 }) + .exec(); + + if (pendingJobs.length > 0) { + logIntent(`JobProcessor: found ${pendingJobs.length} pending jobs`); + await this.processJobs(pendingJobs); + } + } catch (error) { + logError("JobProcessor: poll error", error); + } + + setTimeout(() => this.poll(), POLL_INTERVAL_MS); + } + + private async processJobs(jobs: any[]): Promise { + // Group by companyId + const grouped = new Map(); + for (const job of jobs) { + const companyId = String(job.companyId); + if (!grouped.has(companyId)) { + grouped.set(companyId, []); + } + grouped.get(companyId)!.push(job); + } + + for (const [companyId, companyJobs] of grouped) { + for (const job of companyJobs) { + await this.processJob(job, companyId); + } + } + } + + private async processJob(job: any, companyId: string): Promise { + const now = new Date(); + const jobId = getJobId(job); + + try { + // Check throttle for WhatsApp + if (job.channel === "whatsapp") { + if (!this.throttle.canSend(companyId)) { + const nextRetryAt = new Date(now.getTime() + THROTTLE_RETRY_DELAY_MS); + await this.jobModel.notificationJobList.updateOne( + { _id: job._id }, + { $set: { status: "pending", nextRetryAt } } + ).exec(); + logIntent(`JobProcessor: job ${jobId} throttled WhatsApp for company ${companyId}; retry at ${nextRetryAt.toISOString()}`); + return; + } + } + + const nextAttempt = job.attempts + 1; + await this.jobModel.notificationJobList.updateOne( + { _id: job._id }, + { $set: { status: "processing", attempts: nextAttempt } } + ).exec(); + + const dispatchJob = { + ...job, + _id: job._id || job.get?.("_id"), + appointmentId: job.appointmentId || job.get?.("appointmentId"), + channel: job.channel || job.get?.("channel"), + attempts: nextAttempt, + }; + const content = await resolveNotificationContent(job); + const appointmentNotificationType = resolveAppointmentNotificationType(job.type); + + let result; + switch (job.channel) { + case "whatsapp": + result = await dispatchWhatsApp({ + companyId, + appointmentId: String(job.appointmentId), + type: appointmentNotificationType, + systemToken: `${process.env.API_KEY}`, + phoneNumber: content.phoneNumber || "", + message: content.message, + companyOwnerId: content.companyOwnerId || "", + }); + break; + case "email": + result = await dispatchEmail({ + appointmentId: String(job.appointmentId), + systemToken: `${process.env.API_KEY}`, + type: appointmentNotificationType, + email: content.email || "", + subject: content.subject, + message: content.message, + }); + break; + case "system": + result = await dispatchSystem({ + systemToken: `${process.env.API_KEY}`, + userId: content.userId || "", + subject: content.subject, + message: content.message, + companyId: String(job.companyId), + type: job.type, + code: String(job.appointmentId), + }); + break; + default: + result = { success: false, error: `Unknown channel: ${job.channel}` }; + } + + if (result.success) { + if (job.channel === "whatsapp") { + this.throttle.recordSent(companyId); + } + + await this.jobModel.notificationJobList.updateOne( + { _id: job._id }, + { + $set: { + status: "sent", + sentAt: new Date(), + providerResponse: result.providerResponse, + }, + } + ).exec(); + logDone(`JobProcessor: job ${jobId} sent via ${job.channel}`); + } else { + await this.handleFailure(dispatchJob, result.error || "Unknown error"); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + await this.handleFailure({ ...job, attempts: job.attempts + 1 }, msg); + } + } + + private async handleFailure(job: any, errorMessage: string): Promise { + const now = new Date(); + const jobId = getJobId(job); + + if (job.attempts >= job.maxAttempts) { + await this.jobModel.notificationJobList.updateOne( + { _id: job._id }, + { + $set: { + status: "failed", + lastError: errorMessage, + }, + } + ).exec(); + logError(`JobProcessor: job ${jobId} failed permanently after ${job.attempts} attempts`, errorMessage); + } else { + const nextRetryAt = calculateNextRetryAt(job.attempts, now); + await this.jobModel.notificationJobList.updateOne( + { _id: job._id }, + { + $set: { + status: "pending", + lastError: errorMessage, + nextRetryAt, + }, + } + ).exec(); + logError(`JobProcessor: job ${jobId} failed attempt ${job.attempts}/${job.maxAttempts}; retry at ${nextRetryAt.toISOString()}`, errorMessage); + } + } +} diff --git a/notification-sender/src/Models/Jobs/NotificationContentResolver.ts b/notification-sender/src/Models/Jobs/NotificationContentResolver.ts new file mode 100644 index 0000000..52f8333 --- /dev/null +++ b/notification-sender/src/Models/Jobs/NotificationContentResolver.ts @@ -0,0 +1,191 @@ +import mongoose, { Model } from "mongoose"; +import { INotificationJobDocument, NotificationJobType } from "./NotificationJob.Adapter.Mongoose.js"; + +const { Schema, model, models } = mongoose; + +interface AppointmentDocument { + _id: unknown; + companyId: unknown; + serviceId?: unknown; + clientId?: unknown; + userId?: unknown; + start: Date; +} + +interface ClientDocument { + _id: unknown; + userId?: unknown; + firstName?: string; + lastName?: string; + email?: string; +} + +interface UserDocument { + _id: unknown; + email?: string; + firstName?: string; + lastName?: string; +} + +interface ServiceDocument { + _id: unknown; + name?: string; +} + +interface CompanyDocument { + _id: unknown; + name?: string; + ownerId?: unknown; +} + +export interface ResolvedNotificationContent { + email?: string; + phoneNumber?: string; + userId?: string; + companyOwnerId?: string; + subject: string; + message: string; +} + +const appointmentSchema = new Schema( + { + companyId: { type: Schema.Types.ObjectId, required: true, ref: "Companie" }, + serviceId: { type: Schema.Types.ObjectId, required: false, ref: "Service" }, + clientId: { type: Schema.Types.ObjectId, required: false, ref: "Client" }, + userId: { type: Schema.Types.ObjectId, required: false, ref: "User" }, + start: { type: Date, required: true }, + }, + { strict: false } +); + +const clientSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, required: false, ref: "User" }, + firstName: { type: String, required: false }, + lastName: { type: String, required: false }, + email: { type: String, required: false }, + }, + { strict: false } +); + +const userSchema = new Schema( + { + email: { type: String, required: false }, + firstName: { type: String, required: false }, + lastName: { type: String, required: false }, + }, + { strict: false } +); + +const serviceSchema = new Schema( + { + name: { type: String, required: false }, + }, + { strict: false } +); + +const companySchema = new Schema( + { + name: { type: String, required: false }, + ownerId: { type: Schema.Types.ObjectId, required: false, ref: "User" }, + }, + { strict: false } +); + +const Appointment = (models.Appointment || model("Appointment", appointmentSchema)) as Model; +const Client = (models.Client || model("Client", clientSchema)) as Model; +const User = (models.User || model("User", userSchema)) as Model; +const Service = (models.Service || model("Service", serviceSchema)) as Model; +const Company = (models.Company || model("Company", companySchema)) as Model; + +function compactName(...parts: Array): string { + return parts.filter(Boolean).join(" ").trim(); +} + +function formatDateTime(value?: Date): string { + if (!value) return "fecha pendiente"; + return new Intl.DateTimeFormat("es-AR", { + dateStyle: "short", + timeStyle: "short", + timeZone: "America/Argentina/Buenos_Aires", + }).format(value); +} + +export function buildNotificationText(params: { + type: NotificationJobType; + appointment?: AppointmentDocument | null; + client?: ClientDocument | null; + service?: ServiceDocument | null; + company?: CompanyDocument | null; +}): Pick { + const companyName = params.company?.name || "TurnosXpress"; + const serviceName = params.service?.name || "tu turno"; + const appointmentDate = formatDateTime(params.appointment?.start); + + switch (params.type) { + case "creation": + return { + subject: `Alta de turno en ${companyName}`, + message: `Tu turno para ${serviceName} en ${companyName} fue confirmado para el ${appointmentDate}.`, + }; + case "reminder": + return { + subject: `Recordatorio de turno en ${companyName}`, + message: `Te recordamos tu turno para ${serviceName} en ${companyName} el ${appointmentDate}.`, + }; + case "update": + return { + subject: `Turno actualizado en ${companyName}`, + message: `Tu turno para ${serviceName} en ${companyName} fue actualizado. Nueva fecha: ${appointmentDate}.`, + }; + case "cancellation": + return { + subject: `Turno cancelado en ${companyName}`, + message: `Tu turno para ${serviceName} en ${companyName} del ${appointmentDate} fue cancelado.`, + }; + } +} + +export async function resolveNotificationContent(job: INotificationJobDocument): Promise { + if ((job.type === "creation" || job.type === "reminder" || job.type === "cancellation") && job.payload) { + const { email, phoneNumber, userId, companyOwnerId } = job.payload; + const subject = job.channel === "email" + ? job.payload.emailSubject + : job.channel === "system" + ? job.payload.systemSubject + : job.payload.subject; + const message = job.channel === "whatsapp" + ? job.payload.wapMessage + : job.channel === "email" + ? job.payload.emailMessage + : job.payload.systemMessage; + const fallbackSubject = subject || job.payload.subject; + const fallbackMessage = message || job.payload.message; + if (fallbackSubject?.trim() && fallbackMessage?.trim()) { + return { + email: email?.trim(), + phoneNumber: phoneNumber?.trim(), + userId: userId ? String(userId) : undefined, + companyOwnerId: companyOwnerId ? String(companyOwnerId) : undefined, + subject: fallbackSubject.trim(), + message: fallbackMessage.trim(), + }; + } + } + + const appointment = await Appointment.findOne({ _id: job.appointmentId }).lean().exec(); + const client = await Client.findOne({ _id: job.clientId }).lean().exec(); + const company = await Company.findOne({ _id: job.companyId }).lean().exec(); + const service = appointment?.serviceId ? await Service.findOne({ _id: appointment.serviceId }).lean().exec() : null; + const userId = client?.userId || appointment?.userId || company?.ownerId; + const user = userId ? await User.findOne({ _id: userId }).lean().exec() : null; + const text = buildNotificationText({ type: job.type, appointment, client, service, company }); + const email = client?.email || user?.email; + + return { + email: email?.trim(), + userId: userId ? String(userId) : undefined, + subject: text.subject.trim(), + message: text.message.trim(), + }; +} diff --git a/notification-sender/src/Models/Jobs/NotificationJob.Adapter.Mongoose.ts b/notification-sender/src/Models/Jobs/NotificationJob.Adapter.Mongoose.ts new file mode 100644 index 0000000..896640f --- /dev/null +++ b/notification-sender/src/Models/Jobs/NotificationJob.Adapter.Mongoose.ts @@ -0,0 +1,105 @@ +import mongoose, { Document, Model, Schema, model } from "mongoose"; + +export type NotificationChannel = "whatsapp" | "email" | "system"; +export type NotificationJobStatus = "pending" | "processing" | "sent" | "failed" | "cancelled"; +export type NotificationJobType = "creation" | "reminder" | "update" | "cancellation"; + +export interface NotificationJobPayload { + email?: string; + phoneNumber?: string; + userId?: string; + companyOwnerId?: string; + subject?: string; + message?: string; + emailSubject?: string; + emailMessage?: string; + wapMessage?: string; + systemSubject?: string; + systemMessage?: string; +} + +export interface INotificationJob { + _id: string; + companyId: string; + appointmentId: string; + clientId: string; + channel: NotificationChannel; + type: NotificationJobType; + scheduledAt: Date; + status: NotificationJobStatus; + attempts: number; + maxAttempts: number; + lastError?: string; + nextRetryAt?: Date; + providerResponse?: string; + payload?: NotificationJobPayload; + createdAt: Date; + updatedAt: Date; + sentAt?: Date; +} + +export interface INotificationJobDocument extends Omit, Document {} + +export class NotificationJobModel { + schema: Schema; + notificationJobList: Model; + + constructor() { + this.schema = new Schema( + { + companyId: { + type: Schema.Types.ObjectId, + required: true, + ref: "Companie", + }, + appointmentId: { + type: Schema.Types.ObjectId, + required: true, + ref: "Appointment", + }, + clientId: { + type: Schema.Types.ObjectId, + required: true, + ref: "Client", + }, + channel: { + type: String, + required: true, + enum: ["whatsapp", "email", "system"], + }, + type: { + type: String, + required: true, + enum: ["creation", "reminder", "update", "cancellation"], + }, + scheduledAt: { type: Date, required: true }, + status: { + type: String, + required: true, + enum: ["pending", "processing", "sent", "failed", "cancelled"], + default: "pending", + }, + attempts: { type: Number, required: true, default: 0 }, + maxAttempts: { type: Number, required: true, default: 3 }, + lastError: { type: String, required: false }, + nextRetryAt: { type: Date, required: false }, + providerResponse: { type: String, required: false }, + payload: { type: Schema.Types.Mixed, required: false }, + sentAt: { type: Date, required: false }, + }, + { + timestamps: true, + } + ); + + this.schema.index({ status: 1, scheduledAt: 1 }); + this.schema.index({ status: 1, scheduledAt: 1, nextRetryAt: 1 }); + this.schema.index({ companyId: 1, status: 1 }); + this.schema.index({ appointmentId: 1 }); + + this.notificationJobList = model( + "NotificationJob", + this.schema + ); + } +} diff --git a/notification-sender/src/Models/Jobs/WhatsAppThrottle.ts b/notification-sender/src/Models/Jobs/WhatsAppThrottle.ts new file mode 100644 index 0000000..b4f202a --- /dev/null +++ b/notification-sender/src/Models/Jobs/WhatsAppThrottle.ts @@ -0,0 +1,28 @@ +const DEFAULT_MIN_INTERVAL_MS = 8000; +const JITTER_MAX_MS = 8000; + +export class WhatsAppThrottleClass { + private lastSentAt: Map = new Map(); + private minIntervalMs: number; + + constructor(minIntervalMs: number = DEFAULT_MIN_INTERVAL_MS) { + this.minIntervalMs = minIntervalMs; + } + + canSend(companyId: string): boolean { + const lastSent = this.lastSentAt.get(companyId); + if (!lastSent) { + return true; + } + + const elapsed = Date.now() - lastSent; + const jitter = Math.floor(Math.random() * JITTER_MAX_MS); + const requiredInterval = this.minIntervalMs + jitter; + + return elapsed >= requiredInterval; + } + + recordSent(companyId: string): void { + this.lastSentAt.set(companyId, Date.now()); + } +} diff --git a/notification-sender/src/Models/Jobs/__tests__/ChannelDispatchers.test.ts b/notification-sender/src/Models/Jobs/__tests__/ChannelDispatchers.test.ts new file mode 100644 index 0000000..fdfa015 --- /dev/null +++ b/notification-sender/src/Models/Jobs/__tests__/ChannelDispatchers.test.ts @@ -0,0 +1,71 @@ +import axios from "axios"; +import { dispatchEmail, dispatchWhatsApp } from "../ChannelDispatchers.js"; + +jest.mock("axios"); + +const mockedAxios = axios as jest.Mocked; + +describe("ChannelDispatchers", () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.API_URL = "https://api.example.com/base"; + process.env.API_KEY = "system-token"; + mockedAxios.post.mockResolvedValue({ + data: { success: true }, + status: 200, + statusText: "OK", + headers: {}, + config: { url: "" }, + }); + }); + + it("sends the job type in the email notification payload", async () => { + await dispatchEmail({ + appointmentId: "appointment-1", + systemToken: "system-token", + type: "creation", + email: "client@example.com", + subject: "Appointment created", + message: "Creation message", + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + "https://api.example.com/notifications/send-email", + { + systemToken: "system-token", + email: "client@example.com", + subject: "Appointment created", + message: "Creation message", + }, + { + headers: { "Content-Type": "application/json" }, + } + ); + }); + + it("sends the job type in the WhatsApp system notification payload", async () => { + await dispatchWhatsApp({ + companyId: "company-1", + appointmentId: "appointment-1", + type: "creation", + systemToken: "system-token", + phoneNumber: "5491112345678", + message: "Creation message", + companyOwnerId: "owner-1", + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + "https://api.example.com/notifications/send-wap", + { + systemToken: "system-token", + phoneNumber: "5491112345678", + message: "Creation message", + companyId: "company-1", + sessionUser: "owner-1", + }, + { + headers: { "Content-Type": "application/json" }, + } + ); + }); +}); diff --git a/notification-sender/src/Models/Jobs/__tests__/JobProcessor.test.ts b/notification-sender/src/Models/Jobs/__tests__/JobProcessor.test.ts new file mode 100644 index 0000000..2c54994 --- /dev/null +++ b/notification-sender/src/Models/Jobs/__tests__/JobProcessor.test.ts @@ -0,0 +1,252 @@ +import { dispatchEmail, dispatchWhatsApp } from "../ChannelDispatchers.js"; +import { buildPendingJobQuery, calculateNextRetryAt, JobProcessor } from "../JobProcessor.js"; +import { resolveNotificationContent } from "../NotificationContentResolver.js"; + +jest.mock("../ChannelDispatchers.js", () => ({ + dispatchWhatsApp: jest.fn(), + dispatchEmail: jest.fn(), + dispatchSystem: jest.fn(), +})); + +jest.mock("../NotificationContentResolver.js", () => ({ + resolveNotificationContent: jest.fn(), +})); + +describe("JobProcessor", () => { + describe("calculateNextRetryAt", () => { + it("calculates exponential backoff for attempt 1", () => { + const now = new Date("2026-07-21T12:00:00.000Z"); + const result = calculateNextRetryAt(1, now); + + const minExpected = now.getTime() + 5000; + const maxExpected = now.getTime() + 5000 + 3000; + + expect(result.getTime()).toBeGreaterThanOrEqual(minExpected); + expect(result.getTime()).toBeLessThanOrEqual(maxExpected); + }); + + it("calculates exponential backoff for attempt 2", () => { + const now = new Date("2026-07-21T12:00:00.000Z"); + const result = calculateNextRetryAt(2, now); + + const minExpected = now.getTime() + 10000; + const maxExpected = now.getTime() + 10000 + 3000; + + expect(result.getTime()).toBeGreaterThanOrEqual(minExpected); + expect(result.getTime()).toBeLessThanOrEqual(maxExpected); + }); + + it("calculates exponential backoff for attempt 3", () => { + const now = new Date("2026-07-21T12:00:00.000Z"); + const result = calculateNextRetryAt(3, now); + + const minExpected = now.getTime() + 20000; + const maxExpected = now.getTime() + 20000 + 3000; + + expect(result.getTime()).toBeGreaterThanOrEqual(minExpected); + expect(result.getTime()).toBeLessThanOrEqual(maxExpected); + }); + + it("returns a Date in the future", () => { + const now = new Date("2026-07-21T12:00:00.000Z"); + const result = calculateNextRetryAt(1, now); + expect(result.getTime()).toBeGreaterThan(now.getTime()); + }); + + it("uses base delay for attempt 0 as fallback", () => { + const now = new Date("2026-07-21T12:00:00.000Z"); + const result = calculateNextRetryAt(1, now); + + expect(result).toBeInstanceOf(Date); + }); + }); + + describe("backoff math correctness", () => { + it("exponential growth doubles each attempt", () => { + const now = new Date("2026-07-21T12:00:00.000Z"); + + const r1 = calculateNextRetryAt(1, now).getTime() - now.getTime(); + const r2 = calculateNextRetryAt(2, now).getTime() - now.getTime(); + const r3 = calculateNextRetryAt(3, now).getTime() - now.getTime(); + + // Without jitter: 5000, 10000, 20000 + // With jitter up to 3000ms, the growth pattern should still hold + // Min possible: 5000, 10000, 20000 + expect(r1).toBeGreaterThanOrEqual(5000); + expect(r2).toBeGreaterThanOrEqual(10000); + expect(r3).toBeGreaterThanOrEqual(20000); + }); + }); + + describe("buildPendingJobQuery", () => { + it("only selects due jobs whose retry window is missing, null, or due", () => { + const now = new Date("2026-07-21T12:00:00.000Z"); + + expect(buildPendingJobQuery(now)).toEqual({ + status: "pending", + scheduledAt: { $lte: now }, + $or: [ + { nextRetryAt: { $exists: false } }, + { nextRetryAt: null }, + { nextRetryAt: { $lte: now } }, + ], + }); + }); + }); + + describe("WhatsApp dispatch", () => { + beforeEach(() => { + jest.clearAllMocks(); + (resolveNotificationContent as jest.Mock).mockResolvedValue({ + subject: "Appointment reminder", + message: "Reminder message", + phoneNumber: "5491112345678", + companyOwnerId: "owner-1", + }); + }); + + it("passes the job appointmentId and type to the WhatsApp dispatcher", async () => { + const exec = jest.fn().mockResolvedValue({}); + const updateOne = jest.fn(() => ({ exec })); + const recordSent = jest.fn(); + + (dispatchWhatsApp as jest.Mock).mockResolvedValue({ success: true, providerResponse: "{}" }); + + await (JobProcessor.prototype as any).processJob.call( + { + jobModel: { notificationJobList: { updateOne } }, + throttle: { canSend: jest.fn(() => true), recordSent }, + handleFailure: jest.fn(), + }, + { + _id: "job-1", + companyId: "company-1", + appointmentId: "appointment-1", + channel: "whatsapp", + type: "creation", + attempts: 0, + maxAttempts: 3, + }, + "company-1" + ); + + expect(dispatchWhatsApp).toHaveBeenCalledWith({ + companyId: "company-1", + appointmentId: "appointment-1", + type: "creation", + systemToken: `${process.env.API_KEY}`, + phoneNumber: "5491112345678", + message: "Reminder message", + companyOwnerId: "owner-1", + }); + expect(recordSent).toHaveBeenCalledWith("company-1"); + }); + + it("preserves cancellation WhatsApp job types", async () => { + const exec = jest.fn().mockResolvedValue({}); + const updateOne = jest.fn(() => ({ exec })); + + (dispatchWhatsApp as jest.Mock).mockResolvedValue({ success: true, providerResponse: "{}" }); + + await (JobProcessor.prototype as any).processJob.call( + { + jobModel: { notificationJobList: { updateOne } }, + throttle: { canSend: jest.fn(() => true), recordSent: jest.fn() }, + handleFailure: jest.fn(), + }, + { + _id: "job-1", + companyId: "company-1", + appointmentId: "appointment-1", + channel: "whatsapp", + type: "cancellation", + attempts: 0, + maxAttempts: 3, + }, + "company-1" + ); + + expect(dispatchWhatsApp).toHaveBeenCalledWith({ + companyId: "company-1", + appointmentId: "appointment-1", + type: "cancellation", + systemToken: `${process.env.API_KEY}`, + phoneNumber: "5491112345678", + message: "Reminder message", + companyOwnerId: "owner-1", + }); + }); + + it("does not record a WhatsApp send when dispatch fails", async () => { + const exec = jest.fn().mockResolvedValue({}); + const updateOne = jest.fn(() => ({ exec })); + const recordSent = jest.fn(); + + (dispatchWhatsApp as jest.Mock).mockResolvedValue({ success: false, error: "provider failed" }); + + await (JobProcessor.prototype as any).processJob.call( + { + jobModel: { notificationJobList: { updateOne } }, + throttle: { canSend: jest.fn(() => true), recordSent }, + handleFailure: jest.fn(), + }, + { + _id: "job-1", + companyId: "company-1", + appointmentId: "appointment-1", + channel: "whatsapp", + type: "reminder", + attempts: 0, + maxAttempts: 3, + }, + "company-1" + ); + + expect(recordSent).not.toHaveBeenCalled(); + }); + }); + + describe("Email dispatch", () => { + beforeEach(() => { + jest.clearAllMocks(); + (resolveNotificationContent as jest.Mock).mockResolvedValue({ + email: "client@example.com", + subject: "Appointment created", + message: "Creation message", + }); + (dispatchEmail as jest.Mock).mockResolvedValue({ success: true, providerResponse: "{}" }); + }); + + it("passes creation job type to the email dispatcher", async () => { + const exec = jest.fn().mockResolvedValue({}); + const updateOne = jest.fn(() => ({ exec })); + + await (JobProcessor.prototype as any).processJob.call( + { + jobModel: { notificationJobList: { updateOne } }, + throttle: { canSend: jest.fn(() => true), recordSent: jest.fn() }, + handleFailure: jest.fn(), + }, + { + _id: "job-1", + companyId: "company-1", + appointmentId: "appointment-1", + channel: "email", + type: "creation", + attempts: 0, + maxAttempts: 3, + }, + "company-1" + ); + + expect(dispatchEmail).toHaveBeenCalledWith({ + appointmentId: "appointment-1", + systemToken: `${process.env.API_KEY}`, + type: "creation", + email: "client@example.com", + subject: "Appointment created", + message: "Creation message", + }); + }); + }); +}); diff --git a/notification-sender/src/Models/Jobs/__tests__/NotificationContentResolver.test.ts b/notification-sender/src/Models/Jobs/__tests__/NotificationContentResolver.test.ts new file mode 100644 index 0000000..9c82769 --- /dev/null +++ b/notification-sender/src/Models/Jobs/__tests__/NotificationContentResolver.test.ts @@ -0,0 +1,200 @@ +import mongoose from "mongoose"; +import { buildNotificationText, resolveNotificationContent } from "../NotificationContentResolver.js"; + +describe("NotificationContentResolver", () => { + it("builds appointment creation subject and message as Alta de turno", () => { + const result = buildNotificationText({ + type: "creation", + appointment: { + _id: "appointment-1", + companyId: "company-1", + start: new Date("2026-07-22T15:30:00.000Z"), + }, + service: { _id: "service-1", name: "Corte" }, + company: { _id: "company-1", name: "Barber Shop" }, + }); + + expect(result.subject).toContain("Alta de turno"); + expect(result.subject).toContain("Barber Shop"); + expect(result.message).toContain("Corte"); + expect(result.message).toContain("confirmado"); + }); + + it("builds meaningful appointment reminder subject and message", () => { + const result = buildNotificationText({ + type: "reminder", + appointment: { + _id: "appointment-1", + companyId: "company-1", + start: new Date("2026-07-22T15:30:00.000Z"), + }, + service: { _id: "service-1", name: "Corte" }, + company: { _id: "company-1", name: "Barber Shop" }, + }); + + expect(result.subject).toContain("Recordatorio"); + expect(result.subject).toContain("Barber Shop"); + expect(result.message).toContain("Corte"); + expect(result.message).toContain("Barber Shop"); + expect(result.subject.trim()).not.toBe(""); + expect(result.message.trim()).not.toBe(""); + }); + + it("uses cancellation snapshot payload without requiring appointment lookup", async () => { + const result = await resolveNotificationContent({ + appointmentId: "deleted-appointment-1", + clientId: "client-1", + companyId: "company-1", + channel: "email", + type: "cancellation", + payload: { + email: "client@example.com", + phoneNumber: "5491112345678", + userId: "user-1", + companyOwnerId: "owner-1", + subject: "Turno cancelado en Barber Shop", + message: "Tu turno para Corte fue cancelado.", + emailSubject: "Email cancellation subject", + emailMessage: "Email cancellation body.", + wapMessage: "WAP cancellation body.", + systemSubject: "System cancellation subject", + systemMessage: "System cancellation body.", + }, + } as any); + + expect(result).toEqual({ + email: "client@example.com", + phoneNumber: "5491112345678", + userId: "user-1", + companyOwnerId: "owner-1", + subject: "Email cancellation subject", + message: "Email cancellation body.", + }); + }); + + it("uses creation snapshot payload content for email jobs", async () => { + const result = await resolveNotificationContent({ + appointmentId: "appointment-1", + clientId: "client-1", + companyId: "company-1", + channel: "email", + type: "creation", + payload: { + email: "client@example.com", + phoneNumber: "5491112345678", + userId: "user-1", + companyOwnerId: "owner-1", + subject: "Turno reservado en Barber Shop", + message: "Generic creation body.", + emailSubject: "Email Alta subject", + emailMessage: "Email Alta body.", + wapMessage: "WAP Alta body.", + systemSubject: "System Alta subject", + systemMessage: "System Alta body.", + }, + } as any); + + expect(result).toEqual({ + email: "client@example.com", + phoneNumber: "5491112345678", + userId: "user-1", + companyOwnerId: "owner-1", + subject: "Email Alta subject", + message: "Email Alta body.", + }); + }); + + it("uses creation snapshot payload content for WhatsApp jobs", async () => { + const result = await resolveNotificationContent({ + appointmentId: "appointment-1", + clientId: "client-1", + companyId: "company-1", + channel: "whatsapp", + type: "creation", + payload: { + phoneNumber: "5491112345678", + subject: "Generic subject", + message: "Generic message.", + emailMessage: "Email Alta body.", + wapMessage: "WAP Alta body.", + }, + } as any); + + expect(result.message).toBe("WAP Alta body."); + }); + + it("uses reminder payload content without requiring appointment lookup", async () => { + const appointmentLookup = jest.spyOn(mongoose.models.Appointment, "findOne"); + + const result = await resolveNotificationContent({ + appointmentId: "appointment-1", + clientId: "client-1", + companyId: "company-1", + channel: "whatsapp", + type: "reminder", + payload: { + email: "client@example.com", + phoneNumber: "5491112345678", + userId: "user-1", + companyOwnerId: "owner-1", + subject: "Generic reminder subject", + message: "Generic reminder body.", + emailSubject: "Email reminder subject", + emailMessage: "Email reminder body.", + wapMessage: "WAP reminder body.", + systemSubject: "System reminder subject", + systemMessage: "System reminder body.", + }, + } as any); + + expect(appointmentLookup).not.toHaveBeenCalled(); + expect(result).toEqual({ + email: "client@example.com", + phoneNumber: "5491112345678", + userId: "user-1", + companyOwnerId: "owner-1", + subject: "Generic reminder subject", + message: "WAP reminder body.", + }); + }); + + it("uses WhatsApp cancellation payload content for WhatsApp jobs", async () => { + const result = await resolveNotificationContent({ + appointmentId: "deleted-appointment-1", + clientId: "client-1", + companyId: "company-1", + channel: "whatsapp", + type: "cancellation", + payload: { + phoneNumber: "5491112345678", + companyOwnerId: "owner-1", + subject: "Generic subject", + message: "Generic message.", + emailMessage: "Email cancellation body.", + wapMessage: "WAP cancellation body.", + }, + } as any); + + expect(result.message).toBe("WAP cancellation body."); + }); + + it("uses system cancellation payload content for system jobs", async () => { + const result = await resolveNotificationContent({ + appointmentId: "deleted-appointment-1", + clientId: "client-1", + companyId: "company-1", + channel: "system", + type: "cancellation", + payload: { + userId: "user-1", + subject: "Generic subject", + message: "Generic message.", + systemSubject: "System cancellation subject", + systemMessage: "System cancellation body.", + }, + } as any); + + expect(result.subject).toBe("System cancellation subject"); + expect(result.message).toBe("System cancellation body."); + }); +}); diff --git a/notification-sender/src/Models/Jobs/__tests__/WhatsAppThrottle.test.ts b/notification-sender/src/Models/Jobs/__tests__/WhatsAppThrottle.test.ts new file mode 100644 index 0000000..d04fb1e --- /dev/null +++ b/notification-sender/src/Models/Jobs/__tests__/WhatsAppThrottle.test.ts @@ -0,0 +1,93 @@ +describe("WhatsAppThrottle", () => { + let WhatsAppThrottle: typeof import("../WhatsAppThrottle.js").WhatsAppThrottleClass; + + beforeAll(async () => { + const mod = await import("../WhatsAppThrottle.js"); + WhatsAppThrottle = mod.WhatsAppThrottleClass; + }); + + beforeEach(() => { + jest.restoreAllMocks(); + }); + + it("allows first send for a company immediately", () => { + const throttle = new WhatsAppThrottle(8000); + expect(throttle.canSend("company-1")).toBe(true); + }); + + it("blocks send within minimum interval", () => { + const throttle = new WhatsAppThrottle(8000); + throttle.recordSent("company-1"); + + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-07-21T12:00:03.000Z")); // 3s later + + expect(throttle.canSend("company-1")).toBe(false); + + jest.useRealTimers(); + }); + + it("allows send after maximum possible interval (base + jitter)", () => { + const throttle = new WhatsAppThrottle(8000); + const now = new Date("2026-07-21T12:00:00.000Z"); + jest.useFakeTimers(); + jest.setSystemTime(now); + + throttle.recordSent("company-1"); + + // After 16s (max = 8s base + 8s jitter), should always be allowed + jest.setSystemTime(new Date("2026-07-21T12:00:16.000Z")); + + expect(throttle.canSend("company-1")).toBe(true); + + jest.useRealTimers(); + }); + + it("tracks companies independently", () => { + const throttle = new WhatsAppThrottle(8000); + const now = new Date("2026-07-21T12:00:00.000Z"); + jest.useFakeTimers(); + jest.setSystemTime(now); + + throttle.recordSent("company-1"); + + // company-2 should still be allowed + expect(throttle.canSend("company-2")).toBe(true); + + jest.useRealTimers(); + }); + + it("applies jitter to minimum interval", () => { + const throttle = new WhatsAppThrottle(8000); + const now = new Date("2026-07-21T12:00:00.000Z"); + jest.useFakeTimers(); + jest.setSystemTime(now); + + throttle.recordSent("company-1"); + + // At exactly 8s (base interval), it should still be blocked due to jitter + jest.setSystemTime(new Date("2026-07-21T12:00:08.000Z")); + expect(throttle.canSend("company-1")).toBe(false); + + // At 16s (max interval = base + jitter), it should be allowed + jest.setSystemTime(new Date("2026-07-21T12:00:16.000Z")); + expect(throttle.canSend("company-1")).toBe(true); + + jest.useRealTimers(); + }); + + it("uses default interval of 8000ms when not specified", () => { + const throttle = new WhatsAppThrottle(); + const now = new Date("2026-07-21T12:00:00.000Z"); + jest.useFakeTimers(); + jest.setSystemTime(now); + + throttle.recordSent("company-1"); + + // 7s should be blocked + jest.setSystemTime(new Date("2026-07-21T12:00:07.000Z")); + expect(throttle.canSend("company-1")).toBe(false); + + jest.useRealTimers(); + }); +}); diff --git a/notification-sender/src/config/logger.ts b/notification-sender/src/config/logger.ts index b8a292c..b4f8d6e 100644 --- a/notification-sender/src/config/logger.ts +++ b/notification-sender/src/config/logger.ts @@ -42,7 +42,8 @@ export const logDone = (message: string) => { }; export const logError = (message: string, error: any) => { - logger.info(`❌ ${bold}${message}${reset}\n\r`, error); + const details = error instanceof Error ? error.stack || error.message : String(error || "Unknown error"); + logger.error(`❌ ${bold}${message}${reset}\n${details}`); }; export const logWarn = (message: string) => { diff --git a/notification-sender/src/index.ts b/notification-sender/src/index.ts index 53bace3..e977295 100644 --- a/notification-sender/src/index.ts +++ b/notification-sender/src/index.ts @@ -1,7 +1,7 @@ import dotenv from "dotenv"; import logger, { logDone, logError, logIntent } from "./config/logger.js"; import mongoose from "mongoose"; -import { Notifications } from "./Models/Notifications/Notifications.js"; +import { JobProcessor } from "./Models/Jobs/JobProcessor.js"; logIntent("Iniciando Notification Sender."); @@ -28,10 +28,10 @@ mongoose .then(() => { const start = async () => { logDone("Conexión a MongoDB establecida correctamente."); - logger.info("🚀 Notification Sender running"); + logger.info("🚀 Notification Sender running (job-based polling)"); - const worker = new Notifications(); - await worker.start(); + const processor = new JobProcessor(); + await processor.startPolling(); }; start(); diff --git a/openspec/changes/notification-preferences-ui/tasks.md b/openspec/changes/notification-preferences-ui/tasks.md new file mode 100644 index 0000000..6b46878 --- /dev/null +++ b/openspec/changes/notification-preferences-ui/tasks.md @@ -0,0 +1,62 @@ +# Tasks: Notification Preferences UI + +## Review Workload Forecast + +| Field | Value | +|-------|-------| +| Estimated changed lines | 1000-1200 | +| 400-line budget risk | High | +| Chained PRs recommended | Yes | +| Suggested split | PR 1 → PR 2 → PR 3 → PR 4 | +| Delivery strategy | ask-on-risk | +| Chain strategy | pending | + +Decision needed before apply: Yes +Chained PRs recommended: Yes +Chain strategy: pending +400-line budget risk: High + +### Suggested Work Units + +| Unit | Goal | Likely PR | Focused test command | Runtime harness | Rollback boundary | +|------|------|-----------|----------------------|-----------------|-------------------| +| 1 | Foundation: models, API service, store, validation | PR 1 | `npm run typecheck` | N/A — types only | Remove service, store, model files | +| 2 | Org policy page + navigation | PR 2 | Navigate to `/admin/org/profile/[id]/notifications` | Org profile page loads, toggles render | Remove org notifications page and layout | +| 3 | Client override page + navigation | PR 3 | Navigate to `/admin/org/[oid]/client/[clientId]/notifications` | Client profile page loads, toggles render | Remove client notifications page and layout | +| 4 | Client-facing page + tests | PR 4 | Navigate to `/user/profile/notifications` | Landing profile page loads, toggles render | Remove landing notifications page and tests | + +## Phase 1: Foundation + +- [x] 1.1 Create `txclient/src/Models/NotificationPreferences.model.ts` — TypeScript interfaces: NotificationChannel, QuietHours, ReminderRule, CompanyNotificationPolicy, ClientNotificationPreferences, ClientCompanyNotificationOverride, upsert params +- [x] 1.2 Create `txclient/src/services/Notifications.Service.ts` — 6 API functions using `ApiRequest.post()`: getCompanyPolicy, upsertCompanyPolicy, getClientPreferences, upsertClientPreferences, getClientCompanyOverride, upsertClientCompanyOverride +- [x] 1.3 Create `txclient/src/Store/NotificationPreferences.Store.ts` — Zustand store with mode, form data, dirty flag; actions: setMode, updateFormData, resetForm +- [x] 1.4 Create `txclient/src/Models/NotificationPreferences.validation.ts` — Yup schemas: companyPolicySchema (channels min 1, timezone required, quietHours optional HH:mm, reminderRules max 5), clientPrefsSchema (preferredChannels, mutedChannels) + +## Phase 2: Org Policy Page + +- [x] 2.1 Create `txclient/src/app/admin/(organization-profile)/org/profile/[id]/notifications/layout.tsx` — Saveable wrapper loading company policy on mount, dispatching NEED_SAVE/SAVE events +- [x] 2.2 Create `txclient/src/app/admin/(organization-profile)/org/profile/[id]/notifications/page.tsx` — Org policy form: channel toggles (3x Switch), quiet hours (2x input time), timezone Textbox, reminder rules dynamic list (max 5) with add/remove buttons +- [x] 2.3 Modify `txclient/src/app/components/OrganizationHeader/OrganizationHeader.tsx` — Add "Notificaciones" menu item linking to `/admin/org/profile/[id]/notifications` with NotificationsIcon + +## Phase 3: Client Override Page + +- [x] 3.1 Create `txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/notifications/layout.tsx` — Saveable wrapper loading client company override on mount +- [x] 3.2 Create `txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/notifications/page.tsx` — Client override form: preferred channels (3x Switch), muted channels (3x Switch) +- [x] 3.3 Modify `txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/layout.tsx` — Add "Notificaciones" navigation link to client profile menu + +## Phase 4: Client-Facing Page + +- [x] 4.1 Create `txclient/src/app/user/profile/notifications-preferences/page.tsx` — Client preferences form: preferred channels (3x Switch), muted channels (3x Switch); wrapped in Secure component for auth gate +- [x] 4.2 Modify `txclient/src/app/user/profile/page.tsx` — Add "Preferencias de notificación" navigation link to user profile menu + +## Phase 5: Testing + +- [ ] 5.1 Create `txclient/src/Models/__tests__/NotificationPreferences.validation.test.ts` — Test Yup schemas: valid inputs pass, invalid channels rejected, timezone required, quietHours format validated, reminderRules max 5 enforced (BLOCKED: no test runner in txclient) +- [ ] 5.2 Create `txclient/src/services/__tests__/Notifications.Service.test.ts` — Mock ApiRequest.post, verify 6 functions call correct endpoints with correct params (BLOCKED: no test runner in txclient) +- [ ] 5.3 Create `txclient/src/Store/__tests__/NotificationPreferences.Store.test.ts` — Test store actions: setMode, updateFormData, resetForm, dirty flag transitions (BLOCKED: no test runner in txclient) + +## Phase 6: Cleanup (if needed) + +- [x] 6.1 Remove any temporary console.log statements added during development +- [x] 6.2 Verify all TypeScript types compile without errors +- [x] 6.3 Confirm navigation links work in all three contexts (org, client, landing) diff --git a/openspec/changes/notification-redesign/proposal.md b/openspec/changes/notification-redesign/proposal.md new file mode 100644 index 0000000..0e807ae --- /dev/null +++ b/openspec/changes/notification-redesign/proposal.md @@ -0,0 +1,90 @@ +# Proposal: Notification System Redesign + +## Intent + +The current notification system has three critical limitations: (1) notifications are sent synchronously during appointment lifecycle operations, blocking the API response; (2) the notification-sender worker only handles today's appointments with a global delay (no per-company throttling for WhatsApp rate limits); and (3) there are no per-company or per-client notification preferences — notifications are either on or off for the entire company. This redesign decouples notification delivery from business operations into a job-based, multi-channel, policy-driven system. + +## Scope + +### In Scope +- New `NotificationJob` model: per-notification job records with status, channel, payload, retry state +- New `CompanyNotificationPolicy` model: org-level defaults for channels, scheduling windows, WhatsApp throttle config +- New `ClientNotificationPreferences` model: per-client opt-in/out per channel +- New `ClientCompanyNotificationOverride` model: per-client per-org overrides +- Policy resolution engine: Plan limits → Org override → Client prefs → Org default → System default +- Redesigned notification-sender worker: MongoDB polling, per-company WhatsApp throttle (8–16s), multi-channel dispatch +- Server integration: replace inline notification calls in `Appointments` with job creation +- Multi-channel support: WhatsApp (Baileys), Email (DonWeb), System (in-app + Socket.IO) + +### Out of Scope +- SMS channel (plan feature exists but not implemented today) +- Notification templates / content builder +- Client-facing notification preferences UI +- Notification analytics / delivery reports +- Push notifications (mobile) +- Batch notification operations (e.g., "notify all clients about holiday") + +## Capabilities + +### New Capabilities +- `notification-jobs`: Job creation, status tracking, retry logic, and lifecycle management for notification delivery +- `notification-policies`: Company and client notification preferences, policy resolution hierarchy, channel configuration +- `notification-worker`: Multi-channel job processor with per-company throttling and MongoDB polling +- `notification-integration`: Server-side hooks that create notification jobs from appointment lifecycle events + +### Modified Capabilities +None — no existing specs in `openspec/specs/`. + +## Approach + +**Server (job creation):** Replace inline `NotificationsManager.send*()` calls in `Appointments.ts` with `NotificationJobService.createJob()`. The service resolves the effective policy, checks plan limits, and persists a `NotificationJob` document. This makes API calls non-blocking. + +**notification-sender (worker):** Poll MongoDB for `status: "pending"` jobs. Process per-company with configurable WhatsApp throttle (default 8–16s between messages for same company). Email and System channels have no throttle. Failed jobs retry up to 3x with exponential backoff. + +**Policy resolution:** Resolve in order: Plan feature flags → `ClientCompanyNotificationOverride` (per-client per-org) → `ClientNotificationPreferences` (client global) → `CompanyNotificationPolicy` (org default) → System defaults (all channels on). + +**Migration:** No data migration needed — no pending notifications exist. New models are additive. + +## Affected Areas + +| Area | Impact | Description | +|------|--------|-------------| +| `notification-sender/src/` | New | Complete rewrite: job polling, multi-channel dispatch, throttle | +| `server/src/Models/Notifications/` | Modified | Add job creation service; existing adapters remain for direct sends | +| `server/src/Models/Appointments/Appointments.ts` | Modified | Replace inline sends with job creation calls | +| `server/src/Models/Plans/Plans.interface.ts` | Unchanged | Plan features already define channel support | +| New: `server/src/Models/NotificationJobs/` | New | NotificationJob model + adapter | +| New: `server/src/Models/NotificationPolicies/` | New | Company/Client policy models + resolution engine | +| New: `notification-sender/src/Models/Jobs/` | New | Job polling model for worker | + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| WhatsApp rate limit hit during high-volume periods | High | Per-company throttle (8–16s), exponential backoff on failures | +| Job queue grows faster than worker processes | Medium | Monitor queue depth; add alerting on backlog threshold | +| Policy resolution adds latency to appointment creation | Low | Resolution is a simple DB query cascade; cache per-request | +| Breaking existing notification behavior during transition | Medium | Feature flag: keep inline sends as fallback during rollout | + +## Rollback Plan + +1. Disable job creation in server via feature flag (reverts to inline sends) +2. Stop notification-sender worker +3. Existing inline notification paths remain unchanged — no code removal needed until stable +4. Job collection can be truncated without data loss (jobs are ephemeral) + +## Dependencies + +- MongoDB (shared across all modules) — already available +- Baileys (txbot) — existing WhatsApp integration, no changes needed +- DonWeb email API — existing integration, no changes needed +- Socket.IO — existing in server, no changes needed + +## Success Criteria + +- [ ] Appointment creation/update/delete returns within normal latency (< 200ms) +- [ ] WhatsApp messages for same company spaced ≥ 8s apart +- [ ] Failed notifications retry up to 3x with exponential backoff +- [ ] Policy resolution correctly cascades through hierarchy +- [ ] Zero missed notifications during 24h soak test +- [ ] notification-sender processes backlog within 5 minutes under normal load diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..884dd86 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,34 @@ +# SDD Configuration — turnosxpress +# Generated: 2026-07-21 + +context: | + Multi-module monorepo: Express+TypeScript server (tsoa, Mongoose, JWT), + Next.js 15 client (MUI, Zustand), WhatsApp bot (Baileys), + admin/sysadmin microservices, Android companion app. + MongoDB (Mongoose 8.x), OCI cloud storage, multi-region Docker deployment. + No workspace manager — independent modules. + +strict_tdd: true + +testing: + runner: jest + framework: Jest 30.x + ts-jest 29.x + command: "cd server && npx jest" + coverage: false + layers: + unit: true + integration: false + e2e: false + quality: + linter: "next lint (txclient), oxlint (sysadmin-cli)" + type_checker: tsc + formatter: none + +phases: + proposal: mandatory + spec: mandatory + design: mandatory + tasks: mandatory + apply: mandatory + verify: mandatory + archive: mandatory diff --git a/server/.env b/server/.env index c040181..d37bcd7 100644 --- a/server/.env +++ b/server/.env @@ -54,6 +54,7 @@ DONWEB_MAIL_API_KEY = eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NDYxMTc3N WAP_TIME_LIMIT = 5000 WAP_REMINDER = 🔔Hola <---CLIENTE---> <---BR---> 📌Te recuerdo que el <---FECHA---> te esperamos a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* WAP_ALTA = 🔔Hola <---CLIENTE---> <---BR---> 📌Has realizado una reserva para el día <---FECHA---> a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* +WAP_CANCELACION = 🔔Hola <---CLIENTE---> <---BR---> 📌Tu reserva para el día <---FECHA---> a las <---HORARIO---> fue cancelada. <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR---> *<---ORGANIZACION--->* GOOGLE_CLIENT_ID = 252287289364-bu8mquhttsaa282egh8su2sumvkjqe58.apps.googleusercontent.com @@ -67,4 +68,4 @@ SYSTEM_KEY = 753a2sd1du64-c412-3c3a-9a4hdr9-ac419213957e FILE_SERVER_URL = http://localhost:4000/ FILE_SERVER_URL_prod = https://files.turnosxpress.com.ar/ -SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key \ No newline at end of file +SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key diff --git a/server/.env.android b/server/.env.android index 974f1fb..958b5da 100644 --- a/server/.env.android +++ b/server/.env.android @@ -53,6 +53,7 @@ DONWEB_MAIL_API_KEY = eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NDYxMTc3N WAP_TIME_LIMIT = 5000 WAP_REMINDER = 🔔Hola <---CLIENTE---> <---BR---> 📌Te recuerdo que el <---FECHA---> te esperamos a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* WAP_ALTA = 🔔Hola <---CLIENTE---> <---BR---> 📌Has realizado una reserva para el día <---FECHA---> a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* +WAP_CANCELACION = 🔔Hola <---CLIENTE---> <---BR---> 📌Tu reserva para el día <---FECHA---> a las <---HORARIO---> fue cancelada. <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR---> *<---ORGANIZACION--->* GOOGLE_CLIENT_ID = 252287289364-bu8mquhttsaa282egh8su2sumvkjqe58.apps.googleusercontent.com @@ -65,4 +66,4 @@ SYSTEM_KEY = 753a2sd1du64-c412-3c3a-9a4hdr9-ac419213957e FILE_SERVER_URL = http://10.0.2.2:4000/ -SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key \ No newline at end of file +SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key diff --git a/server/.env.dev b/server/.env.dev index 90d0190..16e278c 100644 --- a/server/.env.dev +++ b/server/.env.dev @@ -53,6 +53,7 @@ DONWEB_MAIL_API_KEY = eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NDYxMTc3N WAP_TIME_LIMIT = 5000 WAP_REMINDER = 🔔Hola <---CLIENTE---> <---BR---> 📌Te recuerdo que el <---FECHA---> te esperamos a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* WAP_ALTA = 🔔Hola <---CLIENTE---> <---BR---> 📌Has realizado una reserva para el día <---FECHA---> a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* +WAP_CANCELACION = 🔔Hola <---CLIENTE---> <---BR---> 📌Tu reserva para el día <---FECHA---> a las <---HORARIO---> fue cancelada. <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR---> *<---ORGANIZACION--->* GOOGLE_CLIENT_ID = 252287289364-bu8mquhttsaa282egh8su2sumvkjqe58.apps.googleusercontent.com @@ -65,4 +66,4 @@ SYSTEM_KEY = 753a2sd1du64-c412-3c3a-9a4hdr9-ac419213957e FILE_SERVER_URL = http://localhost:4000/ -SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key \ No newline at end of file +SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key diff --git a/server/.env.prod b/server/.env.prod index f193073..2d675ad 100644 --- a/server/.env.prod +++ b/server/.env.prod @@ -53,6 +53,7 @@ WAP_TIME_LIMIT = 5000 WAP_REMINDER = 🔔Hola <---CLIENTE---> <---BR---> 📌Te recuerdo que el <---FECHA---> te esperamos a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* WAP_ALTA = 🔔Hola <---CLIENTE---> <---BR---> 📌Has realizado una reserva para el día <---FECHA---> a las <---HORARIO---> ⏰ <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR--->Precio: <---PRECIO---> <---BR---> *<---ORGANIZACION--->* +WAP_CANCELACION = 🔔Hola <---CLIENTE---> <---BR---> 📌Tu reserva para el día <---FECHA---> a las <---HORARIO---> fue cancelada. <---BR---> 🏫 En <---DIRECCION---> <---BR--->Con <---PROFESIONAL---> para <---SERVICIO---> <---BR---> *<---ORGANIZACION--->* GOOGLE_CLIENT_ID = 252287289364-bu8mquhttsaa282egh8su2sumvkjqe58.apps.googleusercontent.com @@ -65,4 +66,4 @@ SYSTEM_KEY = ad7c956a-76bf-45hdr60-8a50-2ca7b2180997 FILE_SERVER_URL = https://files.turnosxpress.com.ar/ -SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key \ No newline at end of file +SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key diff --git a/server/src/Models/Appointments/Appointments.Interface.ts b/server/src/Models/Appointments/Appointments.Interface.ts index a747015..5fbb6af 100644 --- a/server/src/Models/Appointments/Appointments.Interface.ts +++ b/server/src/Models/Appointments/Appointments.Interface.ts @@ -36,9 +36,18 @@ export type FindAppointmentsByCollaboratorPaginatedParams = FindAppointmentsByCo export enum APPOINTMENT_NOTIFICATION_TYPE { "CREATION" = "creation", "REMINDER" = "reminder", + "CANCELLATION" = "cancellation", } export type SendAppointmentNotificationParams = { + appointmentId: string; + sessionUser?: string; + systemToken?: string; + type: APPOINTMENT_NOTIFICATION_TYPE; + channel?: "whatsapp" | "email"; +}; + +export type CreateImmediateAppointmentNotificationJobsParams = { appointmentId: string; sessionUser: string; type: APPOINTMENT_NOTIFICATION_TYPE; @@ -63,6 +72,7 @@ export type ChangeServiceParams = { export type SendWapSystemNotificationParams = { appointmentId: string; systemToken: string; + type: APPOINTMENT_NOTIFICATION_TYPE; }; export type HeatMapConfig = { @@ -121,6 +131,7 @@ export type UpdateAppointmentParams = { export type DeleteAppointmentParams = { id: string; validation?: boolean; + notification?: boolean; sessionUser: string; }; @@ -315,6 +326,7 @@ export interface IAppointmentsManager { getAvailableDates(data: GetAvailableDatesParams): Promise; sendWapNotification(data: SendAppointmentNotificationParams): Promise; sendEmailNotification(data: SendAppointmentNotificationParams): Promise; + createImmediateNotificationJobs(data: CreateImmediateAppointmentNotificationJobsParams): Promise; sendWapSystemNotification(data: SendWapSystemNotificationParams): Promise; deleteAppointmentsByCompany(data: DeleteAppointmentsByCompanyParams): Promise; changeEmployeeToOwner(data: ChangeEmployeeToOwnerParams): Promise; diff --git a/server/src/Models/Appointments/Appointments.ts b/server/src/Models/Appointments/Appointments.ts index 493f978..7d1d3e0 100644 --- a/server/src/Models/Appointments/Appointments.ts +++ b/server/src/Models/Appointments/Appointments.ts @@ -10,6 +10,7 @@ import { ChangeEmployeeToOwnerParams, CountAppointmentsByMonthParams, CreateAppointmentParams, + CreateImmediateAppointmentNotificationJobsParams, DeleteAppointmentDiscountParams, DeleteAppointmentParams, DeleteAppointmentsByCompanyParams, @@ -76,6 +77,7 @@ import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled"; import DiscountsEmail from "../DiscountsEmail/DiscountsEmail"; import SchedulesOverridesList from "../SchedulesOverrides/SchedulesOverrides"; import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface"; +import { NotificationJobService } from "../NotificationPolicies/NotificationJobService"; dayjs.locale("es"); dayjs.extend(isSameOrAfter); @@ -84,9 +86,11 @@ dayjs.extend(weekday); class AppointmentManager implements IAppointmentsManager { Appointments: AppointmentsAdapterMongoose; + private jobService: NotificationJobService; constructor() { this.Appointments = new AppointmentsAdapterMongoose(); + this.jobService = new NotificationJobService(); } private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean { @@ -526,36 +530,35 @@ class AppointmentManager implements IAppointmentsManager { code: String((newAppointment as any)._id) }); - //Intento enviar notificacion al cliente por Email - try { - if (newAppointment.id) { - await this.sendEmailNotification({ - appointmentId: String(newAppointment.id), - type: APPOINTMENT_NOTIFICATION_TYPE.CREATION, - sessionUser: String(companyCheck.ownerId), - }); - } - } catch (err) { - console.log( - `Error al enviar la notificacion por Email para ${companyCheck.name}(${companyCheck._id}):`, - err - ); - } + //Create jobs for email and whatsapp notifications + if (newAppointment.id) { + const emailContent = await this.tryToSendNotification({ + appointmentId: String(newAppointment.id), + sessionUser: String(companyCheck.ownerId), + type: APPOINTMENT_NOTIFICATION_TYPE.CREATION, + channel: "email", + }); + const wapContent = await this.tryToSendNotification({ + appointmentId: String(newAppointment.id), + sessionUser: String(companyCheck.ownerId), + type: APPOINTMENT_NOTIFICATION_TYPE.CREATION, + channel: "whatsapp", + }); + const clientPhoneNumber = await this.getOptionalClientWapNumber(checkClient); - //Intento enviar notificacion al cliente por Whatsapp - try { - if (newAppointment.id) { - await this.sendWapNotification({ - appointmentId: String(newAppointment.id), - type: APPOINTMENT_NOTIFICATION_TYPE.CREATION, - sessionUser: String(companyCheck.ownerId), - }); - } - } catch (err) { - console.log( - `Error al enviar la notificacion por Whatsapp para ${companyCheck.name}(${companyCheck._id}):`, - err - ); + await this.createCreationNotificationJobs({ + appointmentId: String(newAppointment.id), + companyId: String(companyCheck._id), + clientId: String(checkClient._id), + clientUserId: checkClient.userId ? String(checkClient.userId) : undefined, + clientEmail: checkClient.email, + clientPhoneNumber, + companyOwnerId: String(companyCheck.ownerId), + companyName: companyCheck.name, + appointmentStart: new Date(data.start), + emailMessage: emailContent.message, + wapMessage: wapContent.message, + }); } } @@ -2070,36 +2073,18 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { code: String(checkAppointment._id) }); - //Intento enviar notificacion al cliente por Email - try { - if (checkAppointment.id) { - await this.sendEmailNotification({ - appointmentId: String(checkAppointment.id), - type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER, - sessionUser: String(companyCheck.ownerId), - }); - } - } catch (err) { - console.log( - `Error al enviar la notificacion por Email para ${companyCheck.name}(${companyCheck._id}):`, - err - ); - } + //Cancel existing pending jobs before creating new ones for the rescheduled appointment + if (checkAppointment.id) { + await this.jobService.cancelByAppointment(String(checkAppointment.id)); - //Intento enviar notificacion al cliente por Whatsapp - try { - if (checkAppointment.id) { - await this.sendWapNotification({ - appointmentId: String(checkAppointment.id), - type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER, - sessionUser: String(companyCheck.ownerId), - }); - } - } catch (err) { - console.log( - `Error al enviar la notificacion por Whatsapp para ${companyCheck.name}(${companyCheck._id}):`, - err - ); + //Create jobs for email and whatsapp notifications + await this.jobService.createJob({ + companyId: String(companyCheck._id), + clientId: String(checkClient._id), + appointmentId: String(checkAppointment.id), + type: "update" as const, + appointmentStart: new Date(data.start), + }); } } @@ -2155,8 +2140,89 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { return config; } + private async createCancellationNotificationJobs(data: { + appointmentId: string; + companyId: string; + clientId: string; + clientUserId?: string; + clientEmail?: string; + clientPhoneNumber?: string; + companyOwnerId: string; + companyName: string; + emailMessage: string; + wapMessage: string; + }): Promise { + const systemSubject = `Turno cancelado en ${data.companyName}`; + + await this.jobService.createJob({ + companyId: data.companyId, + clientId: data.clientId, + appointmentId: data.appointmentId, + type: "cancellation", + scheduledAt: new Date(), + payload: { + email: data.clientEmail, + phoneNumber: data.clientPhoneNumber, + userId: data.clientUserId, + companyOwnerId: data.companyOwnerId, + subject: systemSubject, + message: data.emailMessage, + emailSubject: "TurnosXpress :: Turno cancelado", + emailMessage: data.emailMessage, + wapMessage: data.wapMessage, + systemSubject, + systemMessage: data.emailMessage, + }, + }); + } + + private async createCreationNotificationJobs(data: { + appointmentId: string; + companyId: string; + clientId: string; + clientUserId?: string; + clientEmail?: string; + clientPhoneNumber?: string; + companyOwnerId: string; + companyName: string; + appointmentStart: Date; + emailMessage: string; + wapMessage: string; + }): Promise { + const systemSubject = `Turno reservado en ${data.companyName}`; + + await this.jobService.createJob({ + companyId: data.companyId, + clientId: data.clientId, + appointmentId: data.appointmentId, + type: "creation", + appointmentStart: data.appointmentStart, + payload: { + email: data.clientEmail, + phoneNumber: data.clientPhoneNumber, + userId: data.clientUserId, + companyOwnerId: data.companyOwnerId, + subject: systemSubject, + message: data.emailMessage, + emailSubject: "TurnosXpress :: Alta de turno", + emailMessage: data.emailMessage, + wapMessage: data.wapMessage, + systemSubject, + systemMessage: data.emailMessage, + }, + }); + } + + private async getOptionalClientWapNumber(client: IClientDocument): Promise { + try { + return await ClientsManager.getClientWapNumber(client); + } catch { + return undefined; + } + } + public async deleteAppointment(data: DeleteAppointmentParams): Promise { - const { validation = false } = data; + const { validation = false, notification = false } = data; const sessionUser = await UsersManager.users.findOne({ _id: data.sessionUser, }); @@ -2234,23 +2300,41 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { try { const config = this.getHeatMapConfig(checkAppointment, serviceCheck.length); + // Cancel pending notification jobs for this appointment before deleting it + await this.jobService.cancelByAppointment(String(checkAppointment._id)); + + if (notification) { + const emailContent = await this.tryToSendNotification({ + appointmentId: String(checkAppointment._id), + sessionUser: data.sessionUser, + type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION, + channel: "email", + }); + const wapContent = await this.tryToSendNotification({ + appointmentId: String(checkAppointment._id), + sessionUser: data.sessionUser, + type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION, + channel: "whatsapp", + }); + + await this.createCancellationNotificationJobs({ + companyId: String(checkAppointment.companyId), + clientId: String(checkAppointment.clientId), + appointmentId: String(checkAppointment._id), + clientUserId: checkClient.userId ? String(checkClient.userId) : undefined, + clientEmail: checkClient.email, + clientPhoneNumber: await this.getOptionalClientWapNumber(checkClient), + companyOwnerId: String(companyCheck.ownerId), + companyName: companyCheck.name, + emailMessage: emailContent.message, + wapMessage: wapContent.message, + }); + } + await this.Appointments.AppointmentList.deleteOne({ _id: data.id }); //Actualizar el heatmap. await this.updateHeatMap(config); - if (checkClient.userId) { - //Enviamos la notificacion de cancelacion al cliente - await NotificationsManager.sendSystemNotification({ - userId: String(checkClient.userId), - subject: "Turno Cancelado", - message: `Se ha cancelado el turno para el día ${dayjs(checkAppointment.start).format( - "DD/MM/YYYY" - )} a las ${dayjs(checkAppointment.start).format("HH:mm")}`, - type: NotificationType.APPOINTMENT, - code: String(checkAppointment._id) - }); - } - //Enviamos la notificacion de cancelacion al profesional await NotificationsManager.sendSystemNotification({ userId: String(employeeCheck.userId), @@ -2384,8 +2468,16 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { throw new Error("La compañia no existe"); } - if (!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))) { - throw new Error(NoPermissionMessage()); + const hasSystemToken = this.hasValidSystemToken(data.systemToken); + + if (!hasSystemToken) { + if (!data.sessionUser) { + throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción"); + } + + if (!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))) { + throw new Error(NoPermissionMessage()); + } } const checkClient = await ClientsManager.clients.findOne({ @@ -2465,9 +2557,13 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) { - if (companyCheck.templateWapAltaId) { + const templateId = data.channel === "email" + ? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId + : companyCheck.templateWapAltaId; + + if (templateId) { const template = await Templates.templates.findOne({ - id: String(companyCheck.templateWapAltaId), + id: String(templateId), companyId: String(companyCheck._id), }); @@ -2477,7 +2573,9 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { } if (!wapMessage) { - wapMessage = process.env.WAP_ALTA ? process.env.WAP_ALTA : ""; + wapMessage = data.channel === "email" + ? process.env.EMAIL_NEW_APPOINTMENT || process.env.EMAIL_ALTA || process.env.WAP_ALTA || "" + : process.env.WAP_ALTA || ""; } wapMessage = wapMessage.replaceAll( @@ -2505,6 +2603,52 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { wapMessage = wapMessage.replaceAll("<---SERVICIO--->", checkService.name); wapMessage = wapMessage.replaceAll("<---PRECIO--->", formatCurrency(checkAppointment.price)); + wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); + wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); + } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION) { + const templateId = data.channel === "email" + ? companyCheck.templateEmailCancellationId || companyCheck.templateWapCancellationId + : companyCheck.templateWapCancellationId; + + if (templateId) { + const template = await Templates.templates.findOne({ + id: String(templateId), + companyId: String(companyCheck._id), + }); + + if (template) { + wapMessage = template.template; + } + } + + if (!wapMessage) { + wapMessage = data.channel === "email" + ? process.env.EMAIL_CANCELLATION || process.env.EMAIL_CANCELACION || process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || "" + : process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || ""; + } + + wapMessage = wapMessage.replaceAll( + "<---CLIENTE--->", + ClientsManager.getClientFullName(checkClient) + ); + wapMessage = wapMessage.replaceAll( + "<---FECHA--->", + dayjs(checkAppointment.start).format("DD/MM/YYYY") + ); + wapMessage = wapMessage.replaceAll( + "<---HORARIO--->", + dayjs(checkAppointment.start).format("HH:mm") + "hs." + ); + wapMessage = wapMessage.replaceAll( + "<---DIRECCION--->", + CompaniesManager.getCompanyAddress(companyCheck) + ); + wapMessage = wapMessage.replaceAll( + "<---PROFESIONAL--->", + UsersManager.getUserFullName(checkEmployeeUser) + ); + wapMessage = wapMessage.replaceAll("<---SERVICIO--->", checkService.name); + wapMessage = wapMessage.replaceAll("<---PRECIO--->", formatCurrency(checkAppointment.price)); wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); } @@ -2517,15 +2661,24 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { } public async sendEmailNotification(data: SendAppointmentNotificationParams): Promise { - const sessionUser = await UsersManager.users.findOne({ - _id: data.sessionUser, - }); + if (!this.hasValidSystemToken(data.systemToken)) { + if (!data.sessionUser) { + throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción"); + } - if (!sessionUser) { - throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción"); + const sessionUser = await UsersManager.users.findOne({ + _id: data.sessionUser, + }); + + if (!sessionUser) { + throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción"); + } } - const { message, checkClient, companyCheck } = await this.tryToSendNotification(data); + const { message, checkClient, companyCheck } = await this.tryToSendNotification({ + ...data, + channel: "email", + }); const canSend = await PlanSubscriptionsList.checkFeature({ userId: String(companyCheck.ownerId), @@ -2548,6 +2701,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { subjectEmail = "TurnosXpress :: Recordatorio"; } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) { subjectEmail = "TurnosXpress :: Alta de turno"; + } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION) { + subjectEmail = "TurnosXpress :: Turno cancelado"; } //Enviar email... @@ -2558,7 +2713,66 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { }); } + public async createImmediateNotificationJobs(data: CreateImmediateAppointmentNotificationJobsParams): Promise { + if (![APPOINTMENT_NOTIFICATION_TYPE.CREATION, APPOINTMENT_NOTIFICATION_TYPE.REMINDER].includes(data.type)) { + throw new Error("El tipo de notificación no es válido para esta acción"); + } + + const emailContent = await this.tryToSendNotification({ + appointmentId: data.appointmentId, + sessionUser: data.sessionUser, + type: data.type, + channel: "email", + }); + const wapContent = await this.tryToSendNotification({ + appointmentId: data.appointmentId, + sessionUser: data.sessionUser, + type: data.type, + channel: "whatsapp", + }); + + const checkAppointment = await this.Appointments.findOne({ + _id: data.appointmentId, + }); + + if (!checkAppointment) { + throw new Error("El turno no existe"); + } + + const checkClient = emailContent.checkClient; + const companyCheck = emailContent.companyCheck; + const isCreation = data.type === APPOINTMENT_NOTIFICATION_TYPE.CREATION; + const systemSubject = isCreation + ? `Turno reservado en ${companyCheck.name}` + : `Recordatorio de turno en ${companyCheck.name}`; + + await this.jobService.createImmediateJob({ + companyId: String(checkAppointment.companyId), + clientId: String(checkAppointment.clientId), + appointmentId: String(checkAppointment._id), + type: data.type, + scheduledAt: new Date(), + payload: { + email: checkClient.email, + phoneNumber: await this.getOptionalClientWapNumber(checkClient), + userId: checkClient.userId ? String(checkClient.userId) : undefined, + companyOwnerId: String(companyCheck.ownerId), + subject: systemSubject, + message: emailContent.message, + emailSubject: isCreation ? "TurnosXpress :: Alta de turno" : "TurnosXpress :: Recordatorio", + emailMessage: emailContent.message, + wapMessage: wapContent.message, + systemSubject, + systemMessage: emailContent.message, + }, + }); + } + public async sendWapNotification(data: SendAppointmentNotificationParams): Promise { + if (!data.sessionUser) { + throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción"); + } + const sessionUser = await UsersManager.users.findOne({ _id: data.sessionUser, }); @@ -2567,7 +2781,10 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción"); } - const { message, checkClient, companyCheck } = await this.tryToSendNotification(data); + const { message, checkClient, companyCheck } = await this.tryToSendNotification({ + ...data, + channel: "whatsapp", + }); const canSend = await PlanSubscriptionsList.checkFeature({ userId: String(companyCheck.ownerId), @@ -2593,13 +2810,7 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { } public async sendWapSystemNotification(data: SendWapSystemNotificationParams): Promise { - if (!data.systemToken || data.systemToken == "") { - throw new Error("No tiene permisos para realizar esta acción"); - } - - const systemToken = `${process.env.SYSTEM_KEY}`; - - if (systemToken != data.systemToken) { + if (!this.hasValidSystemToken(data.systemToken)) { throw new Error("No tiene permisos para realizar esta acción"); } @@ -2622,7 +2833,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { const { message, checkClient, companyCheck } = await this.tryToSendNotification({ appointmentId: data.appointmentId, sessionUser: String(company.ownerId), - type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER, + systemToken: data.systemToken, + type: data.type, }); const canSend = await PlanSubscriptionsList.checkFeature({ @@ -2838,6 +3050,11 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { inRangeDates: [], } as GetAvailableDatesResult; } + + private hasValidSystemToken(systemToken?: string): boolean { + const expectedSystemToken = process.env.SYSTEM_KEY || process.env.API_KEY; + return Boolean(systemToken && expectedSystemToken && systemToken === expectedSystemToken); + } } const AppointmentList = new AppointmentManager(); diff --git a/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts b/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts index 84b5946..39bd606 100644 --- a/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts +++ b/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts @@ -32,6 +32,7 @@ jest.mock("../../Companies/Companies", () => ({ companies: { findOne: jest.fn(), }, + getCompanyAddress: jest.fn(), }, })); @@ -41,6 +42,7 @@ jest.mock("../../Employees/Employee", () => ({ employees: { findOne: jest.fn(), }, + checkPermission: jest.fn(), }, })); @@ -151,6 +153,9 @@ jest.mock("../../../helpers/check", () => ({ jest.mock("../../Templates/Templates", () => ({ __esModule: true, default: { + templates: { + findOne: jest.fn(), + }, sendEmail: jest.fn(), }, })); @@ -180,6 +185,8 @@ import UsersManager from "../../Users/Users"; import EmployeesList from "../../Employees/Employee"; import ClientsManager from "../../Clients/Clients"; import ServiceList from "../../Services/Service"; +import CompaniesManager from "../../Companies/Companies"; +import Templates from "../../Templates/Templates"; describe("getAppointmentEvent — historical read snapshot fallback", () => { let originalAdapter: any; @@ -385,3 +392,265 @@ describe("getAppointmentEvent — historical read snapshot fallback", () => { expect(result.collaboratorName).toBe("Jane Roe"); }); }); + +describe("cancellation notification jobs", () => { + let originalAdapter: any; + let originalJobService: any; + + beforeEach(() => { + jest.clearAllMocks(); + + originalAdapter = AppointmentsList.Appointments; + originalJobService = (AppointmentsList as any).jobService; + + (AppointmentsList as any).Appointments = { + findOne: jest.fn().mockResolvedValue({ + _id: "appt-001", + companyId: "company-001", + clientId: "client-001", + employeeId: "employee-001", + serviceId: "service-001", + start: new Date("2026-07-22T11:00:00.000Z"), + price: 1500, + }), + }; + (AppointmentsList as any).jobService = { + createJob: jest.fn().mockResolvedValue([]), + }; + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + name: "clases llavallol", + ownerId: "owner-001", + templateEmailCancellationId: "email-template-001", + templateWapCancellationId: "wap-template-001", + address: "Av. Siempre Viva 123", + }); + (CompaniesManager.getCompanyAddress as jest.Mock).mockReturnValue("Av. Siempre Viva 123"); + (ClientsManager.clients.findOne as jest.Mock).mockResolvedValue({ + _id: "client-001", + userId: "client-user-001", + firstName: "Ada", + lastName: "Lovelace", + email: "ada@example.com", + }); + (ClientsManager.getClientFullName as jest.Mock).mockReturnValue("Ada Lovelace"); + (EmployeesList.employees.findOne as jest.Mock).mockResolvedValue({ + _id: "employee-001", + userId: "employee-user-001", + }); + (EmployeesList.checkPermission as jest.Mock | undefined)?.mockResolvedValue?.(true); + (UsersManager.users.findOne as jest.Mock).mockResolvedValue({ + _id: "employee-user-001", + firstName: "Grace", + lastName: "Hopper", + }); + (UsersManager.getUserFullName as jest.Mock).mockReturnValue("Grace Hopper"); + (ServiceList.services.findOne as jest.Mock).mockResolvedValue({ + _id: "service-001", + name: "Contabilidad", + }); + (Templates.templates.findOne as jest.Mock).mockImplementation(({ id }) => { + if (id === "email-template-001") { + return Promise.resolve({ + template: "EMAIL CANCEL <---CLIENTE---> <---SERVICIO---> <---ORGANIZACION---> <---FECHA---> <---HORARIO--->", + }); + } + + if (id === "wap-template-001") { + return Promise.resolve({ + template: "WAP CANCEL <---CLIENTE---> <---SERVICIO---> <---ORGANIZACION---> <---FECHA---> <---HORARIO--->", + }); + } + + return Promise.resolve(null); + }); + }); + + afterEach(() => { + (AppointmentsList as any).Appointments = originalAdapter; + (AppointmentsList as any).jobService = originalJobService; + }); + + it("stores organization-rendered cancellation template content per channel", async () => { + const emailContent = await (AppointmentsList as any).tryToSendNotification({ + appointmentId: "appt-001", + sessionUser: "owner-001", + type: "cancellation", + channel: "email", + }); + const wapContent = await (AppointmentsList as any).tryToSendNotification({ + appointmentId: "appt-001", + sessionUser: "owner-001", + type: "cancellation", + channel: "whatsapp", + }); + + await (AppointmentsList as any).createCancellationNotificationJobs({ + appointmentId: "appt-001", + companyId: "company-001", + clientId: "client-001", + clientUserId: "client-user-001", + clientEmail: "ada@example.com", + clientPhoneNumber: "5491112345678", + companyOwnerId: "owner-001", + companyName: "clases llavallol", + emailMessage: emailContent.message, + wapMessage: wapContent.message, + }); + + expect((AppointmentsList as any).jobService.createJob).toHaveBeenCalledWith( + expect.objectContaining({ + type: "cancellation", + payload: expect.objectContaining({ + emailSubject: "TurnosXpress :: Turno cancelado", + emailMessage: "EMAIL CANCEL Ada Lovelace Contabilidad clases llavallol 22/07/2026 08:00hs.", + wapMessage: "WAP CANCEL Ada Lovelace Contabilidad clases llavallol 22/07/2026 08:00hs.", + systemSubject: "Turno cancelado en clases llavallol", + systemMessage: "EMAIL CANCEL Ada Lovelace Contabilidad clases llavallol 22/07/2026 08:00hs.", + }), + }) + ); + }); +}); + +describe("creation notification jobs", () => { + let originalAdapter: any; + let originalJobService: any; + + beforeEach(() => { + jest.clearAllMocks(); + + originalAdapter = AppointmentsList.Appointments; + originalJobService = (AppointmentsList as any).jobService; + + (AppointmentsList as any).Appointments = { + findOne: jest.fn().mockResolvedValue({ + _id: "appt-001", + companyId: "company-001", + clientId: "client-001", + employeeId: "employee-001", + serviceId: "service-001", + start: new Date("2026-07-22T11:00:00.000Z"), + price: 1500, + }), + }; + (AppointmentsList as any).jobService = { + createJob: jest.fn().mockResolvedValue([]), + }; + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + name: "clases llavallol", + ownerId: "owner-001", + templateEmailAltaId: "email-alta-template-001", + templateWapAltaId: "wap-alta-template-001", + address: "Av. Siempre Viva 123", + }); + (CompaniesManager.getCompanyAddress as jest.Mock).mockReturnValue("Av. Siempre Viva 123"); + (ClientsManager.clients.findOne as jest.Mock).mockResolvedValue({ + _id: "client-001", + userId: "client-user-001", + firstName: "Ada", + lastName: "Lovelace", + email: "ada@example.com", + }); + (ClientsManager.getClientFullName as jest.Mock).mockReturnValue("Ada Lovelace"); + (EmployeesList.employees.findOne as jest.Mock).mockResolvedValue({ + _id: "employee-001", + userId: "employee-user-001", + }); + (EmployeesList.checkPermission as jest.Mock | undefined)?.mockResolvedValue?.(true); + (UsersManager.users.findOne as jest.Mock).mockResolvedValue({ + _id: "employee-user-001", + firstName: "Grace", + lastName: "Hopper", + }); + (UsersManager.getUserFullName as jest.Mock).mockReturnValue("Grace Hopper"); + (ServiceList.services.findOne as jest.Mock).mockResolvedValue({ + _id: "service-001", + name: "Historia", + }); + (Templates.templates.findOne as jest.Mock).mockImplementation(({ id }) => { + if (id === "email-alta-template-001") { + return Promise.resolve({ + template: "EMAIL ALTA <---CLIENTE---> <---SERVICIO---> <---ORGANIZACION---> <---FECHA---> <---HORARIO--->", + }); + } + + if (id === "wap-alta-template-001") { + return Promise.resolve({ + template: "WAP ALTA <---CLIENTE---> <---SERVICIO---> <---ORGANIZACION---> <---FECHA---> <---HORARIO--->", + }); + } + + return Promise.resolve(null); + }); + }); + + afterEach(() => { + (AppointmentsList as any).Appointments = originalAdapter; + (AppointmentsList as any).jobService = originalJobService; + }); + + it("stores organization-rendered Alta template content per channel", async () => { + const emailContent = await (AppointmentsList as any).tryToSendNotification({ + appointmentId: "appt-001", + sessionUser: "owner-001", + type: "creation", + channel: "email", + }); + const wapContent = await (AppointmentsList as any).tryToSendNotification({ + appointmentId: "appt-001", + sessionUser: "owner-001", + type: "creation", + channel: "whatsapp", + }); + + await (AppointmentsList as any).createCreationNotificationJobs({ + appointmentId: "appt-001", + companyId: "company-001", + clientId: "client-001", + clientUserId: "client-user-001", + clientEmail: "ada@example.com", + clientPhoneNumber: "5491112345678", + companyOwnerId: "owner-001", + companyName: "clases llavallol", + appointmentStart: new Date("2026-07-22T11:00:00.000Z"), + emailMessage: emailContent.message, + wapMessage: wapContent.message, + }); + + expect((AppointmentsList as any).jobService.createJob).toHaveBeenCalledWith( + expect.objectContaining({ + type: "creation", + payload: expect.objectContaining({ + emailSubject: "TurnosXpress :: Alta de turno", + emailMessage: "EMAIL ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.", + wapMessage: "WAP ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.", + systemSubject: "Turno reservado en clases llavallol", + systemMessage: "EMAIL ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.", + }), + }) + ); + }); + + it("falls back to organization WAP Alta template for email when email Alta is absent", async () => { + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + name: "clases llavallol", + ownerId: "owner-001", + templateWapAltaId: "wap-alta-template-001", + address: "Av. Siempre Viva 123", + }); + + const emailContent = await (AppointmentsList as any).tryToSendNotification({ + appointmentId: "appt-001", + sessionUser: "owner-001", + type: "creation", + channel: "email", + }); + + expect(emailContent.message).toBe("WAP ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs."); + }); +}); diff --git a/server/src/Models/Companies/Companies.Adapter.Mongoose.ts b/server/src/Models/Companies/Companies.Adapter.Mongoose.ts index 65b3f76..04f0c11 100644 --- a/server/src/Models/Companies/Companies.Adapter.Mongoose.ts +++ b/server/src/Models/Companies/Companies.Adapter.Mongoose.ts @@ -58,8 +58,10 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter { templateWapNotifId: { type: Schema.Types.ObjectId, required: false, ref: "Template" }, templateWapAltaId: { type: Schema.Types.ObjectId, required: false, ref: "Template" }, + templateWapCancellationId: { type: Schema.Types.ObjectId, required: false, ref: "Template" }, templateEmailNotifId: { type: Schema.Types.ObjectId, required: false, ref: "Template" }, templateEmailAltaId: { type: Schema.Types.ObjectId, required: false, ref: "Template" }, + templateEmailCancellationId: { type: Schema.Types.ObjectId, required: false, ref: "Template" }, onboardingStep: { type: Number, required: false, default: 0 }, onboardingCompleted: { type: Boolean, required: false, default: false }, @@ -205,6 +207,10 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter { updateCompany.templateWapAltaId = data.templateWapAltaId; } + if (data.templateWapCancellationId) { + updateCompany.templateWapCancellationId = data.templateWapCancellationId; + } + if (data.templateEmailNotifId) { updateCompany.templateEmailNotifId = data.templateEmailNotifId; } @@ -213,6 +219,10 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter { updateCompany.templateEmailAltaId = data.templateEmailAltaId; } + if (data.templateEmailCancellationId) { + updateCompany.templateEmailCancellationId = data.templateEmailCancellationId; + } + if (data.onboardingStep !== undefined) { updateCompany.onboardingStep = data.onboardingStep; } diff --git a/server/src/Models/Companies/Companies.Interface.ts b/server/src/Models/Companies/Companies.Interface.ts index 55684be..a92ecd6 100644 --- a/server/src/Models/Companies/Companies.Interface.ts +++ b/server/src/Models/Companies/Companies.Interface.ts @@ -47,8 +47,10 @@ export type UpdateCompanyParams = { sessionUser: string; templateWapNotifId?: string; templateWapAltaId?: string; + templateWapCancellationId?: string; templateEmailNotifId?: string; templateEmailAltaId?: string; + templateEmailCancellationId?: string; onboardingStep?: number; onboardingCompleted?: boolean; banned?: boolean; @@ -144,8 +146,10 @@ export interface ICompany { automaticNotifications?: boolean; templateWapNotifId?: string; templateWapAltaId?: string; + templateWapCancellationId?: string; templateEmailNotifId?: string; templateEmailAltaId?: string; + templateEmailCancellationId?: string; onboardingStep?: number; onboardingCompleted?: boolean; fixedPostIds?: Array; @@ -192,8 +196,10 @@ export interface MyOranizationsView { appointmentAlert?: string; templateWapNotifId?: string; templateWapAltaId?: string; + templateWapCancellationId?: string; templateEmailNotifId?: string; templateEmailAltaId?: string; + templateEmailCancellationId?: string; onboardingStep?: number; onboardingCompleted?: boolean; banned?: boolean; diff --git a/server/src/Models/NotificationJobs/NotificationJobs.Adapter.Mongoose.ts b/server/src/Models/NotificationJobs/NotificationJobs.Adapter.Mongoose.ts new file mode 100644 index 0000000..8587b2f --- /dev/null +++ b/server/src/Models/NotificationJobs/NotificationJobs.Adapter.Mongoose.ts @@ -0,0 +1,131 @@ +import { Document, Model, Schema, model } from "mongoose"; +import { + CreateJobParams, + INotificationJob, + INotificationJobAdapter, + NotificationJobStatus, +} from "./NotificationJobs.Interface"; + +export interface INotificationJobDocument + extends Omit, + Document {} + +export class NotificationJobsAdapterMongoose implements INotificationJobAdapter { + schema: Schema; + notificationJobList: Model; + + constructor() { + this.schema = new Schema( + { + companyId: { + type: Schema.Types.ObjectId, + required: true, + ref: "Company", + }, + appointmentId: { + type: Schema.Types.ObjectId, + required: true, + ref: "Appointment", + }, + clientId: { + type: Schema.Types.ObjectId, + required: true, + ref: "Client", + }, + channel: { + type: String, + required: true, + enum: ["whatsapp", "email", "system"], + }, + type: { + type: String, + required: true, + enum: ["creation", "reminder", "update", "cancellation"], + }, + scheduledAt: { type: Date, required: true }, + status: { + type: String, + required: true, + enum: Object.values(NotificationJobStatus), + default: NotificationJobStatus.PENDING, + }, + attempts: { type: Number, required: true, default: 0 }, + maxAttempts: { type: Number, required: true, default: 3 }, + lastError: { type: String, required: false }, + nextRetryAt: { type: Date, required: false }, + providerResponse: { type: String, required: false }, + payload: { type: Schema.Types.Mixed, required: false }, + sentAt: { type: Date, required: false }, + }, + { + timestamps: true, + } + ); + + this.schema.index({ status: 1, scheduledAt: 1 }); + this.schema.index({ companyId: 1, status: 1 }); + this.schema.index({ appointmentId: 1 }); + + this.notificationJobList = model( + "NotificationJob", + this.schema + ); + } + + public async create(data: CreateJobParams): Promise { + const doc = await this.notificationJobList.create({ + ...data, + status: NotificationJobStatus.PENDING, + attempts: 0, + maxAttempts: 3, + }); + return doc.toObject() as INotificationJob; + } + + public async findPendingDue(now: Date): Promise { + const docs = await this.notificationJobList + .find({ + status: NotificationJobStatus.PENDING, + scheduledAt: { $lte: now }, + }) + .sort({ scheduledAt: 1 }) + .exec(); + return docs.map((d) => d.toObject() as INotificationJob); + } + + public async updateStatus( + jobId: string, + status: NotificationJobStatus, + data?: { lastError?: string; nextRetryAt?: Date; providerResponse?: string } + ): Promise { + const update: Record = { status }; + if (status === NotificationJobStatus.SENT) { + update.sentAt = new Date(); + } + if (data?.lastError !== undefined) update.lastError = data.lastError; + if (data?.nextRetryAt !== undefined) update.nextRetryAt = data.nextRetryAt; + if (data?.providerResponse !== undefined) + update.providerResponse = data.providerResponse; + + await this.notificationJobList.updateOne({ _id: jobId }, { $set: update }).exec(); + } + + public async cancelByAppointment(appointmentId: string): Promise { + await this.notificationJobList + .updateMany( + { + appointmentId, + status: NotificationJobStatus.PENDING, + }, + { $set: { status: NotificationJobStatus.CANCELLED } } + ) + .exec(); + } + + public async findByAppointment(appointmentId: string): Promise { + const docs = await this.notificationJobList + .find({ appointmentId }) + .exec(); + return docs.map((d) => d.toObject() as INotificationJob); + } +} diff --git a/server/src/Models/NotificationJobs/NotificationJobs.Interface.ts b/server/src/Models/NotificationJobs/NotificationJobs.Interface.ts new file mode 100644 index 0000000..cdb0712 --- /dev/null +++ b/server/src/Models/NotificationJobs/NotificationJobs.Interface.ts @@ -0,0 +1,71 @@ +export enum NotificationJobStatus { + PENDING = "pending", + PROCESSING = "processing", + SENT = "sent", + FAILED = "failed", + CANCELLED = "cancelled", +} + +export type NotificationChannel = "whatsapp" | "email" | "system"; + +export type NotificationJobType = + | "creation" + | "reminder" + | "update" + | "cancellation"; + +export interface NotificationJobPayload { + email?: string; + phoneNumber?: string; + userId?: string; + companyOwnerId?: string; + subject?: string; + message?: string; + emailSubject?: string; + emailMessage?: string; + wapMessage?: string; + systemSubject?: string; + systemMessage?: string; +} + +export interface INotificationJob { + id?: string; + companyId: string; + appointmentId: string; + clientId: string; + channel: NotificationChannel; + type: NotificationJobType; + scheduledAt: Date; + status: NotificationJobStatus; + attempts: number; + maxAttempts: number; + lastError?: string; + nextRetryAt?: Date; + providerResponse?: string; + payload?: NotificationJobPayload; + createdAt: Date; + updatedAt: Date; + sentAt?: Date; +} + +export interface CreateJobParams { + companyId: string; + appointmentId: string; + clientId: string; + channel: NotificationChannel; + type: NotificationJobType; + scheduledAt: Date; + payload?: NotificationJobPayload; +} + +export interface INotificationJobAdapter { + create(data: CreateJobParams): Promise; + findPendingDue(now: Date): Promise; + updateStatus( + jobId: string, + status: NotificationJobStatus, + data?: { lastError?: string; nextRetryAt?: Date; providerResponse?: string } + ): Promise; + cancelByAppointment(appointmentId: string): Promise; + findByAppointment(appointmentId: string): Promise; +} diff --git a/server/src/Models/NotificationJobs/NotificationJobs.ts b/server/src/Models/NotificationJobs/NotificationJobs.ts new file mode 100644 index 0000000..814cecd --- /dev/null +++ b/server/src/Models/NotificationJobs/NotificationJobs.ts @@ -0,0 +1,28 @@ +import { + CreateJobParams, + INotificationJob, +} from "./NotificationJobs.Interface"; +import { NotificationJobsAdapterMongoose } from "./NotificationJobs.Adapter.Mongoose"; + +class NotificationJobManager { + adapter: NotificationJobsAdapterMongoose; + + constructor() { + this.adapter = new NotificationJobsAdapterMongoose(); + } + + public async createJob(data: CreateJobParams): Promise { + return this.adapter.create(data); + } + + public async cancelByAppointment(appointmentId: string): Promise { + return this.adapter.cancelByAppointment(appointmentId); + } + + public async findByAppointment(appointmentId: string): Promise { + return this.adapter.findByAppointment(appointmentId); + } +} + +const NotificationJobsList = new NotificationJobManager(); +export default NotificationJobsList; diff --git a/server/src/Models/NotificationPolicies/ClientCompanyNotificationOverride.Adapter.Mongoose.ts b/server/src/Models/NotificationPolicies/ClientCompanyNotificationOverride.Adapter.Mongoose.ts new file mode 100644 index 0000000..87f27df --- /dev/null +++ b/server/src/Models/NotificationPolicies/ClientCompanyNotificationOverride.Adapter.Mongoose.ts @@ -0,0 +1,84 @@ +import { Document, Model, Schema, model, models } from "mongoose"; +import { + FindClientCompanyOverrideParams, + IClientCompanyNotificationOverride, + IClientCompanyNotificationOverrideAdapter, +} from "./ClientCompanyNotificationOverride.Interface"; + +export interface IClientCompanyNotificationOverrideDocument + extends Omit, + Document {} + +export class ClientCompanyNotificationOverrideAdapterMongoose + implements IClientCompanyNotificationOverrideAdapter +{ + schema: Schema; + overrideList: Model; + + constructor() { + this.schema = new Schema( + { + clientId: { + type: Schema.Types.ObjectId, + required: true, + ref: "Client", + }, + companyId: { + type: Schema.Types.ObjectId, + required: true, + ref: "Company", + }, + preferredChannels: { + type: [String], + required: true, + default: ["whatsapp", "email", "system"], + enum: ["whatsapp", "email", "system"], + }, + mutedChannels: { + type: [String], + required: true, + default: [], + enum: ["whatsapp", "email", "system"], + }, + }, + { + timestamps: true, + } + ); + + this.schema.index({ clientId: 1, companyId: 1 }, { unique: true }); + + this.overrideList = models.ClientCompanyNotificationOverride || + model( + "ClientCompanyNotificationOverride", + this.schema + ); + } + + public async findOne( + filters: FindClientCompanyOverrideParams + ): Promise { + const doc = await this.overrideList.findOne(filters).exec(); + return doc ? (doc.toObject() as IClientCompanyNotificationOverride) : null; + } + + public async upsert( + clientId: string, + companyId: string, + data: Partial< + Omit< + IClientCompanyNotificationOverride, + "id" | "clientId" | "companyId" | "createdAt" | "updatedAt" + > + > + ): Promise { + const doc = await this.overrideList + .findOneAndUpdate( + { clientId, companyId }, + { $set: data }, + { upsert: true, new: true } + ) + .exec(); + return doc.toObject() as IClientCompanyNotificationOverride; + } +} diff --git a/server/src/Models/NotificationPolicies/ClientCompanyNotificationOverride.Interface.ts b/server/src/Models/NotificationPolicies/ClientCompanyNotificationOverride.Interface.ts new file mode 100644 index 0000000..4cc5542 --- /dev/null +++ b/server/src/Models/NotificationPolicies/ClientCompanyNotificationOverride.Interface.ts @@ -0,0 +1,32 @@ +import { NotificationChannel } from "../NotificationJobs/NotificationJobs.Interface"; + +export interface IClientCompanyNotificationOverride { + id?: string; + clientId: string; + companyId: string; + preferredChannels: NotificationChannel[]; + mutedChannels: NotificationChannel[]; + createdAt: Date; + updatedAt: Date; +} + +export interface FindClientCompanyOverrideParams { + clientId?: string; + companyId?: string; +} + +export interface IClientCompanyNotificationOverrideAdapter { + findOne( + filters: FindClientCompanyOverrideParams + ): Promise; + upsert( + clientId: string, + companyId: string, + data: Partial< + Omit< + IClientCompanyNotificationOverride, + "id" | "clientId" | "companyId" | "createdAt" | "updatedAt" + > + > + ): Promise; +} diff --git a/server/src/Models/NotificationPolicies/ClientCompanyNotificationOverride.ts b/server/src/Models/NotificationPolicies/ClientCompanyNotificationOverride.ts new file mode 100644 index 0000000..1140bf5 --- /dev/null +++ b/server/src/Models/NotificationPolicies/ClientCompanyNotificationOverride.ts @@ -0,0 +1,71 @@ +import { ClientCompanyNotificationOverrideAdapterMongoose } from "./ClientCompanyNotificationOverride.Adapter.Mongoose"; +import { + IClientCompanyNotificationOverride, + IClientCompanyNotificationOverrideAdapter, +} from "./ClientCompanyNotificationOverride.Interface"; +import ClientsList from "../Clients/Clients"; +import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check"; + +type FindClientCompanyOverrideParams = { + clientId: string; + companyId: string; + sessionUser: string; +}; + +type UpsertClientCompanyOverrideParams = FindClientCompanyOverrideParams & { + data: Partial< + Omit< + IClientCompanyNotificationOverride, + "id" | "clientId" | "companyId" | "createdAt" | "updatedAt" + > + >; +}; + +class ClientCompanyNotificationOverrideManager { + overrides: IClientCompanyNotificationOverrideAdapter; + + constructor() { + this.overrides = new ClientCompanyNotificationOverrideAdapterMongoose(); + } + + public async findOne( + params: FindClientCompanyOverrideParams + ): Promise { + await this.validateAccess(params); + + return this.overrides.findOne({ + clientId: params.clientId, + companyId: params.companyId, + }); + } + + public async upsert( + params: UpsertClientCompanyOverrideParams + ): Promise { + await this.validateAccess(params); + + return this.overrides.upsert(params.clientId, params.companyId, params.data); + } + + private async validateAccess(params: FindClientCompanyOverrideParams): Promise { + await validateSessionUser({ sessionUser: params.sessionUser }); + await validatePermissionsByCompany({ + companyId: params.companyId, + sessionUser: params.sessionUser, + }); + + const client = await ClientsList.clients.findOne({ _id: params.clientId }); + + if (!client) { + throw new Error("El cliente no existe"); + } + + if (String(client.companyId) !== String(params.companyId)) { + throw new Error("El cliente no pertenece a la organización"); + } + } +} + +const ClientCompanyNotificationOverride = new ClientCompanyNotificationOverrideManager(); + +export default ClientCompanyNotificationOverride; diff --git a/server/src/Models/NotificationPolicies/ClientNotificationPreferences.Adapter.Mongoose.ts b/server/src/Models/NotificationPolicies/ClientNotificationPreferences.Adapter.Mongoose.ts new file mode 100644 index 0000000..c10fd0d --- /dev/null +++ b/server/src/Models/NotificationPolicies/ClientNotificationPreferences.Adapter.Mongoose.ts @@ -0,0 +1,70 @@ +import { Document, Model, Schema, model, models } from "mongoose"; +import { + FindClientPreferencesParams, + IClientNotificationPreferences, + IClientNotificationPreferencesAdapter, +} from "./ClientNotificationPreferences.Interface"; + +export interface IClientNotificationPreferencesDocument + extends Omit, + Document {} + +export class ClientNotificationPreferencesAdapterMongoose + implements IClientNotificationPreferencesAdapter +{ + schema: Schema; + preferencesList: Model; + + constructor() { + this.schema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + required: true, + unique: true, + ref: "User", + }, + preferredChannels: { + type: [String], + required: true, + default: ["whatsapp", "email", "system"], + enum: ["whatsapp", "email", "system"], + }, + mutedChannels: { + type: [String], + required: true, + default: [], + enum: ["whatsapp", "email", "system"], + }, + }, + { + timestamps: true, + } + ); + + this.preferencesList = models.ClientNotificationPreferences || + model( + "ClientNotificationPreferences", + this.schema + ); + } + + public async findOne( + filters: FindClientPreferencesParams + ): Promise { + const doc = await this.preferencesList.findOne(filters).exec(); + return doc ? (doc.toObject() as IClientNotificationPreferences) : null; + } + + public async upsert( + userId: string, + data: Partial< + Omit + > + ): Promise { + const doc = await this.preferencesList + .findOneAndUpdate({ userId }, { $set: data }, { upsert: true, new: true }) + .exec(); + return doc.toObject() as IClientNotificationPreferences; + } +} diff --git a/server/src/Models/NotificationPolicies/ClientNotificationPreferences.Interface.ts b/server/src/Models/NotificationPolicies/ClientNotificationPreferences.Interface.ts new file mode 100644 index 0000000..8d77d31 --- /dev/null +++ b/server/src/Models/NotificationPolicies/ClientNotificationPreferences.Interface.ts @@ -0,0 +1,26 @@ +import { NotificationChannel } from "../NotificationJobs/NotificationJobs.Interface"; + +export interface IClientNotificationPreferences { + id?: string; + userId: string; + preferredChannels: NotificationChannel[]; + mutedChannels: NotificationChannel[]; + createdAt: Date; + updatedAt: Date; +} + +export interface FindClientPreferencesParams { + userId?: string; +} + +export interface IClientNotificationPreferencesAdapter { + findOne( + filters: FindClientPreferencesParams + ): Promise; + upsert( + userId: string, + data: Partial< + Omit + > + ): Promise; +} diff --git a/server/src/Models/NotificationPolicies/ClientNotificationPreferences.ts b/server/src/Models/NotificationPolicies/ClientNotificationPreferences.ts new file mode 100644 index 0000000..5fb8393 --- /dev/null +++ b/server/src/Models/NotificationPolicies/ClientNotificationPreferences.ts @@ -0,0 +1,51 @@ +import { ClientNotificationPreferencesAdapterMongoose } from "./ClientNotificationPreferences.Adapter.Mongoose"; +import { + IClientNotificationPreferences, + IClientNotificationPreferencesAdapter, +} from "./ClientNotificationPreferences.Interface"; +import { validateSessionUser } from "../../helpers/check"; + +type FindClientPreferencesParams = { + userId: string; + sessionUser: string; +}; + +type UpsertClientPreferencesParams = FindClientPreferencesParams & { + data: Partial< + Omit + >; +}; + +class ClientNotificationPreferencesManager { + preferences: IClientNotificationPreferencesAdapter; + + constructor() { + this.preferences = new ClientNotificationPreferencesAdapterMongoose(); + } + + public async findOne( + params: FindClientPreferencesParams + ): Promise { + await this.validateUserOwner(params); + return this.preferences.findOne({ userId: params.userId }); + } + + public async upsert( + params: UpsertClientPreferencesParams + ): Promise { + await this.validateUserOwner(params); + return this.preferences.upsert(params.userId, params.data); + } + + private async validateUserOwner(params: FindClientPreferencesParams): Promise { + await validateSessionUser({ sessionUser: params.sessionUser }); + + if (String(params.userId) !== String(params.sessionUser)) { + throw new Error("No tienes permisos para modificar preferencias de otro usuario"); + } + } +} + +const ClientNotificationPreferences = new ClientNotificationPreferencesManager(); + +export default ClientNotificationPreferences; diff --git a/server/src/Models/NotificationPolicies/CompanyNotificationPolicy.Adapter.Mongoose.ts b/server/src/Models/NotificationPolicies/CompanyNotificationPolicy.Adapter.Mongoose.ts new file mode 100644 index 0000000..6676802 --- /dev/null +++ b/server/src/Models/NotificationPolicies/CompanyNotificationPolicy.Adapter.Mongoose.ts @@ -0,0 +1,83 @@ +import { Document, Model, Schema, model, models } from "mongoose"; +import { + FindCompanyPolicyParams, + ICompanyNotificationPolicy, + ICompanyNotificationPolicyAdapter, +} from "./CompanyNotificationPolicy.Interface"; + +export interface ICompanyNotificationPolicyDocument + extends Omit, + Document {} + +export class CompanyNotificationPolicyAdapterMongoose + implements ICompanyNotificationPolicyAdapter +{ + schema: Schema; + policyList: Model; + + constructor() { + this.schema = new Schema( + { + companyId: { + type: Schema.Types.ObjectId, + required: true, + unique: true, + ref: "Company", + }, + defaultChannels: { + type: [String], + required: true, + default: ["whatsapp", "email", "system"], + enum: ["whatsapp", "email", "system"], + }, + mutedChannels: { + type: [String], + required: false, + default: [], + enum: ["whatsapp", "email", "system"], + }, + timezone: { + type: String, + required: true, + default: "America/Argentina/Buenos_Aires", + }, + quietHours: { + from: { type: String, required: false }, + to: { type: String, required: false }, + }, + reminderRules: [ + { + offset: { type: Number, required: true }, + enabled: { type: Boolean, required: true, default: true }, + }, + ], + }, + { + timestamps: true, + } + ); + + this.policyList = models.CompanyNotificationPolicy || + model( + "CompanyNotificationPolicy", + this.schema + ); + } + + public async findOne( + filters: FindCompanyPolicyParams + ): Promise { + const doc = await this.policyList.findOne(filters).exec(); + return doc ? (doc.toObject() as ICompanyNotificationPolicy) : null; + } + + public async upsert( + companyId: string, + data: Partial> + ): Promise { + const doc = await this.policyList + .findOneAndUpdate({ companyId }, { $set: data }, { upsert: true, new: true }) + .exec(); + return doc.toObject() as ICompanyNotificationPolicy; + } +} diff --git a/server/src/Models/NotificationPolicies/CompanyNotificationPolicy.Interface.ts b/server/src/Models/NotificationPolicies/CompanyNotificationPolicy.Interface.ts new file mode 100644 index 0000000..9f2a40a --- /dev/null +++ b/server/src/Models/NotificationPolicies/CompanyNotificationPolicy.Interface.ts @@ -0,0 +1,35 @@ +import { NotificationChannel } from "../NotificationJobs/NotificationJobs.Interface"; + +export interface QuietHours { + from: string; // "HH:mm" format, e.g. "22:00" + to: string; // "HH:mm" format, e.g. "07:00" +} + +export interface ReminderRules { + offset: number; // minutes before appointment + enabled: boolean; +} + +export interface ICompanyNotificationPolicy { + id?: string; + companyId: string; + defaultChannels: NotificationChannel[]; + mutedChannels?: NotificationChannel[]; + timezone: string; + quietHours?: QuietHours; + reminderRules?: ReminderRules[]; + createdAt: Date; + updatedAt: Date; +} + +export interface FindCompanyPolicyParams { + companyId?: string; +} + +export interface ICompanyNotificationPolicyAdapter { + findOne(filters: FindCompanyPolicyParams): Promise; + upsert( + companyId: string, + data: Partial> + ): Promise; +} diff --git a/server/src/Models/NotificationPolicies/CompanyNotificationPolicy.ts b/server/src/Models/NotificationPolicies/CompanyNotificationPolicy.ts new file mode 100644 index 0000000..a3f0404 --- /dev/null +++ b/server/src/Models/NotificationPolicies/CompanyNotificationPolicy.ts @@ -0,0 +1,51 @@ +import { CompanyNotificationPolicyAdapterMongoose } from "./CompanyNotificationPolicy.Adapter.Mongoose"; +import { + ICompanyNotificationPolicy, + ICompanyNotificationPolicyAdapter, +} from "./CompanyNotificationPolicy.Interface"; +import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check"; + +type FindCompanyPolicyParams = { + companyId: string; + sessionUser: string; +}; + +type UpsertCompanyPolicyParams = FindCompanyPolicyParams & { + data: Partial< + Omit + >; +}; + +class CompanyNotificationPolicyManager { + policies: ICompanyNotificationPolicyAdapter; + + constructor() { + this.policies = new CompanyNotificationPolicyAdapterMongoose(); + } + + public async findOne( + params: FindCompanyPolicyParams + ): Promise { + await validateSessionUser({ sessionUser: params.sessionUser }); + await validatePermissionsByCompany({ + companyId: params.companyId, + sessionUser: params.sessionUser, + }); + + return this.policies.findOne({ companyId: params.companyId }); + } + + public async upsert(params: UpsertCompanyPolicyParams): Promise { + await validateSessionUser({ sessionUser: params.sessionUser }); + await validatePermissionsByCompany({ + companyId: params.companyId, + sessionUser: params.sessionUser, + }); + + return this.policies.upsert(params.companyId, params.data); + } +} + +const CompanyNotificationPolicy = new CompanyNotificationPolicyManager(); + +export default CompanyNotificationPolicy; diff --git a/server/src/Models/NotificationPolicies/NotificationJobService.ts b/server/src/Models/NotificationPolicies/NotificationJobService.ts new file mode 100644 index 0000000..7aca90d --- /dev/null +++ b/server/src/Models/NotificationPolicies/NotificationJobService.ts @@ -0,0 +1,189 @@ +import { + CreateJobParams, +} from "../NotificationJobs/NotificationJobs.Interface"; +import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose"; +import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; + +dayjs.extend(utc); + +export interface CreateJobServiceParams { + companyId: string; + clientId: string; + appointmentId: string; + type: CreateJobParams["type"]; + scheduledAt?: Date; + appointmentStart?: Date; + payload?: CreateJobParams["payload"]; +} + +export class NotificationJobService { + private jobAdapter: NotificationJobsAdapterMongoose; + private policyResolver: PolicyResolver; + + constructor() { + this.jobAdapter = new NotificationJobsAdapterMongoose(); + this.policyResolver = new PolicyResolver(); + } + + async createJob(params: CreateJobServiceParams): Promise { + const resolvedPolicy = await this.policyResolver.resolve( + params.companyId, + params.clientId + ); + + if (resolvedPolicy.channels.length === 0) { + return []; + } + + const now = new Date(); + const baseScheduledAt = params.scheduledAt || now; + const adjustedScheduledAt = this.adjustForQuietHours( + baseScheduledAt, + resolvedPolicy + ); + + const jobs: any[] = []; + for (const channel of resolvedPolicy.channels) { + const job = await this.jobAdapter.create({ + companyId: params.companyId, + appointmentId: params.appointmentId, + clientId: params.clientId, + channel, + type: params.type, + scheduledAt: adjustedScheduledAt, + payload: params.payload, + }); + jobs.push(job); + } + + if (params.appointmentStart) { + const enabledReminderRules = (resolvedPolicy.reminderRules || []).filter( + (rule) => rule.enabled + ); + + for (const rule of enabledReminderRules) { + const reminderScheduledAt = this.adjustForQuietHours( + dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(), + resolvedPolicy + ); + + if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) { + continue; + } + + for (const channel of resolvedPolicy.channels) { + const job = await this.jobAdapter.create({ + companyId: params.companyId, + appointmentId: params.appointmentId, + clientId: params.clientId, + channel, + type: "reminder", + scheduledAt: reminderScheduledAt, + }); + jobs.push(job); + } + } + } + + return jobs; + } + + async createImmediateJob(params: CreateJobServiceParams): Promise { + const resolvedPolicy = await this.policyResolver.resolve( + params.companyId, + params.clientId + ); + + if (resolvedPolicy.channels.length === 0) { + return []; + } + + const scheduledAt = params.scheduledAt || new Date(); + const jobs: any[] = []; + + for (const channel of resolvedPolicy.channels) { + const job = await this.jobAdapter.create({ + companyId: params.companyId, + appointmentId: params.appointmentId, + clientId: params.clientId, + channel, + type: params.type, + scheduledAt, + payload: params.payload, + }); + jobs.push(job); + } + + return jobs; + } + + /** + * Cancel all pending jobs for an appointment. + * Called when an appointment is deleted or rescheduled. + */ + async cancelByAppointment(appointmentId: string): Promise { + return this.jobAdapter.cancelByAppointment(appointmentId); + } + + private adjustForQuietHours(scheduledAt: Date, policy: ResolvedPolicy): Date { + if (!policy.quietHours?.from || !policy.quietHours?.to) { + return scheduledAt; + } + + const scheduled = dayjs.utc(scheduledAt); + const fromHour = parseInt(policy.quietHours.from.split(":")[0], 10); + const fromMinute = parseInt(policy.quietHours.from.split(":")[1], 10); + const toHour = parseInt(policy.quietHours.to.split(":")[0], 10); + const toMinute = parseInt(policy.quietHours.to.split(":")[1], 10); + + const scheduledHour = scheduled.hour(); + const scheduledMinute = scheduled.minute(); + + const isInsideQuietHours = this.isTimeInQuietHours( + scheduledHour, + scheduledMinute, + fromHour, + fromMinute, + toHour, + toMinute + ); + + if (isInsideQuietHours) { + // Shift to quiet hours end + let adjusted = scheduled.hour(toHour).minute(toMinute).second(0); + // If quiet hours cross midnight (e.g. 22:00-07:00) and end is before start, + // the end is on the next day + if (toHour < fromHour) { + if (scheduledHour >= fromHour || scheduledHour < toHour) { + adjusted = adjusted.add(1, "day"); + } + } + return adjusted.toDate(); + } + + return scheduledAt; + } + + private isTimeInQuietHours( + hour: number, + minute: number, + fromHour: number, + fromMinute: number, + toHour: number, + toMinute: number + ): boolean { + const currentMinutes = hour * 60 + minute; + const fromMinutes = fromHour * 60 + fromMinute; + const toMinutes = toHour * 60 + toMinute; + + if (fromMinutes < toMinutes) { + // Quiet hours within same day (e.g. 01:00-06:00) + return currentMinutes >= fromMinutes && currentMinutes < toMinutes; + } else { + // Quiet hours cross midnight (e.g. 22:00-07:00) + return currentMinutes >= fromMinutes || currentMinutes < toMinutes; + } + } +} diff --git a/server/src/Models/NotificationPolicies/PolicyResolver.ts b/server/src/Models/NotificationPolicies/PolicyResolver.ts new file mode 100644 index 0000000..c5606b6 --- /dev/null +++ b/server/src/Models/NotificationPolicies/PolicyResolver.ts @@ -0,0 +1,158 @@ +import { NotificationChannel } from "../NotificationJobs/NotificationJobs.Interface"; +import { CompanyNotificationPolicyAdapterMongoose } from "./CompanyNotificationPolicy.Adapter.Mongoose"; +import { ClientNotificationPreferencesAdapterMongoose } from "./ClientNotificationPreferences.Adapter.Mongoose"; +import { ClientCompanyNotificationOverrideAdapterMongoose } from "./ClientCompanyNotificationOverride.Adapter.Mongoose"; +import { IClientNotificationPreferences } from "./ClientNotificationPreferences.Interface"; +import { IClientCompanyNotificationOverride } from "./ClientCompanyNotificationOverride.Interface"; +import { ICompanyNotificationPolicy } from "./CompanyNotificationPolicy.Interface"; +import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons"; +import { PlanFeatures } from "../Plans/Plans.interface"; +import ClientsList from "../Clients/Clients"; + +const DEFAULT_TIMEZONE = "America/Argentina/Buenos_Aires"; +const SYSTEM_DEFAULT_CHANNELS: NotificationChannel[] = ["whatsapp", "email", "system"]; + +const PLAN_CHANNEL_MAP: Record = { + whatsapp: PlanFeatures.WAP_NOTIFICATIONS, + email: PlanFeatures.MAIL_NOTIFICATIONS, +}; + +export interface ResolvedPolicy { + channels: NotificationChannel[]; + timezone: string; + quietHours?: { from: string; to: string }; + reminderRules?: { offset: number; enabled: boolean }[]; +} + +export class PolicyResolver { + private companyPolicyAdapter: CompanyNotificationPolicyAdapterMongoose; + private clientPreferencesAdapter: ClientNotificationPreferencesAdapterMongoose; + private clientCompanyOverrideAdapter: ClientCompanyNotificationOverrideAdapterMongoose; + + constructor() { + this.companyPolicyAdapter = new CompanyNotificationPolicyAdapterMongoose(); + this.clientPreferencesAdapter = new ClientNotificationPreferencesAdapterMongoose(); + this.clientCompanyOverrideAdapter = new ClientCompanyNotificationOverrideAdapterMongoose(); + } + + async resolve(companyId: string, clientId: string): Promise { + // Level 2: ClientCompanyNotificationOverride (per-client per-org) + const override = await this.clientCompanyOverrideAdapter.findOne({ + clientId, + companyId, + }); + + const client = await ClientsList.clients.findOne({ _id: clientId }); + + // Level 3: ClientNotificationPreferences (user global) + const clientPrefs = client?.userId + ? await this.clientPreferencesAdapter.findOne({ userId: String(client.userId) }) + : null; + + // Level 4: CompanyNotificationPolicy (org defaults) + const companyPolicy = await this.companyPolicyAdapter.findOne({ companyId }); + + // Determine channels from cascade + let channels: NotificationChannel[]; + let timezone: string; + let quietHours: ResolvedPolicy["quietHours"]; + let reminderRules: ResolvedPolicy["reminderRules"]; + + if (override) { + channels = [...override.preferredChannels]; + } else if (clientPrefs) { + channels = [...clientPrefs.preferredChannels]; + } else if (companyPolicy) { + channels = [...companyPolicy.defaultChannels]; + } else { + // Level 5: System defaults + channels = [...SYSTEM_DEFAULT_CHANNELS]; + } + + // Apply muted channels from the winning level only — do NOT merge across levels + const mutedChannels = this.getMutedChannels(override, clientPrefs, companyPolicy); + channels = channels.filter((ch) => !mutedChannels.includes(ch)); + + // Timezone and quiet hours from company policy + if (companyPolicy) { + timezone = companyPolicy.timezone; + quietHours = companyPolicy.quietHours; + reminderRules = companyPolicy.reminderRules; + } else { + timezone = DEFAULT_TIMEZONE; + } + + // Level 1: Plan limits — filter channels by plan features + channels = await this.applyPlanLimits(companyId, channels); + + return { + channels, + timezone, + quietHours, + reminderRules, + }; + } + + /** + * Returns muted channels from the winning cascade level only. + * Per spec, muted channels are NOT merged across levels. + * The level that determines preferredChannels also determines mutedChannels. + */ + private getMutedChannels( + override: IClientCompanyNotificationOverride | null, + clientPrefs: IClientNotificationPreferences | null, + companyPolicy: ICompanyNotificationPolicy | null + ): NotificationChannel[] { + if (override) { + // Override wins — use only override's muted channels + return override.mutedChannels || []; + } + if (clientPrefs) { + // Client prefs wins — use only clientPrefs' muted channels + return clientPrefs.mutedChannels || []; + } + if (companyPolicy) { + // Company policy wins — use only companyPolicy' muted channels + return companyPolicy.mutedChannels || []; + } + // System defaults — no muted channels + return []; + } + + private async applyPlanLimits( + companyId: string, + channels: NotificationChannel[] + ): Promise { + // Resolve the company owner's userId for plan feature checks + // Use lazy require to avoid circular dependency (PolicyResolver → CompaniesManager → Appointments → NotificationJobService → PolicyResolver) + const CompaniesManager = require("../Companies/Companies").default; + const company = await CompaniesManager.companies.findOne({ _id: companyId }); + if (!company) { + // Company not found — return channels unchanged (fail-open) + return channels; + } + + const ownerUserId = String(company.ownerId); + const filtered: NotificationChannel[] = []; + + for (const channel of channels) { + const planFeature = PLAN_CHANNEL_MAP[channel]; + if (!planFeature) { + // Channel has no plan restriction (e.g., system) + filtered.push(channel); + continue; + } + + const canSend = await PlanSubscriptionsList.checkFeature({ + userId: ownerUserId, + feature: planFeature, + }); + + if (canSend) { + filtered.push(channel); + } + } + + return filtered; + } +} diff --git a/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts b/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts new file mode 100644 index 0000000..dd75dcd --- /dev/null +++ b/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts @@ -0,0 +1,340 @@ +import { NotificationJobStatus } from "../../NotificationJobs/NotificationJobs.Interface"; + +// Mock all external dependencies +jest.mock("../../NotificationJobs/NotificationJobs.Adapter.Mongoose", () => ({ + NotificationJobsAdapterMongoose: jest.fn().mockImplementation(() => ({ + create: jest.fn(), + })), +})); + +jest.mock("../PolicyResolver", () => ({ + PolicyResolver: jest.fn().mockImplementation(() => ({ + resolve: jest.fn(), + })), +})); + +jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({ + __esModule: true, + default: { + checkFeature: jest.fn(), + }, +})); + +import { NotificationJobService } from "../NotificationJobService"; +import { ResolvedPolicy } from "../PolicyResolver"; + +describe("NotificationJobService.createJob", () => { + let service: NotificationJobService; + let mockJobCreate: jest.Mock; + let mockResolverResolve: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + + service = new NotificationJobService(); + + mockJobCreate = (service as any).jobAdapter.create as jest.Mock; + mockResolverResolve = (service as any).policyResolver.resolve as jest.Mock; + }); + + it("creates a job with resolved channels", async () => { + const resolvedPolicy: ResolvedPolicy = { + channels: ["whatsapp", "email"], + timezone: "America/Argentina/Buenos_Aires", + }; + mockResolverResolve.mockResolvedValue(resolvedPolicy); + mockJobCreate.mockResolvedValue({ + id: "job-1", + status: NotificationJobStatus.PENDING, + }); + + const result = await service.createJob({ + companyId: "company-1", + clientId: "client-1", + appointmentId: "appt-1", + type: "creation", + }); + + expect(mockResolverResolve).toHaveBeenCalledWith("company-1", "client-1"); + expect(mockJobCreate).toHaveBeenCalledTimes(2); // one per channel + expect(result).toHaveLength(2); + }); + + it("creates reminder jobs for each enabled rule and resolved channel in addition to immediate jobs", async () => { + jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z")); + const resolvedPolicy: ResolvedPolicy = { + channels: ["system", "email"], + timezone: "America/Argentina/Buenos_Aires", + reminderRules: [ + { offset: 2, enabled: true }, + { offset: 5, enabled: true }, + ], + }; + mockResolverResolve.mockResolvedValue(resolvedPolicy); + mockJobCreate.mockResolvedValue({ + id: "job-1", + status: NotificationJobStatus.PENDING, + }); + + const result = await service.createJob({ + companyId: "company-1", + clientId: "client-1", + appointmentId: "appt-1", + type: "creation", + appointmentStart: new Date("2026-07-21T14:30:00.000Z"), + }); + + const createCalls = mockJobCreate.mock.calls.map((call) => call[0]); + const immediateJobs = createCalls.filter((job) => job.type === "creation"); + const reminderJobs = createCalls.filter((job) => job.type === "reminder"); + + expect(mockJobCreate).toHaveBeenCalledTimes(6); + expect(result).toHaveLength(6); + expect(immediateJobs).toHaveLength(2); + expect(reminderJobs).toHaveLength(4); + expect(immediateJobs.map((job) => job.channel).sort()).toEqual(["email", "system"]); + expect(immediateJobs.map((job) => job.scheduledAt.toISOString())).toEqual([ + "2026-07-21T14:00:00.000Z", + "2026-07-21T14:00:00.000Z", + ]); + expect(reminderJobs.map((job) => job.channel).sort()).toEqual([ + "email", + "email", + "system", + "system", + ]); + expect(reminderJobs.map((job) => job.scheduledAt.toISOString()).sort()).toEqual([ + "2026-07-21T14:25:00.000Z", + "2026-07-21T14:25:00.000Z", + "2026-07-21T14:28:00.000Z", + "2026-07-21T14:28:00.000Z", + ]); + }); + + it("skips reminder jobs scheduled in the past", async () => { + jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:29:00.000Z")); + const resolvedPolicy: ResolvedPolicy = { + channels: ["system", "email"], + timezone: "America/Argentina/Buenos_Aires", + reminderRules: [ + { offset: 2, enabled: true }, + { offset: 5, enabled: true }, + ], + }; + mockResolverResolve.mockResolvedValue(resolvedPolicy); + mockJobCreate.mockResolvedValue({ id: "job-1" }); + + await service.createJob({ + companyId: "company-1", + clientId: "client-1", + appointmentId: "appt-1", + type: "creation", + appointmentStart: new Date("2026-07-21T14:30:00.000Z"), + }); + + const createCalls = mockJobCreate.mock.calls.map((call) => call[0]); + expect(createCalls.filter((job) => job.type === "creation")).toHaveLength(2); + expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0); + }); + + it("skips reminder jobs adjusted by quiet hours after appointment start", async () => { + jest.useFakeTimers().setSystemTime(new Date("2026-07-21T20:00:00.000Z")); + const resolvedPolicy: ResolvedPolicy = { + channels: ["system", "email"], + timezone: "America/Argentina/Buenos_Aires", + quietHours: { from: "22:00", to: "07:00" }, + reminderRules: [{ offset: 15, enabled: true }], + }; + mockResolverResolve.mockResolvedValue(resolvedPolicy); + mockJobCreate.mockResolvedValue({ id: "job-1" }); + + await service.createJob({ + companyId: "company-1", + clientId: "client-1", + appointmentId: "appt-1", + type: "creation", + appointmentStart: new Date("2026-07-21T23:30:00.000Z"), + }); + + const createCalls = mockJobCreate.mock.calls.map((call) => call[0]); + expect(createCalls.filter((job) => job.type === "creation")).toHaveLength(2); + expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0); + }); + + it("creates no jobs when resolved channels are empty", async () => { + const resolvedPolicy: ResolvedPolicy = { + channels: [], + timezone: "America/Argentina/Buenos_Aires", + }; + mockResolverResolve.mockResolvedValue(resolvedPolicy); + + const result = await service.createJob({ + companyId: "company-1", + clientId: "client-1", + appointmentId: "appt-1", + type: "creation", + }); + + expect(mockJobCreate).not.toHaveBeenCalled(); + expect(result).toHaveLength(0); + }); + + it("adjusts scheduledAt for quiet hours crossing midnight", async () => { + const resolvedPolicy: ResolvedPolicy = { + channels: ["system"], + timezone: "America/Argentina/Buenos_Aires", + quietHours: { from: "22:00", to: "07:00" }, + }; + mockResolverResolve.mockResolvedValue(resolvedPolicy); + mockJobCreate.mockResolvedValue({ id: "job-1" }); + + // scheduledAt at 23:30 UTC — should be adjusted to 07:00 next day UTC + const scheduledAt = new Date("2026-07-21T23:30:00.000Z"); + + await service.createJob({ + companyId: "company-1", + clientId: "client-1", + appointmentId: "appt-1", + type: "creation", + scheduledAt, + }); + + const createCall = mockJobCreate.mock.calls[0][0]; + // 23:30 is inside quiet hours (22:00-07:00), adjusted to 07:00 next day + expect(createCall.scheduledAt.getUTCHours()).toBe(7); + expect(createCall.scheduledAt.getUTCDate()).toBe(22); // next day + }); + + it("does not adjust scheduledAt when outside quiet hours", async () => { + const resolvedPolicy: ResolvedPolicy = { + channels: ["system"], + timezone: "America/Argentina/Buenos_Aires", + quietHours: { from: "22:00", to: "07:00" }, + }; + mockResolverResolve.mockResolvedValue(resolvedPolicy); + mockJobCreate.mockResolvedValue({ id: "job-1" }); + + // scheduledAt at 14:00 UTC — outside quiet hours (22:00-07:00) + const scheduledAt = new Date("2026-07-21T14:00:00.000Z"); + + await service.createJob({ + companyId: "company-1", + clientId: "client-1", + appointmentId: "appt-1", + type: "creation", + scheduledAt, + }); + + const createCall = mockJobCreate.mock.calls[0][0]; + // 14:00 is outside quiet hours, should remain unchanged + expect(createCall.scheduledAt.getUTCHours()).toBe(14); + expect(createCall.scheduledAt.getUTCDate()).toBe(21); + }); + + it("passes appointmentId and type to job creation", async () => { + const resolvedPolicy: ResolvedPolicy = { + channels: ["system"], + timezone: "America/Argentina/Buenos_Aires", + }; + mockResolverResolve.mockResolvedValue(resolvedPolicy); + mockJobCreate.mockResolvedValue({ id: "job-1" }); + + const scheduledAt = new Date("2026-07-21T15:00:00.000Z"); + + await service.createJob({ + companyId: "company-1", + clientId: "client-1", + appointmentId: "appt-42", + type: "reminder", + scheduledAt, + }); + + const createCall = mockJobCreate.mock.calls[0][0]; + expect(createCall.appointmentId).toBe("appt-42"); + expect(createCall.type).toBe("reminder"); + expect(createCall.companyId).toBe("company-1"); + expect(createCall.clientId).toBe("client-1"); + }); + + it("creates immediate jobs without reminder offsets", async () => { + jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z")); + const resolvedPolicy: ResolvedPolicy = { + channels: ["system", "email"], + timezone: "America/Argentina/Buenos_Aires", + reminderRules: [{ offset: 30, enabled: true }], + }; + mockResolverResolve.mockResolvedValue(resolvedPolicy); + mockJobCreate.mockResolvedValue({ id: "job-1" }); + + const result = await service.createImmediateJob({ + companyId: "company-1", + clientId: "client-1", + appointmentId: "appt-1", + type: "reminder", + appointmentStart: new Date("2026-07-21T16:00:00.000Z"), + }); + + const createCalls = mockJobCreate.mock.calls.map((call) => call[0]); + expect(result).toHaveLength(2); + expect(mockJobCreate).toHaveBeenCalledTimes(2); + expect(createCalls).toEqual([ + expect.objectContaining({ + channel: "system", + type: "reminder", + scheduledAt: new Date("2026-07-21T14:00:00.000Z"), + }), + expect.objectContaining({ + channel: "email", + type: "reminder", + scheduledAt: new Date("2026-07-21T14:00:00.000Z"), + }), + ]); + }); + + it("creates immediate cancellation jobs with snapshot payload for each resolved channel", async () => { + jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z")); + const resolvedPolicy: ResolvedPolicy = { + channels: ["system", "email"], + timezone: "America/Argentina/Buenos_Aires", + }; + const payload = { + email: "client@example.com", + phoneNumber: "5491112345678", + userId: "user-1", + companyOwnerId: "owner-1", + subject: "Turno cancelado en Barber Shop", + message: "Tu turno fue cancelado.", + }; + mockResolverResolve.mockResolvedValue(resolvedPolicy); + mockJobCreate.mockResolvedValue({ id: "job-1" }); + + const result = await service.createJob({ + companyId: "company-1", + clientId: "client-1", + appointmentId: "appt-1", + type: "cancellation", + scheduledAt: new Date(), + payload, + }); + + const createCalls = mockJobCreate.mock.calls.map((call) => call[0]); + expect(result).toHaveLength(2); + expect(createCalls).toEqual([ + expect.objectContaining({ + appointmentId: "appt-1", + channel: "system", + type: "cancellation", + scheduledAt: new Date("2026-07-21T14:00:00.000Z"), + payload, + }), + expect.objectContaining({ + appointmentId: "appt-1", + channel: "email", + type: "cancellation", + scheduledAt: new Date("2026-07-21T14:00:00.000Z"), + payload, + }), + ]); + }); +}); diff --git a/server/src/Models/NotificationPolicies/__tests__/PolicyResolver.test.ts b/server/src/Models/NotificationPolicies/__tests__/PolicyResolver.test.ts new file mode 100644 index 0000000..7ef8589 --- /dev/null +++ b/server/src/Models/NotificationPolicies/__tests__/PolicyResolver.test.ts @@ -0,0 +1,312 @@ +import { NotificationChannel } from "../../NotificationJobs/NotificationJobs.Interface"; + +// Mock all external dependencies before importing the module under test +jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({ + __esModule: true, + default: { + checkFeature: jest.fn(), + }, +})); + +jest.mock("../CompanyNotificationPolicy.Adapter.Mongoose", () => ({ + CompanyNotificationPolicyAdapterMongoose: jest.fn().mockImplementation(() => ({ + findOne: jest.fn(), + })), +})); + +jest.mock("../ClientNotificationPreferences.Adapter.Mongoose", () => ({ + ClientNotificationPreferencesAdapterMongoose: jest.fn().mockImplementation(() => ({ + findOne: jest.fn(), + })), +})); + +jest.mock("../ClientCompanyNotificationOverride.Adapter.Mongoose", () => ({ + ClientCompanyNotificationOverrideAdapterMongoose: jest.fn().mockImplementation(() => ({ + findOne: jest.fn(), + })), +})); + +jest.mock("../../Companies/Companies", () => ({ + __esModule: true, + default: { + companies: { + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../Clients/Clients", () => ({ + __esModule: true, + default: { + clients: { + findOne: jest.fn(), + }, + }, +})); + +import { PolicyResolver } from "../PolicyResolver"; +import PlanSubscriptionsList from "../../PlanSubscriptions/PlanSubscriptons"; +import CompaniesManager from "../../Companies/Companies"; +import ClientsList from "../../Clients/Clients"; +import { PlanFeatures } from "../../Plans/Plans.interface"; + +describe("PolicyResolver", () => { + let resolver: PolicyResolver; + let mockCompanyPolicyFindOne: jest.Mock; + let mockClientPreferencesFindOne: jest.Mock; + let mockOverrideFindOne: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + resolver = new PolicyResolver(); + + // Access the mocked adapter instances + mockCompanyPolicyFindOne = ( + resolver as any + ).companyPolicyAdapter.findOne as jest.Mock; + mockClientPreferencesFindOne = ( + resolver as any + ).clientPreferencesAdapter.findOne as jest.Mock; + mockOverrideFindOne = ( + resolver as any + ).clientCompanyOverrideAdapter.findOne as jest.Mock; + + // Mock CompaniesManager to return a company with an ownerId + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-1", + ownerId: "owner-user-1", + }); + (ClientsList.clients.findOne as jest.Mock).mockResolvedValue({ + _id: "client-1", + userId: "client-user-1", + }); + }); + + it("returns system defaults when no policies exist", async () => { + mockCompanyPolicyFindOne.mockResolvedValue(null); + mockClientPreferencesFindOne.mockResolvedValue(null); + mockOverrideFindOne.mockResolvedValue(null); + (PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true); + + const result = await resolver.resolve("company-1", "client-1"); + + expect(result.channels).toEqual( + expect.arrayContaining(["whatsapp", "email", "system"]) + ); + expect(result.channels).toHaveLength(3); + }); + + it("uses company policy defaults when no client override exists", async () => { + mockCompanyPolicyFindOne.mockResolvedValue({ + companyId: "company-1", + defaultChannels: ["email" as NotificationChannel], + timezone: "America/Argentina/Buenos_Aires", + }); + mockClientPreferencesFindOne.mockResolvedValue(null); + mockOverrideFindOne.mockResolvedValue(null); + (PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true); + + const result = await resolver.resolve("company-1", "client-1"); + + expect(result.channels).toEqual(["email"]); + }); + + it("client override wins over company default", async () => { + mockCompanyPolicyFindOne.mockResolvedValue({ + companyId: "company-1", + defaultChannels: ["email" as NotificationChannel], + timezone: "America/Argentina/Buenos_Aires", + }); + mockClientPreferencesFindOne.mockResolvedValue(null); + mockOverrideFindOne.mockResolvedValue({ + clientId: "client-1", + companyId: "company-1", + preferredChannels: ["whatsapp" as NotificationChannel], + mutedChannels: [], + }); + (PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true); + + const result = await resolver.resolve("company-1", "client-1"); + + expect(result.channels).toEqual(["whatsapp"]); + }); + + it("client global preferences used when no override exists", async () => { + mockCompanyPolicyFindOne.mockResolvedValue({ + companyId: "company-1", + defaultChannels: ["whatsapp" as NotificationChannel, "email" as NotificationChannel], + timezone: "America/Argentina/Buenos_Aires", + }); + mockClientPreferencesFindOne.mockResolvedValue({ + userId: "client-user-1", + preferredChannels: ["email" as NotificationChannel], + mutedChannels: [], + }); + mockOverrideFindOne.mockResolvedValue(null); + (PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true); + + const result = await resolver.resolve("company-1", "client-1"); + + expect(result.channels).toEqual(["email"]); + }); + + it("excludes muted channels from override", async () => { + mockCompanyPolicyFindOne.mockResolvedValue(null); + mockClientPreferencesFindOne.mockResolvedValue(null); + mockOverrideFindOne.mockResolvedValue({ + clientId: "client-1", + companyId: "company-1", + preferredChannels: [ + "whatsapp" as NotificationChannel, + "email" as NotificationChannel, + ], + mutedChannels: ["whatsapp" as NotificationChannel], + }); + (PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true); + + const result = await resolver.resolve("company-1", "client-1"); + + expect(result.channels).toEqual(["email"]); + expect(result.channels).not.toContain("whatsapp"); + }); + + it("excludes muted channels from client preferences", async () => { + mockCompanyPolicyFindOne.mockResolvedValue(null); + mockClientPreferencesFindOne.mockResolvedValue({ + userId: "client-user-1", + preferredChannels: [ + "whatsapp" as NotificationChannel, + "email" as NotificationChannel, + "system" as NotificationChannel, + ], + mutedChannels: ["email" as NotificationChannel], + }); + mockOverrideFindOne.mockResolvedValue(null); + (PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true); + + const result = await resolver.resolve("company-1", "client-1"); + + expect(result.channels).toEqual( + expect.arrayContaining(["whatsapp", "system"]) + ); + expect(result.channels).not.toContain("email"); + }); + + it("loads global preferences by the appointment client's userId", async () => { + mockCompanyPolicyFindOne.mockResolvedValue(null); + mockClientPreferencesFindOne.mockResolvedValue({ + userId: "client-user-1", + preferredChannels: ["system" as NotificationChannel], + mutedChannels: [], + }); + mockOverrideFindOne.mockResolvedValue(null); + (PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true); + + const result = await resolver.resolve("company-1", "client-1"); + + expect(ClientsList.clients.findOne).toHaveBeenCalledWith({ _id: "client-1" }); + expect(mockClientPreferencesFindOne).toHaveBeenCalledWith({ userId: "client-user-1" }); + expect(result.channels).toEqual(["system"]); + }); + + it("plan blocks WhatsApp when feature not available", async () => { + mockCompanyPolicyFindOne.mockResolvedValue({ + companyId: "company-1", + defaultChannels: [ + "whatsapp" as NotificationChannel, + "email" as NotificationChannel, + ], + timezone: "America/Argentina/Buenos_Aires", + }); + mockClientPreferencesFindOne.mockResolvedValue(null); + mockOverrideFindOne.mockResolvedValue(null); + (PlanSubscriptionsList.checkFeature as jest.Mock).mockImplementation( + async (params: { feature: string }) => { + if (params.feature === PlanFeatures.WAP_NOTIFICATIONS) return false; + return true; + } + ); + + const result = await resolver.resolve("company-1", "client-1"); + + expect(result.channels).not.toContain("whatsapp"); + expect(result.channels).toContain("email"); + }); + + it("plan blocks email when feature not available", async () => { + mockCompanyPolicyFindOne.mockResolvedValue({ + companyId: "company-1", + defaultChannels: [ + "whatsapp" as NotificationChannel, + "email" as NotificationChannel, + ], + timezone: "America/Argentina/Buenos_Aires", + }); + mockClientPreferencesFindOne.mockResolvedValue(null); + mockOverrideFindOne.mockResolvedValue(null); + (PlanSubscriptionsList.checkFeature as jest.Mock).mockImplementation( + async (params: { feature: string }) => { + if (params.feature === PlanFeatures.MAIL_NOTIFICATIONS) return false; + return true; + } + ); + + const result = await resolver.resolve("company-1", "client-1"); + + expect(result.channels).not.toContain("email"); + expect(result.channels).toContain("whatsapp"); + }); + + it("returns timezone from company policy", async () => { + mockCompanyPolicyFindOne.mockResolvedValue({ + companyId: "company-1", + defaultChannels: ["system" as NotificationChannel], + timezone: "America/New_York", + quietHours: { from: "22:00", to: "07:00" }, + }); + mockClientPreferencesFindOne.mockResolvedValue(null); + mockOverrideFindOne.mockResolvedValue(null); + (PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true); + + const result = await resolver.resolve("company-1", "client-1"); + + expect(result.timezone).toBe("America/New_York"); + expect(result.quietHours).toEqual({ from: "22:00", to: "07:00" }); + }); + + it("returns default timezone when no company policy", async () => { + mockCompanyPolicyFindOne.mockResolvedValue(null); + mockClientPreferencesFindOne.mockResolvedValue(null); + mockOverrideFindOne.mockResolvedValue(null); + (PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true); + + const result = await resolver.resolve("company-1", "client-1"); + + expect(result.timezone).toBe("America/Argentina/Buenos_Aires"); + }); + + it("returns empty channels when all are muted via override", async () => { + mockCompanyPolicyFindOne.mockResolvedValue(null); + mockClientPreferencesFindOne.mockResolvedValue(null); + mockOverrideFindOne.mockResolvedValue({ + clientId: "client-1", + companyId: "company-1", + preferredChannels: [ + "whatsapp" as NotificationChannel, + "email" as NotificationChannel, + "system" as NotificationChannel, + ], + mutedChannels: [ + "whatsapp" as NotificationChannel, + "email" as NotificationChannel, + "system" as NotificationChannel, + ], + }); + (PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true); + + const result = await resolver.resolve("company-1", "client-1"); + + expect(result.channels).toEqual([]); + }); +}); diff --git a/server/src/Models/SystemNotifications/SystemNotification.Interface.ts b/server/src/Models/SystemNotifications/SystemNotification.Interface.ts index de01e44..2d40cdb 100644 --- a/server/src/Models/SystemNotifications/SystemNotification.Interface.ts +++ b/server/src/Models/SystemNotifications/SystemNotification.Interface.ts @@ -63,6 +63,11 @@ export type CreateSystemNotificationParams = { code?: string; }; +export type CreateSystemNotificationBySystemParams = CreateSystemNotificationParams & { + userId: string; + systemToken: string; +}; + export type UpdateSystemNotificationParams = { id: string; readed: boolean; @@ -119,6 +124,7 @@ export interface ISystemNotificationsAdapter { export interface ISystemNotificationsManager { notifications: ISystemNotificationsAdapter; createNotification(data: CreateSystemNotificationParams): Promise; + createNotificationBySystem(data: CreateSystemNotificationBySystemParams): Promise; updateNotification(data: UpdateSystemNotificationParams): Promise; deleteNotification(data: DeleteSystemNotificationParams): Promise; deleteConversationNotifications(data: DeleteSystemNotificationsByConversation): Promise; diff --git a/server/src/Models/SystemNotifications/SystemNotification.ts b/server/src/Models/SystemNotifications/SystemNotification.ts index 0548889..7ea7a8a 100644 --- a/server/src/Models/SystemNotifications/SystemNotification.ts +++ b/server/src/Models/SystemNotifications/SystemNotification.ts @@ -1,6 +1,7 @@ import CompaniesManager from "../Companies/Companies"; import { CreateSystemNotificationParams, + CreateSystemNotificationBySystemParams, DeleteNotificationsByDateParams, DeleteNotificationsByUserParams, DeleteSystemNotificationParams, @@ -108,6 +109,28 @@ class SystemNotificationsManager implements ISystemNotificationsManager { return newNotification; } + public async createNotificationBySystem( + data: CreateSystemNotificationBySystemParams + ): Promise { + const expectedToken = process.env.SYSTEM_KEY || process.env.API_KEY; + + if (!expectedToken || data.systemToken !== expectedToken) { + throw new Error("Token de sistema inválido"); + } + + const { systemToken, ...notificationData } = data; + const notification = await this.createNotification(notificationData); + + const countUnreadNotifications = await this.finUnreadNotifications({ + userId: data.userId, + sessionUser: data.userId, + }); + + io.to(`user:${data.userId}`).emit("new_system_notification", countUnreadNotifications); + + return notification; + } + public async updateNotification(data: UpdateSystemNotificationParams): Promise { await validateSessionUser({ sessionUser: data.sessionUser, diff --git a/server/src/Models/Templates/Templates.Interface.ts b/server/src/Models/Templates/Templates.Interface.ts index 828aaeb..79a2b3b 100644 --- a/server/src/Models/Templates/Templates.Interface.ts +++ b/server/src/Models/Templates/Templates.Interface.ts @@ -4,8 +4,10 @@ import { TextObjectFilterResult } from "../TextObjectFilter.model"; export enum TemplateTypes { WAP_NEW_APPOINTMENT = "wap-new-appointment", WAP_REMINDER = "wap-reminder", + WAP_CANCELLATION = "wap-cancellation", EMAIL_NEW_APPOINTMENT = "email-new-appointment", EMAIL_REMINDER = "email-reminder", + EMAIL_CANCELLATION = "email-cancellation", } export interface ITemplate { id?: string; diff --git a/server/src/Models/Templates/Templates.ts b/server/src/Models/Templates/Templates.ts index 699f53e..dcd0b02 100644 --- a/server/src/Models/Templates/Templates.ts +++ b/server/src/Models/Templates/Templates.ts @@ -215,12 +215,18 @@ export class TemplatesManager implements ITemplatesManager { case TemplateTypes.WAP_NEW_APPOINTMENT: defaultTemplate = `${process.env.WAP_ALTA}`; break; + case TemplateTypes.WAP_CANCELLATION: + defaultTemplate = `${process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || ""}`; + break; case TemplateTypes.EMAIL_REMINDER: defaultTemplate = `${process.env.WAP_REMINDER}`; break; case TemplateTypes.EMAIL_NEW_APPOINTMENT: defaultTemplate = `${process.env.WAP_ALTA}`; break; + case TemplateTypes.EMAIL_CANCELLATION: + defaultTemplate = `${process.env.EMAIL_CANCELLATION || process.env.EMAIL_CANCELACION || process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || ""}`; + break; } return defaultTemplate; } diff --git a/server/src/api/Appointments/Appointments.Controller.ts b/server/src/api/Appointments/Appointments.Controller.ts index 5519cb2..1e22c59 100644 --- a/server/src/api/Appointments/Appointments.Controller.ts +++ b/server/src/api/Appointments/Appointments.Controller.ts @@ -5,6 +5,7 @@ import { AppointmentAdminByClientView, AppointmentAdminByDateView, CreateAppointmentParams, + CreateImmediateAppointmentNotificationJobsParams, DeleteAppointmentParams, FindAppointmentsParams, FindAppointmentSchedulesParams, @@ -28,6 +29,7 @@ import { AppointmentService } from "./Appointments.Service"; import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model"; import { rateLimiter } from "../../middleware/ratelimiter"; +import { systemTokenMiddleware } from "../../middleware/systemToken"; @Route("appointments/create") @Middlewares(authenticateMiddleware) @@ -294,7 +296,7 @@ export class SendWapNotificationContrnoller extends Controller { } @Route("appointments/send-wap-sys-notification") -@Middlewares(rateLimiter) +@Middlewares(systemTokenMiddleware) export class SendWapSystemNotificationContrnoller extends Controller { @Response(500, "Ha ocurrido un error") @SuccessResponse(200, "Done") @@ -319,7 +321,7 @@ export class SendWapSystemNotificationContrnoller extends Controller { } @Route("appointments/send-email-notification") -@Middlewares(rateLimiter) +@Middlewares([rateLimiter, systemTokenMiddleware]) export class SendEmailNotificationContrnoller extends Controller { @Response(500, "Ha ocurrido un error") @SuccessResponse(200, "Done") @@ -342,6 +344,30 @@ export class SendEmailNotificationContrnoller extends Controller { } } +@Route("appointments/create-immediate-notification-jobs") +@Middlewares(authenticateMiddleware) +export class CreateImmediateAppointmentNotificationJobsController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Done") + @Post() + public async createImmediateNotificationJobs( + @Body() requestBody: CreateImmediateAppointmentNotificationJobsParams + ): Promise { + try { + await new AppointmentService().createImmediateNotificationJobs(requestBody); + this.setStatus(200); + return { + message: "Done", + success: true, + }; + } catch (e) { + const errorOccurred: Error = e as Error; console.error("GET EVENT ERROR:", errorOccurred); + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + @Route("appointments/apply-discount") @Middlewares(authenticateMiddleware) export class ApplyAppointmentDiscountContrnoller extends Controller { diff --git a/server/src/api/Appointments/Appointments.Service.ts b/server/src/api/Appointments/Appointments.Service.ts index 766c75b..fbf91a7 100644 --- a/server/src/api/Appointments/Appointments.Service.ts +++ b/server/src/api/Appointments/Appointments.Service.ts @@ -1,6 +1,7 @@ import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model"; import AppointmentList from "../../Models/Appointments/Appointments"; import { + CreateImmediateAppointmentNotificationJobsParams, AppointmentAdminByClientView, AppointmentAdminByDateView, CreateAppointmentParams, @@ -161,6 +162,11 @@ export class AppointmentService { await AppointmentList.sendEmailNotification(data); } + public async createImmediateNotificationJobs(data: CreateImmediateAppointmentNotificationJobsParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + await AppointmentList.createImmediateNotificationJobs(data); + } + public async applyDiscount(data: ApplyAppointmentDiscountParams): Promise { await connect(`${process.env.DATABASE_CONNECTION}`); await AppointmentList.applyDiscount(data); diff --git a/server/src/api/Notifications/NotificationPolicies.Controller.ts b/server/src/api/Notifications/NotificationPolicies.Controller.ts new file mode 100644 index 0000000..5cc9167 --- /dev/null +++ b/server/src/api/Notifications/NotificationPolicies.Controller.ts @@ -0,0 +1,150 @@ +import { ApiValidationError } from "../../Models/Server.Error.model"; +import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa"; +import { authenticateMiddleware } from "../../middleware/authentication"; +import { + NotificationPoliciesService, + FindCompanyPolicyParams, + UpsertCompanyPolicyParams, + FindClientPreferencesParams, + UpsertClientPreferencesParams, + FindClientCompanyOverrideParams, + UpsertClientCompanyOverrideParams, +} from "./NotificationPolicies.Service"; +import { ICompanyNotificationPolicy } from "../../Models/NotificationPolicies/CompanyNotificationPolicy.Interface"; +import { IClientNotificationPreferences } from "../../Models/NotificationPolicies/ClientNotificationPreferences.Interface"; +import { IClientCompanyNotificationOverride } from "../../Models/NotificationPolicies/ClientCompanyNotificationOverride.Interface"; + +@Route("notifications/policy/find") +@Middlewares(authenticateMiddleware) +export class FindCompanyNotificationPolicyController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Política encontrada") + @Post() + public async findCompanyPolicy( + @Body() requestBody: FindCompanyPolicyParams + ): Promise { + try { + const policy = await new NotificationPoliciesService().getCompanyPolicy(requestBody); + this.setStatus(200); + if (!policy) { + return new ApiValidationError(404, "No se encontró una política de notificación para esta organización"); + } + return policy; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("notifications/policy/save") +@Middlewares(authenticateMiddleware) +export class UpsertCompanyNotificationPolicyController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Política guardada con éxito") + @Post() + public async upsertCompanyPolicy( + @Body() requestBody: UpsertCompanyPolicyParams + ): Promise { + try { + const policy = await new NotificationPoliciesService().upsertCompanyPolicy(requestBody); + this.setStatus(200); + return policy; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("notifications/preferences/find") +@Middlewares(authenticateMiddleware) +export class FindClientNotificationPreferencesController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Preferencias encontradas") + @Post() + public async findClientPreferences( + @Body() requestBody: FindClientPreferencesParams + ): Promise { + try { + const preferences = await new NotificationPoliciesService().getClientPreferences(requestBody); + this.setStatus(200); + if (!preferences) { + return new ApiValidationError(404, "No se encontraron preferencias de notificación para este cliente"); + } + return preferences; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("notifications/preferences/save") +@Middlewares(authenticateMiddleware) +export class UpsertClientNotificationPreferencesController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Preferencias guardadas con éxito") + @Post() + public async upsertClientPreferences( + @Body() requestBody: UpsertClientPreferencesParams + ): Promise { + try { + const preferences = await new NotificationPoliciesService().upsertClientPreferences(requestBody); + this.setStatus(200); + return preferences; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("notifications/override/find") +@Middlewares(authenticateMiddleware) +export class FindClientCompanyNotificationOverrideController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Override encontrado") + @Post() + public async findClientCompanyOverride( + @Body() requestBody: FindClientCompanyOverrideParams + ): Promise { + try { + const override = await new NotificationPoliciesService().getClientCompanyOverride(requestBody); + this.setStatus(200); + if (!override) { + return new ApiValidationError(404, "No se encontró una preferencia específica para este cliente en esta organización"); + } + return override; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("notifications/override/save") +@Middlewares(authenticateMiddleware) +export class UpsertClientCompanyNotificationOverrideController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Override guardado con éxito") + @Post() + public async upsertClientCompanyOverride( + @Body() requestBody: UpsertClientCompanyOverrideParams + ): Promise { + try { + const override = await new NotificationPoliciesService().upsertClientCompanyOverride(requestBody); + this.setStatus(200); + return override; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} diff --git a/server/src/api/Notifications/NotificationPolicies.Service.ts b/server/src/api/Notifications/NotificationPolicies.Service.ts new file mode 100644 index 0000000..69ba839 --- /dev/null +++ b/server/src/api/Notifications/NotificationPolicies.Service.ts @@ -0,0 +1,132 @@ +import { connect } from "mongoose"; +import CompanyNotificationPolicy from "../../Models/NotificationPolicies/CompanyNotificationPolicy"; +import ClientNotificationPreferences from "../../Models/NotificationPolicies/ClientNotificationPreferences"; +import ClientCompanyNotificationOverride from "../../Models/NotificationPolicies/ClientCompanyNotificationOverride"; +import { + ICompanyNotificationPolicy, +} from "../../Models/NotificationPolicies/CompanyNotificationPolicy.Interface"; +import { + IClientNotificationPreferences, +} from "../../Models/NotificationPolicies/ClientNotificationPreferences.Interface"; +import { + IClientCompanyNotificationOverride, +} from "../../Models/NotificationPolicies/ClientCompanyNotificationOverride.Interface"; +import { NotificationChannel } from "../../Models/NotificationJobs/NotificationJobs.Interface"; + +export interface SessionUserParams { + sessionUser: string; +} + +export interface FindCompanyPolicyParams extends SessionUserParams { + companyId: string; +} + +export interface UpsertCompanyPolicyParams extends SessionUserParams { + companyId: string; + defaultChannels?: NotificationChannel[]; + timezone?: string; + quietHours?: { from: string; to: string }; + reminderRules?: { offset: number; enabled: boolean }[]; +} + +export interface FindClientPreferencesParams extends SessionUserParams { + clientId: string; +} + +export interface UpsertClientPreferencesParams extends SessionUserParams { + clientId: string; + preferredChannels?: NotificationChannel[]; + mutedChannels?: NotificationChannel[]; +} + +export interface FindClientCompanyOverrideParams extends SessionUserParams { + clientId: string; + companyId: string; +} + +export interface UpsertClientCompanyOverrideParams extends SessionUserParams { + clientId: string; + companyId: string; + preferredChannels?: NotificationChannel[]; + mutedChannels?: NotificationChannel[]; +} + +export class NotificationPoliciesService { + public async getCompanyPolicy( + params: FindCompanyPolicyParams + ): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return CompanyNotificationPolicy.findOne(params); + } + + public async upsertCompanyPolicy( + params: UpsertCompanyPolicyParams + ): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + const data: Partial< + Omit + > = {}; + if (params.defaultChannels !== undefined) data.defaultChannels = params.defaultChannels; + if (params.timezone !== undefined) data.timezone = params.timezone; + if (params.quietHours !== undefined) data.quietHours = params.quietHours; + if (params.reminderRules !== undefined) data.reminderRules = params.reminderRules; + return CompanyNotificationPolicy.upsert({ + companyId: params.companyId, + sessionUser: params.sessionUser, + data, + }); + } + + public async getClientPreferences( + params: FindClientPreferencesParams + ): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return ClientNotificationPreferences.findOne({ + userId: params.clientId, + sessionUser: params.sessionUser, + }); + } + + public async upsertClientPreferences( + params: UpsertClientPreferencesParams + ): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + const data: Partial< + Omit + > = {}; + if (params.preferredChannels !== undefined) data.preferredChannels = params.preferredChannels; + if (params.mutedChannels !== undefined) data.mutedChannels = params.mutedChannels; + return ClientNotificationPreferences.upsert({ + userId: params.clientId, + sessionUser: params.sessionUser, + data, + }); + } + + public async getClientCompanyOverride( + params: FindClientCompanyOverrideParams + ): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return ClientCompanyNotificationOverride.findOne(params); + } + + public async upsertClientCompanyOverride( + params: UpsertClientCompanyOverrideParams + ): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + const data: Partial< + Omit< + IClientCompanyNotificationOverride, + "id" | "clientId" | "companyId" | "createdAt" | "updatedAt" + > + > = {}; + if (params.preferredChannels !== undefined) data.preferredChannels = params.preferredChannels; + if (params.mutedChannels !== undefined) data.mutedChannels = params.mutedChannels; + return ClientCompanyNotificationOverride.upsert({ + clientId: params.clientId, + companyId: params.companyId, + sessionUser: params.sessionUser, + data, + }); + } +} diff --git a/server/src/api/Notifications/Notifications.Controller.ts b/server/src/api/Notifications/Notifications.Controller.ts new file mode 100644 index 0000000..0a86f97 --- /dev/null +++ b/server/src/api/Notifications/Notifications.Controller.ts @@ -0,0 +1,61 @@ +import { Body, Controller, Middlewares, Post, Route, SuccessResponse } from "tsoa"; +import { rateLimiter } from "../../middleware/ratelimiter"; +import { systemTokenMiddleware } from "../../middleware/systemToken"; +import { NotificationsManager } from "../../Models/Notifications/Notifications"; +import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; + +interface SendEmailByContentParams { + systemToken: string; + email: string; + subject: string; + message: string; +} + +interface SendWapByContentParams { + systemToken: string; + phoneNumber: string; + message: string; + companyId: string; + sessionUser: string; +} + +@Route("notifications/send-email") +@Middlewares([rateLimiter, systemTokenMiddleware]) +export class SendEmailByContentController extends Controller { + @SuccessResponse(200, "Done") + @Post() + public async sendEmailByContent(@Body() requestBody: SendEmailByContentParams): Promise { + await NotificationsManager.sendEmail({ + email: requestBody.email, + subject: requestBody.subject, + message: requestBody.message, + }); + + this.setStatus(200); + return { + message: "Done", + success: true, + }; + } +} + +@Route("notifications/send-wap") +@Middlewares([rateLimiter, systemTokenMiddleware]) +export class SendWapByContentController extends Controller { + @SuccessResponse(200, "Done") + @Post() + public async sendWapByContent(@Body() requestBody: SendWapByContentParams): Promise { + await NotificationsManager.sendWap({ + phoneNumber: requestBody.phoneNumber, + message: requestBody.message, + companyId: requestBody.companyId, + sessionUser: requestBody.sessionUser, + }); + + this.setStatus(200); + return { + message: "Done", + success: true, + }; + } +} diff --git a/server/src/api/SystemNotifications/SystemNotifications.Controller.ts b/server/src/api/SystemNotifications/SystemNotifications.Controller.ts index 73bef1b..92cd680 100644 --- a/server/src/api/SystemNotifications/SystemNotifications.Controller.ts +++ b/server/src/api/SystemNotifications/SystemNotifications.Controller.ts @@ -5,6 +5,8 @@ import { authenticateMiddleware } from "../../middleware/authentication"; import { DeleteNotificationsByDateParams, DeleteSystemNotificationParams, + CreateSystemNotificationBySystemParams, + ISystemNotification, FindSystemNotificationsParams, PaginateSystemNotificationsParams, PaginateSystemNotificationsResults, @@ -13,6 +15,7 @@ import { UpdateSystemNotificationParams, } from "../../Models/SystemNotifications/SystemNotification.Interface"; import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; +import { systemTokenMiddleware } from "../../middleware/systemToken"; @Route("systemnotifications/set-state") @Middlewares(authenticateMiddleware) @@ -151,3 +154,24 @@ export class DeleteSystemNotificationsByDateController extends Controller { } } } + +@Route("systemnotifications/create") +@Middlewares(systemTokenMiddleware) +export class CreateSystemNotificationsBySystemController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Done") + @Post() + public async createSystemNotification( + @Body() requestBody: CreateSystemNotificationBySystemParams + ): Promise { + try { + const notification = await new SystemNotificationsService().createSystemNotification(requestBody); + this.setStatus(200); + return notification; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} diff --git a/server/src/api/SystemNotifications/SystemNotifications.Service.ts b/server/src/api/SystemNotifications/SystemNotifications.Service.ts index ce96baf..5ab94b7 100644 --- a/server/src/api/SystemNotifications/SystemNotifications.Service.ts +++ b/server/src/api/SystemNotifications/SystemNotifications.Service.ts @@ -3,6 +3,8 @@ import SystemNoficationsList from "../../Models/SystemNotifications/SystemNotifi import { DeleteNotificationsByDateParams, DeleteSystemNotificationParams, + CreateSystemNotificationBySystemParams, + ISystemNotification, FindSystemNotificationsParams, PaginateSystemNotificationsParams, PaginateSystemNotificationsResults, @@ -48,4 +50,12 @@ export class SystemNotificationsService { const notifications = await SystemNoficationsList.finUnreadNotifications(data); return notifications; } + + public async createSystemNotification( + data: CreateSystemNotificationBySystemParams + ): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + const notification = await SystemNoficationsList.createNotificationBySystem(data); + return notification; + } } diff --git a/server/src/middleware/__tests__/systemToken.test.ts b/server/src/middleware/__tests__/systemToken.test.ts new file mode 100644 index 0000000..3ae846c --- /dev/null +++ b/server/src/middleware/__tests__/systemToken.test.ts @@ -0,0 +1,60 @@ +import { Request, Response } from "express"; +import { systemTokenMiddleware } from "../systemToken"; + +describe("systemTokenMiddleware", () => { + const originalSystemKey = process.env.SYSTEM_KEY; + const originalApiKey = process.env.API_KEY; + + afterEach(() => { + process.env.SYSTEM_KEY = originalSystemKey; + process.env.API_KEY = originalApiKey; + jest.clearAllMocks(); + }); + + function createResponse(): Response { + return { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + } as unknown as Response; + } + + it("allows requests with a valid system token", async () => { + process.env.SYSTEM_KEY = "system-token"; + const request = { body: { systemToken: "system-token" } } as Request; + const response = createResponse(); + const next = jest.fn(); + + await systemTokenMiddleware(request, response, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(response.status).not.toHaveBeenCalled(); + }); + + it("rejects requests without a valid system token", async () => { + process.env.SYSTEM_KEY = "system-token"; + const request = { body: { systemToken: "wrong-token" } } as Request; + const response = createResponse(); + const next = jest.fn(); + + await systemTokenMiddleware(request, response, next); + + expect(next).not.toHaveBeenCalled(); + expect(response.status).toHaveBeenCalledWith(401); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ desc: "Token de sistema inválido" }) + ); + }); + + it("rejects requests when no server-side system token is configured", async () => { + delete process.env.SYSTEM_KEY; + delete process.env.API_KEY; + const request = { body: { systemToken: "system-token" } } as Request; + const response = createResponse(); + const next = jest.fn(); + + await systemTokenMiddleware(request, response, next); + + expect(next).not.toHaveBeenCalled(); + expect(response.status).toHaveBeenCalledWith(401); + }); +}); diff --git a/server/src/middleware/systemToken.ts b/server/src/middleware/systemToken.ts new file mode 100644 index 0000000..8bdf07c --- /dev/null +++ b/server/src/middleware/systemToken.ts @@ -0,0 +1,19 @@ +import { NextFunction, Request, Response } from "express"; +import { ApiValidationError } from "../Models/Server.Error.model"; + +export async function systemTokenMiddleware( + request: Request, + response: Response, + next: NextFunction +): Promise { + const expectedSystemToken = process.env.SYSTEM_KEY || process.env.API_KEY; + const systemToken = request.body?.systemToken; + + if (!expectedSystemToken || typeof systemToken !== "string" || systemToken !== expectedSystemToken) { + return response + .status(401) + .json(new ApiValidationError(401, "Token de sistema inválido")); + } + + next(); +} diff --git a/txclient/src/Models/Appointments.model.ts b/txclient/src/Models/Appointments.model.ts index 624bedb..fc86951 100644 --- a/txclient/src/Models/Appointments.model.ts +++ b/txclient/src/Models/Appointments.model.ts @@ -30,6 +30,7 @@ export type UpdateAppointmentParams = { export enum APPOINTMENT_NOTIFICATION_TYPE { "CREATION" = "creation", "REMINDER" = "reminder", + "CANCELLATION" = "cancellation", } export type SendAppointmentNotificationParams = { @@ -41,6 +42,7 @@ export type SendAppointmentNotificationParams = { export type DeleteAppointmentParams = { id: string; validation?: boolean; + notification?: boolean; sessionUser: string; }; diff --git a/txclient/src/Models/Company.model.ts b/txclient/src/Models/Company.model.ts index 499fd62..cf9b34d 100644 --- a/txclient/src/Models/Company.model.ts +++ b/txclient/src/Models/Company.model.ts @@ -39,8 +39,10 @@ export interface MyOranizationsView { appointmentAlert: string; templateWapNotifId: string; templateWapAltaId: string; + templateWapCancellationId: string; templateEmailNotifId: string; templateEmailAltaId: string; + templateEmailCancellationId: string; onboardingStep?: number; onboardingCompleted?: boolean; } @@ -85,8 +87,10 @@ export type UpdateCompanyParams = { sessionUser: string; templateWapNotifId?: string; templateWapAltaId?: string; + templateWapCancellationId?: string; templateEmailNotifId?: string; templateEmailAltaId?: string; + templateEmailCancellationId?: string; onboardingStep?: number; onboardingCompleted?: boolean; }; @@ -146,8 +150,10 @@ export interface ICompany { appointmentAlert?: string; templateWapNotifId?: string; templateWapAltaId?: string; + templateWapCancellationId?: string; templateEmailNotifId?: string; templateEmailAltaId?: string; + templateEmailCancellationId?: string; onboardingStep?: number; onboardingCompleted?: boolean; fixedPostIds?: Array; diff --git a/txclient/src/Models/NotificationPreferences.model.ts b/txclient/src/Models/NotificationPreferences.model.ts new file mode 100644 index 0000000..5bcc06c --- /dev/null +++ b/txclient/src/Models/NotificationPreferences.model.ts @@ -0,0 +1,63 @@ +export type NotificationChannel = "whatsapp" | "email" | "system"; + +export interface QuietHours { + from: string; // "HH:mm" + to: string; // "HH:mm" +} + +export interface ReminderRule { + offset: number; // minutes before appointment + enabled: boolean; +} + +export interface CompanyNotificationPolicy { + id?: string; + companyId: string; + defaultChannels: NotificationChannel[]; + mutedChannels?: NotificationChannel[]; + timezone: string; + quietHours?: QuietHours; + reminderRules?: ReminderRule[]; + createdAt: Date; + updatedAt: Date; +} + +export interface ClientNotificationPreferences { + id?: string; + userId: string; + preferredChannels: NotificationChannel[]; + mutedChannels: NotificationChannel[]; + createdAt: Date; + updatedAt: Date; +} + +export interface ClientCompanyNotificationOverride { + id?: string; + clientId: string; + companyId: string; + preferredChannels: NotificationChannel[]; + mutedChannels: NotificationChannel[]; + createdAt: Date; + updatedAt: Date; +} + +// API params (match backend UpsertCompanyPolicyParams) +export interface UpsertCompanyPolicyParams { + defaultChannels?: NotificationChannel[]; + timezone?: string; + quietHours?: QuietHours; + reminderRules?: ReminderRule[]; +} + +export interface UpsertClientPreferencesParams { + preferredChannels?: NotificationChannel[]; + mutedChannels?: NotificationChannel[]; +} + +export interface UpsertClientCompanyOverrideParams { + preferredChannels?: NotificationChannel[]; + mutedChannels?: NotificationChannel[]; +} + +// Store mode +export type NotificationFormMode = "company-policy" | "client-override" | "client-preferences"; diff --git a/txclient/src/Models/NotificationPreferences.validation.ts b/txclient/src/Models/NotificationPreferences.validation.ts new file mode 100644 index 0000000..94b1ad7 --- /dev/null +++ b/txclient/src/Models/NotificationPreferences.validation.ts @@ -0,0 +1,44 @@ +import * as Yup from "yup"; + +const validChannels = ["whatsapp", "email", "system"] as const; + +export const companyPolicySchema = Yup.object().shape({ + defaultChannels: Yup.array() + .of(Yup.string().oneOf(validChannels)) + .min(1, "Select at least one channel") + .required("Channels are required"), + timezone: Yup.string().required("Timezone is required"), + quietHours: Yup.object() + .shape({ + from: Yup.string().matches( + /^([01]\d|2[0-3]):([0-5]\d)$/, + "Invalid time format (HH:mm)" + ), + to: Yup.string().matches( + /^([01]\d|2[0-3]):([0-5]\d)$/, + "Invalid time format (HH:mm)" + ) + }) + .optional(), + reminderRules: Yup.array() + .of( + Yup.object().shape({ + offset: Yup.number() + .min(5, "Minimum 5 minutes") + .max(10080, "Maximum 7 days (10080 minutes)") + .required("Offset is required"), + enabled: Yup.boolean().required() + }) + ) + .max(5, "Maximum 5 reminder rules") + .optional() +}); + +export const clientPrefsSchema = Yup.object().shape({ + preferredChannels: Yup.array() + .of(Yup.string().oneOf(validChannels)) + .optional(), + mutedChannels: Yup.array() + .of(Yup.string().oneOf(validChannels)) + .optional() +}); diff --git a/txclient/src/Models/Templates.model.ts b/txclient/src/Models/Templates.model.ts index 6efa8cc..ec8809c 100644 --- a/txclient/src/Models/Templates.model.ts +++ b/txclient/src/Models/Templates.model.ts @@ -1,8 +1,10 @@ export enum TemplateTypes { WAP_NEW_APPOINTMENT = "wap-new-appointment", WAP_REMINDER = "wap-reminder", + WAP_CANCELLATION = "wap-cancellation", EMAIL_NEW_APPOINTMENT = "email-new-appointment", EMAIL_REMINDER = "email-reminder", + EMAIL_CANCELLATION = "email-cancellation", } export const refactorFields = (text: string) => { diff --git a/txclient/src/Services/Notifications.Service.ts b/txclient/src/Services/Notifications.Service.ts new file mode 100644 index 0000000..e0af9ff --- /dev/null +++ b/txclient/src/Services/Notifications.Service.ts @@ -0,0 +1,27 @@ +import ApiRequest from "@services/Api.Service"; +import { + CompanyNotificationPolicy, + ClientNotificationPreferences, + ClientCompanyNotificationOverride, + UpsertCompanyPolicyParams, + UpsertClientPreferencesParams, + UpsertClientCompanyOverrideParams +} from "@models/NotificationPreferences.model"; + +export const getCompanyPolicy = (companyId: string, sessionUser: string): Promise => + ApiRequest.post("notifications/policy/find", { companyId, sessionUser }); + +export const upsertCompanyPolicy = (companyId: string, sessionUser: string, data: UpsertCompanyPolicyParams): Promise => + ApiRequest.post("notifications/policy/save", { companyId, sessionUser, ...data }); + +export const getClientPreferences = (userId: string, sessionUser: string): Promise => + ApiRequest.post("notifications/preferences/find", { clientId: userId, sessionUser }); + +export const upsertClientPreferences = (userId: string, sessionUser: string, data: UpsertClientPreferencesParams): Promise => + ApiRequest.post("notifications/preferences/save", { clientId: userId, sessionUser, ...data }); + +export const getClientCompanyOverride = (clientId: string, companyId: string, sessionUser: string): Promise => + ApiRequest.post("notifications/override/find", { clientId, companyId, sessionUser }); + +export const upsertClientCompanyOverride = (clientId: string, companyId: string, sessionUser: string, data: UpsertClientCompanyOverrideParams): Promise => + ApiRequest.post("notifications/override/save", { clientId, companyId, sessionUser, ...data }); diff --git a/txclient/src/Store/Confirm.Store.ts b/txclient/src/Store/Confirm.Store.ts index 6d49e8d..616ff29 100644 --- a/txclient/src/Store/Confirm.Store.ts +++ b/txclient/src/Store/Confirm.Store.ts @@ -1,12 +1,15 @@ import { create } from "zustand"; +import type { ReactNode } from "react"; interface IConfirmStore { visible: boolean; message: string; + content?: ReactNode; title: string; onConfirm: () => void; onCancel: () => void; show: (message: string, onConfirm: () => void, onCancel: () => void) => void; + showContent: (message: string, content: ReactNode, onConfirm: () => void, onCancel: () => void) => void; close: () => void; clear: () => void; } @@ -16,6 +19,7 @@ export type ConfirmState = IConfirmStore; const useConfirmStore = create()((set) => ({ visible: false, message: "", + content: undefined, title: "", onConfirm: () => {}, onCancel: () => {}, @@ -23,6 +27,16 @@ const useConfirmStore = create()((set) => ({ set(() => ({ visible: true, message: message, + content: undefined, + onConfirm: onConfirm, + onCancel: onCancel, + title: "Atención", + })), + showContent: (message: string, content: ReactNode, onConfirm: () => void, onCancel: () => void) => + set(() => ({ + visible: true, + message: message, + content: content, onConfirm: onConfirm, onCancel: onCancel, title: "Atención", @@ -31,6 +45,7 @@ const useConfirmStore = create()((set) => ({ set(() => ({ visible: false, message: "", + content: undefined, onConfirm: () => {}, onCancel: () => {}, title: "", @@ -39,6 +54,7 @@ const useConfirmStore = create()((set) => ({ set(() => ({ visible: false, message: "", + content: undefined, onConfirm: () => {}, onCancel: () => {}, title: "", diff --git a/txclient/src/Store/NotificationPreferences.Store.ts b/txclient/src/Store/NotificationPreferences.Store.ts new file mode 100644 index 0000000..18b6d59 --- /dev/null +++ b/txclient/src/Store/NotificationPreferences.Store.ts @@ -0,0 +1,75 @@ +import { create } from "zustand"; +import { + NotificationFormMode, + CompanyNotificationPolicy, + ClientNotificationPreferences, + ClientCompanyNotificationOverride +} from "@models/NotificationPreferences.model"; + +interface NotificationPreferencesState { + mode: NotificationFormMode; + companyId: string; + clientId: string; + companyPolicy: CompanyNotificationPolicy | null; + clientPreferences: ClientNotificationPreferences | null; + clientOverride: ClientCompanyNotificationOverride | null; + formData: { + defaultChannels: string[]; + preferredChannels: string[]; + mutedChannels: string[]; + timezone: string; + quietHours: { from: string; to: string } | null; + reminderRules: { offset: number; enabled: boolean }[]; + }; + isDirty: boolean; + loading: boolean; + setMode: (mode: NotificationFormMode) => void; + setCompanyId: (companyId: string) => void; + setClientId: (clientId: string) => void; + setCompanyPolicy: (policy: CompanyNotificationPolicy | null) => void; + setClientPreferences: (preferences: ClientNotificationPreferences | null) => void; + setClientOverride: (override: ClientCompanyNotificationOverride | null) => void; + updateFormData: (data: Partial) => void; + setDirty: (dirty: boolean) => void; + setLoading: (loading: boolean) => void; + resetForm: () => void; +} + +const initialFormData = { + defaultChannels: [], + preferredChannels: [], + mutedChannels: [], + timezone: "America/Argentina/Buenos_Aires", + quietHours: null, + reminderRules: [] +}; + +export const useNotificationPreferencesStore = create()((set) => ({ + mode: "company-policy", + companyId: "", + clientId: "", + companyPolicy: null, + clientPreferences: null, + clientOverride: null, + formData: { ...initialFormData }, + isDirty: false, + loading: false, + setMode: (mode) => set({ mode }), + setCompanyId: (companyId) => set({ companyId }), + setClientId: (clientId) => set({ clientId }), + setCompanyPolicy: (policy) => set({ companyPolicy: policy }), + setClientPreferences: (preferences) => set({ clientPreferences: preferences }), + setClientOverride: (override) => set({ clientOverride: override }), + updateFormData: (data) => + set((state) => ({ + formData: { ...state.formData, ...data }, + isDirty: true + })), + setDirty: (dirty) => set({ isDirty: dirty }), + setLoading: (loading) => set({ loading }), + resetForm: () => + set({ + formData: { ...initialFormData }, + isDirty: false + }) +})); diff --git a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/Appointments.Service.ts b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/Appointments.Service.ts index 34c0130..f705e82 100644 --- a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/Appointments.Service.ts +++ b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/Appointments.Service.ts @@ -91,6 +91,23 @@ export const sendEmailNotification = async ( }); }; +export const createImmediateNotificationJobs = async ( + data: SendAppointmentNotificationParams +): Promise => { + return new Promise((resolve, reject) => { + schemaSendWapNotification + .validate(data, { abortEarly: true }) + .then(() => { + resolve( + ApiRequest.post("appointments/create-immediate-notification-jobs", data) + ); + }) + .catch((error) => { + reject(new ApiError(500, error.message)); + }); + }); +}; + const schemaApplyDiscount = Yup.object().shape({ appointmentId: Yup.string().required("Debe seleccionar un turno."), companyId: Yup.string().required("Organización no seleccionada."), diff --git a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/[appointmentId]/page.tsx b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/[appointmentId]/page.tsx index cafaecc..d6ef330 100644 --- a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/[appointmentId]/page.tsx +++ b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/appointments/[appointmentId]/page.tsx @@ -9,11 +9,10 @@ import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; import { applyAppointmentDiscount, + createImmediateNotificationJobs, deleteAppointment, deleteAppointmentDiscount, findAppointmentsAdminByClient, - sendEmailNotification, - sendWapNotification, updateAppointment, } from "../Appointments.Service"; import { @@ -26,8 +25,7 @@ import { import { ChangeServiceParams } from "@models/Appointments.model"; import Textbox from "@core/app/components/Textbox/Textbox"; import { useMenuStore } from "@core/Store/Menu.Store"; -import WhatsAppIcon from "@mui/icons-material/WhatsApp"; -import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined"; +import NotificationsActiveOutlinedIcon from "@mui/icons-material/NotificationsActiveOutlined"; import DeleteIcon from "@mui/icons-material/Delete"; import Switch from "@mui/material/Switch"; @@ -54,6 +52,8 @@ import filterObjects from "@components/TextObjectFilter/Filter.Service"; import DialogWindow from "@core/app/components/DialogWindow/DialogWindow"; import { DialogActions, DialogContent } from "@mui/material"; +import Checkbox from "@mui/material/Checkbox"; +import FormControlLabel from "@mui/material/FormControlLabel"; import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model"; import { FindDiscountsParams } from "@core/Models/Discounts.model"; import DiscountsIcon from "@mui/icons-material/LocalOfferOutlined"; @@ -312,71 +312,19 @@ export default function ClientAppointment() { menu.clear(); menu.add({ - text: "Enviar recordatorio por Whatsapp", + text: "Enviar notificación de alta", onClick: () => { if (!appointmentId) { return; } eventHandler.setEventType(EVENT_TYPES.LOADING); - sendWapNotification({ - appointmentId: appointmentId.toString(), - type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER, - sessionUser: SessionUser.userId, - }) - .then(() => { - alert.showSuccess("La notificatión fue enviada con éxito"); - }) - .catch((error) => { - alert.showError(error.format()); - }) - .finally(() => { - eventHandler.setEventType(EVENT_TYPES.SLEEP); - }); - }, - icon: , - }); - - menu.add({ - text: "Enviar recordatorio por Email", - onClick: () => { - if (!appointmentId) { - return; - } - eventHandler.setEventType(EVENT_TYPES.LOADING); - sendEmailNotification({ - appointmentId: appointmentId.toString(), - type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER, - sessionUser: SessionUser.userId, - }) - .then(() => { - alert.showSuccess("La notificatión fue enviada con éxito"); - }) - .catch((error) => { - alert.showError(error.format()); - }) - .finally(() => { - eventHandler.setEventType(EVENT_TYPES.SLEEP); - }); - }, - icon: , - }); - - menu.divider(); - - menu.add({ - text: "Enviar alta de turno por Whatsapp", - onClick: () => { - if (!appointmentId) { - return; - } - eventHandler.setEventType(EVENT_TYPES.LOADING); - sendWapNotification({ + createImmediateNotificationJobs({ appointmentId: appointmentId.toString(), type: APPOINTMENT_NOTIFICATION_TYPE.CREATION, sessionUser: SessionUser.userId, }) .then(() => { - alert.showSuccess("La notificatión fue enviada con éxito"); + alert.showSuccess("La notificación fue encolada con éxito"); }) .catch((error) => { alert.showError(error.format()); @@ -385,23 +333,23 @@ export default function ClientAppointment() { eventHandler.setEventType(EVENT_TYPES.SLEEP); }); }, - icon: , + icon: , }); menu.add({ - text: "Enviar alta de turno por Email", + text: "Enviar recordatorio", onClick: () => { if (!appointmentId) { return; } eventHandler.setEventType(EVENT_TYPES.LOADING); - sendEmailNotification({ + createImmediateNotificationJobs({ appointmentId: appointmentId.toString(), - type: APPOINTMENT_NOTIFICATION_TYPE.CREATION, + type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER, sessionUser: SessionUser.userId, }) .then(() => { - alert.showSuccess("La notificatión fue enviada con éxito"); + alert.showSuccess("La notificación fue encolada con éxito"); }) .catch((error) => { alert.showError(error.format()); @@ -410,7 +358,7 @@ export default function ClientAppointment() { eventHandler.setEventType(EVENT_TYPES.SLEEP); }); }, - icon: , + icon: , }); if (showQuitDiscount) { @@ -593,12 +541,25 @@ export default function ClientAppointment() { return; } - confirm.show( + let sendNotification = false; + + confirm.showContent( "Estas seguro que queres cancelar el turno?", + { + sendNotification = event.target.checked; + }} + /> + } + label="Enviar notificación al cliente" + />, () => { eventHandler.setEventType(EVENT_TYPES.LOADING); deleteAppointment({ id: appointmentId?.toString(), + notification: sendNotification, sessionUser: SessionUser.userId, }) .then(() => { diff --git a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/notifications/NotificationsPage.module.css b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/notifications/NotificationsPage.module.css new file mode 100644 index 0000000..012aded --- /dev/null +++ b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/notifications/NotificationsPage.module.css @@ -0,0 +1,85 @@ +.container { + margin-top: 32px; + display: flex; + flex-direction: column; + gap: 10px; + width: min(100%, 900px); + margin-left: auto; + margin-right: auto; +} + +.channels { + display: flex; + flex-direction: column; + gap: 10px; +} + +.card { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(360px, 1fr); + gap: 16px; + align-items: center; + padding: 16px; + border-radius: 10px; + border: 1px solid var(--border-color); + background-color: var(--bg-primary); +} + +.channelTitle { + color: var(--text-primary); + font-weight: 700; + font-size: 16px; +} + +.channelDescription { + color: var(--text-secondary); + font-size: 13px; + margin-top: 4px; +} + +.options { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.optionButton { + cursor: pointer; + width: 100%; + padding: 10px 14px; + border-radius: 999px; + border: 1px solid var(--border-color); + background-color: var(--bg-primary); + color: var(--text-secondary); + font-weight: 500; + font-size: 14px; + transition: all 0.2s ease; +} + +.optionButtonActive { + border-color: var(--primary-color); + background-color: rgba(25, 118, 210, 0.12); + color: var(--primary-color); + font-weight: 700; +} + +@media (max-width: 760px) { + .card { + grid-template-columns: 1fr; + gap: 12px; + } +} + +@media (max-width: 480px) { + .container { + margin-top: 24px; + } + + .card { + padding: 14px; + } + + .options { + grid-template-columns: 1fr; + } +} diff --git a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/notifications/layout.tsx b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/notifications/layout.tsx new file mode 100644 index 0000000..3590a46 --- /dev/null +++ b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/notifications/layout.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useEffect } from "react"; +import Saveable from "@components/Saveable/Saveable"; +import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store"; +import { useSessionStore } from "@store/Sesion.Store"; +import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store"; +import { useAlert } from "@store/Alert.Store"; +import { getClientCompanyOverride, upsertClientCompanyOverride } from "@services/Notifications.Service"; +import { NotificationChannel } from "@models/NotificationPreferences.model"; + +type UrlData = { + oid: string; + clientId: string; +}; + +export default function NotificationsLayout({ children }: { children: React.ReactNode }) { + const data = useParams(); + const oid = data.oid; + const clientId = data.clientId; + const store = useNotificationPreferencesStore(); + const SessionInfo = useSessionStore(); + const eventHandler = useEventHandlerStore(); + const alert = useAlert(); + + useEffect(() => { + if (!SessionInfo.userId || !oid || !clientId) { + return; + } + + store.setCompanyId(oid); + store.setClientId(clientId); + store.setMode("client-override"); + + eventHandler.setEventType(EVENT_TYPES.LOADING); + getClientCompanyOverride(clientId, oid, SessionInfo.userId) + .then((override) => { + store.setClientOverride(override); + store.updateFormData({ + preferredChannels: override.preferredChannels || [], + mutedChannels: override.mutedChannels || [] + }); + store.setDirty(false); + }) + .catch((error) => { + alert.showError(error.format()); + }) + .finally(() => { + eventHandler.setEventType(EVENT_TYPES.SLEEP); + }); + }, [SessionInfo.userId, oid, clientId]); + + const handleSave = () => { + eventHandler.setEventType(EVENT_TYPES.LOADING); + upsertClientCompanyOverride(clientId, oid, SessionInfo.userId, { + preferredChannels: store.formData.preferredChannels as NotificationChannel[], + mutedChannels: store.formData.mutedChannels as NotificationChannel[] + }) + .then((override) => { + store.setClientOverride(override); + store.setDirty(false); + eventHandler.setEventType(EVENT_TYPES.SLEEP); + }) + .catch((error) => { + alert.showError(error.format()); + eventHandler.setEventType(EVENT_TYPES.SLEEP); + }); + }; + + useEffect(() => { + if (store.isDirty) { + eventHandler.setEventType(EVENT_TYPES.NEED_SAVE); + } + }, [store.isDirty]); + + return ( + + {children} + + ); +} diff --git a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/notifications/page.tsx b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/notifications/page.tsx new file mode 100644 index 0000000..1f209fd --- /dev/null +++ b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/notifications/page.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useEffect } from "react"; +import Location from "@components/Location/Location"; +import { useLocationStore } from "@store/Location.Store"; +import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store"; +import { NotificationChannel } from "@models/NotificationPreferences.model"; +import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined"; +import ClientsIcon from "@mui/icons-material/AccountBoxOutlined"; +import NotificationsIcon from "@mui/icons-material/NotificationsOutlined"; +import MaterialAlert from "@mui/material/Alert"; +import styles from "./NotificationsPage.module.css"; + +type UrlData = { + oid: string; + clientId: string; +}; + +const CHANNELS: { key: NotificationChannel; label: string }[] = [ + { key: "whatsapp", label: "WhatsApp" }, + { key: "email", label: "Email" }, + { key: "system", label: "Sistema" } +]; + +type ChannelPreference = "default" | "preferred" | "muted"; + +export default function NotificationsPage() { + const data = useParams(); + const oid = data.oid; + const clientId = data.clientId; + const location = useLocationStore(); + const store = useNotificationPreferencesStore(); + + useEffect(() => { + location.clear(); + + location.add({ + url: "/admin/org", + title: "Organizaciones", + icon: null + }); + + location.add({ + url: "/admin/org/profile/" + oid, + title: "Organización", + icon: + }); + + location.add({ + url: "/admin/org/" + oid + "/client/" + clientId + "/profile", + title: "Cliente", + icon: + }); + + location.add({ + url: "", + title: "Notificaciones", + icon: + }); + }, [oid, clientId]); + + const getChannelPreference = (channel: NotificationChannel): ChannelPreference => { + if (store.formData.mutedChannels.includes(channel)) { + return "muted"; + } + + if (store.formData.preferredChannels.includes(channel)) { + return "preferred"; + } + + return "default"; + }; + + const selectPreference = (channel: NotificationChannel, preference: ChannelPreference) => { + store.updateFormData({ + preferredChannels: + preference === "preferred" + ? [...store.formData.preferredChannels.filter((c) => c !== channel), channel] + : store.formData.preferredChannels.filter((c) => c !== channel), + mutedChannels: + preference === "muted" + ? [...store.formData.mutedChannels.filter((c) => c !== channel), channel] + : store.formData.mutedChannels.filter((c) => c !== channel) + }); + }; + + return ( + <> + + +
+ + Preferencias del cliente: Configure cómo esta organización debe comunicarse con este cliente. Si deja un canal en estándar, se usará la política general de la organización. + + +
+ {CHANNELS.map((channel) => { + const selectedPreference = getChannelPreference(channel.key); + return ( +
+
+
{channel.label}
+
+ {selectedPreference === "preferred" && "Priorizar este canal para el cliente."} + {selectedPreference === "muted" && "No enviar notificaciones por este canal."} + {selectedPreference === "default" && "Usar la configuración general de la organización."} +
+
+ +
+ {[ + { key: "default" as ChannelPreference, label: "Estándar" }, + { key: "preferred" as ChannelPreference, label: "Preferido" }, + { key: "muted" as ChannelPreference, label: "Silenciado" } + ].map((option) => { + const isSelected = selectedPreference === option.key; + return ( + + ); + })} +
+
+ ); + })} +
+
+ +
 
+ + ); +} diff --git a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/profile/page.tsx b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/profile/page.tsx index 385f734..b6152ce 100644 --- a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/profile/page.tsx +++ b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/profile/page.tsx @@ -3,6 +3,7 @@ import ButtonOption from "@components/ButtonOption/ButtonOption"; import Location from "@components/Location/Location"; import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined"; import ClientsIcon from "@mui/icons-material/AccountBoxOutlined"; +import NotificationsIcon from "@mui/icons-material/Notifications"; import { useLocationStore } from "@store/Location.Store"; import { useNavigation } from "@hooks/goto"; import { useParams } from "next/navigation"; @@ -120,6 +121,19 @@ export default function ClientProfile() { ) } /> + + goTo( + "/admin/org/" + + oid + + "/client/" + + clientId + + "/notifications" + ) + } + /> ); diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/edit/page.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/edit/page.tsx index ef1d50b..770da1f 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/edit/page.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/edit/page.tsx @@ -148,6 +148,10 @@ export default function OrganizationEdit() { TextObjectFilterResult[] >([]); const [templateWapAltaId, setTemplateWapAltaId] = useState(""); + const [templateWapCancellationFilterData, setTemplateWapCancellationFilterData] = useState< + TextObjectFilterResult[] + >([]); + const [templateWapCancellationId, setTemplateWapCancellationId] = useState(""); const loadTemplatesCombos = () => { if (!SessionInfo.userId) return; @@ -168,6 +172,7 @@ export default function OrganizationEdit() { }; filterObjects("templates/filter-all", filter).then((res) => { setTemplateWapAltaFilterData(res); + setTemplateWapCancellationFilterData(res); setCombosLoaded(true); eventHandler.setEventType(EVENT_TYPES.SLEEP); }); @@ -222,6 +227,7 @@ export default function OrganizationEdit() { requestAnimationFrame(() => { setTemplateWapNotifId(response.templateWapNotifId); setTemplateWapAltaId(response.templateWapAltaId); + setTemplateWapCancellationId(response.templateWapCancellationId); }); }) .catch((error) => { @@ -263,6 +269,7 @@ export default function OrganizationEdit() { phoneNumber: phone, templateWapNotifId: templateWapNotifId === "" ? undefined : templateWapNotifId, templateWapAltaId: templateWapAltaId === "" ? undefined : templateWapAltaId, + templateWapCancellationId: templateWapCancellationId === "" ? undefined : templateWapCancellationId, }; const schema = Yup.object().shape({ @@ -317,8 +324,10 @@ export default function OrganizationEdit() { .positive("El Plazo Mínimo de Cancelación debe ser un número positivo."), templateWapNotifId: Yup.string().optional().default(""), templateWapAltaId: Yup.string().optional().default(""), + templateWapCancellationId: Yup.string().optional().default(""), templateEmailNotifId: Yup.string().optional().default(""), templateEmailAltaId: Yup.string().optional().default(""), + templateEmailCancellationId: Yup.string().optional().default(""), }); schema @@ -603,6 +612,17 @@ export default function OrganizationEdit() { selectedId={templateWapAltaId} /> + { + setTemplateWapCancellationId(id); + eventHandler.setEventType(EVENT_TYPES.NEED_SAVE); + }} + selectedId={templateWapCancellationId} + /> +
 
); diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/notifications/layout.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/notifications/layout.tsx new file mode 100644 index 0000000..626cc86 --- /dev/null +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/notifications/layout.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useEffect } from "react"; +import Saveable from "@components/Saveable/Saveable"; +import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store"; +import { useSessionStore } from "@store/Sesion.Store"; +import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store"; +import { useAlert } from "@store/Alert.Store"; +import { getCompanyPolicy, upsertCompanyPolicy } from "@services/Notifications.Service"; +import { NotificationChannel } from "@models/NotificationPreferences.model"; + +type UrlData = { + id: string; +}; + +export default function NotificationsLayout({ children }: { children: React.ReactNode }) { + const data = useParams(); + const id = data.id; + const store = useNotificationPreferencesStore(); + const SessionInfo = useSessionStore(); + const eventHandler = useEventHandlerStore(); + const alert = useAlert(); + + useEffect(() => { + if (!SessionInfo.userId || !id) { + return; + } + + store.setCompanyId(id); + store.setMode("company-policy"); + + eventHandler.setEventType(EVENT_TYPES.LOADING); + getCompanyPolicy(id, SessionInfo.userId) + .then((policy) => { + store.setCompanyPolicy(policy); + store.updateFormData({ + defaultChannels: policy.defaultChannels || [], + mutedChannels: policy.mutedChannels || [], + quietHours: policy.quietHours || null, + reminderRules: policy.reminderRules || [] + }); + store.setDirty(false); + }) + .catch((error) => { + alert.showError(error.format()); + }) + .finally(() => { + eventHandler.setEventType(EVENT_TYPES.SLEEP); + }); + }, [SessionInfo.userId, id]); + + const handleSave = () => { + eventHandler.setEventType(EVENT_TYPES.LOADING); + upsertCompanyPolicy(id, SessionInfo.userId, { + defaultChannels: store.formData.defaultChannels as NotificationChannel[], + timezone: "America/Argentina/Buenos_Aires", + quietHours: store.formData.quietHours || undefined, + reminderRules: store.formData.reminderRules + }) + .then((policy) => { + store.setCompanyPolicy(policy); + store.setDirty(false); + eventHandler.setEventType(EVENT_TYPES.SLEEP); + }) + .catch((error) => { + alert.showError(error.format()); + eventHandler.setEventType(EVENT_TYPES.SLEEP); + }); + }; + + useEffect(() => { + if (store.isDirty) { + eventHandler.setEventType(EVENT_TYPES.NEED_SAVE); + } + }, [store.isDirty]); + + return ( + + {children} + + ); +} diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/notifications/page.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/notifications/page.tsx new file mode 100644 index 0000000..7abcf95 --- /dev/null +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/notifications/page.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useEffect } from "react"; +import Location from "@components/Location/Location"; +import TextTime from "@components/TextTime/TextTime"; +import { useLocationStore } from "@store/Location.Store"; +import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store"; +import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store"; +import { NotificationChannel } from "@models/NotificationPreferences.model"; +import Switch from "@core/app/components/Switch/Switch"; +import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined"; +import NotificationsIcon from "@mui/icons-material/NotificationsOutlined"; +import MaterialAlert from "@mui/material/Alert"; +import dayjs from "dayjs"; + +type UrlData = { + id: string; +}; + +const CHANNELS: { key: NotificationChannel; label: string }[] = [ + { key: "whatsapp", label: "WhatsApp" }, + { key: "email", label: "Email" }, + { key: "system", label: "Sistema" } +]; + +const REMINDER_PRESETS: { minutes: number; label: string }[] = [ + { minutes: 2, label: "2 minutos antes" }, + { minutes: 5, label: "5 minutos antes" }, + { minutes: 10, label: "10 minutos antes" } +]; + +export default function NotificationsPage() { + const data = useParams(); + const id = data.id; + const location = useLocationStore(); + const store = useNotificationPreferencesStore(); + const eventHandler = useEventHandlerStore(); + + useEffect(() => { + location.clear(); + + location.add({ + url: "/admin/org", + title: "Organizaciones", + icon: null + }); + + location.add({ + url: "/admin/org/profile/" + id, + title: "Perfil", + icon: + }); + + location.add({ + url: "/admin/org/profile/" + id + "/notifications", + title: "Notificaciones", + icon: + }); + }, [id]); + + const toggleChannel = (channel: NotificationChannel) => { + const current = store.formData.defaultChannels; + const updated = current.includes(channel) + ? current.filter((c) => c !== channel) + : [...current, channel]; + store.updateFormData({ defaultChannels: updated }); + }; + + const updateQuietHours = (field: "from" | "to", value: string) => { + const current = store.formData.quietHours || { from: "00:00", to: "00:00" }; + store.updateFormData({ + quietHours: { ...current, [field]: value } + }); + }; + + const quietHourValue = (value: string | undefined, fallback: string) => { + const [hour, minute] = (value || fallback).split(":").map(Number); + return dayjs(new Date()).hour(hour).minute(minute || 0).second(0).millisecond(0); + }; + + const toggleQuietHours = () => { + store.updateFormData({ + quietHours: store.formData.quietHours ? null : { from: "22:00", to: "08:00" } + }); + }; + + const toggleReminder = (minutes: number) => { + const exists = store.formData.reminderRules.some((r) => r.offset === minutes); + if (exists) { + const updated = store.formData.reminderRules.filter((r) => r.offset !== minutes); + store.updateFormData({ reminderRules: updated }); + } else { + store.updateFormData({ + reminderRules: [...store.formData.reminderRules, { offset: minutes, enabled: true }] + }); + } + }; + + return ( + <> + + +
+ {/* Channel Toggles Section */} + + Canales de notificación: Seleccione los canales por los cuales se enviarán las notificaciones automáticas. + + +
+ {CHANNELS.map((channel) => ( +
+ {channel.label} + toggleChannel(channel.key)} + /> +
+ ))} +
+ + {/* Quiet Hours Section */} + + Horario silencioso: Las notificaciones no se enviarán durante este rango horario. + + +
+ + Activar horario silencioso + + +
+ + {store.formData.quietHours && ( +
+
+ updateQuietHours("from", value.format("HH:mm"))} + /> +
+
+ updateQuietHours("to", value.format("HH:mm"))} + /> +
+
+ )} + + {/* Reminder Rules Section */} + + Reglas de recordatorio: Active los momentos en los que desea enviar recordatorios antes del turno. + + +
+ {REMINDER_PRESETS.map((preset) => { + const isActive = store.formData.reminderRules.some((r) => r.offset === preset.minutes); + return ( + + ); + })} +
+
+ +
 
+ + ); +} diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/page.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/page.tsx index 432363a..b6c797c 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/page.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/page.tsx @@ -20,6 +20,7 @@ import CashIcon from "@mui/icons-material/AccountBalanceOutlined"; import DiscountsIcon from "@mui/icons-material/LocalOfferOutlined"; import TemplatesIcon from "@mui/icons-material/SpeakerNotesOutlined"; import AssistanceIcon from "@mui/icons-material/HeadsetMicOutlined"; +import NotificationsOutlinedIcon from "@mui/icons-material/NotificationsOutlined"; import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined"; import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; @@ -88,6 +89,11 @@ export default function OrganizationProfile() { text: "WhatsApp", onClick: () => goTo("/admin/org/profile/" + id + "/wap"), }, + { + icon: , + text: "Notificaciones", + onClick: () => goTo("/admin/org/profile/" + id + "/notifications"), + }, { icon: , text: "Servicios", diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/templates/new/[templateId]/page.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/templates/new/[templateId]/page.tsx index 3b7c797..ee39e29 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/templates/new/[templateId]/page.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/templates/new/[templateId]/page.tsx @@ -197,7 +197,7 @@ export default function TemplateCreate() { eventHandler.setEventType(EVENT_TYPES.LOADING); getDefaultTemplate({ companyId: id, - type: type, + type: typeRef.current, sessionUser: SessionInfo.userId, }) .then((res) => { @@ -267,6 +267,7 @@ export default function TemplateCreate() { data={[ { id: TemplateTypes.WAP_REMINDER, text: "WhatsApp - Recordatorio" }, { id: TemplateTypes.WAP_NEW_APPOINTMENT, text: "WhatsApp - Nueva reserva" }, + { id: TemplateTypes.WAP_CANCELLATION, text: "WhatsApp - Cancelación" }, ]} setSelectedValue={(value) => { setType(value as TemplateTypes); diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/templates/page.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/templates/page.tsx index c441762..fdb2fb9 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/templates/page.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/templates/page.tsx @@ -37,10 +37,14 @@ export default function TemplatesGrid() { return "WhatsApp - Nueva reserva"; case TemplateTypes.WAP_REMINDER: return "WhatsApp - Recordatorio"; + case TemplateTypes.WAP_CANCELLATION: + return "WhatsApp - Cancelación"; case TemplateTypes.EMAIL_NEW_APPOINTMENT: return "Email - Nueva reserva"; case TemplateTypes.EMAIL_REMINDER: return "Email - Recordatorio"; + case TemplateTypes.EMAIL_CANCELLATION: + return "Email - Cancelación"; } }; diff --git a/txclient/src/app/components/Confirm/Confirm.tsx b/txclient/src/app/components/Confirm/Confirm.tsx index 320bef3..41b8285 100644 --- a/txclient/src/app/components/Confirm/Confirm.tsx +++ b/txclient/src/app/components/Confirm/Confirm.tsx @@ -44,6 +44,7 @@ export default function ConfirmDialog() { {confirm.message} + {confirm.content} diff --git a/txclient/src/app/components/OrganizationHeader/OrganizationHeader.tsx b/txclient/src/app/components/OrganizationHeader/OrganizationHeader.tsx index a3e9b41..8191d95 100644 --- a/txclient/src/app/components/OrganizationHeader/OrganizationHeader.tsx +++ b/txclient/src/app/components/OrganizationHeader/OrganizationHeader.tsx @@ -12,6 +12,7 @@ import { useOrganizationHeaderStore, } from "@store/OrganizationHeader.Store"; import SettingsIcon from "@mui/icons-material/Settings"; +import NotificationsIcon from "@mui/icons-material/Notifications"; import classNames from "classnames"; import { useNavigation } from "@core/app/hooks/goto"; @@ -68,6 +69,14 @@ export default function OrganizationHeader(props: OrganizationHeaderParams): Rea icon: , }); + menu.add({ + text: "Notificaciones", + onClick: () => { + goTo("/admin/org/profile/" + props.data.id + "/notifications"); + }, + icon: , + }); + menu.divider(); menu.add({ diff --git a/txclient/src/app/landing/my-appointments/[aid]/page.tsx b/txclient/src/app/landing/my-appointments/[aid]/page.tsx index ad0918e..fb2c5d4 100644 --- a/txclient/src/app/landing/my-appointments/[aid]/page.tsx +++ b/txclient/src/app/landing/my-appointments/[aid]/page.tsx @@ -77,6 +77,7 @@ export default function OrganizationPublicProfile() { const data: DeleteAppointmentParams = { id: aid, validation: true, + notification: true, sessionUser: SessionInfo.userId, }; eventHandler.setEventType(EVENT_TYPES.LOADING); diff --git a/txclient/src/app/user/profile/notifications-preferences/NotificationsPreferencesPage.module.css b/txclient/src/app/user/profile/notifications-preferences/NotificationsPreferencesPage.module.css new file mode 100644 index 0000000..c0dddfc --- /dev/null +++ b/txclient/src/app/user/profile/notifications-preferences/NotificationsPreferencesPage.module.css @@ -0,0 +1,101 @@ +.container { + margin-top: 32px; + display: flex; + flex-direction: column; + gap: 10px; + width: min(100%, 900px); + margin-left: auto; + margin-right: auto; +} + +.channels { + display: flex; + flex-direction: column; + gap: 10px; +} + +.card { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(360px, 1fr); + gap: 16px; + align-items: center; + padding: 16px; + border-radius: 10px; + border: 1px solid var(--border-color); + background-color: var(--bg-primary); +} + +.channelTitle { + color: var(--text-primary); + font-weight: 700; + font-size: 16px; +} + +.channelDescription { + color: var(--text-secondary); + font-size: 13px; + margin-top: 4px; +} + +.options { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.optionButton { + cursor: pointer; + width: 100%; + padding: 10px 14px; + border-radius: 999px; + border: 1px solid var(--border-color); + background-color: var(--bg-primary); + color: var(--text-secondary); + font-weight: 500; + font-size: 14px; + transition: all 0.2s ease; +} + +.optionButtonActive { + border-color: var(--primary-color); + background-color: rgba(25, 118, 210, 0.12); + color: var(--primary-color); + font-weight: 700; +} + +.saveButton { + align-self: flex-end; + padding: 12px 24px; + border-radius: 8px; + border: none; + background-color: var(--primary-color); + color: white; + cursor: pointer; + font-weight: bold; + font-size: 16px; +} + +@media (max-width: 760px) { + .card { + grid-template-columns: 1fr; + gap: 12px; + } +} + +@media (max-width: 480px) { + .container { + margin-top: 24px; + } + + .card { + padding: 14px; + } + + .options { + grid-template-columns: 1fr; + } + + .saveButton { + width: 100%; + } +} diff --git a/txclient/src/app/user/profile/notifications-preferences/layout.tsx b/txclient/src/app/user/profile/notifications-preferences/layout.tsx new file mode 100644 index 0000000..86ca0ca --- /dev/null +++ b/txclient/src/app/user/profile/notifications-preferences/layout.tsx @@ -0,0 +1,7 @@ +"use client"; + +import Saveable from "@components/Saveable/Saveable"; + +export default function NotificationsPreferencesLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/txclient/src/app/user/profile/notifications-preferences/page.tsx b/txclient/src/app/user/profile/notifications-preferences/page.tsx new file mode 100644 index 0000000..16f1729 --- /dev/null +++ b/txclient/src/app/user/profile/notifications-preferences/page.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useEffect } from "react"; +import Location from "@components/Location/Location"; +import { useLocationStore } from "@store/Location.Store"; +import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store"; +import { useSessionStore } from "@store/Sesion.Store"; +import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store"; +import { useAlert } from "@store/Alert.Store"; +import { getClientPreferences, upsertClientPreferences } from "@services/Notifications.Service"; +import { NotificationChannel } from "@models/NotificationPreferences.model"; +import AccountCircleOutlinedIcon from "@mui/icons-material/AccountCircleOutlined"; +import NotificationsIcon from "@mui/icons-material/NotificationsOutlined"; +import MaterialAlert from "@mui/material/Alert"; +import styles from "./NotificationsPreferencesPage.module.css"; + +const CHANNELS: { key: NotificationChannel; label: string }[] = [ + { key: "whatsapp", label: "WhatsApp" }, + { key: "email", label: "Email" }, + { key: "system", label: "Sistema" } +]; + +type ChannelPreference = "default" | "preferred" | "muted"; + +export default function NotificationsPreferencesPage() { + const location = useLocationStore(); + const store = useNotificationPreferencesStore(); + const SessionInfo = useSessionStore(); + const eventHandler = useEventHandlerStore(); + const alert = useAlert(); + + useEffect(() => { + location.clear(); + + location.add({ + url: "/user/profile", + title: "Perfil de usuario", + icon: + }); + + location.add({ + url: "", + title: "Preferencias de notificación", + icon: + }); + }, []); + + useEffect(() => { + if (!SessionInfo.userId) { + return; + } + + store.setClientId(SessionInfo.userId); + store.setMode("client-preferences"); + + eventHandler.setEventType(EVENT_TYPES.LOADING); + getClientPreferences(SessionInfo.userId, SessionInfo.userId) + .then((preferences) => { + store.setClientPreferences(preferences); + store.updateFormData({ + preferredChannels: preferences.preferredChannels || [], + mutedChannels: preferences.mutedChannels || [] + }); + store.setDirty(false); + }) + .catch((error) => { + alert.showError(error.format()); + }) + .finally(() => { + eventHandler.setEventType(EVENT_TYPES.SLEEP); + }); + }, [SessionInfo.userId]); + + const getChannelPreference = (channel: NotificationChannel): ChannelPreference => { + if (store.formData.mutedChannels.includes(channel)) { + return "muted"; + } + + if (store.formData.preferredChannels.includes(channel)) { + return "preferred"; + } + + return "default"; + }; + + const selectPreference = (channel: NotificationChannel, preference: ChannelPreference) => { + store.updateFormData({ + preferredChannels: + preference === "preferred" + ? [...store.formData.preferredChannels.filter((c) => c !== channel), channel] + : store.formData.preferredChannels.filter((c) => c !== channel), + mutedChannels: + preference === "muted" + ? [...store.formData.mutedChannels.filter((c) => c !== channel), channel] + : store.formData.mutedChannels.filter((c) => c !== channel) + }); + eventHandler.setEventType(EVENT_TYPES.NEED_SAVE); + }; + + const handleSave = () => { + eventHandler.setEventType(EVENT_TYPES.LOADING); + upsertClientPreferences(SessionInfo.userId, SessionInfo.userId, { + preferredChannels: store.formData.preferredChannels as NotificationChannel[], + mutedChannels: store.formData.mutedChannels as NotificationChannel[] + }) + .then((preferences) => { + store.setClientPreferences(preferences); + store.setDirty(false); + }) + .catch((error) => { + alert.showError(error.format()); + }) + .finally(() => { + eventHandler.setEventType(EVENT_TYPES.SLEEP); + }); + }; + + useEffect(() => { + if (eventHandler.eventType === EVENT_TYPES.SAVE) { + handleSave(); + } + }, [eventHandler.eventType]); + + return ( + <> + + +
+ + Preferencias globales: Configure cómo prefiere recibir notificaciones. Si deja un canal en estándar, cada organización podrá usar su configuración general. + + +
+ {CHANNELS.map((channel) => { + const selectedPreference = getChannelPreference(channel.key); + return ( +
+
+
{channel.label}
+
+ {selectedPreference === "preferred" && "Priorizar este canal para tus notificaciones."} + {selectedPreference === "muted" && "No recibir notificaciones por este canal."} + {selectedPreference === "default" && "Usar la configuración estándar de cada organización."} +
+
+ +
+ {[ + { key: "default" as ChannelPreference, label: "Estándar" }, + { key: "preferred" as ChannelPreference, label: "Preferido" }, + { key: "muted" as ChannelPreference, label: "Silenciado" } + ].map((option) => { + const isSelected = selectedPreference === option.key; + return ( + + ); + })} +
+
+ ); + })} +
+ + {store.isDirty && ( + + )} +
+ +
 
+ + ); +} diff --git a/txclient/src/app/user/profile/page.tsx b/txclient/src/app/user/profile/page.tsx index b0f3851..9e55753 100644 --- a/txclient/src/app/user/profile/page.tsx +++ b/txclient/src/app/user/profile/page.tsx @@ -5,6 +5,7 @@ import Location from "@components/Location/Location"; import { useLocationStore } from "@store/Location.Store"; import { useEffect } from "react"; import AccountCircleOutlinedIcon from "@mui/icons-material/AccountCircleOutlined"; +import NotificationsIcon from "@mui/icons-material/Notifications"; import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store"; export default function Profile() { @@ -52,6 +53,11 @@ export default function Profile() { icon="/notifications-icon.svg" onClick={() => goTo("/user/profile/notifications")} /> + goTo("/user/profile/notifications-preferences")} + />