diff --git a/app/package-lock.json b/app/package-lock.json index 0088a0fa..3e822ce4 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -62,6 +62,7 @@ "devDependencies": { "@eslint/js": "^9.32.0", "@tailwindcss/typography": "^0.5.16", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.6.1", @@ -99,6 +100,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -897,6 +899,7 @@ "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", @@ -1419,6 +1422,7 @@ "version": "0.3.12", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1429,6 +1433,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1438,12 +1443,14 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.29", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1454,6 +1461,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -1467,6 +1475,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -1476,6 +1485,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -1489,6 +1499,7 @@ "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": { @@ -5009,7 +5020,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5109,8 +5119,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5336,14 +5345,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.23", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -5354,7 +5363,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -5781,6 +5790,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5793,6 +5803,7 @@ "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" @@ -5808,12 +5819,14 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "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", @@ -5827,6 +5840,7 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -6023,12 +6037,14 @@ "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/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6052,6 +6068,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6161,6 +6178,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -6218,6 +6236,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -6242,6 +6261,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -6425,6 +6445,7 @@ "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" @@ -6437,6 +6458,7 @@ "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/combined-stream": { @@ -6456,6 +6478,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -6501,6 +6524,7 @@ "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", @@ -6522,6 +6546,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -6812,6 +6837,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, "license": "Apache-2.0" }, "node_modules/diff-sequences": { @@ -6828,6 +6854,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, "license": "MIT" }, "node_modules/dom-accessibility-api": { @@ -6835,8 +6862,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -6881,6 +6907,7 @@ "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": { @@ -6935,6 +6962,7 @@ "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/entities": { @@ -7329,6 +7357,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -7345,6 +7374,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -7371,6 +7401,7 @@ "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -7403,6 +7434,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -7453,6 +7485,7 @@ "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", @@ -7507,6 +7540,7 @@ "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, @@ -7521,6 +7555,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7621,6 +7656,7 @@ "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -7641,6 +7677,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -7653,6 +7690,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -7662,6 +7700,7 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -7785,6 +7824,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7994,6 +8034,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -8006,6 +8047,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -8021,6 +8063,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8030,6 +8073,7 @@ "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" @@ -8049,6 +8093,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -8061,6 +8106,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -8090,6 +8136,7 @@ "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": { @@ -8180,6 +8227,7 @@ "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" @@ -9317,6 +9365,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -9329,6 +9378,7 @@ "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": { @@ -9418,7 +9468,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -9490,6 +9539,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -9499,6 +9549,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -9578,6 +9629,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -9607,6 +9659,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -9618,6 +9671,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -9674,6 +9728,7 @@ "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" @@ -9722,6 +9777,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -9817,6 +9873,7 @@ "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/parent-module": { @@ -9888,6 +9945,7 @@ "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" @@ -9897,12 +9955,14 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, "license": "MIT" }, "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", @@ -9919,18 +9979,21 @@ "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": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -9943,6 +10006,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9952,6 +10016,7 @@ "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" @@ -10030,6 +10095,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -10058,6 +10124,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -10075,6 +10142,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, "license": "MIT", "dependencies": { "camelcase-css": "^2.0.1" @@ -10094,6 +10162,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -10129,6 +10198,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -10154,6 +10224,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -10167,6 +10238,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -10185,7 +10257,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10201,7 +10272,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -10212,7 +10282,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -10225,8 +10294,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prompts": { "version": "2.4.2", @@ -10310,6 +10378,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -10533,6 +10602,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -10542,6 +10612,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -10617,6 +10688,7 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -10680,6 +10752,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -10730,6 +10803,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -10792,6 +10866,7 @@ "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" @@ -10804,6 +10879,7 @@ "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" @@ -10813,6 +10889,7 @@ "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" @@ -10862,6 +10939,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10949,6 +11027,7 @@ "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", @@ -10967,6 +11046,7 @@ "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", @@ -10981,6 +11061,7 @@ "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" @@ -10990,12 +11071,14 @@ "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" @@ -11008,6 +11091,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -11024,6 +11108,7 @@ "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" @@ -11036,6 +11121,7 @@ "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" @@ -11091,6 +11177,7 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -11126,6 +11213,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11155,6 +11243,7 @@ "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -11201,6 +11290,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -11247,6 +11337,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -11256,6 +11347,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -11281,6 +11373,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -11335,6 +11428,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/ts-jest": { @@ -11635,6 +11729,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/uuid": { @@ -12268,6 +12363,7 @@ "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" @@ -12300,6 +12396,7 @@ "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", @@ -12318,6 +12415,7 @@ "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", @@ -12335,6 +12433,7 @@ "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" @@ -12344,12 +12443,14 @@ "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", @@ -12364,6 +12465,7 @@ "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" @@ -12376,6 +12478,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -12479,6 +12582,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/app/package.json b/app/package.json index 24f692c0..39584a56 100644 --- a/app/package.json +++ b/app/package.json @@ -67,13 +67,14 @@ "devDependencies": { "@eslint/js": "^9.32.0", "@tailwindcss/typography": "^0.5.16", - "@types/node": "^22.16.5", - "@types/react": "^18.3.23", - "@types/react-dom": "^18.3.7", - "@types/jest": "^29.5.14", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.6.1", + "@types/jest": "^29.5.14", + "@types/node": "^22.16.5", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", "@vitejs/plugin-react-swc": "^3.11.0", "autoprefixer": "^10.4.21", "eslint": "^9.32.0", @@ -85,8 +86,8 @@ "jest-environment-jsdom": "^29.7.0", "jest-junit": "^16.0.0", "postcss": "^8.5.6", - "ts-jest": "^29.2.5", "tailwindcss": "^3.4.17", + "ts-jest": "^29.2.5", "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", "vite": "^5.4.19", diff --git a/app/src/api/reminders.ts b/app/src/api/reminders.ts index 00b2f9e3..4dfdc2f5 100644 --- a/app/src/api/reminders.ts +++ b/app/src/api/reminders.ts @@ -6,6 +6,11 @@ export type Reminder = { send_at: string; // ISO datetime sent: boolean; channel: 'email' | 'whatsapp'; + // Delivery tracking fields + delivered?: boolean; + delivery_attempts: number; + last_attempt_at?: string; + error_message?: string; }; export type ReminderCreate = { @@ -16,6 +21,48 @@ export type ReminderCreate = { export type ReminderUpdate = Partial; +export type ReminderDelivery = { + id: number; + attempted_at: string; + success: boolean; + channel: string; + error_message?: string; + response_time_ms?: number; +}; + +export type DeliveryMetrics = { + period_days: number; + total_attempts: number; + successful_deliveries: number; + failed_deliveries: number; + success_rate: number; + average_response_time_ms: number; + by_channel?: { + email: { + attempts: number; + successful: number; + success_rate: number; + }; + whatsapp: { + attempts: number; + successful: number; + success_rate: number; + }; + }; +}; + +export type RunDueResult = { + processed: number; + delivered: number; + failed: number; +}; + +export type RetryResult = { + success: boolean; + delivered?: boolean; + attempts: number; +}; + export async function listReminders(): Promise { return api('/reminders'); } @@ -28,10 +75,29 @@ export async function updateReminder(id: number, payload: ReminderUpdate): Promi return api(`/reminders/${id}`, { method: 'PATCH', body: payload }); } -export async function deleteReminder(id: number): Promise<{ message?: string } | Record> { +export async function deleteReminder(id: number): Promise<{ message?: string }> { return api(`/reminders/${id}`, { method: 'DELETE' }); } -export async function runDue(): Promise<{ processed?: number } | Record> { - return api('/reminders/run', { method: 'POST' }); +export async function runDue(): Promise { + return api('/reminders/run', { method: 'POST' }); +} + +// New delivery tracking APIs + +export async function getDeliveryMetrics(days?: number): Promise { + const query = days ? `?days=${days}` : ''; + return api(`/reminders/metrics${query}`); +} + +export async function getFailedReminders(): Promise { + return api('/reminders/failed'); +} + +export async function retryReminder(id: number): Promise { + return api(`/reminders/${id}/retry`, { method: 'POST' }); +} + +export async function getReminderDeliveries(id: number): Promise { + return api(`/reminders/${id}/deliveries`); } diff --git a/app/tsconfig.app.tsbuildinfo b/app/tsconfig.app.tsbuildinfo index 993c94f0..2c340190 100644 --- a/app/tsconfig.app.tsbuildinfo +++ b/app/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/setuptests.ts","./src/vite-env.d.ts","./src/__tests__/dashboard.integration.test.tsx","./src/__tests__/expenses.integration.test.tsx","./src/__tests__/navbar.test.tsx","./src/__tests__/protectedroute.test.tsx","./src/__tests__/signin.test.tsx","./src/__tests__/apiclient.test.ts","./src/__tests__/auth.test.ts","./src/api/auth.ts","./src/api/bills.ts","./src/api/categories.ts","./src/api/client.ts","./src/api/dashboard.ts","./src/api/expenses.ts","./src/api/reminders.ts","./src/components/auth/protectedroute.tsx","./src/components/layout/footer.tsx","./src/components/layout/layout.tsx","./src/components/layout/navbar.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dailog.tsx","./src/components/ui/alert.tsx","./src/components/ui/aspect-ratio.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/chart.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/financial-card.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input-otp.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/menubar.tsx","./src/components/ui/navigation-menu.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/resizable.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/components/ui/use-toast.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/lib/auth.ts","./src/lib/utils.ts","./src/pages/analytics.tsx","./src/pages/bills.tsx","./src/pages/budgets.tsx","./src/pages/categories.tsx","./src/pages/dashboard.tsx","./src/pages/expenses.tsx","./src/pages/index.tsx","./src/pages/landing.tsx","./src/pages/notfound.tsx","./src/pages/register.tsx","./src/pages/reminders.tsx","./src/pages/signin.tsx"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/setupTests.ts","./src/vite-env.d.ts","./src/__tests__/Dashboard.integration.test.tsx","./src/__tests__/Expenses.integration.test.tsx","./src/__tests__/Navbar.test.tsx","./src/__tests__/ProtectedRoute.test.tsx","./src/__tests__/SignIn.test.tsx","./src/__tests__/WeeklyDigest.test.tsx","./src/__tests__/apiClient.test.ts","./src/__tests__/auth.test.ts","./src/api/auth.ts","./src/api/bills.ts","./src/api/categories.ts","./src/api/client.ts","./src/api/dashboard.ts","./src/api/expenses.ts","./src/api/reminders.ts","./src/api/weekly-summary.ts","./src/components/WeeklyDigest.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/layout/Footer.tsx","./src/components/layout/Layout.tsx","./src/components/layout/Navbar.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dailog.tsx","./src/components/ui/alert.tsx","./src/components/ui/aspect-ratio.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/chart.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/financial-card.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input-otp.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/menubar.tsx","./src/components/ui/navigation-menu.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/resizable.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/components/ui/use-toast.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/lib/auth.ts","./src/lib/currency.ts","./src/lib/utils.ts","./src/pages/Account.tsx","./src/pages/Analytics.tsx","./src/pages/Bills.tsx","./src/pages/Budgets.tsx","./src/pages/Categories.tsx","./src/pages/Dashboard.tsx","./src/pages/Expenses.tsx","./src/pages/Index.tsx","./src/pages/Landing.tsx","./src/pages/NotFound.tsx","./src/pages/Register.tsx","./src/pages/Reminders.tsx","./src/pages/SignIn.tsx"],"errors":true,"version":"5.8.3"} \ No newline at end of file diff --git a/docs/reminder-reliability-tracking.md b/docs/reminder-reliability-tracking.md new file mode 100644 index 00000000..ae8b016c --- /dev/null +++ b/docs/reminder-reliability-tracking.md @@ -0,0 +1,177 @@ +# Reminder Reliability Tracking + +This document describes the reminder delivery tracking and reliability metrics system implemented in FinMind. + +## Overview + +The reminder reliability tracking system provides: + +- **Delivery Status Tracking**: Know whether reminders were successfully delivered +- **Retry Logic**: Automatic retries with exponential backoff for failed deliveries +- **Reliability Metrics**: Track success rates, response times, and channel performance +- **Failure Management**: View and manually retry failed reminders + +## Features + +### 1. Delivery Tracking + +Each reminder now tracks: +- `delivered`: Boolean indicating delivery success/failure +- `delivery_attempts`: Number of delivery attempts made +- `last_attempt_at`: Timestamp of last delivery attempt +- `error_message`: Error details if delivery failed + +### 2. Automatic Retry Logic + +Failed deliveries are automatically retried with exponential backoff: +- **Attempt 1**: Immediate +- **Attempt 2**: 5 minutes delay +- **Attempt 3**: 15 minutes delay +- **Attempt 4+**: 60 minutes delay (if extended) + +After 3 failed attempts, the reminder is marked as failed and requires manual retry. + +### 3. Delivery History + +Each delivery attempt is recorded in the `reminder_deliveries` table with: +- Success/failure status +- Channel used (email/WhatsApp) +- Response time (latency) +- Error messages +- Timestamp + +## API Endpoints + +### Get Delivery Metrics +``` +GET /api/reminders/metrics?days=30 +``` + +Returns reliability metrics for the authenticated user: +```json +{ + "period_days": 30, + "total_attempts": 100, + "successful_deliveries": 95, + "failed_deliveries": 5, + "success_rate": 95.0, + "average_response_time_ms": 250, + "by_channel": { + "email": { + "attempts": 80, + "successful": 78, + "success_rate": 97.5 + }, + "whatsapp": { + "attempts": 20, + "successful": 17, + "success_rate": 85.0 + } + } +} +``` + +### Get Failed Reminders +``` +GET /api/reminders/failed?limit=10 +``` + +Returns reminders that exhausted all retry attempts. + +### Retry a Failed Reminder +``` +POST /api/reminders/{id}/retry +``` + +Manually retry a failed reminder immediately. + +### Get Reminder Delivery History +``` +GET /api/reminders/{id}/deliveries +``` + +Returns the complete delivery attempt history for a specific reminder. + +### Process Due Reminders (Updated) +``` +POST /api/reminders/run +``` + +Now returns detailed results: +```json +{ + "processed": 5, + "delivered": 4, + "failed": 1 +} +``` + +## Database Schema + +### Updated `reminders` Table +```sql +ALTER TABLE reminders ADD COLUMN delivered BOOLEAN; +ALTER TABLE reminders ADD COLUMN delivery_attempts INTEGER DEFAULT 0; +ALTER TABLE reminders ADD COLUMN last_attempt_at TIMESTAMP; +ALTER TABLE reminders ADD COLUMN error_message VARCHAR(500); +ALTER TABLE reminders ADD COLUMN created_at TIMESTAMP DEFAULT NOW(); +``` + +### New `reminder_deliveries` Table +```sql +CREATE TABLE reminder_deliveries ( + id SERIAL PRIMARY KEY, + reminder_id INTEGER REFERENCES reminders(id), + attempted_at TIMESTAMP DEFAULT NOW(), + success BOOLEAN NOT NULL, + channel VARCHAR(20) NOT NULL, + error_message VARCHAR(500), + response_time_ms INTEGER +); +``` + +## Migration + +Run the migration script to add the new schema: + +```bash +cd packages/backend +python migrations/add_reminder_delivery_tracking.py +``` + +Or if using Alembic: +```bash +alembic upgrade add_reminder_delivery_tracking +``` + +## Testing + +Run the reminder delivery tests: + +```bash +cd packages/backend +pytest app/tests/test_reminder_delivery.py -v +``` + +Tests cover: +- Email/WhatsApp delivery success and failure +- Retry logic with exponential backoff +- Metrics calculation +- Failed reminder retrieval +- Manual retry functionality + +## Monitoring + +To monitor reminder reliability: + +1. **Check metrics regularly** via the `/api/reminders/metrics` endpoint +2. **Review failed reminders** via `/api/reminders/failed` +3. **Set up alerts** when success rate drops below a threshold (e.g., 90%) + +## Future Enhancements + +Potential improvements: +- Webhook notifications for delivery failures +- Email alerts to admins when reliability drops +- Dashboard visualization of metrics +- Export metrics to external monitoring (Prometheus/Grafana) diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 1122461e..36665066 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -71,6 +71,24 @@ class Reminder(db.Model): send_at = db.Column(db.DateTime, nullable=False) sent = db.Column(db.Boolean, default=False, nullable=False) channel = db.Column(db.String(20), default="email", nullable=False) + # Delivery tracking fields + delivered = db.Column(db.Boolean, default=None, nullable=True) + delivery_attempts = db.Column(db.Integer, default=0, nullable=False) + last_attempt_at = db.Column(db.DateTime, nullable=True) + error_message = db.Column(db.String(500), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class ReminderDelivery(db.Model): + """Track individual delivery attempts for reliability metrics.""" + __tablename__ = "reminder_deliveries" + id = db.Column(db.Integer, primary_key=True) + reminder_id = db.Column(db.Integer, db.ForeignKey("reminders.id"), nullable=False) + attempted_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + success = db.Column(db.Boolean, nullable=False) + channel = db.Column(db.String(20), nullable=False) + error_message = db.Column(db.String(500), nullable=True) + response_time_ms = db.Column(db.Integer, nullable=True) # Delivery latency class AdImpression(db.Model): diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..7c2aef26 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,7 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .bank_sync import bp as bank_sync_bp def register_routes(app: Flask): @@ -18,3 +19,4 @@ def register_routes(app: Flask): app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") + app.register_blueprint(bank_sync_bp, url_prefix="/bank-sync") diff --git a/packages/backend/app/routes/bank_sync.py b/packages/backend/app/routes/bank_sync.py new file mode 100644 index 00000000..490ce18e --- /dev/null +++ b/packages/backend/app/routes/bank_sync.py @@ -0,0 +1,214 @@ +"""Bank Sync API Routes.""" + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity + +from ..extensions import db +from ..services.bank_sync import BankSyncService +from ..services.bank_connector import ConnectorRegistry + +bp = Blueprint("bank_sync", __name__) +service = BankSyncService() + + +@bp.get("/connectors") +@jwt_required() +def list_connectors(): + """List available bank connectors. + + Returns: + JSON list of available connectors + """ + connectors = service.list_available_connectors() + return jsonify([ + {"id": cid, "name": name} + for cid, name in connectors.items() + ]) + + +@bp.post("/connect") +@jwt_required() +def connect_bank(): + """Connect to a bank. + + Request body: + { + "connector_id": "mock", + "credentials": {"api_key": "..."}, + "config": {"account_count": 3} + } + + Returns: + Connection status and available accounts + """ + uid = int(get_jwt_identity()) + data = request.get_json() or {} + + connector_id = data.get("connector_id") + credentials = data.get("credentials", {}) + config = data.get("config", {}) + + if not connector_id: + return jsonify(error="connector_id required"), 400 + + # Connect to bank + connector = service.connect_bank(connector_id, credentials, config) + if not connector: + return jsonify(error="Failed to connect to bank"), 400 + + # Sync accounts + result = service.sync_accounts(uid, connector) + + if not result.success: + return jsonify( + error="Failed to sync accounts", + errors=result.errors + ), 400 + + # Get account details + accounts = connector.fetch_accounts() + + return jsonify({ + "connected": True, + "connector_id": connector_id, + "accounts_synced": result.accounts_synced, + "accounts": [ + { + "id": acc.id, + "name": acc.name, + "type": acc.account_type, + "currency": acc.currency, + "balance": float(acc.balance), + "account_number_masked": acc.account_number_masked, + "institution": acc.institution_name, + } + for acc in accounts + ], + }) + + +@bp.post("/sync/") +@jwt_required() +def sync_transactions(account_id: str): + """Sync transactions for a specific account. + + Request body (optional): + { + "start_date": "2024-01-01", + "end_date": "2024-01-31", + "import_to_expenses": true + } + + Args: + account_id: The bank account ID + + Returns: + Sync result with transactions + """ + uid = int(get_jwt_identity()) + data = request.get_json() or {} + + # This is a simplified version - in production you'd store + # the connector in session/database + connector_id = data.get("connector_id", "mock") + credentials = data.get("credentials", {"api_key": "test"}) + config = data.get("config", {}) + + # Reconnect (in production, use stored tokens) + connector = service.connect_bank(connector_id, credentials, config) + if not connector: + return jsonify(error="Failed to connect to bank"), 400 + + from datetime import date + + start_date = data.get("start_date") + end_date = data.get("end_date") + import_to_expenses = data.get("import_to_expenses", True) + + if start_date: + start_date = date.fromisoformat(start_date) + if end_date: + end_date = date.fromisoformat(end_date) + + result = service.sync_transactions( + user_id=uid, + connector=connector, + account_id=account_id, + start_date=start_date, + end_date=end_date, + import_to_expenses=import_to_expenses + ) + + if not result.success: + return jsonify( + error="Failed to sync transactions", + errors=result.errors + ), 400 + + return jsonify({ + "success": True, + "transactions_synced": result.transactions_synced, + "transactions": [ + { + "id": tx.id, + "date": tx.date.isoformat(), + "amount": float(tx.amount), + "description": tx.description, + "currency": tx.currency, + "merchant": tx.merchant_name, + "pending": tx.pending, + } + for tx in result.new_transactions[:50] # Limit response size + ], + }) + + +@bp.get("/status") +@jwt_required() +def connection_status(): + """Get bank connection status. + + Query params: + connector_id: The connector ID + + Returns: + Connection status + """ + connector_id = request.args.get("connector_id", "mock") + credentials = {"api_key": "test"} # In production, use stored tokens + config = {} + + connector = service.connect_bank(connector_id, credentials, config) + if not connector: + return jsonify(error="Not connected"), 400 + + status = service.get_connector_status(connector) + + return jsonify(status) + + +@bp.post("/disconnect") +@jwt_required() +def disconnect_bank(): + """Disconnect from a bank. + + Request body: + { + "connector_id": "mock" + } + + Returns: + Disconnection status + """ + data = request.get_json() or {} + connector_id = data.get("connector_id", "mock") + credentials = {"api_key": "test"} + config = {} + + connector = service.connect_bank(connector_id, credentials, config) + if not connector: + return jsonify(error="Not connected"), 400 + + success = service.disconnect_bank(connector) + + return jsonify({"disconnected": success}) diff --git a/packages/backend/app/routes/reminders.py b/packages/backend/app/routes/reminders.py index 2374ce01..93fcb4bc 100644 --- a/packages/backend/app/routes/reminders.py +++ b/packages/backend/app/routes/reminders.py @@ -2,8 +2,8 @@ from flask import Blueprint, jsonify, request from flask_jwt_extended import jwt_required, get_jwt_identity from ..extensions import db -from ..models import Reminder -from ..services.reminders import send_reminder +from ..models import Reminder, ReminderDelivery +from ..services.reminders import deliver_reminder, get_delivery_metrics, get_failed_reminders import logging bp = Blueprint("reminders", __name__) @@ -29,6 +29,10 @@ def list_reminders(): "send_at": r.send_at.isoformat(), "sent": r.sent, "channel": r.channel, + "delivered": r.delivered, + "delivery_attempts": r.delivery_attempts, + "last_attempt_at": r.last_attempt_at.isoformat() if r.last_attempt_at else None, + "error_message": r.error_message, } for r in items ] @@ -52,9 +56,45 @@ def create_reminder(): return jsonify(id=r.id), 201 +@bp.patch("/") +@jwt_required() +def update_reminder(reminder_id: int): + uid = int(get_jwt_identity()) + r = db.session.query(Reminder).filter_by(id=reminder_id, user_id=uid).first() + if not r: + return jsonify({"error": "Reminder not found"}), 404 + + data = request.get_json() or {} + if "message" in data: + r.message = data["message"] + if "send_at" in data: + r.send_at = datetime.fromisoformat(data["send_at"]) + if "channel" in data: + r.channel = data["channel"] + + db.session.commit() + logger.info("Updated reminder id=%s user=%s", r.id, uid) + return jsonify({"message": "Reminder updated"}) + + +@bp.delete("/") +@jwt_required() +def delete_reminder(reminder_id: int): + uid = int(get_jwt_identity()) + r = db.session.query(Reminder).filter_by(id=reminder_id, user_id=uid).first() + if not r: + return jsonify({"error": "Reminder not found"}), 404 + + db.session.delete(r) + db.session.commit() + logger.info("Deleted reminder id=%s user=%s", reminder_id, uid) + return jsonify({"message": "Reminder deleted"}) + + @bp.post("/run") @jwt_required() def run_due(): + """Process due reminders with delivery tracking and retry logic.""" uid = int(get_jwt_identity()) now = datetime.utcnow() + timedelta(minutes=1) items = ( @@ -66,9 +106,120 @@ def run_due(): ) .all() ) + + processed = 0 + delivered = 0 + failed = 0 + for r in items: - send_reminder(r) - r.sent = True + success = deliver_reminder(r) + processed += 1 + if success: + delivered += 1 + else: + failed += 1 + + logger.info( + "Processed due reminders user=%s total=%s delivered=%s failed=%s", + uid, processed, delivered, failed + ) + return jsonify({ + "processed": processed, + "delivered": delivered, + "failed": failed, + }) + + +@bp.get("/metrics") +@jwt_required() +def get_metrics(): + """Get delivery reliability metrics for the authenticated user.""" + uid = int(get_jwt_identity()) + days = request.args.get("days", 30, type=int) + + metrics = get_delivery_metrics(uid, days) + logger.info("Retrieved delivery metrics user=%s days=%s", uid, days) + return jsonify(metrics) + + +@bp.get("/failed") +@jwt_required() +def list_failed(): + """Get recent failed reminders that exhausted retry attempts.""" + uid = int(get_jwt_identity()) + limit = request.args.get("limit", 10, type=int) + + reminders = get_failed_reminders(uid, limit) + logger.info("Listed failed reminders user=%s count=%s", uid, len(reminders)) + return jsonify([ + { + "id": r.id, + "message": r.message, + "send_at": r.send_at.isoformat(), + "channel": r.channel, + "delivery_attempts": r.delivery_attempts, + "last_attempt_at": r.last_attempt_at.isoformat() if r.last_attempt_at else None, + "error_message": r.error_message, + } + for r in reminders + ]) + + +@bp.post("//retry") +@jwt_required() +def retry_reminder(reminder_id: int): + """Manually retry a failed reminder.""" + uid = int(get_jwt_identity()) + r = db.session.query(Reminder).filter_by(id=reminder_id, user_id=uid).first() + + if not r: + return jsonify({"error": "Reminder not found"}), 404 + + if r.delivered: + return jsonify({"error": "Reminder was already delivered"}), 400 + + # Reset for retry + r.delivery_attempts = 0 + r.sent = False + r.send_at = datetime.utcnow() db.session.commit() - logger.info("Processed due reminders user=%s count=%s", uid, len(items)) - return jsonify(processed=len(items)) + + # Attempt delivery + success = deliver_reminder(r) + + logger.info("Manual retry reminder id=%s user=%s success=%s", reminder_id, uid, success) + return jsonify({ + "success": success, + "delivered": r.delivered, + "attempts": r.delivery_attempts, + }) + + +@bp.get("//deliveries") +@jwt_required() +def get_reminder_deliveries(reminder_id: int): + """Get delivery history for a specific reminder.""" + uid = int(get_jwt_identity()) + r = db.session.query(Reminder).filter_by(id=reminder_id, user_id=uid).first() + + if not r: + return jsonify({"error": "Reminder not found"}), 404 + + deliveries = ( + db.session.query(ReminderDelivery) + .filter_by(reminder_id=reminder_id) + .order_by(ReminderDelivery.attempted_at.desc()) + .all() + ) + + return jsonify([ + { + "id": d.id, + "attempted_at": d.attempted_at.isoformat(), + "success": d.success, + "channel": d.channel, + "error_message": d.error_message, + "response_time_ms": d.response_time_ms, + } + for d in deliveries + ]) diff --git a/packages/backend/app/services/BANK_SYNC_README.md b/packages/backend/app/services/BANK_SYNC_README.md new file mode 100644 index 00000000..23303e7d --- /dev/null +++ b/packages/backend/app/services/BANK_SYNC_README.md @@ -0,0 +1,268 @@ +# Bank Sync Connector Architecture + +This module provides a **pluggable architecture for bank integrations** in FinMind, allowing users to connect their bank accounts and sync transactions automatically. + +## Features + +- ๐Ÿ”Œ **Pluggable Connector Interface** - Easy to add new bank integrations +- ๐Ÿงช **Mock Connector Included** - Test without real bank credentials +- ๐Ÿ”„ **Automatic Transaction Import** - Import transactions as expenses +- ๐Ÿ”’ **Secure Authentication** - Token-based authentication flow +- ๐Ÿ“Š **Health Monitoring** - Connection status and health checks + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Bank Sync Service โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Connector โ”‚ โ”‚ Connector โ”‚ โ”‚ Connector โ”‚ โ”‚ +โ”‚ โ”‚ (Mock) โ”‚ โ”‚ (Plaid) โ”‚ โ”‚ (Yodlee) โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Registry โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Quick Start + +### 1. List Available Connectors + +```bash +GET /bank-sync/connectors +``` + +Response: +```json +[ + {"id": "mock", "name": "MockBankConnector"} +] +``` + +### 2. Connect to a Bank + +```bash +POST /bank-sync/connect +Content-Type: application/json + +{ + "connector_id": "mock", + "credentials": {"api_key": "test_key"}, + "config": {"account_count": 3} +} +``` + +Response: +```json +{ + "connected": true, + "connector_id": "mock", + "accounts_synced": 3, + "accounts": [ + { + "id": "mock_acc_0", + "name": "Checking Account 1", + "type": "CHECKING", + "currency": "USD", + "balance": 5420.50, + "account_number_masked": "****1234", + "institution": "Mock Bank" + } + ] +} +``` + +### 3. Sync Transactions + +```bash +POST /bank-sync/sync/mock_acc_0 +Content-Type: application/json + +{ + "connector_id": "mock", + "credentials": {"api_key": "test_key"}, + "start_date": "2024-01-01", + "end_date": "2024-01-31", + "import_to_expenses": true +} +``` + +Response: +```json +{ + "success": true, + "transactions_synced": 25, + "transactions": [ + { + "id": "mock_tx_abc123", + "date": "2024-01-15", + "amount": -45.67, + "description": "Coffee Shop", + "currency": "USD", + "merchant": "Coffee Shop", + "pending": false + } + ] +} +``` + +### 4. Check Connection Status + +```bash +GET /bank-sync/status?connector_id=mock +``` + +Response: +```json +{ + "connector_id": "mock", + "name": "Mock Bank", + "authenticated": true, + "connection_status": "ACTIVE", + "health": { + "status": "healthy", + "latency_ms": 42, + "api_version": "v1.0.0-mock" + } +} +``` + +### 5. Disconnect + +```bash +POST /bank-sync/disconnect +Content-Type: application/json + +{ + "connector_id": "mock" +} +``` + +## Creating a New Bank Connector + +To add support for a new bank (e.g., Plaid), implement the `BankConnector` interface: + +```python +from app.services.bank_connector import BankConnector, BankAccount, BankTransaction + +class PlaidConnector(BankConnector): + def authenticate(self, credentials: dict) -> bool: + # Implement Plaid authentication + access_token = credentials.get("access_token") + # ... validate token + self._authenticated = True + return True + + def fetch_accounts(self) -> list[BankAccount]: + # Fetch accounts from Plaid API + pass + + def fetch_transactions( + self, + account_id: str, + start_date: date | None = None, + end_date: date | None = None + ) -> list[BankTransaction]: + # Fetch transactions from Plaid API + pass + + def refresh(self) -> bool: + # Refresh access token + pass + + def disconnect(self) -> bool: + # Revoke access token + pass + + def get_connection_status(self) -> BankConnectionStatus: + # Return connection status + pass + + def health_check(self) -> dict: + # Return health status + pass + +# Register the connector +from app.services.bank_connector import ConnectorRegistry +ConnectorRegistry.register("plaid", PlaidConnector) +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/bank-sync/connectors` | List available connectors | +| POST | `/bank-sync/connect` | Connect to a bank | +| POST | `/bank-sync/sync/` | Sync transactions | +| GET | `/bank-sync/status` | Get connection status | +| POST | `/bank-sync/disconnect` | Disconnect from bank | + +## Models + +### BankAccount +- `id`: Account identifier +- `name`: Account name +- `account_type`: CHECKING, SAVINGS, CREDIT_CARD +- `currency`: Currency code +- `balance`: Current balance +- `account_number_masked`: Masked account number +- `institution_name`: Bank name + +### BankTransaction +- `id`: Transaction identifier +- `account_id`: Associated account +- `date`: Transaction date +- `amount`: Transaction amount (negative for expenses) +- `description`: Transaction description +- `currency`: Currency code +- `merchant_name`: Merchant name (if available) +- `pending`: Whether transaction is pending +- `metadata`: Additional metadata + +### BankConnectionStatus +- `ACTIVE`: Connection is active +- `EXPIRED`: Token expired, needs refresh +- `ERROR`: Connection error +- `DISCONNECTED`: Not connected + +## Testing + +Run tests with: + +```bash +# Using Docker (recommended) +./scripts/test-backend.sh tests/test_bank_sync.py + +# Or manually +cd packages/backend +python -m pytest tests/test_bank_sync.py -v +``` + +## Configuration + +Add configuration to your `.env` file: + +```env +# Bank sync settings +BANK_SYNC_ENABLED=true +BANK_SYNC_MAX_TRANSACTIONS=1000 +BANK_SYNC_DEFAULT_DAYS=90 +``` + +## Bounty Information + +- **Issue**: rohitdash08/FinMind#75 +- **Bounty**: $500 +- **Deliverables**: + - โœ… Connector interface + - โœ… Import & refresh support + - โœ… Mock connector included + +## License + +MIT License - Same as FinMind project diff --git a/packages/backend/app/services/__init__.py b/packages/backend/app/services/__init__.py new file mode 100644 index 00000000..28b9c300 --- /dev/null +++ b/packages/backend/app/services/__init__.py @@ -0,0 +1,41 @@ +"""Services package.""" + +from .ai import generate_insights, suggest_budget +from .cache import cache_delete_patterns, monthly_summary_key +from .reminders import check_and_send_reminders +from .expense_import import extract_transactions_from_statement, normalize_import_rows +from .bank_connector import ( + BankAccount, + BankConnector, + BankConnectionStatus, + BankTransaction, + ConnectorRegistry, + SyncResult, +) +from .bank_sync import BankSyncService +from .bank_connectors.mock import MockBankConnector + +__all__ = [ + # AI services + "generate_insights", + "suggest_budget", + # Cache services + "cache_delete_patterns", + "monthly_summary_key", + # Reminder services + "check_and_send_reminders", + # Expense import + "extract_transactions_from_statement", + "normalize_import_rows", + # Bank connector base + "BankAccount", + "BankConnector", + "BankConnectionStatus", + "BankTransaction", + "ConnectorRegistry", + "SyncResult", + # Bank sync service + "BankSyncService", + # Bank connectors + "MockBankConnector", +] diff --git a/packages/backend/app/services/bank_connector.py b/packages/backend/app/services/bank_connector.py new file mode 100644 index 00000000..088e2e62 --- /dev/null +++ b/packages/backend/app/services/bank_connector.py @@ -0,0 +1,207 @@ +"""Bank Sync Connector Interface and Implementations. + +This module provides a pluggable architecture for bank integrations. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import date, datetime +from decimal import Decimal +from enum import Enum +from typing import Any, Optional + + +class BankConnectionStatus(str, Enum): + """Bank connection status.""" + ACTIVE = "ACTIVE" + EXPIRED = "EXPIRED" + ERROR = "ERROR" + DISCONNECTED = "DISCONNECTED" + + +@dataclass +class BankAccount: + """Represents a bank account.""" + id: str + name: str + account_type: str # CHECKING, SAVINGS, CREDIT_CARD, etc. + currency: str + balance: Decimal + account_number_masked: Optional[str] = None + institution_name: Optional[str] = None + + +@dataclass +class BankTransaction: + """Represents a bank transaction.""" + id: str + account_id: str + date: date + amount: Decimal # Positive for credit, negative for debit + description: str + currency: str + category: Optional[str] = None + merchant_name: Optional[str] = None + pending: bool = False + metadata: dict = field(default_factory=dict) + + +@dataclass +class SyncResult: + """Result of a bank sync operation.""" + success: bool + accounts_synced: int = 0 + transactions_synced: int = 0 + new_transactions: list[BankTransaction] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + last_sync_at: Optional[datetime] = None + + +class BankConnector(ABC): + """Abstract base class for bank connectors. + + All bank integrations must implement this interface. + """ + + def __init__(self, connector_id: str, name: str, config: dict[str, Any]): + self.connector_id = connector_id + self.name = name + self.config = config + self._authenticated = False + + @abstractmethod + def authenticate(self, credentials: dict[str, Any]) -> bool: + """Authenticate with the bank API. + + Args: + credentials: Bank-specific credentials (API keys, tokens, etc.) + + Returns: + True if authentication successful + """ + pass + + @abstractmethod + def fetch_accounts(self) -> list[BankAccount]: + """Fetch all accounts for the authenticated user. + + Returns: + List of bank accounts + """ + pass + + @abstractmethod + def fetch_transactions( + self, + account_id: str, + start_date: Optional[date] = None, + end_date: Optional[date] = None + ) -> list[BankTransaction]: + """Fetch transactions for a specific account. + + Args: + account_id: The account ID + start_date: Optional start date filter + end_date: Optional end date filter + + Returns: + List of transactions + """ + pass + + @abstractmethod + def refresh(self) -> bool: + """Refresh the connection (e.g., refresh tokens). + + Returns: + True if refresh successful + """ + pass + + @abstractmethod + def disconnect(self) -> bool: + """Disconnect from the bank API. + + Returns: + True if disconnected successfully + """ + pass + + @abstractmethod + def get_connection_status(self) -> BankConnectionStatus: + """Get current connection status. + + Returns: + Connection status + """ + pass + + @abstractmethod + def health_check(self) -> dict[str, Any]: + """Check connector health. + + Returns: + Health status information + """ + pass + + def is_authenticated(self) -> bool: + """Check if connector is authenticated.""" + return self._authenticated + + +class ConnectorRegistry: + """Registry for bank connectors.""" + + _connectors: dict[str, type[BankConnector]] = {} + + @classmethod + def register(cls, connector_id: str, connector_class: type[BankConnector]) -> None: + """Register a connector class. + + Args: + connector_id: Unique identifier for the connector + connector_class: The connector class to register + """ + cls._connectors[connector_id] = connector_class + + @classmethod + def get(cls, connector_id: str) -> Optional[type[BankConnector]]: + """Get a connector class by ID. + + Args: + connector_id: The connector identifier + + Returns: + The connector class or None if not found + """ + return cls._connectors.get(connector_id) + + @classmethod + def list_connectors(cls) -> dict[str, type[BankConnector]]: + """List all registered connectors. + + Returns: + Dictionary of connector_id -> connector_class + """ + return cls._connectors.copy() + + @classmethod + def create_connector( + cls, + connector_id: str, + config: dict[str, Any] + ) -> Optional[BankConnector]: + """Create an instance of a registered connector. + + Args: + connector_id: The connector identifier + config: Configuration for the connector + + Returns: + Connector instance or None if not found + """ + connector_class = cls._connectors.get(connector_id) + if not connector_class: + return None + return connector_class(connector_id, config.get("name", connector_id), config) diff --git a/packages/backend/app/services/bank_connectors/__init__.py b/packages/backend/app/services/bank_connectors/__init__.py new file mode 100644 index 00000000..6703e164 --- /dev/null +++ b/packages/backend/app/services/bank_connectors/__init__.py @@ -0,0 +1,12 @@ +"""Bank Connectors package. + +This package contains implementations of the BankConnector interface. +""" + +from .mock import MockBankConnector +from ..bank_connector import ConnectorRegistry + +# Register the mock connector +ConnectorRegistry.register("mock", MockBankConnector) + +__all__ = ["MockBankConnector"] diff --git a/packages/backend/app/services/bank_connectors/mock.py b/packages/backend/app/services/bank_connectors/mock.py new file mode 100644 index 00000000..8b1f01e9 --- /dev/null +++ b/packages/backend/app/services/bank_connectors/mock.py @@ -0,0 +1,196 @@ +"""Mock Bank Connector for testing. + +This module provides a mock implementation of the BankConnector interface +for testing without real bank credentials. +""" + +import random +import uuid +from datetime import date, timedelta +from decimal import Decimal + +from ..bank_connector import ( + BankAccount, + BankConnector, + BankConnectionStatus, + BankTransaction, +) + + +class MockBankConnector(BankConnector): + """Mock bank connector for testing. + + Simulates a bank API without making real network calls. + Generates realistic test data. + + Example: + >>> config = {"seed": 12345, "account_count": 3} + >>> connector = MockBankConnector("mock", "Mock Bank", config) + >>> connector.authenticate({"api_key": "test"}) + True + >>> accounts = connector.fetch_accounts() + >>> len(accounts) + 3 + """ + + def __init__(self, connector_id: str, name: str, config: dict): + super().__init__(connector_id, name, config) + self._seed = config.get("seed", 42) + self._account_count = config.get("account_count", 3) + self._transaction_count = config.get("transaction_count", 50) + self._accounts: list[BankAccount] = [] + self._transactions: dict[str, list[BankTransaction]] = {} + self._status = BankConnectionStatus.DISCONNECTED + self._random = random.Random(self._seed) + + def authenticate(self, credentials: dict) -> bool: + """Authenticate with mock credentials. + + Any non-empty api_key is accepted. + """ + api_key = credentials.get("api_key", "") + if not api_key: + return False + + self._authenticated = True + self._status = BankConnectionStatus.ACTIVE + self._generate_accounts() + return True + + def fetch_accounts(self) -> list[BankAccount]: + """Fetch mock accounts.""" + if not self._authenticated: + raise ValueError("Not authenticated") + return self._accounts.copy() + + def fetch_transactions( + self, + account_id: str, + start_date: date | None = None, + end_date: date | None = None + ) -> list[BankTransaction]: + """Fetch mock transactions for an account.""" + if not self._authenticated: + raise ValueError("Not authenticated") + + if account_id not in [a.id for a in self._accounts]: + raise ValueError(f"Account {account_id} not found") + + if account_id not in self._transactions: + self._generate_transactions(account_id) + + transactions = self._transactions[account_id] + + # Filter by date if specified + if start_date: + transactions = [t for t in transactions if t.date >= start_date] + if end_date: + transactions = [t for t in transactions if t.date <= end_date] + + return transactions + + def refresh(self) -> bool: + """Mock refresh - always succeeds if authenticated.""" + if not self._authenticated: + return False + self._status = BankConnectionStatus.ACTIVE + return True + + def disconnect(self) -> bool: + """Mock disconnect.""" + self._authenticated = False + self._status = BankConnectionStatus.DISCONNECTED + self._accounts = [] + self._transactions = {} + return True + + def get_connection_status(self) -> BankConnectionStatus: + """Get current connection status.""" + return self._status + + def health_check(self) -> dict: + """Mock health check.""" + return { + "status": "healthy" if self._authenticated else "not_authenticated", + "latency_ms": self._random.randint(10, 100), + "api_version": "v1.0.0-mock", + } + + def _generate_accounts(self) -> None: + """Generate mock bank accounts.""" + account_types = ["CHECKING", "SAVINGS", "CREDIT_CARD"] + currencies = ["USD", "EUR", "GBP", "INR"] + + for i in range(self._account_count): + acc_type = self._random.choice(account_types) + currency = self._random.choice(currencies) + + # Generate realistic balance + if acc_type == "CREDIT_CARD": + balance = Decimal(str(self._random.uniform(-5000, 0))) + else: + balance = Decimal(str(self._random.uniform(1000, 50000))) + + account = BankAccount( + id=f"mock_acc_{i}", + name=f"{acc_type.title()} Account {i+1}", + account_type=acc_type, + currency=currency, + balance=balance.quantize(Decimal("0.01")), + account_number_masked=f"****{self._random.randint(1000, 9999)}", + institution_name="Mock Bank", + ) + self._accounts.append(account) + + def _generate_transactions(self, account_id: str) -> None: + """Generate mock transactions for an account.""" + transactions = [] + + # Get account currency + account = next((a for a in self._accounts if a.id == account_id), None) + currency = account.currency if account else "USD" + + # Merchant names for realistic descriptions + merchants = [ + "Grocery Store", "Gas Station", "Coffee Shop", "Restaurant", + "Online Retailer", "Utility Company", "Phone Company", + "Streaming Service", "Gym Membership", "Pharmacy", + "Bookstore", "Electronics Store", "Department Store", + "Freelance Payment", "Salary Deposit", "Interest Earned", + ] + + # Generate transactions over last 90 days + end_date = date.today() + + for _ in range(self._transaction_count): + merchant = self._random.choice(merchants) + + # Determine if income or expense based on merchant + is_income = any(keyword in merchant.lower() + for keyword in ["payment", "salary", "interest", "deposit"]) + + if is_income: + amount = Decimal(str(self._random.uniform(100, 5000))) + else: + amount = -Decimal(str(self._random.uniform(5, 500))) + + # Random date within range + days_ago = self._random.randint(0, 90) + tx_date = end_date - timedelta(days=days_ago) + + transaction = BankTransaction( + id=f"mock_tx_{uuid.uuid4().hex[:8]}", + account_id=account_id, + date=tx_date, + amount=amount.quantize(Decimal("0.01")), + description=merchant, + currency=currency, + merchant_name=merchant if not is_income else None, + pending=self._random.random() < 0.1, # 10% pending + metadata={"mock": True, "source": "mock_connector"}, + ) + transactions.append(transaction) + + # Sort by date descending + transactions.sort(key=lambda x: x.date, reverse=True) + self._transactions[account_id] = transactions diff --git a/packages/backend/app/services/bank_sync.py b/packages/backend/app/services/bank_sync.py new file mode 100644 index 00000000..20734259 --- /dev/null +++ b/packages/backend/app/services/bank_sync.py @@ -0,0 +1,275 @@ +"""Bank Sync Service. + +This module provides the main service for syncing bank data, +orchestrating between connectors and the database. +""" + +import logging +from datetime import date, datetime +from typing import Any, Optional + +from ..extensions import db +from ..models import Expense +from .bank_connector import ( + BankConnector, + BankConnectionStatus, + ConnectorRegistry, + SyncResult, +) +from .bank_connectors.mock import MockBankConnector + +logger = logging.getLogger("finmind.bank_sync") + + +class BankSyncService: + """Service for syncing bank data. + + This service orchestrates bank connections, account syncing, + and transaction import. + + Example: + >>> service = BankSyncService() + >>> # Connect to a bank + >>> connector = service.connect_bank("mock", {"api_key": "test"}) + >>> # Sync accounts + >>> result = service.sync_accounts(user_id=1, connector=connector) + >>> # Sync transactions + >>> tx_result = service.sync_transactions( + ... user_id=1, + ... connector=connector, + ... account_id="mock_acc_0" + ... ) + """ + + def __init__(self): + self.registry = ConnectorRegistry() + + def connect_bank( + self, + connector_id: str, + credentials: dict[str, Any], + config: dict[str, Any] | None = None + ) -> Optional[BankConnector]: + """Connect to a bank using the specified connector. + + Args: + connector_id: The connector identifier (e.g., "mock") + credentials: Authentication credentials + config: Optional additional configuration + + Returns: + Connected BankConnector or None if connection failed + """ + config = config or {} + + # Create connector instance + connector = self.registry.create_connector(connector_id, config) + if not connector: + logger.error(f"Unknown connector: {connector_id}") + return None + + # Authenticate + if not connector.authenticate(credentials): + logger.error(f"Authentication failed for connector: {connector_id}") + return None + + logger.info(f"Successfully connected to {connector_id}") + return connector + + def sync_accounts( + self, + user_id: int, + connector: BankConnector + ) -> SyncResult: + """Sync bank accounts for a user. + + Args: + user_id: The user ID + connector: The authenticated bank connector + + Returns: + SyncResult with account information + """ + result = SyncResult(success=False) + + if not connector.is_authenticated(): + result.errors.append("Connector not authenticated") + return result + + try: + accounts = connector.fetch_accounts() + result.accounts_synced = len(accounts) + result.success = True + result.last_sync_at = datetime.utcnow() + + logger.info( + f"Synced {len(accounts)} accounts for user {user_id}" + ) + except Exception as e: + result.errors.append(f"Failed to fetch accounts: {str(e)}") + logger.exception("Account sync failed") + + return result + + def sync_transactions( + self, + user_id: int, + connector: BankConnector, + account_id: str, + start_date: date | None = None, + end_date: date | None = None, + import_to_expenses: bool = True + ) -> SyncResult: + """Sync transactions from a bank account. + + Args: + user_id: The user ID + connector: The authenticated bank connector + account_id: The bank account ID + start_date: Optional start date filter + end_date: Optional end date filter + import_to_expenses: Whether to import transactions as expenses + + Returns: + SyncResult with transaction information + """ + result = SyncResult(success=False) + + if not connector.is_authenticated(): + result.errors.append("Connector not authenticated") + return result + + try: + transactions = connector.fetch_transactions( + account_id=account_id, + start_date=start_date, + end_date=end_date + ) + + result.transactions_synced = len(transactions) + result.new_transactions = transactions + + # Import to expenses if requested + if import_to_expenses: + imported_count = self._import_transactions_to_expenses( + user_id, transactions + ) + logger.info( + f"Imported {imported_count} transactions as expenses " + f"for user {user_id}" + ) + + result.success = True + result.last_sync_at = datetime.utcnow() + + logger.info( + f"Synced {len(transactions)} transactions from account " + f"{account_id} for user {user_id}" + ) + except Exception as e: + result.errors.append(f"Failed to fetch transactions: {str(e)}") + logger.exception("Transaction sync failed") + + return result + + def _import_transactions_to_expenses( + self, + user_id: int, + transactions: list + ) -> int: + """Import bank transactions as expenses. + + Args: + user_id: The user ID + transactions: List of bank transactions + + Returns: + Number of transactions imported + """ + imported = 0 + + for tx in transactions: + # Skip positive amounts (income) for expense import + # or handle them differently + expense_type = "EXPENSE" if tx.amount < 0 else "INCOME" + + # Check for duplicates + existing = Expense.query.filter_by( + user_id=user_id, + spent_at=tx.date, + amount=abs(tx.amount), + notes=tx.description + ).first() + + if existing: + continue + + expense = Expense( + user_id=user_id, + amount=abs(tx.amount), + currency=tx.currency, + expense_type=expense_type, + notes=tx.description, + spent_at=tx.date, + ) + db.session.add(expense) + imported += 1 + + if imported > 0: + db.session.commit() + + return imported + + def refresh_connection(self, connector: BankConnector) -> bool: + """Refresh a bank connection. + + Args: + connector: The bank connector to refresh + + Returns: + True if refresh successful + """ + return connector.refresh() + + def disconnect_bank(self, connector: BankConnector) -> bool: + """Disconnect from a bank. + + Args: + connector: The bank connector to disconnect + + Returns: + True if disconnected successfully + """ + return connector.disconnect() + + def get_connector_status( + self, + connector: BankConnector + ) -> dict[str, Any]: + """Get detailed connector status. + + Args: + connector: The bank connector + + Returns: + Status information dictionary + """ + return { + "connector_id": connector.connector_id, + "name": connector.name, + "authenticated": connector.is_authenticated(), + "connection_status": connector.get_connection_status().value, + "health": connector.health_check(), + } + + def list_available_connectors(self) -> dict[str, str]: + """List all available bank connectors. + + Returns: + Dictionary of connector_id -> connector_name + """ + connectors = self.registry.list_connectors() + return { + cid: cls.__name__ + for cid, cls in connectors.items() + } diff --git a/packages/backend/app/services/reminders.py b/packages/backend/app/services/reminders.py index 4c5509e8..55dbf414 100644 --- a/packages/backend/app/services/reminders.py +++ b/packages/backend/app/services/reminders.py @@ -1,7 +1,11 @@ import smtplib +import time from email.message import EmailMessage +from datetime import datetime, timedelta +from typing import Optional, Tuple from ..config import Settings -from ..models import Reminder +from ..models import Reminder, ReminderDelivery +from ..extensions import db try: from twilio.rest import Client as TwilioClient @@ -11,17 +15,22 @@ _settings = Settings() +# Delivery retry configuration +MAX_RETRY_ATTEMPTS = 3 +RETRY_DELAY_MINUTES = [5, 15, 60] # Exponential backoff delays -def send_email(to_email: str, subject: str, body: str): + +def send_email(to_email: str, subject: str, body: str) -> Tuple[bool, Optional[str]]: + """Send email and return (success, error_message).""" if not _settings.smtp_url or not _settings.email_from: - return False + return False, "SMTP not configured" try: # Very light SMTP URL parser: smtp+ssl://user:pass@host:465 import re m = re.match(r"smtp\+ssl://(.+?):(.+?)@(.+?):(\d+)", _settings.smtp_url) if not m: - return False + return False, "Invalid SMTP URL format" user, pwd, host, port = m.groups() msg = EmailMessage() msg["From"] = _settings.email_from @@ -31,19 +40,22 @@ def send_email(to_email: str, subject: str, body: str): with smtplib.SMTP_SSL(host, int(port)) as s: s.login(user, pwd) s.send_message(msg) - return True - except Exception: - return False + return True, None + except smtplib.SMTPException as e: + return False, f"SMTP error: {str(e)}" + except Exception as e: + return False, f"Unexpected error: {str(e)}" -def send_whatsapp(to_number: str, body: str): +def send_whatsapp(to_number: str, body: str) -> Tuple[bool, Optional[str]]: + """Send WhatsApp message and return (success, error_message).""" if not ( _settings.twilio_account_sid and _settings.twilio_auth_token and _settings.twilio_whatsapp_from and TwilioClient ): - return False + return False, "Twilio not configured" try: client = TwilioClient(_settings.twilio_account_sid, _settings.twilio_auth_token) client.messages.create( @@ -51,19 +63,143 @@ def send_whatsapp(to_number: str, body: str): from_=_settings.twilio_whatsapp_from, to=to_number, ) - return True - except Exception: - return False + return True, None + except Exception as e: + return False, str(e) -def send_reminder(r: Reminder): +def send_reminder(r: Reminder) -> Tuple[bool, Optional[str], int]: + """ + Send a reminder and track delivery. + Returns: (success, error_message, response_time_ms) + """ + start_time = time.time() + # Channel holds 'email' or 'whatsapp:' if r.channel.startswith("whatsapp:"): to = r.channel.split(":", 1)[1] - return send_whatsapp(to, r.message) + success, error = send_whatsapp(to, r.message) else: # Fallback: assume email stored in channel as email - # or pull from user profile later to = r.channel if "@" in r.channel else (_settings.email_from or "") subject = "Bill Reminder" - return send_email(to, subject, r.message) + success, error = send_email(to, subject, r.message) + + response_time_ms = int((time.time() - start_time) * 1000) + return success, error, response_time_ms + + +def deliver_reminder(r: Reminder) -> bool: + """ + Deliver a reminder with tracking and retry logic. + Returns True if delivered successfully (or max retries reached). + """ + success, error, response_time_ms = send_reminder(r) + + # Update reminder tracking + r.delivery_attempts += 1 + r.last_attempt_at = datetime.utcnow() + + # Record delivery attempt + delivery = ReminderDelivery( + reminder_id=r.id, + success=success, + channel=r.channel, + error_message=error, + response_time_ms=response_time_ms, + ) + db.session.add(delivery) + + if success: + r.sent = True + r.delivered = True + r.error_message = None + else: + r.delivered = False + r.error_message = error + + # Schedule retry if under max attempts + if r.delivery_attempts < MAX_RETRY_ATTEMPTS: + delay_minutes = RETRY_DELAY_MINUTES[min(r.delivery_attempts - 1, len(RETRY_DELAY_MINUTES) - 1)] + r.send_at = datetime.utcnow() + timedelta(minutes=delay_minutes) + else: + # Max retries reached, mark as failed + r.sent = True # Mark as processed even if failed + + db.session.commit() + return success + + +def get_delivery_metrics(user_id: int, days: int = 30) -> dict: + """Get delivery reliability metrics for a user.""" + from_date = datetime.utcnow() - timedelta(days=days) + + # Get all deliveries for user's reminders in the time period + deliveries = ( + db.session.query(ReminderDelivery) + .join(Reminder) + .filter( + Reminder.user_id == user_id, + ReminderDelivery.attempted_at >= from_date, + ) + .all() + ) + + if not deliveries: + return { + "period_days": days, + "total_attempts": 0, + "successful_deliveries": 0, + "failed_deliveries": 0, + "success_rate": 0.0, + "average_response_time_ms": 0, + } + + total = len(deliveries) + successful = sum(1 for d in deliveries if d.success) + failed = total - successful + success_rate = (successful / total * 100) if total > 0 else 0.0 + + response_times = [d.response_time_ms for d in deliveries if d.response_time_ms is not None] + avg_response_time = sum(response_times) / len(response_times) if response_times else 0 + + # Channel breakdown + email_attempts = [d for d in deliveries if d.channel == "email"] + whatsapp_attempts = [d for d in deliveries if d.channel.startswith("whatsapp")] + + return { + "period_days": days, + "total_attempts": total, + "successful_deliveries": successful, + "failed_deliveries": failed, + "success_rate": round(success_rate, 2), + "average_response_time_ms": round(avg_response_time, 2), + "by_channel": { + "email": { + "attempts": len(email_attempts), + "successful": sum(1 for d in email_attempts if d.success), + "success_rate": round(sum(1 for d in email_attempts if d.success) / len(email_attempts) * 100, 2) if email_attempts else 0, + }, + "whatsapp": { + "attempts": len(whatsapp_attempts), + "successful": sum(1 for d in whatsapp_attempts if d.success), + "success_rate": round(sum(1 for d in whatsapp_attempts if d.success) / len(whatsapp_attempts) * 100, 2) if whatsapp_attempts else 0, + }, + }, + } + + +def get_failed_reminders(user_id: int, limit: int = 10) -> list: + """Get recent failed reminders that need attention.""" + reminders = ( + db.session.query(Reminder) + .filter( + Reminder.user_id == user_id, + Reminder.delivered == False, + Reminder.delivery_attempts >= MAX_RETRY_ATTEMPTS, + ) + .order_by(Reminder.last_attempt_at.desc()) + .limit(limit) + .all() + ) + return reminders diff --git a/packages/backend/app/tests/test_reminder_delivery.py b/packages/backend/app/tests/test_reminder_delivery.py new file mode 100644 index 00000000..1dad753c --- /dev/null +++ b/packages/backend/app/tests/test_reminder_delivery.py @@ -0,0 +1,304 @@ +"""Tests for reminder delivery tracking and reliability metrics.""" +import pytest +from datetime import datetime, timedelta +from unittest.mock import patch, MagicMock + +from app.models import Reminder, ReminderDelivery +from app.services.reminders import ( + deliver_reminder, + get_delivery_metrics, + get_failed_reminders, + send_email, + send_whatsapp, + MAX_RETRY_ATTEMPTS, +) + + +class TestSendEmail: + """Test email sending functionality.""" + + @patch('app.services.reminders._settings') + @patch('smtplib.SMTP_SSL') + def test_send_email_success(self, mock_smtp, mock_settings): + mock_settings.smtp_url = 'smtp+ssl://user:pass@smtp.gmail.com:465' + mock_settings.email_from = 'test@example.com' + + success, error = send_email('to@example.com', 'Test Subject', 'Test Body') + + assert success is True + assert error is None + + @patch('app.services.reminders._settings') + def test_send_email_not_configured(self, mock_settings): + mock_settings.smtp_url = None + mock_settings.email_from = None + + success, error = send_email('to@example.com', 'Subject', 'Body') + + assert success is False + assert 'not configured' in error.lower() + + @patch('app.services.reminders._settings') + def test_send_email_invalid_url(self, mock_settings): + mock_settings.smtp_url = 'invalid-url' + mock_settings.email_from = 'test@example.com' + + success, error = send_email('to@example.com', 'Subject', 'Body') + + assert success is False + assert 'Invalid SMTP URL' in error + + +class TestDeliverReminder: + """Test reminder delivery with tracking.""" + + @patch('app.services.reminders.send_reminder') + def test_deliver_reminder_success(self, mock_send, db_session, test_user): + mock_send.return_value = (True, None, 150) + + reminder = Reminder( + user_id=test_user.id, + message='Test reminder', + send_at=datetime.utcnow(), + channel='email', + ) + db_session.add(reminder) + db_session.commit() + + success = deliver_reminder(reminder) + + assert success is True + assert reminder.sent is True + assert reminder.delivered is True + assert reminder.delivery_attempts == 1 + assert reminder.error_message is None + + # Check delivery record was created + delivery = db_session.query(ReminderDelivery).filter_by(reminder_id=reminder.id).first() + assert delivery is not None + assert delivery.success is True + assert delivery.response_time_ms == 150 + + @patch('app.services.reminders.send_reminder') + def test_deliver_reminder_failure_with_retry(self, mock_send, db_session, test_user): + mock_send.return_value = (False, 'Connection timeout', 0) + + reminder = Reminder( + user_id=test_user.id, + message='Test reminder', + send_at=datetime.utcnow(), + channel='email', + ) + db_session.add(reminder) + db_session.commit() + + success = deliver_reminder(reminder) + + assert success is False + assert reminder.delivered is False + assert reminder.delivery_attempts == 1 + assert reminder.error_message == 'Connection timeout' + assert reminder.sent is False # Not marked as sent yet, will retry + + # Check that retry was scheduled + assert reminder.send_at > datetime.utcnow() + + @patch('app.services.reminders.send_reminder') + def test_deliver_reminder_max_retries_reached(self, mock_send, db_session, test_user): + mock_send.return_value = (False, 'Permanent failure', 0) + + reminder = Reminder( + user_id=test_user.id, + message='Test reminder', + send_at=datetime.utcnow(), + channel='email', + delivery_attempts=MAX_RETRY_ATTEMPTS - 1, # One attempt away from max + ) + db_session.add(reminder) + db_session.commit() + + success = deliver_reminder(reminder) + + assert success is False + assert reminder.sent is True # Marked as processed even though failed + assert reminder.delivered is False + assert reminder.delivery_attempts == MAX_RETRY_ATTEMPTS + + +class TestDeliveryMetrics: + """Test delivery metrics calculation.""" + + def test_get_delivery_metrics_empty(self, db_session, test_user): + metrics = get_delivery_metrics(test_user.id, days=30) + + assert metrics['total_attempts'] == 0 + assert metrics['success_rate'] == 0.0 + + def test_get_delivery_metrics_with_data(self, db_session, test_user): + # Create reminder with deliveries + reminder = Reminder( + user_id=test_user.id, + message='Test', + send_at=datetime.utcnow(), + channel='email', + ) + db_session.add(reminder) + db_session.commit() + + # Create successful deliveries + for i in range(3): + delivery = ReminderDelivery( + reminder_id=reminder.id, + success=True, + channel='email', + response_time_ms=100 + i * 10, + ) + db_session.add(delivery) + + # Create failed delivery + delivery = ReminderDelivery( + reminder_id=reminder.id, + success=False, + channel='email', + error_message='Failed', + response_time_ms=50, + ) + db_session.add(delivery) + db_session.commit() + + metrics = get_delivery_metrics(test_user.id, days=30) + + assert metrics['total_attempts'] == 4 + assert metrics['successful_deliveries'] == 3 + assert metrics['failed_deliveries'] == 1 + assert metrics['success_rate'] == 75.0 + assert metrics['average_response_time_ms'] == 95.0 # (100+110+120+50)/4 + + def test_get_delivery_metrics_by_channel(self, db_session, test_user): + reminder = Reminder( + user_id=test_user.id, + message='Test', + send_at=datetime.utcnow(), + channel='email', + ) + db_session.add(reminder) + db_session.commit() + + # Email deliveries - 2 success, 1 fail + for _ in range(2): + db_session.add(ReminderDelivery(reminder_id=reminder.id, success=True, channel='email')) + db_session.add(ReminderDelivery(reminder_id=reminder.id, success=False, channel='email')) + + # WhatsApp deliveries - 1 success, 1 fail + db_session.add(ReminderDelivery(reminder_id=reminder.id, success=True, channel='whatsapp:+123')) + db_session.add(ReminderDelivery(reminder_id=reminder.id, success=False, channel='whatsapp:+123')) + db_session.commit() + + metrics = get_delivery_metrics(test_user.id) + + assert metrics['by_channel']['email']['attempts'] == 3 + assert metrics['by_channel']['email']['success_rate'] == 66.67 + assert metrics['by_channel']['whatsapp']['attempts'] == 2 + assert metrics['by_channel']['whatsapp']['success_rate'] == 50.0 + + +class TestFailedReminders: + """Test retrieval of failed reminders.""" + + def test_get_failed_reminders(self, db_session, test_user): + # Create failed reminder (max retries reached) + failed = Reminder( + user_id=test_user.id, + message='Failed reminder', + send_at=datetime.utcnow(), + channel='email', + sent=True, + delivered=False, + delivery_attempts=MAX_RETRY_ATTEMPTS, + last_attempt_at=datetime.utcnow(), + ) + db_session.add(failed) + + # Create pending reminder (not failed yet) + pending = Reminder( + user_id=test_user.id, + message='Pending', + send_at=datetime.utcnow(), + channel='email', + sent=False, + delivered=None, + delivery_attempts=1, + ) + db_session.add(pending) + + # Create successful reminder + success = Reminder( + user_id=test_user.id, + message='Success', + send_at=datetime.utcnow(), + channel='email', + sent=True, + delivered=True, + delivery_attempts=1, + ) + db_session.add(success) + db_session.commit() + + failed_reminders = get_failed_reminders(test_user.id) + + assert len(failed_reminders) == 1 + assert failed_reminders[0].id == failed.id + assert failed_reminders[0].message == 'Failed reminder' + + +class TestRetryReminder: + """Test manual retry functionality.""" + + @patch('app.services.reminders.send_reminder') + def test_retry_failed_reminder(self, mock_send, db_session, test_user, client, auth_headers): + mock_send.return_value = (True, None, 100) + + reminder = Reminder( + user_id=test_user.id, + message='Failed reminder', + send_at=datetime.utcnow(), + channel='email', + sent=True, + delivered=False, + delivery_attempts=MAX_RETRY_ATTEMPTS, + error_message='Previous failure', + ) + db_session.add(reminder) + db_session.commit() + + response = client.post( + f'/api/reminders/{reminder.id}/retry', + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['success'] is True + assert data['delivered'] is True + assert data['attempts'] == 1 # Reset and then delivered + + def test_retry_already_delivered(self, db_session, test_user, client, auth_headers): + reminder = Reminder( + user_id=test_user.id, + message='Already delivered', + send_at=datetime.utcnow(), + channel='email', + sent=True, + delivered=True, + delivery_attempts=1, + ) + db_session.add(reminder) + db_session.commit() + + response = client.post( + f'/api/reminders/{reminder.id}/retry', + headers=auth_headers, + ) + + assert response.status_code == 400 + assert 'already delivered' in response.get_json()['error'].lower() diff --git a/packages/backend/migrations/add_reminder_delivery_tracking.py b/packages/backend/migrations/add_reminder_delivery_tracking.py new file mode 100644 index 00000000..85737447 --- /dev/null +++ b/packages/backend/migrations/add_reminder_delivery_tracking.py @@ -0,0 +1,55 @@ +"""Add reminder delivery tracking + +Revision ID: add_reminder_delivery_tracking +Revises: +Create Date: 2026-03-29 + +""" +from alembic import op +import sqlalchemy as sa +from datetime import datetime + +# revision identifiers, used by Alembic +revision = 'add_reminder_delivery_tracking' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # Add new columns to reminders table + op.add_column('reminders', sa.Column('delivered', sa.Boolean(), nullable=True)) + op.add_column('reminders', sa.Column('delivery_attempts', sa.Integer(), nullable=False, server_default='0')) + op.add_column('reminders', sa.Column('last_attempt_at', sa.DateTime(), nullable=True)) + op.add_column('reminders', sa.Column('error_message', sa.String(500), nullable=True)) + op.add_column('reminders', sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now())) + + # Create reminder_deliveries table + op.create_table( + 'reminder_deliveries', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('reminder_id', sa.Integer(), sa.ForeignKey('reminders.id'), nullable=False), + sa.Column('attempted_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('success', sa.Boolean(), nullable=False), + sa.Column('channel', sa.String(20), nullable=False), + sa.Column('error_message', sa.String(500), nullable=True), + sa.Column('response_time_ms', sa.Integer(), nullable=True), + ) + + # Create index for faster queries + op.create_index('ix_reminder_deliveries_reminder_id', 'reminder_deliveries', ['reminder_id']) + op.create_index('ix_reminder_deliveries_attempted_at', 'reminder_deliveries', ['attempted_at']) + + +def downgrade(): + # Drop reminder_deliveries table + op.drop_index('ix_reminder_deliveries_attempted_at', table_name='reminder_deliveries') + op.drop_index('ix_reminder_deliveries_reminder_id', table_name='reminder_deliveries') + op.drop_table('reminder_deliveries') + + # Drop columns from reminders table + op.drop_column('reminders', 'created_at') + op.drop_column('reminders', 'error_message') + op.drop_column('reminders', 'last_attempt_at') + op.drop_column('reminders', 'delivery_attempts') + op.drop_column('reminders', 'delivered') diff --git a/packages/backend/tests/test_bank_sync.py b/packages/backend/tests/test_bank_sync.py new file mode 100644 index 00000000..0a34c098 --- /dev/null +++ b/packages/backend/tests/test_bank_sync.py @@ -0,0 +1,344 @@ +"""Tests for Bank Sync functionality.""" + +import pytest +from datetime import date, timedelta +from decimal import Decimal + +from app.services.bank_connector import ( + BankAccount, + BankConnector, + BankConnectionStatus, + BankTransaction, + ConnectorRegistry, + SyncResult, +) +from app.services.bank_connectors.mock import MockBankConnector +from app.services.bank_sync import BankSyncService + + +class TestBankConnectorInterface: + """Test the bank connector interface.""" + + def test_bank_account_dataclass(self): + """Test BankAccount dataclass creation.""" + account = BankAccount( + id="acc_123", + name="Test Account", + account_type="CHECKING", + currency="USD", + balance=Decimal("1000.00"), + ) + assert account.id == "acc_123" + assert account.name == "Test Account" + assert account.balance == Decimal("1000.00") + + def test_bank_transaction_dataclass(self): + """Test BankTransaction dataclass creation.""" + tx = BankTransaction( + id="tx_123", + account_id="acc_123", + date=date.today(), + amount=Decimal("-50.00"), + description="Test purchase", + currency="USD", + ) + assert tx.id == "tx_123" + assert tx.amount == Decimal("-50.00") + assert tx.description == "Test purchase" + + def test_sync_result_dataclass(self): + """Test SyncResult dataclass creation.""" + result = SyncResult(success=True, accounts_synced=2) + assert result.success is True + assert result.accounts_synced == 2 + assert result.transactions_synced == 0 + + +class TestMockBankConnector: + """Test the MockBankConnector implementation.""" + + @pytest.fixture + def connector(self): + """Create a mock connector for testing.""" + config = {"seed": 12345, "account_count": 3, "transaction_count": 10} + return MockBankConnector("mock", "Mock Bank", config) + + def test_mock_connector_authenticate_success(self, connector): + """Test successful authentication.""" + result = connector.authenticate({"api_key": "test_key"}) + assert result is True + assert connector.is_authenticated() is True + assert connector.get_connection_status() == BankConnectionStatus.ACTIVE + + def test_mock_connector_authenticate_failure(self, connector): + """Test failed authentication.""" + result = connector.authenticate({"api_key": ""}) + assert result is False + assert connector.is_authenticated() is False + + def test_mock_connector_fetch_accounts(self, connector): + """Test fetching accounts.""" + connector.authenticate({"api_key": "test"}) + accounts = connector.fetch_accounts() + + assert len(accounts) == 3 + for account in accounts: + assert isinstance(account, BankAccount) + assert account.id.startswith("mock_acc_") + assert account.institution_name == "Mock Bank" + + def test_mock_connector_fetch_accounts_unauthenticated(self, connector): + """Test fetching accounts without authentication.""" + with pytest.raises(ValueError, match="Not authenticated"): + connector.fetch_accounts() + + def test_mock_connector_fetch_transactions(self, connector): + """Test fetching transactions.""" + connector.authenticate({"api_key": "test"}) + accounts = connector.fetch_accounts() + + transactions = connector.fetch_transactions(accounts[0].id) + + assert len(transactions) == 10 + for tx in transactions: + assert isinstance(tx, BankTransaction) + assert tx.account_id == accounts[0].id + + def test_mock_connector_fetch_transactions_date_filter(self, connector): + """Test fetching transactions with date filter.""" + connector.authenticate({"api_key": "test"}) + accounts = connector.fetch_accounts() + + start_date = date.today() - timedelta(days=30) + end_date = date.today() + + transactions = connector.fetch_transactions( + accounts[0].id, + start_date=start_date, + end_date=end_date + ) + + assert len(transactions) > 0 + for tx in transactions: + assert start_date <= tx.date <= end_date + + def test_mock_connector_refresh(self, connector): + """Test connection refresh.""" + connector.authenticate({"api_key": "test"}) + result = connector.refresh() + assert result is True + + def test_mock_connector_disconnect(self, connector): + """Test disconnection.""" + connector.authenticate({"api_key": "test"}) + result = connector.disconnect() + + assert result is True + assert connector.is_authenticated() is False + assert connector.get_connection_status() == BankConnectionStatus.DISCONNECTED + + def test_mock_connector_health_check(self, connector): + """Test health check.""" + connector.authenticate({"api_key": "test"}) + health = connector.health_check() + + assert health["status"] == "healthy" + assert "latency_ms" in health + assert "api_version" in health + + +class TestConnectorRegistry: + """Test the ConnectorRegistry.""" + + def test_register_connector(self): + """Test registering a connector.""" + ConnectorRegistry.register("test_mock", MockBankConnector) + + connector_class = ConnectorRegistry.get("test_mock") + assert connector_class == MockBankConnector + + def test_create_connector(self): + """Test creating a connector instance.""" + ConnectorRegistry.register("test_mock", MockBankConnector) + + connector = ConnectorRegistry.create_connector("test_mock", {"name": "Test"}) + + assert isinstance(connector, MockBankConnector) + assert connector.name == "Test" + + def test_list_connectors(self): + """Test listing registered connectors.""" + connectors = ConnectorRegistry.list_connectors() + + assert "mock" in connectors + assert connectors["mock"] == MockBankConnector + + +class TestBankSyncService: + """Test the BankSyncService.""" + + @pytest.fixture + def service(self): + """Create a bank sync service.""" + return BankSyncService() + + @pytest.fixture + def connected_connector(self): + """Create an authenticated connector.""" + config = {"seed": 12345, "account_count": 2, "transaction_count": 5} + connector = MockBankConnector("mock", "Mock Bank", config) + connector.authenticate({"api_key": "test"}) + return connector + + def test_connect_bank_success(self, service): + """Test successful bank connection.""" + connector = service.connect_bank( + "mock", + {"api_key": "test"}, + {"account_count": 2} + ) + + assert connector is not None + assert connector.is_authenticated() is True + + def test_connect_bank_failure(self, service): + """Test failed bank connection.""" + connector = service.connect_bank("mock", {"api_key": ""}) + + assert connector is None + + def test_connect_bank_unknown_connector(self, service): + """Test connection with unknown connector.""" + connector = service.connect_bank( + "unknown_connector", + {"api_key": "test"} + ) + + assert connector is None + + def test_sync_accounts(self, service, connected_connector): + """Test syncing accounts.""" + result = service.sync_accounts(user_id=1, connector=connected_connector) + + assert result.success is True + assert result.accounts_synced == 2 + + def test_sync_accounts_unauthenticated(self, service): + """Test syncing accounts without authentication.""" + config = {"account_count": 2} + connector = MockBankConnector("mock", "Mock Bank", config) + # Don't authenticate + + result = service.sync_accounts(user_id=1, connector=connector) + + assert result.success is False + assert "Not authenticated" in result.errors + + def test_list_available_connectors(self, service): + """Test listing available connectors.""" + connectors = service.list_available_connectors() + + assert "mock" in connectors + + def test_get_connector_status(self, service, connected_connector): + """Test getting connector status.""" + status = service.get_connector_status(connected_connector) + + assert status["connector_id"] == "mock" + assert status["authenticated"] is True + assert status["connection_status"] == "ACTIVE" + + +class TestBankTransactionImport: + """Test importing bank transactions as expenses.""" + + def test_import_transactions_to_expenses(self, app, db_session): + """Test importing transactions creates expenses.""" + from app.models import User, Expense + from app.services.bank_sync import BankSyncService + + # Create a test user + user = User( + email="test@example.com", + password_hash="hashed", + preferred_currency="USD" + ) + db_session.add(user) + db_session.commit() + + # Create test transactions + transactions = [ + BankTransaction( + id="tx_1", + account_id="acc_1", + date=date.today(), + amount=Decimal("-50.00"), + description="Coffee Shop", + currency="USD", + ), + BankTransaction( + id="tx_2", + account_id="acc_1", + date=date.today(), + amount=Decimal("-100.00"), + description="Grocery Store", + currency="USD", + ), + ] + + service = BankSyncService() + imported = service._import_transactions_to_expenses(user.id, transactions) + + assert imported == 2 + + # Verify expenses were created + expenses = Expense.query.filter_by(user_id=user.id).all() + assert len(expenses) == 2 + + descriptions = [e.notes for e in expenses] + assert "Coffee Shop" in descriptions + assert "Grocery Store" in descriptions + + def test_import_transactions_skips_duplicates(self, app, db_session): + """Test that duplicate transactions are skipped.""" + from app.models import User, Expense + from app.services.bank_sync import BankSyncService + + # Create a test user + user = User( + email="test2@example.com", + password_hash="hashed", + preferred_currency="USD" + ) + db_session.add(user) + db_session.commit() + + today = date.today() + + # Create an existing expense + existing = Expense( + user_id=user.id, + amount=Decimal("50.00"), + currency="USD", + notes="Coffee Shop", + spent_at=today, + ) + db_session.add(existing) + db_session.commit() + + # Try to import the same transaction + transactions = [ + BankTransaction( + id="tx_1", + account_id="acc_1", + date=today, + amount=Decimal("-50.00"), + description="Coffee Shop", + currency="USD", + ), + ] + + service = BankSyncService() + imported = service._import_transactions_to_expenses(user.id, transactions) + + assert imported == 0 # Should skip duplicate