diff --git a/client/package-lock.json b/client/package-lock.json index 2722ac74..9c17cff1 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", + "dexie": "^4.4.2", "leaflet": "^1.9.4", "lucide-react": "^0.344.0", "marked": "^18.0.0", @@ -37,6 +38,7 @@ "@vitejs/plugin-react": "^4.2.1", "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.18", + "fake-indexeddb": "^6.2.5", "jsdom": "^29.0.1", "msw": "^2.13.0", "postcss": "^8.4.35", @@ -100,9 +102,9 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.9.tgz", - "integrity": "sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==", + "version": "5.1.10", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.10.tgz", + "integrity": "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==", "dev": true, "license": "MIT", "dependencies": { @@ -1737,9 +1739,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", "dev": true, "funding": [ { @@ -1761,9 +1763,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", "dev": true, "funding": [ { @@ -1778,7 +1780,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" + "@csstools/css-calc": "^3.2.0" }, "engines": { "node": ">=20.19.0" @@ -1812,9 +1814,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", - "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", "dev": true, "funding": [ { @@ -2364,6 +2366,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2381,6 +2386,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2398,6 +2406,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2415,6 +2426,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2432,6 +2446,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2449,6 +2466,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2466,6 +2486,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2489,6 +2512,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2512,6 +2538,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2535,6 +2564,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2558,6 +2590,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2581,6 +2616,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2794,9 +2832,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -3020,16 +3058,16 @@ } }, "node_modules/@react-pdf/layout": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.5.0.tgz", - "integrity": "sha512-XlGLzcZFdEYQHK6b9z9uPOVywbjv6pQ92D4RvqRmYNjpf7lQLnhdHr63tbiF7fB5k8Lg9/lGBXkHbzkeQW5geQ==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.5.1.tgz", + "integrity": "sha512-1V8ssgg9FHVsmvuCKmp7TWoUiPGgxAR2cgyvdcao8UQm7emWB7rP1o4CieHH56kgZyXXbwWqQAmmtgvcju+xfA==", "license": "MIT", "dependencies": { "@react-pdf/fns": "3.1.3", "@react-pdf/image": "^3.0.4", "@react-pdf/primitives": "^4.2.0", "@react-pdf/stylesheet": "^6.1.4", - "@react-pdf/textkit": "^6.1.1", + "@react-pdf/textkit": "^6.2.0", "@react-pdf/types": "^2.10.0", "emoji-regex-xs": "^1.0.0", "queue": "^6.0.1", @@ -3083,15 +3121,15 @@ } }, "node_modules/@react-pdf/render": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.4.0.tgz", - "integrity": "sha512-+gGa9ymGosN6Ld3hFFSIVCV03Vva5S+asW7vmKetZY4LnhX5ea2gYNy6wL3e9VsLHqFDAefIXGtGDLH6XxQHag==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.4.1.tgz", + "integrity": "sha512-TBaEw6F+IBI4oVHUF7LL2OJX87unRrk6r7mkEmgjehN9BV5LF53I8CzVtdAchuO1+YhvE4MoMzkNelA+X2luRA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.13", "@react-pdf/fns": "3.1.3", "@react-pdf/primitives": "^4.2.0", - "@react-pdf/textkit": "^6.1.1", + "@react-pdf/textkit": "^6.2.0", "@react-pdf/types": "^2.10.0", "abs-svg-path": "^0.1.1", "color-string": "^1.9.1", @@ -3101,19 +3139,19 @@ } }, "node_modules/@react-pdf/renderer": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.4.0.tgz", - "integrity": "sha512-TtCcz1vyYD6fddhvBagwr9Aj3gRFLCqvduERQyNhTzHJi3zAKayHvJFr2PxcP4sRyIDRhibDW6ApqNhTqIVPoQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.4.1.tgz", + "integrity": "sha512-mK7xyCdDUagO1kg8jraad3aUzdVAGBru08qyjjp8FMhGsh4BcuPGa0SycQ8Pv8EDEdyEOfmiE+XI1sBybSLwaQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.13", "@react-pdf/fns": "3.1.3", "@react-pdf/font": "^4.0.6", - "@react-pdf/layout": "^4.5.0", + "@react-pdf/layout": "^4.5.1", "@react-pdf/pdfkit": "^5.0.0", "@react-pdf/primitives": "^4.2.0", "@react-pdf/reconciler": "^2.0.0", - "@react-pdf/render": "^4.4.0", + "@react-pdf/render": "^4.4.1", "@react-pdf/types": "^2.10.0", "events": "^3.3.0", "object-assign": "^4.1.1", @@ -3139,9 +3177,9 @@ } }, "node_modules/@react-pdf/textkit": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.1.1.tgz", - "integrity": "sha512-HAHoa407q0UHLzwe/oL6VwgJj2cGKs5vORSVY+cRG/GC0kt7nxUV9N+2hA6VcqJA37gSRg7BTLsVr8Tt+4l5ow==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.2.0.tgz", + "integrity": "sha512-0B22Kue/ALHiEcYNbrx2BdkpHPTq2j3u2xmAyCnf3XJbTyANjljJjtWRohkVLQKqOlieD88BvmQt7OeWLj+ZYg==", "license": "MIT", "dependencies": { "@react-pdf/fns": "3.1.3", @@ -3360,6 +3398,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3374,6 +3415,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3388,6 +3432,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3402,6 +3449,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3416,6 +3466,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3430,6 +3483,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3444,6 +3500,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3458,6 +3517,9 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3472,6 +3534,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3486,6 +3551,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3500,6 +3568,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3514,6 +3585,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3528,6 +3602,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4324,9 +4401,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.27", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", - "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", "dev": true, "funding": [ { @@ -4344,8 +4421,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001774", + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -4470,9 +4547,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.17", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", - "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4659,9 +4736,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001787", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", - "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", "dev": true, "funding": [ { @@ -5284,6 +5361,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dexie": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz", + "integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==", + "license": "Apache-2.0" + }, "node_modules/dfa": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", @@ -5350,9 +5433,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.334", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", - "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "version": "1.5.336", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", + "integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==", "dev": true, "license": "ISC" }, @@ -5637,6 +5720,16 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5737,9 +5830,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -5773,9 +5866,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -6078,9 +6171,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -7094,9 +7187,9 @@ } }, "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", - "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -8333,9 +8426,9 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.2.tgz", - "integrity": "sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A==", + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.3.tgz", + "integrity": "sha512-/F49bxavkNGfreMlrKmTxZs6YorjfMbbDLd89Q3pWi+cXGtQQNXXaHt4MkXN7li91xnQJ24HWXqW9QDm5id33w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -8351,7 +8444,7 @@ "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", - "rettime": "^0.10.1", + "rettime": "^0.11.7", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", @@ -9388,12 +9481,13 @@ } }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -9415,9 +9509,9 @@ "license": "MIT" }, "node_modules/rettime": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", - "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.7.tgz", + "integrity": "sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==", "dev": true, "license": "MIT" }, @@ -10832,9 +10926,9 @@ } }, "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "engines": { @@ -11692,9 +11786,9 @@ } }, "node_modules/workbox-build/node_modules/lru-cache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", - "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { diff --git a/client/package.json b/client/package.json index ce178383..de4bf795 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", + "dexie": "^4.4.2", "leaflet": "^1.9.4", "lucide-react": "^0.344.0", "marked": "^18.0.0", @@ -44,6 +45,7 @@ "@vitejs/plugin-react": "^4.2.1", "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.18", + "fake-indexeddb": "^6.2.5", "jsdom": "^29.0.1", "msw": "^2.13.0", "postcss": "^8.4.35", diff --git a/client/src/App.tsx b/client/src/App.tsx index 31ac7787..90e82cdf 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -22,6 +22,8 @@ import { TranslationProvider, useTranslation } from './i18n' import { authApi } from './api/client' import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts' +import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers' +import OfflineBanner from './components/Layout/OfflineBanner' interface ProtectedRouteProps { children: ReactNode @@ -146,6 +148,11 @@ export default function App() { } }, [isAuthenticated]) + useEffect(() => { + registerSyncTriggers() + return () => unregisterSyncTriggers() + }, []) + const location = useLocation() const isSharedPage = location.pathname.startsWith('/shared/') @@ -178,6 +185,7 @@ export default function App() { return ( + } /> } /> diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 6d981112..ac3e6f65 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -38,13 +38,25 @@ export const apiClient: AxiosInstance = axios.create({ }, }) -// Request interceptor - add socket ID +const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete']) + +// Request interceptor - add socket ID + idempotency key for mutating requests apiClient.interceptors.request.use( (config) => { const sid = getSocketId() if (sid) { config.headers['X-Socket-Id'] = sid } + // Attach a per-request idempotency key to all write operations so the + // server can deduplicate retried requests (e.g. network blips). + // The mutation queue sets its own pre-generated key; skip if already set. + const method = (config.method ?? '').toLowerCase() + if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) { + const key = typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : Math.random().toString(36).slice(2) + config.headers['X-Idempotency-Key'] = key + } return config }, (error) => Promise.reject(error) @@ -161,6 +173,7 @@ export const tripsApi = { addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data), removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data), copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data), + bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data), } export const daysApi = { diff --git a/client/src/api/websocket.ts b/client/src/api/websocket.ts index 2b4a5207..5a07d357 100644 --- a/client/src/api/websocket.ts +++ b/client/src/api/websocket.ts @@ -13,6 +13,8 @@ let shouldReconnect = false let refetchCallback: RefetchCallback | null = null let mySocketId: string | null = null let connecting = false +/** Hook run before refetchCallback on reconnect. Awaited so mutations land first. */ +let preReconnectHook: (() => Promise) | null = null export function getSocketId(): string | null { return mySocketId @@ -22,6 +24,16 @@ export function setRefetchCallback(fn: RefetchCallback | null): void { refetchCallback = fn } +/** + * Register a hook that runs (and is awaited) before the refetch callback + * fires on WS reconnect. Use this to flush the mutation queue so queued + * local writes reach the server before the app reads back canonical state. + * Pass null to clear. + */ +export function setPreReconnectHook(fn: (() => Promise) | null): void { + preReconnectHook = fn +} + function getWsUrl(wsToken: string): string { const protocol = location.protocol === 'https:' ? 'wss' : 'ws' return `${protocol}://${location.host}/ws?token=${wsToken}` @@ -99,11 +111,20 @@ async function connectInternal(_isReconnect = false): Promise { } }) if (refetchCallback) { - activeTrips.forEach(tripId => { - try { refetchCallback!(tripId) } catch (err: unknown) { - console.error('Failed to refetch trip data on reconnect:', err) - } - }) + const doRefetch = () => { + activeTrips.forEach(tripId => { + try { refetchCallback!(tripId) } catch (err: unknown) { + console.error('Failed to refetch trip data on reconnect:', err) + } + }) + } + // Flush queued mutations first so local writes land before server read-back. + // If the hook fails, still refetch to keep the UI correct. + if (preReconnectHook) { + preReconnectHook().catch(console.error).then(doRefetch) + } else { + doRefetch() + } } } } diff --git a/client/src/components/Collab/CollabChat.test.tsx b/client/src/components/Collab/CollabChat.test.tsx index fdfd6dee..072cbd62 100644 --- a/client/src/components/Collab/CollabChat.test.tsx +++ b/client/src/components/Collab/CollabChat.test.tsx @@ -10,6 +10,7 @@ vi.mock('../../api/websocket', () => ({ disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })); diff --git a/client/src/components/Collab/CollabNotes.test.tsx b/client/src/components/Collab/CollabNotes.test.tsx index c3cfb3ca..9c8fc884 100644 --- a/client/src/components/Collab/CollabNotes.test.tsx +++ b/client/src/components/Collab/CollabNotes.test.tsx @@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({ disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })); diff --git a/client/src/components/Collab/CollabPanel.test.tsx b/client/src/components/Collab/CollabPanel.test.tsx index 23baa81d..d13217b0 100644 --- a/client/src/components/Collab/CollabPanel.test.tsx +++ b/client/src/components/Collab/CollabPanel.test.tsx @@ -13,6 +13,7 @@ vi.mock('../../api/websocket', () => ({ disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })) diff --git a/client/src/components/Collab/CollabPolls.test.tsx b/client/src/components/Collab/CollabPolls.test.tsx index 150ac2ac..2fef0d88 100644 --- a/client/src/components/Collab/CollabPolls.test.tsx +++ b/client/src/components/Collab/CollabPolls.test.tsx @@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({ disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })); diff --git a/client/src/components/Journey/JourneyMap.test.tsx b/client/src/components/Journey/JourneyMap.test.tsx index a44e9dbc..4eff50a8 100644 --- a/client/src/components/Journey/JourneyMap.test.tsx +++ b/client/src/components/Journey/JourneyMap.test.tsx @@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({ disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })); diff --git a/client/src/components/Journey/PhotoLightbox.test.tsx b/client/src/components/Journey/PhotoLightbox.test.tsx index 048801e8..4d5d8d62 100644 --- a/client/src/components/Journey/PhotoLightbox.test.tsx +++ b/client/src/components/Journey/PhotoLightbox.test.tsx @@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({ disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })); diff --git a/client/src/components/Layout/BottomNav.test.tsx b/client/src/components/Layout/BottomNav.test.tsx index 9be7ce16..d603a6a9 100644 --- a/client/src/components/Layout/BottomNav.test.tsx +++ b/client/src/components/Layout/BottomNav.test.tsx @@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({ disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })); diff --git a/client/src/components/Layout/OfflineBanner.tsx b/client/src/components/Layout/OfflineBanner.tsx new file mode 100644 index 00000000..cde8e610 --- /dev/null +++ b/client/src/components/Layout/OfflineBanner.tsx @@ -0,0 +1,83 @@ +/** + * OfflineBanner — persistent top bar indicating connectivity + sync state. + * + * States: + * offline + N queued → amber bar "Offline — N changes queued" + * offline + 0 queued → amber bar "Offline" + * online + N pending → blue bar "Syncing N changes…" + * online + 0 pending → hidden + */ +import React, { useState, useEffect } from 'react' +import { WifiOff, RefreshCw } from 'lucide-react' +import { mutationQueue } from '../../sync/mutationQueue' + +const POLL_MS = 3_000 + +export default function OfflineBanner(): React.ReactElement | null { + const [isOnline, setIsOnline] = useState(navigator.onLine) + const [pendingCount, setPendingCount] = useState(0) + + useEffect(() => { + const onOnline = () => setIsOnline(true) + const onOffline = () => setIsOnline(false) + window.addEventListener('online', onOnline) + window.addEventListener('offline', onOffline) + return () => { + window.removeEventListener('online', onOnline) + window.removeEventListener('offline', onOffline) + } + }, []) + + useEffect(() => { + let cancelled = false + async function poll() { + const n = await mutationQueue.pendingCount() + if (!cancelled) setPendingCount(n) + } + poll() + const id = setInterval(poll, POLL_MS) + return () => { cancelled = true; clearInterval(id) } + }, []) + + const hidden = isOnline && pendingCount === 0 + if (hidden) return null + + const offline = !isOnline + const bg = offline ? '#92400e' : '#1e40af' + const text = '#fff' + + const label = offline + ? pendingCount > 0 + ? `Offline — ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued` + : 'Offline' + : `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}…` + + return ( +
+ {offline + ? + : + } + {label} +
+ ) +} diff --git a/client/src/components/Settings/OfflineTab.tsx b/client/src/components/Settings/OfflineTab.tsx new file mode 100644 index 00000000..299958a0 --- /dev/null +++ b/client/src/components/Settings/OfflineTab.tsx @@ -0,0 +1,180 @@ +/** + * Offline settings tab — shows cached trips, storage info, and controls + * to re-sync or clear the offline cache. + */ +import React, { useState, useEffect, useCallback } from 'react' +import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react' +import Section from './Section' +import { offlineDb, clearAll } from '../../db/offlineDb' +import { tripSyncManager } from '../../sync/tripSyncManager' +import { mutationQueue } from '../../sync/mutationQueue' +import type { SyncMeta } from '../../db/offlineDb' +import type { Trip } from '../../types' + +interface CachedTripRow { + trip: Trip + meta: SyncMeta + placeCount: number + fileCount: number +} + +export default function OfflineTab(): React.ReactElement { + const [rows, setRows] = useState([]) + const [pendingCount, setPendingCount] = useState(0) + const [syncing, setSyncing] = useState(false) + const [clearing, setClearing] = useState(false) + const [loading, setLoading] = useState(true) + + const load = useCallback(async () => { + setLoading(true) + try { + const [metas, pending] = await Promise.all([ + offlineDb.syncMeta.toArray(), + mutationQueue.pendingCount(), + ]) + setPendingCount(pending) + + const result: CachedTripRow[] = [] + for (const meta of metas) { + const trip = await offlineDb.trips.get(meta.tripId) + if (!trip) continue + const [placeCount, fileCount] = await Promise.all([ + offlineDb.places.where('trip_id').equals(meta.tripId).count(), + offlineDb.tripFiles.where('trip_id').equals(meta.tripId).count(), + ]) + result.push({ trip, meta, placeCount, fileCount }) + } + result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? '')) + setRows(result) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { load() }, [load]) + + async function handleResync() { + setSyncing(true) + try { + await tripSyncManager.syncAll() + await load() + } finally { + setSyncing(false) + } + } + + async function handleClear() { + if (!window.confirm('Clear all offline trip data? You can re-sync anytime while online.')) return + setClearing(true) + try { + await clearAll() + await load() + } finally { + setClearing(false) + } + } + + const formatDate = (d: string | null | undefined) => + d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' + + return ( +
+
+ + {/* Stats row */} +
+ + +
+ + {/* Actions */} +
+ + + +
+ + {/* Cached trip list */} + {loading ? ( +

Loading…

+ ) : rows.length === 0 ? ( +

+ No trips cached yet. Connect to internet to sync. +

+ ) : ( +
+ {rows.map(({ trip, meta, placeCount, fileCount }) => ( +
+
+ + {trip.name} + + + + {meta.lastSyncedAt + ? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) + : '—'} + +
+ + {formatDate(trip.start_date)} – {formatDate(trip.end_date)} + {' · '} + {placeCount} place{placeCount !== 1 ? 's' : ''} + {' · '} + {fileCount} file{fileCount !== 1 ? 's' : ''} + +
+ ))} +
+ )} +
+
+ ) +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
{value}
+
{label}
+
+ ) +} diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts new file mode 100644 index 00000000..57b33dfe --- /dev/null +++ b/client/src/db/offlineDb.ts @@ -0,0 +1,156 @@ +import Dexie, { type Table } from 'dexie'; +import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../types'; + +// ── Queue + sync types ──────────────────────────────────────────────────────── + +export type MutationStatus = 'pending' | 'syncing' | 'failed'; + +export interface QueuedMutation { + /** UUID — also used as X-Idempotency-Key sent to the server */ + id: string; + tripId: number; + method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + url: string; + body: unknown; + createdAt: number; + status: MutationStatus; + attempts: number; + lastError: string | null; + /** Dexie table name to write the server response into after flush (e.g. 'places') */ + resource?: string; + /** For CREATE mutations enqueued offline: the temporary negative id written to Dexie */ + tempId?: number; + /** For DELETE mutations: the entity id to remove from Dexie on flush */ + entityId?: number; +} + +export interface SyncMeta { + tripId: number; + lastSyncedAt: number | null; + status: 'idle' | 'syncing' | 'error'; + /** Bounding box [minLng, minLat, maxLng, maxLat] of pre-downloaded map tiles */ + tilesBbox: [number, number, number, number] | null; + filesCachedCount: number; +} + +export interface BlobCacheEntry { + /** Relative URL, e.g. "/api/files/42/download" */ + url: string; + blob: Blob; + mime: string; + cachedAt: number; +} + +// ── Dexie class ──────────────────────────────────────────────────────────────── + +class TrekOfflineDb extends Dexie { + trips!: Table; + days!: Table; + places!: Table; + packingItems!: Table; + todoItems!: Table; + budgetItems!: Table; + reservations!: Table; + tripFiles!: Table; + mutationQueue!: Table; + syncMeta!: Table; + blobCache!: Table; + + constructor() { + super('trek-offline'); + + this.version(1).stores({ + trips: 'id', + days: 'id, trip_id', + places: 'id, trip_id', + packingItems: 'id, trip_id', + todoItems: 'id, trip_id', + budgetItems: 'id, trip_id', + reservations: 'id, trip_id', + tripFiles: 'id, trip_id', + mutationQueue:'id, tripId, status, createdAt', + syncMeta: 'tripId', + blobCache: 'url, cachedAt', + }); + } +} + +export const offlineDb = new TrekOfflineDb(); + +// ── Bulk upsert helpers ──────────────────────────────────────────────────────── + +export async function upsertTrip(trip: Trip): Promise { + await offlineDb.trips.put(trip); +} + +export async function upsertDays(days: Day[]): Promise { + await offlineDb.days.bulkPut(days); +} + +export async function upsertPlaces(places: Place[]): Promise { + await offlineDb.places.bulkPut(places); +} + +export async function upsertPackingItems(items: PackingItem[]): Promise { + await offlineDb.packingItems.bulkPut(items); +} + +export async function upsertTodoItems(items: TodoItem[]): Promise { + await offlineDb.todoItems.bulkPut(items); +} + +export async function upsertBudgetItems(items: BudgetItem[]): Promise { + await offlineDb.budgetItems.bulkPut(items); +} + +export async function upsertReservations(items: Reservation[]): Promise { + await offlineDb.reservations.bulkPut(items); +} + +export async function upsertTripFiles(files: TripFile[]): Promise { + await offlineDb.tripFiles.bulkPut(files); +} + +export async function upsertSyncMeta(meta: SyncMeta): Promise { + await offlineDb.syncMeta.put(meta); +} + +// ── Eviction / cleanup ──────────────────────────────────────────────────────── + +/** Delete all cached data for one trip (eviction or explicit clear). */ +export async function clearTripData(tripId: number): Promise { + await offlineDb.transaction( + 'rw', + [ + offlineDb.days, + offlineDb.places, + offlineDb.packingItems, + offlineDb.todoItems, + offlineDb.budgetItems, + offlineDb.reservations, + offlineDb.tripFiles, + offlineDb.mutationQueue, + offlineDb.syncMeta, + ], + async () => { + await offlineDb.days.where('trip_id').equals(tripId).delete(); + await offlineDb.places.where('trip_id').equals(tripId).delete(); + await offlineDb.packingItems.where('trip_id').equals(tripId).delete(); + await offlineDb.todoItems.where('trip_id').equals(tripId).delete(); + await offlineDb.budgetItems.where('trip_id').equals(tripId).delete(); + await offlineDb.reservations.where('trip_id').equals(tripId).delete(); + await offlineDb.tripFiles.where('trip_id').equals(tripId).delete(); + await offlineDb.mutationQueue.where('tripId').equals(tripId).delete(); + await offlineDb.syncMeta.where('tripId').equals(tripId).delete(); + }, + ); + // Remove the trip row itself outside the transaction since it's a separate table + await offlineDb.trips.delete(tripId); +} + +/** Wipe the entire offline database (called on logout). */ +export async function clearAll(): Promise { + await offlineDb.delete(); + // Re-open so subsequent operations don't fail + await offlineDb.open(); +} diff --git a/client/src/hooks/usePendingMutations.ts b/client/src/hooks/usePendingMutations.ts new file mode 100644 index 00000000..4d73b9aa --- /dev/null +++ b/client/src/hooks/usePendingMutations.ts @@ -0,0 +1,43 @@ +/** + * usePendingMutations — returns the set of entity IDs that have a pending + * or syncing mutation for a given trip. + * + * Components use this to render a clock/pending indicator on list rows. + * Polls Dexie every 2 s so the indicator clears automatically once synced. + */ +import { useState, useEffect } from 'react' +import { mutationQueue } from '../sync/mutationQueue' + +const POLL_MS = 2_000 + +export function usePendingMutations(tripId: number): Set { + const [pendingIds, setPendingIds] = useState>(new Set()) + + useEffect(() => { + let cancelled = false + + async function refresh() { + const pending = await mutationQueue.pending(tripId) + if (cancelled) return + + const ids = new Set() + for (const m of pending) { + // Extract entity id from the mutation URL (last numeric segment) + const match = m.url.match(/\/(\d+)$/) + if (match) ids.add(Number(match[1])) + // Also include tempId for offline-created items + if (m.tempId !== undefined) ids.add(m.tempId) + } + setPendingIds(ids) + } + + refresh() + const timer = setInterval(refresh, POLL_MS) + return () => { + cancelled = true + clearInterval(timer) + } + }, [tripId]) + + return pendingIds +} diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index ea45480c..3cd44406 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -17,6 +17,7 @@ vi.mock('../api/websocket', () => ({ disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })); diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 68055452..1d740a25 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -11,6 +11,7 @@ import NotificationsTab from '../components/Settings/NotificationsTab' import IntegrationsTab from '../components/Settings/IntegrationsTab' import AccountTab from '../components/Settings/AccountTab' import AboutTab from '../components/Settings/AboutTab' +import OfflineTab from '../components/Settings/OfflineTab' export default function SettingsPage(): React.ReactElement { const { t } = useTranslation() @@ -41,6 +42,7 @@ export default function SettingsPage(): React.ReactElement { { id: 'map', label: t('settings.tabs.map') }, { id: 'notifications', label: t('settings.tabs.notifications') }, ...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations') }] : []), + { id: 'offline', label: t('settings.tabs.offline', 'Offline') }, { id: 'account', label: t('settings.tabs.account') }, ...(appVersion ? [{ id: 'about', label: t('settings.tabs.about') }] : []), ] @@ -84,6 +86,7 @@ export default function SettingsPage(): React.ReactElement { {activeTab === 'map' && } {activeTab === 'notifications' && } {activeTab === 'integrations' && hasIntegrations && } + {activeTab === 'offline' && } {activeTab === 'account' && } {activeTab === 'about' && appVersion && } diff --git a/client/src/repo/budgetRepo.ts b/client/src/repo/budgetRepo.ts new file mode 100644 index 00000000..3ea50a7b --- /dev/null +++ b/client/src/repo/budgetRepo.ts @@ -0,0 +1,18 @@ +import { budgetApi } from '../api/client' +import { offlineDb, upsertBudgetItems } from '../db/offlineDb' +import type { BudgetItem } from '../types' + +export const budgetRepo = { + async list(tripId: number | string): Promise<{ items: BudgetItem[] }> { + if (!navigator.onLine) { + const cached = await offlineDb.budgetItems + .where('trip_id') + .equals(Number(tripId)) + .toArray() + return { items: cached } + } + const result = await budgetApi.list(tripId) + upsertBudgetItems(result.items) + return result + }, +} diff --git a/client/src/repo/dayRepo.ts b/client/src/repo/dayRepo.ts new file mode 100644 index 00000000..de105748 --- /dev/null +++ b/client/src/repo/dayRepo.ts @@ -0,0 +1,18 @@ +import { daysApi } from '../api/client' +import { offlineDb, upsertDays } from '../db/offlineDb' +import type { Day } from '../types' + +export const dayRepo = { + async list(tripId: number | string): Promise<{ days: Day[] }> { + if (!navigator.onLine) { + const cached = await offlineDb.days + .where('trip_id') + .equals(Number(tripId)) + .sortBy('day_number' as keyof Day) + return { days: cached as Day[] } + } + const result = await daysApi.list(tripId) + upsertDays(result.days) + return result + }, +} diff --git a/client/src/repo/fileRepo.ts b/client/src/repo/fileRepo.ts new file mode 100644 index 00000000..db96bad8 --- /dev/null +++ b/client/src/repo/fileRepo.ts @@ -0,0 +1,18 @@ +import { filesApi } from '../api/client' +import { offlineDb, upsertTripFiles } from '../db/offlineDb' +import type { TripFile } from '../types' + +export const fileRepo = { + async list(tripId: number | string): Promise<{ files: TripFile[] }> { + if (!navigator.onLine) { + const cached = await offlineDb.tripFiles + .where('trip_id') + .equals(Number(tripId)) + .toArray() + return { files: cached } + } + const result = await filesApi.list(tripId) + upsertTripFiles(result.files) + return result + }, +} diff --git a/client/src/repo/packingRepo.ts b/client/src/repo/packingRepo.ts new file mode 100644 index 00000000..30859fc6 --- /dev/null +++ b/client/src/repo/packingRepo.ts @@ -0,0 +1,88 @@ +import { packingApi } from '../api/client' +import { offlineDb, upsertPackingItems } from '../db/offlineDb' +import { mutationQueue, generateUUID } from '../sync/mutationQueue' +import type { PackingItem } from '../types' + +export const packingRepo = { + async list(tripId: number | string): Promise<{ items: PackingItem[] }> { + if (!navigator.onLine) { + const cached = await offlineDb.packingItems + .where('trip_id') + .equals(Number(tripId)) + .toArray() + return { items: cached } + } + const result = await packingApi.list(tripId) + upsertPackingItems(result.items) + return result + }, + + async create(tripId: number | string, data: Record): Promise<{ item: PackingItem }> { + if (!navigator.onLine) { + const tempId = -(Date.now()) + const tempItem: PackingItem = { + ...(data as Partial), + id: tempId, + trip_id: Number(tripId), + name: (data.name as string) ?? 'New item', + checked: 0, + } as PackingItem + await offlineDb.packingItems.put(tempItem) + const id = generateUUID() + await mutationQueue.enqueue({ + id, + tripId: Number(tripId), + method: 'POST', + url: `/trips/${tripId}/packing`, + body: data, + resource: 'packingItems', + tempId, + }) + return { item: tempItem } + } + const result = await packingApi.create(tripId, data) + offlineDb.packingItems.put(result.item) + return result + }, + + async update(tripId: number | string, id: number, data: Record): Promise<{ item: PackingItem }> { + if (!navigator.onLine) { + const existing = await offlineDb.packingItems.get(id) + const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial), id } + await offlineDb.packingItems.put(optimistic) + const mutId = generateUUID() + await mutationQueue.enqueue({ + id: mutId, + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/packing/${id}`, + body: data, + resource: 'packingItems', + }) + return { item: optimistic } + } + const result = await packingApi.update(tripId, id, data) + offlineDb.packingItems.put(result.item) + return result + }, + + async delete(tripId: number | string, id: number): Promise { + if (!navigator.onLine) { + await offlineDb.packingItems.delete(id) + const mutId = generateUUID() + await mutationQueue.enqueue({ + id: mutId, + tripId: Number(tripId), + method: 'DELETE', + url: `/trips/${tripId}/packing/${id}`, + body: undefined, + resource: 'packingItems', + entityId: id, + }) + return { success: true } + } + const result = await packingApi.delete(tripId, id) + offlineDb.packingItems.delete(id) + return result + }, +} diff --git a/client/src/repo/placeRepo.ts b/client/src/repo/placeRepo.ts new file mode 100644 index 00000000..3e5e1d1f --- /dev/null +++ b/client/src/repo/placeRepo.ts @@ -0,0 +1,87 @@ +import { placesApi } from '../api/client' +import { offlineDb, upsertPlaces } from '../db/offlineDb' +import { mutationQueue, generateUUID } from '../sync/mutationQueue' +import type { Place } from '../types' + +export const placeRepo = { + async list(tripId: number | string, params?: Record): Promise<{ places: Place[] }> { + if (!navigator.onLine) { + const cached = await offlineDb.places + .where('trip_id') + .equals(Number(tripId)) + .toArray() + return { places: cached } + } + const result = await placesApi.list(tripId, params) + upsertPlaces(result.places) + return result + }, + + async create(tripId: number | string, data: Record): Promise<{ place: Place }> { + if (!navigator.onLine) { + const tempId = -(Date.now()) + const tempPlace: Place = { + ...(data as Partial), + id: tempId, + trip_id: Number(tripId), + name: (data.name as string) ?? 'New place', + } as Place + await offlineDb.places.put(tempPlace) + const id = generateUUID() + await mutationQueue.enqueue({ + id, + tripId: Number(tripId), + method: 'POST', + url: `/trips/${tripId}/places`, + body: data, + resource: 'places', + tempId, + }) + return { place: tempPlace } + } + const result = await placesApi.create(tripId, data) + offlineDb.places.put(result.place) + return result + }, + + async update(tripId: number | string, id: number | string, data: Record): Promise<{ place: Place }> { + if (!navigator.onLine) { + const existing = await offlineDb.places.get(Number(id)) + const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial), id: Number(id) } + await offlineDb.places.put(optimistic) + const mutId = generateUUID() + await mutationQueue.enqueue({ + id: mutId, + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/places/${id}`, + body: data, + resource: 'places', + }) + return { place: optimistic } + } + const result = await placesApi.update(tripId, id, data) + offlineDb.places.put(result.place) + return result + }, + + async delete(tripId: number | string, id: number | string): Promise { + if (!navigator.onLine) { + await offlineDb.places.delete(Number(id)) + const mutId = generateUUID() + await mutationQueue.enqueue({ + id: mutId, + tripId: Number(tripId), + method: 'DELETE', + url: `/trips/${tripId}/places/${id}`, + body: undefined, + resource: 'places', + entityId: Number(id), + }) + return { success: true } + } + const result = await placesApi.delete(tripId, id) + offlineDb.places.delete(Number(id)) + return result + }, +} diff --git a/client/src/repo/reservationRepo.ts b/client/src/repo/reservationRepo.ts new file mode 100644 index 00000000..575b8075 --- /dev/null +++ b/client/src/repo/reservationRepo.ts @@ -0,0 +1,18 @@ +import { reservationsApi } from '../api/client' +import { offlineDb, upsertReservations } from '../db/offlineDb' +import type { Reservation } from '../types' + +export const reservationRepo = { + async list(tripId: number | string): Promise<{ reservations: Reservation[] }> { + if (!navigator.onLine) { + const cached = await offlineDb.reservations + .where('trip_id') + .equals(Number(tripId)) + .toArray() + return { reservations: cached } + } + const result = await reservationsApi.list(tripId) + upsertReservations(result.reservations) + return result + }, +} diff --git a/client/src/repo/todoRepo.ts b/client/src/repo/todoRepo.ts new file mode 100644 index 00000000..e284b23a --- /dev/null +++ b/client/src/repo/todoRepo.ts @@ -0,0 +1,18 @@ +import { todoApi } from '../api/client' +import { offlineDb, upsertTodoItems } from '../db/offlineDb' +import type { TodoItem } from '../types' + +export const todoRepo = { + async list(tripId: number | string): Promise<{ items: TodoItem[] }> { + if (!navigator.onLine) { + const cached = await offlineDb.todoItems + .where('trip_id') + .equals(Number(tripId)) + .toArray() + return { items: cached } + } + const result = await todoApi.list(tripId) + upsertTodoItems(result.items) + return result + }, +} diff --git a/client/src/repo/tripRepo.ts b/client/src/repo/tripRepo.ts new file mode 100644 index 00000000..6b6c4696 --- /dev/null +++ b/client/src/repo/tripRepo.ts @@ -0,0 +1,16 @@ +import { tripsApi } from '../api/client' +import { offlineDb, upsertTrip } from '../db/offlineDb' +import type { Trip } from '../types' + +export const tripRepo = { + async get(tripId: number | string): Promise<{ trip: Trip }> { + if (!navigator.onLine) { + const cached = await offlineDb.trips.get(Number(tripId)) + if (cached) return { trip: cached } + throw new Error('No cached trip data available offline') + } + const result = await tripsApi.get(tripId) + upsertTrip(result.trip) + return result + }, +} diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 3897aa26..d08f844a 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -4,6 +4,8 @@ import { authApi } from '../api/client' import { connect, disconnect } from '../api/websocket' import type { User } from '../types' import { getApiErrorMessage } from '../types' +import { tripSyncManager } from '../sync/tripSyncManager' +import { clearAll } from '../db/offlineDb' interface AuthResponse { user: User @@ -88,6 +90,7 @@ export const useAuthStore = create()( error: null, }) connect() + tripSyncManager.syncAll().catch(console.error) return data as AuthResponse } catch (err: unknown) { const error = getApiErrorMessage(err, 'Login failed') @@ -108,6 +111,7 @@ export const useAuthStore = create()( error: null, }) connect() + tripSyncManager.syncAll().catch(console.error) return data as AuthResponse } catch (err: unknown) { const error = getApiErrorMessage(err, 'Verification failed') @@ -128,6 +132,7 @@ export const useAuthStore = create()( error: null, }) connect() + tripSyncManager.syncAll().catch(console.error) return data } catch (err: unknown) { const error = getApiErrorMessage(err, 'Registration failed') @@ -145,6 +150,8 @@ export const useAuthStore = create()( caches.delete('api-data').catch(() => {}) caches.delete('user-uploads').catch(() => {}) } + // Purge all cached trip data from IndexedDB + clearAll().catch(console.error) set({ user: null, isAuthenticated: false, diff --git a/client/src/store/slices/packingSlice.ts b/client/src/store/slices/packingSlice.ts index adc74d85..ab3f373b 100644 --- a/client/src/store/slices/packingSlice.ts +++ b/client/src/store/slices/packingSlice.ts @@ -1,4 +1,4 @@ -import { packingApi } from '../../api/client' +import { packingRepo } from '../../repo/packingRepo' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { PackingItem } from '../../types' @@ -17,7 +17,7 @@ export interface PackingSlice { export const createPackingSlice = (set: SetState, get: GetState): PackingSlice => ({ addPackingItem: async (tripId, data) => { try { - const result = await packingApi.create(tripId, data) + const result = await packingRepo.create(tripId, data as Record) set(state => ({ packingItems: [...state.packingItems, result.item] })) return result.item } catch (err: unknown) { @@ -27,7 +27,7 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice = updatePackingItem: async (tripId, id, data) => { try { - const result = await packingApi.update(tripId, id, data) + const result = await packingRepo.update(tripId, id, data as Record) set(state => ({ packingItems: state.packingItems.map(item => item.id === id ? result.item : item) })) @@ -41,7 +41,7 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice = const prev = get().packingItems set(state => ({ packingItems: state.packingItems.filter(item => item.id !== id) })) try { - await packingApi.delete(tripId, id) + await packingRepo.delete(tripId, id) } catch (err: unknown) { set({ packingItems: prev }) throw new Error(getApiErrorMessage(err, 'Error deleting item')) @@ -55,7 +55,7 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice = ) })) try { - await packingApi.update(tripId, id, { checked }) + await packingRepo.update(tripId, id, { checked }) } catch { set(state => ({ packingItems: state.packingItems.map(item => diff --git a/client/src/store/slices/placesSlice.ts b/client/src/store/slices/placesSlice.ts index 01cab189..27e6df21 100644 --- a/client/src/store/slices/placesSlice.ts +++ b/client/src/store/slices/placesSlice.ts @@ -1,4 +1,4 @@ -import { placesApi } from '../../api/client' +import { placeRepo } from '../../repo/placeRepo' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { Place, Assignment } from '../../types' @@ -17,7 +17,7 @@ export interface PlacesSlice { export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => ({ refreshPlaces: async (tripId) => { try { - const data = await placesApi.list(tripId) + const data = await placeRepo.list(tripId) set({ places: data.places }) } catch (err: unknown) { console.error('Failed to refresh places:', err) @@ -26,7 +26,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => addPlace: async (tripId, placeData) => { try { - const data = await placesApi.create(tripId, placeData) + const data = await placeRepo.create(tripId, placeData as Record) set(state => ({ places: [data.place, ...state.places] })) return data.place } catch (err: unknown) { @@ -36,7 +36,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => updatePlace: async (tripId, placeId, placeData) => { try { - const data = await placesApi.update(tripId, placeId, placeData) + const data = await placeRepo.update(tripId, placeId, placeData as Record) set(state => { const updatedAssignments = { ...state.assignments } let changed = false @@ -61,7 +61,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => deletePlace: async (tripId, placeId) => { try { - await placesApi.delete(tripId, placeId) + await placeRepo.delete(tripId, placeId) set(state => { const updatedAssignments = { ...state.assignments } let changed = false diff --git a/client/src/store/slices/remoteEventHandler.ts b/client/src/store/slices/remoteEventHandler.ts index e86efd9d..68bc2668 100644 --- a/client/src/store/slices/remoteEventHandler.ts +++ b/client/src/store/slices/remoteEventHandler.ts @@ -1,14 +1,167 @@ import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types' +import { offlineDb } from '../../db/offlineDb' type SetState = StoreApi['setState'] +type GetState = StoreApi['getState'] + +// ── Dexie write-through ─────────────────────────────────────────────────────── + +/** + * Persist remote event to IndexedDB so the data is available offline. + * Fire-and-forget: errors are swallowed to never block the Zustand update. + * Called AFTER set() so `state` already reflects the update. + */ +function writeToDexie( + type: string, + payload: Record, + state: TripStoreState, +): void { + ;(async () => { + try { + switch (type) { + // ── Places ────────────────────────────────────────────────────────── + case 'place:created': + case 'place:updated': + await offlineDb.places.put(payload.place as Place) + break + case 'place:deleted': + await offlineDb.places.delete(payload.placeId as number) + break + + // ── Assignments (embedded in Day rows) ────────────────────────────── + // Read the already-updated Day from the Zustand state and persist it. + case 'assignment:created': + case 'assignment:updated': { + const assignment = payload.assignment as Assignment + await _writeDayToDb(assignment.day_id, state) + break + } + case 'assignment:deleted': { + await _writeDayToDb(payload.dayId as number, state) + break + } + case 'assignment:moved': { + const movedAssignment = payload.assignment as Assignment + await Promise.all([ + _writeDayToDb(payload.oldDayId as number, state), + _writeDayToDb(movedAssignment.day_id, state), + ]) + break + } + case 'assignment:reordered': + await _writeDayToDb(payload.dayId as number, state) + break + + // ── Days ───────────────────────────────────────────────────────────── + case 'day:created': + case 'day:updated': { + const day = payload.day as Day + await _writeDayToDb(day.id, state) + break + } + case 'day:deleted': + await offlineDb.days.delete(payload.dayId as number) + break + + // ── Day notes (embedded in Day rows) ───────────────────────────────── + case 'dayNote:created': + case 'dayNote:updated': + case 'dayNote:deleted': + await _writeDayToDb(payload.dayId as number, state) + break + + // ── Packing ────────────────────────────────────────────────────────── + case 'packing:created': + case 'packing:updated': + await offlineDb.packingItems.put(payload.item as PackingItem) + break + case 'packing:deleted': + await offlineDb.packingItems.delete(payload.itemId as number) + break + + // ── Todo ───────────────────────────────────────────────────────────── + case 'todo:created': + case 'todo:updated': + await offlineDb.todoItems.put(payload.item as TodoItem) + break + case 'todo:deleted': + await offlineDb.todoItems.delete(payload.itemId as number) + break + + // ── Budget ─────────────────────────────────────────────────────────── + case 'budget:created': + case 'budget:updated': + await offlineDb.budgetItems.put(payload.item as BudgetItem) + break + case 'budget:deleted': + await offlineDb.budgetItems.delete(payload.itemId as number) + break + case 'budget:members-updated': + case 'budget:member-paid-updated': + case 'budget:reordered': { + // Partial update — read canonical item(s) from updated Zustand state + if (type === 'budget:reordered') { + await offlineDb.budgetItems.bulkPut(state.budgetItems) + } else { + const item = state.budgetItems.find(i => i.id === (payload.itemId as number)) + if (item) await offlineDb.budgetItems.put(item) + } + break + } + + // ── Reservations ───────────────────────────────────────────────────── + case 'reservation:created': + case 'reservation:updated': + await offlineDb.reservations.put(payload.reservation as Reservation) + break + case 'reservation:deleted': + await offlineDb.reservations.delete(payload.reservationId as number) + break + + // ── Trip ───────────────────────────────────────────────────────────── + case 'trip:updated': + await offlineDb.trips.put(payload.trip as Trip) + break + + // ── Files ───────────────────────────────────────────────────────────── + case 'file:created': + case 'file:updated': + await offlineDb.tripFiles.put(payload.file as TripFile) + break + case 'file:deleted': + await offlineDb.tripFiles.delete(payload.fileId as number) + break + + default: + break + } + } catch { + // Dexie write failures are non-fatal — online state is source of truth + } + })() +} + +/** Write a Day (with its current assignments + notes from Zustand) to Dexie. */ +async function _writeDayToDb(dayId: number, state: TripStoreState): Promise { + const day = state.days.find(d => d.id === dayId) + if (!day) return + await offlineDb.days.put({ + ...day, + assignments: state.assignments[String(dayId)] ?? [], + notes_items: state.dayNotes[String(dayId)] ?? [], + }) +} + +// ── Zustand event reducer ───────────────────────────────────────────────────── /** * Applies a remote WebSocket event to the local Zustand store, keeping state in sync across collaborators. * Each event type maps to an immutable state update (create/update/delete) for the relevant entity. + * After the Zustand update, the change is also written through to IndexedDB for offline access. */ -export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void { +export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocketEvent): void { const { type, ...payload } = event set(state => { @@ -285,4 +438,7 @@ export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void { return {} } }) + + // Write the change through to IndexedDB using the post-update state + writeToDexie(type, payload as Record, get()) } diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts index fce8403f..f7588442 100644 --- a/client/src/store/tripStore.ts +++ b/client/src/store/tripStore.ts @@ -1,6 +1,11 @@ import { create } from 'zustand' import type { StoreApi } from 'zustand' -import { tripsApi, daysApi, placesApi, packingApi, todoApi, tagsApi, categoriesApi } from '../api/client' +import { tripsApi, tagsApi, categoriesApi } from '../api/client' +import { tripRepo } from '../repo/tripRepo' +import { dayRepo } from '../repo/dayRepo' +import { placeRepo } from '../repo/placeRepo' +import { packingRepo } from '../repo/packingRepo' +import { todoRepo } from '../repo/todoRepo' import { createPlacesSlice } from './slices/placesSlice' import { createAssignmentsSlice } from './slices/assignmentsSlice' import { createDayNotesSlice } from './slices/dayNotesSlice' @@ -78,19 +83,19 @@ export const useTripStore = create((set, get) => ({ setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }), - handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, event), + handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event), loadTrip: async (tripId: number | string) => { set({ isLoading: true, error: null }) try { const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([ - tripsApi.get(tripId), - daysApi.list(tripId), - placesApi.list(tripId), - packingApi.list(tripId), - todoApi.list(tripId), - tagsApi.list(), - categoriesApi.list(), + tripRepo.get(tripId), + dayRepo.list(tripId), + placeRepo.list(tripId), + packingRepo.list(tripId), + todoRepo.list(tripId), + tagsApi.list().catch(() => ({ tags: [] })), + categoriesApi.list().catch(() => ({ categories: [] })), ]) const assignmentsMap: AssignmentsMap = {} @@ -121,7 +126,7 @@ export const useTripStore = create((set, get) => ({ refreshDays: async (tripId: number | string) => { try { - const daysData = await daysApi.list(tripId) + const daysData = await dayRepo.list(tripId) const assignmentsMap: AssignmentsMap = {} const dayNotesMap: DayNotesMap = {} for (const day of daysData.days) { @@ -138,7 +143,7 @@ export const useTripStore = create((set, get) => ({ try { const result = await tripsApi.update(tripId, data) set({ trip: result.trip }) - const daysData = await daysApi.list(tripId) + const daysData = await dayRepo.list(tripId) const assignmentsMap: AssignmentsMap = {} const dayNotesMap: DayNotesMap = {} for (const day of daysData.days) { diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts new file mode 100644 index 00000000..8ad6e643 --- /dev/null +++ b/client/src/sync/mutationQueue.ts @@ -0,0 +1,162 @@ +/** + * Mutation queue — offline write queue backed by IndexedDB (Dexie). + * + * Flow: + * offline create/update/delete → enqueue() → optimistic Dexie write (in repo) + * online trigger → flush() → replay REST with X-Idempotency-Key header → update Dexie + */ +import { offlineDb } from '../db/offlineDb' +import { apiClient } from '../api/client' +import type { QueuedMutation } from '../db/offlineDb' +import type { Table } from 'dexie' + +// Map Dexie table names used in `resource` field → actual Dexie tables. +function getTable(resource: string): Table | undefined { + const map: Record = { + places: offlineDb.places, + packingItems: offlineDb.packingItems, + todoItems: offlineDb.todoItems, + budgetItems: offlineDb.budgetItems, + reservations: offlineDb.reservations, + tripFiles: offlineDb.tripFiles, + } + return map[resource] +} + +/** Generate a v4-style UUID using the platform crypto API. */ +export function generateUUID(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID() + } + // Fallback for environments without crypto.randomUUID (e.g. old Node) + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16) + }) +} + +let _flushing = false + +export const mutationQueue = { + /** + * Add a mutation to the queue. + * Returns the UUID (= idempotency key). + */ + async enqueue( + mutation: Omit, + ): Promise { + const item: QueuedMutation = { + ...mutation, + status: 'pending', + attempts: 0, + createdAt: Date.now(), + lastError: null, + } + await offlineDb.mutationQueue.put(item) + return item.id + }, + + /** + * Drain the queue: replay each pending mutation against the server in FIFO order. + * Stops on first network error (will retry on next trigger). + * 4xx responses are marked failed and skipped. + */ + async flush(): Promise { + if (_flushing || !navigator.onLine) return + _flushing = true + try { + const pending = await offlineDb.mutationQueue + .where('status') + .equals('pending') + .sortBy('createdAt') + + for (const mutation of pending) { + // Mark as syncing so UI can show progress + await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' }) + + try { + const response = await apiClient.request({ + method: mutation.method, + url: mutation.url, + data: mutation.body, + headers: { 'X-Idempotency-Key': mutation.id }, + }) + + // Apply canonical server response to Dexie + if (mutation.method !== 'DELETE' && mutation.resource) { + const table = getTable(mutation.resource) + if (table && response.data && typeof response.data === 'object') { + // Server returns { place: {...} } or { item: {...} } — grab first value + const values = Object.values(response.data as Record) + const entity = values[0] + if (entity && typeof entity === 'object' && 'id' in entity) { + // Remove temp optimistic entry if id changed (CREATE case) + if (mutation.tempId !== undefined && mutation.tempId !== (entity as { id: number }).id) { + await table.delete(mutation.tempId) + } + await table.put(entity) + } + } + } else if (mutation.method === 'DELETE' && mutation.resource && mutation.entityId !== undefined) { + // DELETE was already applied optimistically; ensure it's gone + const table = getTable(mutation.resource) + if (table) await table.delete(mutation.entityId) + } + + await offlineDb.mutationQueue.delete(mutation.id) + } catch (err: unknown) { + const httpStatus = (err as { response?: { status: number } })?.response?.status + if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) { + // Permanent client error — mark failed, continue with next + await offlineDb.mutationQueue.update(mutation.id, { + status: 'failed', + attempts: mutation.attempts + 1, + lastError: String(err), + }) + } else { + // Network error — reset to pending, abort flush (retry on next trigger) + await offlineDb.mutationQueue.update(mutation.id, { + status: 'pending', + attempts: mutation.attempts + 1, + lastError: String(err), + }) + break + } + } + } + } finally { + _flushing = false + } + }, + + /** + * Return all pending/syncing mutations, optionally filtered by tripId. + * Used by the UI to show per-item pending indicators. + */ + async pending(tripId?: number): Promise { + if (tripId !== undefined) { + return offlineDb.mutationQueue + .where('tripId') + .equals(tripId) + .filter(m => m.status === 'pending' || m.status === 'syncing') + .toArray() + } + return offlineDb.mutationQueue + .where('status') + .anyOf(['pending', 'syncing']) + .toArray() + }, + + /** Count pending mutations (for banner badge). */ + async pendingCount(): Promise { + return offlineDb.mutationQueue + .where('status') + .anyOf(['pending', 'syncing']) + .count() + }, + + /** Reset internal flushing flag — useful in tests. */ + _resetFlushing(): void { + _flushing = false + }, +} diff --git a/client/src/sync/syncTriggers.ts b/client/src/sync/syncTriggers.ts new file mode 100644 index 00000000..2c84afe1 --- /dev/null +++ b/client/src/sync/syncTriggers.ts @@ -0,0 +1,68 @@ +/** + * Sync triggers — register event listeners that flush the mutation queue + * and/or run a full trip sync based on the connectivity trigger source. + * + * Trigger matrix: + * window 'online' → flush mutations + full syncAll (network truly back) + * visibilitychange visible → flush mutations only (avoid hammering server on tab switch) + * periodic 30s → flush mutations only + * WS reconnect → flush mutations only (no syncAll — avoids rate-limiter + * on server restart / socket timeout while already online) + * + * Call `registerSyncTriggers()` once on app mount. + * Call `unregisterSyncTriggers()` on unmount / logout. + */ +import { mutationQueue } from './mutationQueue' +import { tripSyncManager } from './tripSyncManager' +import { setPreReconnectHook } from '../api/websocket' + +const PERIODIC_MS = 30_000 + +let _intervalId: ReturnType | null = null +let _registered = false + +/** Network came back — flush mutations AND re-seed Dexie for all cacheable trips. */ +function onOnline() { + mutationQueue.flush().catch(console.error) + tripSyncManager.syncAll().catch(console.error) +} + +/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */ +function onVisibility() { + if (!document.hidden && navigator.onLine) { + mutationQueue.flush().catch(console.error) + } +} + +/** Periodic heartbeat — drain any lingering pending mutations. */ +function onPeriodic() { + if (navigator.onLine) { + mutationQueue.flush().catch(console.error) + } +} + +export function registerSyncTriggers(): void { + if (_registered) return + _registered = true + + // WS reconnect: flush mutations only — no syncAll to avoid triggering rate + // limiters when the socket drops and reconnects while the device is online. + setPreReconnectHook(() => mutationQueue.flush()) + + window.addEventListener('online', onOnline) + document.addEventListener('visibilitychange', onVisibility) + _intervalId = setInterval(onPeriodic, PERIODIC_MS) +} + +export function unregisterSyncTriggers(): void { + if (!_registered) return + _registered = false + + setPreReconnectHook(null) + window.removeEventListener('online', onOnline) + document.removeEventListener('visibilitychange', onVisibility) + if (_intervalId !== null) { + clearInterval(_intervalId) + _intervalId = null + } +} diff --git a/client/src/sync/tilePrefetcher.ts b/client/src/sync/tilePrefetcher.ts new file mode 100644 index 00000000..086b0beb --- /dev/null +++ b/client/src/sync/tilePrefetcher.ts @@ -0,0 +1,203 @@ +/** + * Map tile prefetcher — warms the Workbox 'map-tiles' cache for a trip's + * bounding box so maps render offline. + * + * Algorithm: + * 1. Compute bbox from trip's place coordinates + padding. + * 2. For zooms 10–16, enumerate tile XYZ coordinates within bbox. + * 3. Stop when cumulative tile estimate exceeds MAX_TILES (~50 MB). + * 4. Fetch each tile URL so the Service Worker CacheFirst handler caches it. + * + * Tile URL template format: Leaflet-compatible {z}/{x}/{y} with optional + * {s} (subdomain) and {r} (retina suffix). + */ + +import type { Place } from '../types' +import { offlineDb, upsertSyncMeta } from '../db/offlineDb' + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Estimated average tile size in KB (road/transit tiles ~15 KB). */ +const AVG_TILE_KB = 15 + +/** Hard cap: ~50 MB worth of tiles. */ +export const MAX_TILES = Math.floor((50 * 1024) / AVG_TILE_KB) // ≈ 3413 + +const DEFAULT_TILE_URL = + 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' + +const SUBDOMAINS = ['a', 'b', 'c', 'd'] +let _subIdx = 0 +function nextSubdomain(): string { + return SUBDOMAINS[_subIdx++ % SUBDOMAINS.length] +} + +// ── Tile math ────────────────────────────────────────────────────────────────── + +/** Longitude → tile X at given zoom. */ +export function lngToTileX(lng: number, zoom: number): number { + return Math.floor(((lng + 180) / 360) * Math.pow(2, zoom)) +} + +/** Latitude → tile Y at given zoom (Web Mercator, y increases southward). */ +export function latToTileY(lat: number, zoom: number): number { + const n = Math.pow(2, zoom) + const latRad = (lat * Math.PI) / 180 + return Math.floor( + ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n, + ) +} + +/** Expand a single-point bbox to min 0.1° span (~10 km) in each axis. */ +function ensureMinSpan(min: number, max: number, minSpan = 0.1): [number, number] { + if (max - min < minSpan) { + const mid = (min + max) / 2 + return [mid - minSpan / 2, mid + minSpan / 2] + } + return [min, max] +} + +// ── Public types ────────────────────────────────────────────────────────────── + +export interface TileBbox { + minLat: number + maxLat: number + minLng: number + maxLng: number +} + +// ── Core logic ──────────────────────────────────────────────────────────────── + +/** + * Compute the bounding box for a list of places with optional padding. + * Returns null if no places have coordinates. + */ +export function computeBbox(places: Place[], paddingFraction = 0.1): TileBbox | null { + const valid = places.filter(p => p.lat !== null && p.lng !== null) + if (valid.length === 0) return null + + const lats = valid.map(p => p.lat as number) + const lngs = valid.map(p => p.lng as number) + + const [rawMinLat, rawMaxLat] = ensureMinSpan(Math.min(...lats), Math.max(...lats)) + const [rawMinLng, rawMaxLng] = ensureMinSpan(Math.min(...lngs), Math.max(...lngs)) + + const latPad = (rawMaxLat - rawMinLat) * paddingFraction + const lngPad = (rawMaxLng - rawMinLng) * paddingFraction + + return { + minLat: Math.max(-85.0511, rawMinLat - latPad), + maxLat: Math.min(85.0511, rawMaxLat + latPad), + minLng: Math.max(-180, rawMinLng - lngPad), + maxLng: Math.min(180, rawMaxLng + lngPad), + } +} + +/** + * Count tiles that would be fetched across the zoom range for a bbox. + * Used to enforce the size guard without actually fetching. + */ +export function countTiles(bbox: TileBbox, minZoom: number, maxZoom: number): number { + let total = 0 + for (let z = minZoom; z <= maxZoom; z++) { + const minX = lngToTileX(bbox.minLng, z) + const maxX = lngToTileX(bbox.maxLng, z) + const minY = latToTileY(bbox.maxLat, z) // northern edge → smaller y + const maxY = latToTileY(bbox.minLat, z) // southern edge → larger y + total += (maxX - minX + 1) * (maxY - minY + 1) + if (total > MAX_TILES) return total + } + return total +} + +/** + * Build the concrete tile URL for given z/x/y from a Leaflet template. + * Rotates through subdomains (a–d). + */ +export function buildTileUrl(template: string, z: number, x: number, y: number): string { + return template + .replace('{z}', String(z)) + .replace('{x}', String(x)) + .replace('{y}', String(y)) + .replace('{s}', nextSubdomain()) + .replace('{r}', '') +} + +/** + * Prefetch tiles for a bbox into the Service Worker cache. + * Stops at the zoom level where the size cap would be exceeded. + * No-ops when: + * - offline + * - no active Service Worker (tiles won't be cached anyway) + * - total tile count exceeds MAX_TILES before even starting zoom 10 + */ +export async function prefetchTiles( + bbox: TileBbox, + tileUrlTemplate: string, + minZoom = 10, + maxZoom = 16, +): Promise { + if (!navigator.onLine) return 0 + if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0 + + let fetched = 0 + + for (let z = minZoom; z <= maxZoom; z++) { + const minX = lngToTileX(bbox.minLng, z) + const maxX = lngToTileX(bbox.maxLng, z) + const minY = latToTileY(bbox.maxLat, z) + const maxY = latToTileY(bbox.minLat, z) + const count = (maxX - minX + 1) * (maxY - minY + 1) + + if (fetched + count > MAX_TILES) break + + for (let x = minX; x <= maxX; x++) { + for (let y = minY; y <= maxY; y++) { + const url = buildTileUrl(tileUrlTemplate, z, x, y) + // Fire-and-forget: SW CacheFirst handler stores the response + fetch(url, { mode: 'no-cors' }).catch(() => {}) + fetched++ + } + } + } + + return fetched +} + +/** + * Full pipeline: compute bbox → guard → prefetch → update syncMeta. + * Designed to be called fire-and-forget from tripSyncManager. + */ +export async function prefetchTilesForTrip( + tripId: number, + places: Place[], + tileUrlTemplate?: string, +): Promise { + const template = tileUrlTemplate || DEFAULT_TILE_URL + const bbox = computeBbox(places) + if (!bbox) return + + // Size guard: if total tile count across all zooms exceeds cap, skip + const estimated = countTiles(bbox, 10, 16) + if (estimated > MAX_TILES) { + console.warn( + `[tilePrefetch] trip ${tripId}: estimated ${estimated} tiles exceeds cap (${MAX_TILES}), skipping`, + ) + return + } + + const fetched = await prefetchTiles(bbox, template) + + // Update syncMeta with bbox and tile count + const meta = await offlineDb.syncMeta.get(tripId) + if (meta) { + await upsertSyncMeta({ + ...meta, + tilesBbox: [bbox.minLng, bbox.minLat, bbox.maxLng, bbox.maxLat], + }) + } + + if (fetched > 0) { + console.info(`[tilePrefetch] trip ${tripId}: queued ${fetched} tiles for caching`) + } +} diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts new file mode 100644 index 00000000..dc868244 --- /dev/null +++ b/client/src/sync/tripSyncManager.ts @@ -0,0 +1,166 @@ +/** + * Trip sync manager — seeds Dexie with trip data for offline use. + * + * Cache scope: trips where end_date >= today OR end_date is null/empty. + * Eviction: trips where end_date < today - 7 days. + * File blobs: all non-photo files (MIME type != image/*) for cached trips. + * + * Call syncAll() on: + * - login success + * - trip list refresh (DashboardPage) + * - WS reconnect (phase 7) + */ +import { tripsApi } from '../api/client' +import { + offlineDb, + upsertTrip, + upsertDays, + upsertPlaces, + upsertPackingItems, + upsertTodoItems, + upsertBudgetItems, + upsertReservations, + upsertTripFiles, + upsertSyncMeta, + clearTripData, +} from '../db/offlineDb' +import { prefetchTilesForTrip } from './tilePrefetcher' +import { useSettingsStore } from '../store/settingsStore' +import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../types' + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface TripBundle { + trip: Trip + days: Day[] + places: Place[] + packingItems: PackingItem[] + todoItems: TodoItem[] + budgetItems: BudgetItem[] + reservations: Reservation[] + files: TripFile[] +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function todayStr(): string { + return new Date().toISOString().slice(0, 10) +} + +function shouldCache(trip: Trip): boolean { + if (!trip.end_date) return true // no end date → cache forever + return trip.end_date >= todayStr() // ongoing or future +} + +function isStale(trip: Trip): boolean { + if (!trip.end_date) return false + const cutoff = new Date() + cutoff.setDate(cutoff.getDate() - 7) + return trip.end_date < cutoff.toISOString().slice(0, 10) +} + +function isPhoto(file: TripFile): boolean { + return file.mime_type.startsWith('image/') +} + +// ── Core logic ──────────────────────────────────────────────────────────────── + +/** Fetch bundle + write all entities for one trip into Dexie. */ +async function syncTrip(tripId: number): Promise { + const bundle = await tripsApi.bundle(tripId) as TripBundle + + await upsertTrip(bundle.trip) + await upsertDays(bundle.days) + await upsertPlaces(bundle.places) + await upsertPackingItems(bundle.packingItems) + await upsertTodoItems(bundle.todoItems) + await upsertBudgetItems(bundle.budgetItems) + await upsertReservations(bundle.reservations) + await upsertTripFiles(bundle.files) + await upsertSyncMeta({ + tripId, + lastSyncedAt: Date.now(), + status: 'idle', + tilesBbox: null, + filesCachedCount: 0, + }) +} + +/** Cache non-photo file blobs for a trip. Fire-and-forget safe. */ +async function cacheFilesForTrip(files: TripFile[]): Promise { + const nonPhotos = files.filter(f => f.url && !isPhoto(f)) + let cached = 0 + + for (const file of nonPhotos) { + // Skip if already cached + const existing = await offlineDb.blobCache.get(file.url!) + if (existing) { cached++; continue } + + try { + const resp = await fetch(file.url!, { credentials: 'include' }) + if (!resp.ok) continue + const blob = await resp.blob() + await offlineDb.blobCache.put({ url: file.url!, blob, mime: file.mime_type, cachedAt: Date.now() }) + cached++ + } catch { + // Network failure — skip this file, will retry next sync + } + } + + // Update filesCachedCount in syncMeta + const tripId = files[0]?.trip_id + if (tripId) { + const meta = await offlineDb.syncMeta.get(tripId) + if (meta) await upsertSyncMeta({ ...meta, filesCachedCount: cached }) + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +let _syncing = false + +export const tripSyncManager = { + /** + * Sync all cache-eligible trips. + * Evicts stale trips. Caches file blobs in the background. + * No-ops when offline. + */ + async syncAll(): Promise { + if (_syncing || !navigator.onLine) return + _syncing = true + try { + const { trips } = await tripsApi.list() as { trips: Trip[] } + + // Evict stale trips first + const stale = trips.filter(isStale) + await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error))) + + // Sync eligible trips + const toSync = trips.filter(shouldCache) + for (const trip of toSync) { + try { + await syncTrip(trip.id) + } catch (err) { + console.error(`[tripSync] failed for trip ${trip.id}:`, err) + } + } + + // Cache file blobs + map tiles in background (don't block syncAll) + const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined + for (const trip of toSync) { + const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray() + cacheFilesForTrip(files).catch(console.error) + + const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray() + prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error) + } + } finally { + _syncing = false + } + }, + + /** Reset syncing flag — useful in tests. */ + _resetSyncing(): void { + _syncing = false + }, +} diff --git a/client/tests/helpers/msw/handlers/trips.ts b/client/tests/helpers/msw/handlers/trips.ts index 421001f7..9ffde479 100644 --- a/client/tests/helpers/msw/handlers/trips.ts +++ b/client/tests/helpers/msw/handlers/trips.ts @@ -1,5 +1,5 @@ import { http, HttpResponse } from 'msw'; -import { buildTrip, buildDay, buildUser } from '../../factories'; +import { buildTrip, buildDay, buildUser, buildPlace, buildPackingItem, buildTodoItem, buildBudgetItem, buildReservation, buildTripFile } from '../../factories'; export const tripsHandlers = [ // List all trips (active or archived) @@ -47,6 +47,22 @@ export const tripsHandlers = [ return HttpResponse.json({ accommodations: [] }); }), + http.get('/api/trips/:id/bundle', ({ params }) => { + const tripId = Number(params.id); + const trip = buildTrip({ id: tripId }); + const day = buildDay({ trip_id: tripId, assignments: [], notes_items: [] }); + return HttpResponse.json({ + trip, + days: [day], + places: [buildPlace({ trip_id: tripId })], + packingItems: [buildPackingItem({ trip_id: tripId })], + todoItems: [buildTodoItem({ trip_id: tripId })], + budgetItems: [buildBudgetItem({ trip_id: tripId })], + reservations: [buildReservation({ trip_id: tripId })], + files: [buildTripFile({ trip_id: tripId })], + }); + }), + http.delete('/api/trips/:id', () => { return HttpResponse.json({ success: true }); }), diff --git a/client/tests/integration/api/client.test.ts b/client/tests/integration/api/client.test.ts index b7c081e0..68ed2605 100644 --- a/client/tests/integration/api/client.test.ts +++ b/client/tests/integration/api/client.test.ts @@ -11,6 +11,7 @@ vi.mock('../../../src/api/websocket', () => ({ disconnect: vi.fn(), getSocketId: vi.fn(() => 'mock-socket-id'), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), joinTrip: vi.fn(), leaveTrip: vi.fn(), addListener: vi.fn(), diff --git a/client/tests/integration/hooks/useInAppNotificationListener.test.ts b/client/tests/integration/hooks/useInAppNotificationListener.test.ts index 532707e1..13cfeb0a 100644 --- a/client/tests/integration/hooks/useInAppNotificationListener.test.ts +++ b/client/tests/integration/hooks/useInAppNotificationListener.test.ts @@ -11,6 +11,7 @@ vi.mock('../../../src/api/websocket', () => ({ disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), joinTrip: vi.fn(), leaveTrip: vi.fn(), addListener: vi.fn((fn) => { diff --git a/client/tests/integration/hooks/useTripWebSocket.test.ts b/client/tests/integration/hooks/useTripWebSocket.test.ts index 6f982e1a..6ea0ccbc 100644 --- a/client/tests/integration/hooks/useTripWebSocket.test.ts +++ b/client/tests/integration/hooks/useTripWebSocket.test.ts @@ -12,6 +12,7 @@ vi.mock('../../../src/api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn(), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), })); // Import the mocked module AFTER vi.mock diff --git a/client/tests/setup.ts b/client/tests/setup.ts index 0ff906a7..d92bf394 100644 --- a/client/tests/setup.ts +++ b/client/tests/setup.ts @@ -1,4 +1,5 @@ import '@testing-library/jest-dom/vitest'; +import 'fake-indexeddb/auto'; import { cleanup } from '@testing-library/react'; import { afterAll, afterEach, beforeAll, vi } from 'vitest'; import { server } from './helpers/msw/server'; @@ -9,6 +10,7 @@ vi.mock('../src/api/websocket', () => ({ disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), })); // MSW lifecycle diff --git a/client/tests/unit/db/offlineDb.test.ts b/client/tests/unit/db/offlineDb.test.ts new file mode 100644 index 00000000..b0645c09 --- /dev/null +++ b/client/tests/unit/db/offlineDb.test.ts @@ -0,0 +1,273 @@ +/** + * offlineDb unit tests. + * + * Uses fake-indexeddb so no real browser IDB is needed. + * Each test gets a fresh database by using `use-fake-indexeddb` with Dexie. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import Dexie from 'dexie'; + +// Re-import after fake-indexeddb is set up so Dexie picks up the shim. +// We re-open a clean db in each test to isolate state. +import { + offlineDb, + clearTripData, + clearAll, + upsertTrip, + upsertDays, + upsertPlaces, + upsertPackingItems, + upsertTodoItems, + upsertBudgetItems, + upsertReservations, + upsertTripFiles, + upsertSyncMeta, + type QueuedMutation, + type SyncMeta, + type BlobCacheEntry, +} from '../../../src/db/offlineDb'; +import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../../../src/types'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const makeTrip = (id = 1): Trip => ({ + id, + name: `Trip ${id}`, + description: null, + start_date: '2026-07-01', + end_date: '2026-07-05', + cover_url: null, + is_archived: false, + reminder_days: 3, + owner_id: 42, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', +}); + +const makeDay = (id: number, tripId = 1): Day => ({ + id, + trip_id: tripId, + date: '2026-07-01', + title: null, + notes: null, + assignments: [], + notes_items: [], +}); + +const makePlace = (id: number, tripId = 1): Place => ({ + id, + trip_id: tripId, + name: `Place ${id}`, + description: null, + notes: null, + lat: 48.8566, + lng: 2.3522, + address: null, + category_id: null, + icon: null, + price: null, + currency: null, + image_url: null, + google_place_id: null, + osm_id: null, + route_geometry: null, + place_time: null, + end_time: null, + duration_minutes: null, + transport_mode: null, + website: null, + phone: null, + created_at: '2026-01-01T00:00:00Z', +}); + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +beforeEach(async () => { + // Ensure DB is open (fake-indexeddb resets between test files but not between tests). + if (!offlineDb.isOpen()) await offlineDb.open(); + // Clear all tables before each test. + await clearAll(); +}); + +afterEach(async () => { + if (!offlineDb.isOpen()) await offlineDb.open(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('offlineDb — trips', () => { + it('stores and retrieves a trip via upsertTrip', async () => { + const trip = makeTrip(10); + await upsertTrip(trip); + const stored = await offlineDb.trips.get(10); + expect(stored).toBeDefined(); + expect(stored!.name).toBe('Trip 10'); + }); + + it('upsertTrip overwrites an existing trip (put semantics)', async () => { + await upsertTrip(makeTrip(1)); + await upsertTrip({ ...makeTrip(1), name: 'Updated' }); + const stored = await offlineDb.trips.get(1); + expect(stored!.name).toBe('Updated'); + }); +}); + +describe('offlineDb — days', () => { + it('stores days and retrieves by trip_id index', async () => { + await upsertDays([makeDay(1, 5), makeDay(2, 5), makeDay(3, 9)]); + const trip5Days = await offlineDb.days.where('trip_id').equals(5).toArray(); + expect(trip5Days).toHaveLength(2); + expect(trip5Days.map(d => d.id)).toContain(1); + expect(trip5Days.map(d => d.id)).toContain(2); + }); +}); + +describe('offlineDb — places', () => { + it('stores places and retrieves by trip_id', async () => { + await upsertPlaces([makePlace(10, 1), makePlace(11, 1), makePlace(12, 2)]); + const places = await offlineDb.places.where('trip_id').equals(1).toArray(); + expect(places).toHaveLength(2); + }); +}); + +describe('offlineDb — packing / todo / budget / reservations / files', () => { + it('upserts packing items', async () => { + const item: PackingItem = { id: 1, trip_id: 1, name: 'Passport', category: null, checked: 0, quantity: 1 }; + await upsertPackingItems([item]); + expect(await offlineDb.packingItems.count()).toBe(1); + }); + + it('upserts todo items', async () => { + const item: TodoItem = { + id: 1, trip_id: 1, name: 'Book hotel', category: null, checked: 0, + sort_order: 0, due_date: null, description: null, assigned_user_id: null, priority: 0, + }; + await upsertTodoItems([item]); + expect(await offlineDb.todoItems.count()).toBe(1); + }); + + it('upserts budget items', async () => { + const item: BudgetItem = { + id: 1, trip_id: 1, name: 'Flight', amount: 500, currency: 'EUR', + category: 'Transport', paid_by: null, persons: 1, members: [], expense_date: null, + }; + await upsertBudgetItems([item]); + expect(await offlineDb.budgetItems.count()).toBe(1); + }); + + it('upserts reservations', async () => { + const item: Reservation = { + id: 1, trip_id: 1, name: 'Hotel', type: 'hotel', status: 'confirmed', + date: null, time: null, confirmation_number: null, notes: null, url: null, created_at: '2026-01-01T00:00:00Z', + }; + await upsertReservations([item]); + expect(await offlineDb.reservations.count()).toBe(1); + }); + + it('upserts trip files', async () => { + const file: TripFile = { + id: 1, trip_id: 1, filename: 'ticket.pdf', original_name: 'Ticket.pdf', + mime_type: 'application/pdf', created_at: '2026-01-01T00:00:00Z', + }; + await upsertTripFiles([file]); + expect(await offlineDb.tripFiles.count()).toBe(1); + }); +}); + +describe('offlineDb — syncMeta', () => { + it('stores and retrieves syncMeta by tripId', async () => { + const meta: SyncMeta = { + tripId: 7, + lastSyncedAt: Date.now(), + status: 'idle', + tilesBbox: null, + filesCachedCount: 0, + }; + await upsertSyncMeta(meta); + const stored = await offlineDb.syncMeta.get(7); + expect(stored).toBeDefined(); + expect(stored!.status).toBe('idle'); + }); +}); + +describe('offlineDb — mutationQueue', () => { + it('stores queued mutations queryable by status', async () => { + const pending: QueuedMutation = { + id: 'uuid-1', tripId: 1, method: 'POST', url: '/api/trips/1/places', + body: { name: 'Eiffel Tower' }, createdAt: Date.now(), + status: 'pending', attempts: 0, lastError: null, + }; + const failed: QueuedMutation = { + id: 'uuid-2', tripId: 1, method: 'PUT', url: '/api/trips/1/places/5', + body: { name: 'Updated' }, createdAt: Date.now(), + status: 'failed', attempts: 3, lastError: 'Network error', + }; + await offlineDb.mutationQueue.bulkPut([pending, failed]); + + const pendingRows = await offlineDb.mutationQueue.where('status').equals('pending').toArray(); + expect(pendingRows).toHaveLength(1); + expect(pendingRows[0].id).toBe('uuid-1'); + + const failedRows = await offlineDb.mutationQueue.where('status').equals('failed').toArray(); + expect(failedRows).toHaveLength(1); + expect(failedRows[0].lastError).toBe('Network error'); + }); +}); + +describe('offlineDb — blobCache', () => { + it('stores and retrieves a Blob entry', async () => { + const blob = new Blob(['%PDF-1.4 test'], { type: 'application/pdf' }); + const entry: BlobCacheEntry = { + url: '/api/files/99/download', + blob, + mime: 'application/pdf', + cachedAt: Date.now(), + }; + await offlineDb.blobCache.put(entry); + + const stored = await offlineDb.blobCache.get('/api/files/99/download'); + expect(stored).toBeDefined(); + expect(stored!.mime).toBe('application/pdf'); + expect(stored!.blob).toBeDefined(); + }); +}); + +describe('offlineDb — clearTripData', () => { + it('removes all data for the given trip across all tables', async () => { + await upsertTrip(makeTrip(1)); + await upsertDays([makeDay(1, 1), makeDay(2, 1)]); + await upsertPlaces([makePlace(10, 1)]); + const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, quantity: 1 }; + await upsertPackingItems([item]); + + // Also add data for a different trip — should NOT be removed + await upsertTrip(makeTrip(2)); + await upsertDays([makeDay(99, 2)]); + + await clearTripData(1); + + expect(await offlineDb.trips.get(1)).toBeUndefined(); + expect(await offlineDb.days.where('trip_id').equals(1).count()).toBe(0); + expect(await offlineDb.places.where('trip_id').equals(1).count()).toBe(0); + expect(await offlineDb.packingItems.where('trip_id').equals(1).count()).toBe(0); + + // Trip 2 intact + expect(await offlineDb.trips.get(2)).toBeDefined(); + expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1); + }); +}); + +describe('offlineDb — clearAll', () => { + it('empties all tables', async () => { + await upsertTrip(makeTrip(1)); + await upsertDays([makeDay(1, 1), makeDay(2, 1)]); + await upsertPlaces([makePlace(10, 1)]); + + await clearAll(); + + expect(await offlineDb.trips.count()).toBe(0); + expect(await offlineDb.days.count()).toBe(0); + expect(await offlineDb.places.count()).toBe(0); + }); +}); diff --git a/client/tests/unit/repo/packingRepo.test.ts b/client/tests/unit/repo/packingRepo.test.ts new file mode 100644 index 00000000..4c25ada2 --- /dev/null +++ b/client/tests/unit/repo/packingRepo.test.ts @@ -0,0 +1,119 @@ +/** + * packingRepo unit tests. + * + * Online path: calls REST via MSW, writes result to Dexie. + * Offline path: returns Dexie cache, skips REST. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import 'fake-indexeddb/auto'; +import { server } from '../../helpers/msw/server'; +import { http, HttpResponse } from 'msw'; +import { packingRepo } from '../../../src/repo/packingRepo'; +import { offlineDb, clearAll } from '../../../src/db/offlineDb'; +import { buildPackingItem } from '../../helpers/factories'; + +beforeEach(async () => { + await clearAll(); + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('packingRepo.list', () => { + it('online — fetches from REST and caches in Dexie', async () => { + const item = buildPackingItem({ trip_id: 1 }); + server.use( + http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [item] })), + ); + + const result = await packingRepo.list(1); + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe(item.id); + + await new Promise(r => setTimeout(r, 0)); + const cached = await offlineDb.packingItems.where('trip_id').equals(1).toArray(); + expect(cached).toHaveLength(1); + expect(cached[0].id).toBe(item.id); + }); + + it('offline — returns Dexie cache without REST call', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + + const item = buildPackingItem({ trip_id: 1 }); + await offlineDb.packingItems.put(item); + + let restCalled = false; + server.use( + http.get('/api/trips/1/packing', () => { + restCalled = true; + return HttpResponse.json({ items: [] }); + }), + ); + + const result = await packingRepo.list(1); + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe(item.id); + expect(restCalled).toBe(false); + }); + + it('offline — returns empty array when nothing cached', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + const result = await packingRepo.list(99); + expect(result.items).toHaveLength(0); + }); +}); + +describe('packingRepo.create', () => { + it('calls REST and caches created item in Dexie', async () => { + const item = buildPackingItem({ trip_id: 1, name: 'Sunscreen' }); + server.use( + http.post('/api/trips/1/packing', () => HttpResponse.json({ item })), + ); + + const result = await packingRepo.create(1, { name: 'Sunscreen' }); + expect(result.item.name).toBe('Sunscreen'); + + await new Promise(r => setTimeout(r, 0)); + const cached = await offlineDb.packingItems.get(item.id); + expect(cached).toBeDefined(); + expect(cached!.name).toBe('Sunscreen'); + }); +}); + +describe('packingRepo.update', () => { + it('calls REST and updates Dexie cache', async () => { + const original = buildPackingItem({ trip_id: 1, name: 'Jacket', checked: 0 }); + await offlineDb.packingItems.put(original); + + const updated = { ...original, checked: 1 }; + server.use( + http.put(`/api/trips/1/packing/${original.id}`, () => HttpResponse.json({ item: updated })), + ); + + const result = await packingRepo.update(1, original.id, { checked: true }); + expect(result.item.checked).toBe(1); + + await new Promise(r => setTimeout(r, 0)); + const cached = await offlineDb.packingItems.get(original.id); + expect(cached!.checked).toBe(1); + }); +}); + +describe('packingRepo.delete', () => { + it('calls REST and removes from Dexie', async () => { + const item = buildPackingItem({ trip_id: 1 }); + await offlineDb.packingItems.put(item); + + server.use( + http.delete(`/api/trips/1/packing/${item.id}`, () => HttpResponse.json({ success: true })), + ); + + await packingRepo.delete(1, item.id); + + await new Promise(r => setTimeout(r, 0)); + const cached = await offlineDb.packingItems.get(item.id); + expect(cached).toBeUndefined(); + }); +}); diff --git a/client/tests/unit/repo/placeRepo.test.ts b/client/tests/unit/repo/placeRepo.test.ts new file mode 100644 index 00000000..45387841 --- /dev/null +++ b/client/tests/unit/repo/placeRepo.test.ts @@ -0,0 +1,120 @@ +/** + * placeRepo unit tests. + * + * Online path: calls REST via MSW, writes result to Dexie. + * Offline path: returns Dexie cache, skips REST. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import 'fake-indexeddb/auto'; +import { server } from '../../helpers/msw/server'; +import { http, HttpResponse } from 'msw'; +import { placeRepo } from '../../../src/repo/placeRepo'; +import { offlineDb, clearAll } from '../../../src/db/offlineDb'; +import { buildPlace } from '../../helpers/factories'; + +beforeEach(async () => { + await clearAll(); + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('placeRepo.list', () => { + it('online — fetches from REST and caches in Dexie', async () => { + const place = buildPlace({ trip_id: 1 }); + server.use( + http.get('/api/trips/1/places', () => HttpResponse.json({ places: [place] })), + ); + + const result = await placeRepo.list(1); + expect(result.places).toHaveLength(1); + expect(result.places[0].id).toBe(place.id); + + // Give fire-and-forget a tick to flush + await new Promise(r => setTimeout(r, 0)); + const cached = await offlineDb.places.where('trip_id').equals(1).toArray(); + expect(cached).toHaveLength(1); + expect(cached[0].id).toBe(place.id); + }); + + it('offline — returns Dexie cache without REST call', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + + const place = buildPlace({ trip_id: 1 }); + await offlineDb.places.put(place); + + let restCalled = false; + server.use( + http.get('/api/trips/1/places', () => { + restCalled = true; + return HttpResponse.json({ places: [] }); + }), + ); + + const result = await placeRepo.list(1); + expect(result.places).toHaveLength(1); + expect(result.places[0].id).toBe(place.id); + expect(restCalled).toBe(false); + }); + + it('offline — returns empty array when nothing cached', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + const result = await placeRepo.list(99); + expect(result.places).toHaveLength(0); + }); +}); + +describe('placeRepo.create', () => { + it('calls REST and caches created place in Dexie', async () => { + const place = buildPlace({ trip_id: 1, name: 'Eiffel Tower' }); + server.use( + http.post('/api/trips/1/places', () => HttpResponse.json({ place })), + ); + + const result = await placeRepo.create(1, { name: 'Eiffel Tower' }); + expect(result.place.name).toBe('Eiffel Tower'); + + await new Promise(r => setTimeout(r, 0)); + const cached = await offlineDb.places.get(place.id); + expect(cached).toBeDefined(); + expect(cached!.name).toBe('Eiffel Tower'); + }); +}); + +describe('placeRepo.update', () => { + it('calls REST and updates Dexie cache', async () => { + const original = buildPlace({ trip_id: 1, name: 'Old Name' }); + await offlineDb.places.put(original); + + const updated = { ...original, name: 'New Name' }; + server.use( + http.put(`/api/trips/1/places/${original.id}`, () => HttpResponse.json({ place: updated })), + ); + + const result = await placeRepo.update(1, original.id, { name: 'New Name' }); + expect(result.place.name).toBe('New Name'); + + await new Promise(r => setTimeout(r, 0)); + const cached = await offlineDb.places.get(original.id); + expect(cached!.name).toBe('New Name'); + }); +}); + +describe('placeRepo.delete', () => { + it('calls REST and removes from Dexie', async () => { + const place = buildPlace({ trip_id: 1 }); + await offlineDb.places.put(place); + + server.use( + http.delete(`/api/trips/1/places/${place.id}`, () => HttpResponse.json({ success: true })), + ); + + await placeRepo.delete(1, place.id); + + await new Promise(r => setTimeout(r, 0)); + const cached = await offlineDb.places.get(place.id); + expect(cached).toBeUndefined(); + }); +}); diff --git a/client/tests/unit/slices/assignmentsSlice.test.ts b/client/tests/unit/slices/assignmentsSlice.test.ts index e510c1e1..29785818 100644 --- a/client/tests/unit/slices/assignmentsSlice.test.ts +++ b/client/tests/unit/slices/assignmentsSlice.test.ts @@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn(), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), })); beforeEach(() => { diff --git a/client/tests/unit/slices/budgetSlice.test.ts b/client/tests/unit/slices/budgetSlice.test.ts index ac122ce0..e847dd86 100644 --- a/client/tests/unit/slices/budgetSlice.test.ts +++ b/client/tests/unit/slices/budgetSlice.test.ts @@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn(), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), })); beforeEach(() => { diff --git a/client/tests/unit/slices/dayNotesSlice.test.ts b/client/tests/unit/slices/dayNotesSlice.test.ts index 2021d22b..3d770ec0 100644 --- a/client/tests/unit/slices/dayNotesSlice.test.ts +++ b/client/tests/unit/slices/dayNotesSlice.test.ts @@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn(), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), })); beforeEach(() => { diff --git a/client/tests/unit/slices/filesSlice.test.ts b/client/tests/unit/slices/filesSlice.test.ts index 78bcb9b5..7f5adc8c 100644 --- a/client/tests/unit/slices/filesSlice.test.ts +++ b/client/tests/unit/slices/filesSlice.test.ts @@ -15,6 +15,7 @@ vi.mock('../../../src/api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn(), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), })); beforeEach(() => { diff --git a/client/tests/unit/slices/packingSlice.test.ts b/client/tests/unit/slices/packingSlice.test.ts index 1ccc653b..901c0a08 100644 --- a/client/tests/unit/slices/packingSlice.test.ts +++ b/client/tests/unit/slices/packingSlice.test.ts @@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn(), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), })); beforeEach(() => { diff --git a/client/tests/unit/slices/placesSlice.test.ts b/client/tests/unit/slices/placesSlice.test.ts index 6a55094f..93a9310e 100644 --- a/client/tests/unit/slices/placesSlice.test.ts +++ b/client/tests/unit/slices/placesSlice.test.ts @@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn(), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), })); beforeEach(() => { diff --git a/client/tests/unit/slices/reservationsSlice.test.ts b/client/tests/unit/slices/reservationsSlice.test.ts index d2beb5b1..b0b5e134 100644 --- a/client/tests/unit/slices/reservationsSlice.test.ts +++ b/client/tests/unit/slices/reservationsSlice.test.ts @@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn(), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), })); beforeEach(() => { diff --git a/client/tests/unit/slices/todoSlice.test.ts b/client/tests/unit/slices/todoSlice.test.ts index 2060d722..123426bc 100644 --- a/client/tests/unit/slices/todoSlice.test.ts +++ b/client/tests/unit/slices/todoSlice.test.ts @@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn(), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), })); beforeEach(() => { diff --git a/client/tests/unit/sync/mutationQueue.test.ts b/client/tests/unit/sync/mutationQueue.test.ts new file mode 100644 index 00000000..6a473bb7 --- /dev/null +++ b/client/tests/unit/sync/mutationQueue.test.ts @@ -0,0 +1,267 @@ +/** + * mutationQueue unit tests. + * + * Covers: enqueue, flush (2xx success, 4xx fail, network error), idempotency header, + * pending count, create temp-id reconciliation, delete Dexie cleanup. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import 'fake-indexeddb/auto'; +import { server } from '../../helpers/msw/server'; +import { http, HttpResponse } from 'msw'; +import { mutationQueue, generateUUID } from '../../../src/sync/mutationQueue'; +import { offlineDb, clearAll } from '../../../src/db/offlineDb'; +import { buildPlace, buildPackingItem } from '../../helpers/factories'; + +beforeEach(async () => { + await clearAll(); + mutationQueue._resetFlushing(); + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ── helpers ────────────────────────────────────────────────────────────────── + +function makeMutation(overrides: Partial[0]> = {}) { + return { + id: generateUUID(), + tripId: 1, + method: 'POST' as const, + url: '/trips/1/places', + body: { name: 'Eiffel Tower' }, + resource: 'places', + ...overrides, + }; +} + +// ── enqueue ─────────────────────────────────────────────────────────────────── + +describe('mutationQueue.enqueue', () => { + it('stores mutation with pending status', async () => { + const id = generateUUID(); + await mutationQueue.enqueue(makeMutation({ id })); + + const stored = await offlineDb.mutationQueue.get(id); + expect(stored).toBeDefined(); + expect(stored!.status).toBe('pending'); + expect(stored!.attempts).toBe(0); + }); + + it('returns the mutation id', async () => { + const id = generateUUID(); + const returned = await mutationQueue.enqueue(makeMutation({ id })); + expect(returned).toBe(id); + }); +}); + +// ── flush — success path ────────────────────────────────────────────────────── + +describe('mutationQueue.flush — 2xx success', () => { + it('removes mutation from queue and writes canonical entity to Dexie', async () => { + const place = buildPlace({ trip_id: 1, id: 42 }); + const id = generateUUID(); + await mutationQueue.enqueue(makeMutation({ id })); + + server.use( + http.post('/api/trips/1/places', () => HttpResponse.json({ place })), + ); + + await mutationQueue.flush(); + + const queued = await offlineDb.mutationQueue.get(id); + expect(queued).toBeUndefined(); + + const cached = await offlineDb.places.get(42); + expect(cached).toBeDefined(); + expect(cached!.name).toBe(place.name); + }); + + it('attaches X-Idempotency-Key header matching the mutation id', async () => { + const place = buildPlace({ trip_id: 1 }); + const id = generateUUID(); + await mutationQueue.enqueue(makeMutation({ id })); + + let capturedKey: string | null = null; + server.use( + http.post('/api/trips/1/places', ({ request }) => { + capturedKey = request.headers.get('X-Idempotency-Key'); + return HttpResponse.json({ place }); + }), + ); + + await mutationQueue.flush(); + expect(capturedKey).toBe(id); + }); + + it('removes temp entry and adds canonical entry on CREATE flush', async () => { + const tempId = -12345; + const place = buildPlace({ trip_id: 1, id: 99 }); + const id = generateUUID(); + + // Optimistic temp entry in Dexie + await offlineDb.places.put({ ...place, id: tempId }); + + await mutationQueue.enqueue(makeMutation({ id, tempId })); + + server.use( + http.post('/api/trips/1/places', () => HttpResponse.json({ place })), + ); + + await mutationQueue.flush(); + + expect(await offlineDb.places.get(tempId)).toBeUndefined(); + expect(await offlineDb.places.get(99)).toBeDefined(); + }); + + it('handles DELETE: removes entity from Dexie after flush', async () => { + const place = buildPlace({ trip_id: 1, id: 55 }); + await offlineDb.places.put(place); + + const id = generateUUID(); + await mutationQueue.enqueue({ + id, + tripId: 1, + method: 'DELETE', + url: '/trips/1/places/55', + body: undefined, + resource: 'places', + entityId: 55, + }); + + server.use( + http.delete('/api/trips/1/places/55', () => HttpResponse.json({ success: true })), + ); + + await mutationQueue.flush(); + + expect(await offlineDb.mutationQueue.get(id)).toBeUndefined(); + expect(await offlineDb.places.get(55)).toBeUndefined(); + }); +}); + +// ── flush — error paths ─────────────────────────────────────────────────────── + +describe('mutationQueue.flush — 4xx client error', () => { + it('marks mutation as failed and continues to next mutation', async () => { + const id1 = generateUUID(); + const id2 = generateUUID(); + const place = buildPlace({ trip_id: 1 }); + + // Enqueue in order + await mutationQueue.enqueue(makeMutation({ id: id1 })); + await mutationQueue.enqueue(makeMutation({ id: id2 })); + + let callCount = 0; + server.use( + http.post('/api/trips/1/places', () => { + callCount++; + if (callCount === 1) { + return HttpResponse.json({ error: 'Bad request' }, { status: 400 }); + } + return HttpResponse.json({ place }); + }), + ); + + await mutationQueue.flush(); + + const m1 = await offlineDb.mutationQueue.get(id1); + expect(m1).toBeDefined(); + expect(m1!.status).toBe('failed'); + + // Second mutation succeeded and was removed + expect(await offlineDb.mutationQueue.get(id2)).toBeUndefined(); + }); +}); + +describe('mutationQueue.flush — network error', () => { + it('resets to pending and stops flush without marking failed', async () => { + const id = generateUUID(); + await mutationQueue.enqueue(makeMutation({ id })); + + server.use( + http.post('/api/trips/1/places', () => HttpResponse.error()), + ); + + await mutationQueue.flush(); + + const m = await offlineDb.mutationQueue.get(id); + expect(m).toBeDefined(); + expect(m!.status).toBe('pending'); + expect(m!.attempts).toBe(1); + }); +}); + +// ── flush — offline guard ───────────────────────────────────────────────────── + +describe('mutationQueue.flush — offline guard', () => { + it('does nothing when offline', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + const id = generateUUID(); + await mutationQueue.enqueue(makeMutation({ id })); + + let called = false; + server.use( + http.post('/api/trips/1/places', () => { + called = true; + return HttpResponse.json({ place: buildPlace({ trip_id: 1 }) }); + }), + ); + + await mutationQueue.flush(); + expect(called).toBe(false); + const m = await offlineDb.mutationQueue.get(id); + expect(m!.status).toBe('pending'); + }); +}); + +// ── pending / pendingCount ──────────────────────────────────────────────────── + +describe('mutationQueue.pending', () => { + it('returns pending mutations for a trip', async () => { + const id1 = generateUUID(); + const id2 = generateUUID(); + await mutationQueue.enqueue(makeMutation({ id: id1, tripId: 1 })); + await mutationQueue.enqueue(makeMutation({ id: id2, tripId: 2 })); + + const trip1 = await mutationQueue.pending(1); + expect(trip1).toHaveLength(1); + expect(trip1[0].id).toBe(id1); + }); + + it('returns all pending when no tripId given', async () => { + await mutationQueue.enqueue(makeMutation({ id: generateUUID(), tripId: 1 })); + await mutationQueue.enqueue(makeMutation({ id: generateUUID(), tripId: 2 })); + + const all = await mutationQueue.pending(); + expect(all).toHaveLength(2); + }); + + it('excludes failed mutations', async () => { + const id = generateUUID(); + await mutationQueue.enqueue(makeMutation({ id })); + await offlineDb.mutationQueue.update(id, { status: 'failed' }); + + const pending = await mutationQueue.pending(1); + expect(pending).toHaveLength(0); + }); +}); + +describe('mutationQueue.pendingCount', () => { + it('returns zero for empty queue', async () => { + expect(await mutationQueue.pendingCount()).toBe(0); + }); + + it('counts pending and syncing, excludes failed', async () => { + const id1 = generateUUID(); + const id2 = generateUUID(); + const id3 = generateUUID(); + await mutationQueue.enqueue(makeMutation({ id: id1 })); + await mutationQueue.enqueue(makeMutation({ id: id2 })); + await mutationQueue.enqueue(makeMutation({ id: id3 })); + await offlineDb.mutationQueue.update(id3, { status: 'failed' }); + + expect(await mutationQueue.pendingCount()).toBe(2); + }); +}); diff --git a/client/tests/unit/sync/tilePrefetcher.test.ts b/client/tests/unit/sync/tilePrefetcher.test.ts new file mode 100644 index 00000000..f0666594 --- /dev/null +++ b/client/tests/unit/sync/tilePrefetcher.test.ts @@ -0,0 +1,223 @@ +/** + * tilePrefetcher unit tests. + * + * Covers: bbox computation, tile math, URL building, size guard, + * offline/no-SW guard, syncMeta update. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import 'fake-indexeddb/auto'; +import { + computeBbox, + lngToTileX, + latToTileY, + buildTileUrl, + countTiles, + prefetchTiles, + prefetchTilesForTrip, + MAX_TILES, + type TileBbox, +} from '../../../src/sync/tilePrefetcher'; +import { offlineDb, clearAll, upsertSyncMeta } from '../../../src/db/offlineDb'; +import { buildPlace } from '../../helpers/factories'; + +beforeEach(async () => { + await clearAll(); + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }); + // Stub fetch + serviceWorker so prefetch path is exercised + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + Object.defineProperty(navigator, 'serviceWorker', { + value: { controller: {} }, + writable: true, + configurable: true, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +// ── bbox computation ────────────────────────────────────────────────────────── + +describe('computeBbox', () => { + it('returns null when no places have coordinates', () => { + const places = [buildPlace({ lat: null, lng: null })]; + expect(computeBbox(places)).toBeNull(); + }); + + it('expands single-point bbox to at least 0.1° span', () => { + const place = buildPlace({ lat: 48.8566, lng: 2.3522 }); + const bbox = computeBbox([place])!; + expect(bbox.maxLat - bbox.minLat).toBeGreaterThan(0.09); + expect(bbox.maxLng - bbox.minLng).toBeGreaterThan(0.09); + }); + + it('computes multi-point bbox with padding', () => { + const places = [ + buildPlace({ lat: 48.8566, lng: 2.3522 }), // Paris + buildPlace({ lat: 51.5074, lng: -0.1278 }), // London + ]; + const bbox = computeBbox(places, 0.1)!; + // Padded bbox should extend beyond raw points + expect(bbox.minLat).toBeLessThan(48.8566); + expect(bbox.maxLat).toBeGreaterThan(51.5074); + expect(bbox.minLng).toBeLessThan(-0.1278); + expect(bbox.maxLng).toBeGreaterThan(2.3522); + }); + + it('clamps to valid Mercator lat bounds', () => { + const places = [buildPlace({ lat: 85.0, lng: 0 })]; + const bbox = computeBbox(places, 0.5)!; + expect(bbox.maxLat).toBeLessThanOrEqual(85.0511); + }); +}); + +// ── tile math ───────────────────────────────────────────────────────────────── + +describe('lngToTileX', () => { + it('returns 0 for lng=-180 at any zoom', () => { + expect(lngToTileX(-180, 10)).toBe(0); + }); + + it('returns max tile for lng=180 at zoom 1', () => { + // At zoom 1: 2^1 = 2 tiles, lng=180 → x = floor(360/360 * 2) = floor(2) = 2 + // But tile range is 0..1, so this is the "overflow" edge — that's fine + expect(lngToTileX(180, 1)).toBe(2); + }); + + it('increases with more easterly longitude', () => { + const x1 = lngToTileX(0, 10); + const x2 = lngToTileX(10, 10); + expect(x2).toBeGreaterThan(x1); + }); +}); + +describe('latToTileY', () => { + it('returns smaller y for higher latitude (north = top)', () => { + const yNorth = latToTileY(60, 10); + const ySouth = latToTileY(10, 10); + expect(yNorth).toBeLessThan(ySouth); + }); + + it('equator is roughly half the tile grid', () => { + const yEq = latToTileY(0, 1); + // zoom 1 → 2 rows, equator ≈ row 1 + expect(yEq).toBe(1); + }); +}); + +// ── URL building ─────────────────────────────────────────────────────────────── + +describe('buildTileUrl', () => { + it('replaces {z}, {x}, {y}, {r} correctly', () => { + const tmpl = 'https://tile.example.com/{z}/{x}/{y}.png'; + const url = buildTileUrl(tmpl, 10, 500, 300); + expect(url).toBe('https://tile.example.com/10/500/300.png'); + }); + + it('replaces {s} with a subdomain character', () => { + const tmpl = 'https://{s}.tiles.example.com/{z}/{x}/{y}.png'; + const url = buildTileUrl(tmpl, 10, 0, 0); + expect(url).toMatch(/^https:\/\/[abcd]\.tiles\.example\.com\/10\/0\/0\.png$/); + }); + + it('removes {r} (retina placeholder)', () => { + const tmpl = 'https://tiles.example.com/{z}/{x}/{y}{r}.png'; + const url = buildTileUrl(tmpl, 10, 0, 0); + expect(url).toBe('https://tiles.example.com/10/0/0.png'); + }); +}); + +// ── countTiles ──────────────────────────────────────────────────────────────── + +describe('countTiles', () => { + it('returns more tiles at higher zoom levels', () => { + const bbox: TileBbox = { minLat: 48.7, maxLat: 49.0, minLng: 2.2, maxLng: 2.5 }; + const low = countTiles(bbox, 10, 10); + const high = countTiles(bbox, 12, 12); + expect(high).toBeGreaterThan(low); + }); + + it('stops counting after exceeding MAX_TILES', () => { + // Very large bbox — should hit cap quickly at high zooms + const bbox: TileBbox = { minLat: -60, maxLat: 60, minLng: -180, maxLng: 180 }; + const count = countTiles(bbox, 10, 16); + expect(count).toBeGreaterThan(MAX_TILES); + }); +}); + +// ── prefetchTiles guards ─────────────────────────────────────────────────────── + +describe('prefetchTiles — offline guard', () => { + it('returns 0 and does not fetch when offline', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 }; + const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 10); + expect(count).toBe(0); + expect(vi.mocked(fetch)).not.toHaveBeenCalled(); + }); + + it('returns 0 when no service worker controller', async () => { + Object.defineProperty(navigator, 'serviceWorker', { + value: { controller: null }, + configurable: true, + }); + const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 }; + const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 10); + expect(count).toBe(0); + }); +}); + +describe('prefetchTiles — normal operation', () => { + it('fetches tiles and returns count', async () => { + const bbox: TileBbox = { minLat: 48.84, maxLat: 48.87, minLng: 2.33, maxLng: 2.37 }; + const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 11); + expect(count).toBeGreaterThan(0); + expect(vi.mocked(fetch)).toHaveBeenCalled(); + }); + + it('stops at zoom level where cap is exceeded', async () => { + // Use a very small MAX_TILES override by using a huge bbox + const bbox: TileBbox = { minLat: -80, maxLat: 80, minLng: -170, maxLng: 170 }; + // This bbox at zoom 10 alone has thousands of tiles — should trigger early stop + const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 16); + expect(count).toBeLessThanOrEqual(MAX_TILES); + }); +}); + +// ── prefetchTilesForTrip ────────────────────────────────────────────────────── + +describe('prefetchTilesForTrip', () => { + it('no-ops when no places have coordinates', async () => { + const places = [buildPlace({ lat: null, lng: null })]; + await prefetchTilesForTrip(1, places); + expect(vi.mocked(fetch)).not.toHaveBeenCalled(); + }); + + it('updates syncMeta tilesBbox after prefetch', async () => { + await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 }); + + const places = [ + buildPlace({ trip_id: 1, lat: 48.8566, lng: 2.3522 }), + ]; + await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png'); + + const meta = await offlineDb.syncMeta.get(1); + expect(meta!.tilesBbox).not.toBeNull(); + expect(meta!.tilesBbox).toHaveLength(4); + }); + + it('skips prefetch when estimated tiles exceed MAX_TILES', async () => { + await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 }); + + // Places far apart → huge bbox → estimate > MAX_TILES + const places = [ + buildPlace({ trip_id: 1, lat: -60, lng: -170 }), + buildPlace({ trip_id: 1, lat: 60, lng: 170 }), + ]; + await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png'); + + // No fetches should have been made + expect(vi.mocked(fetch)).not.toHaveBeenCalled(); + }); +}); diff --git a/client/tests/unit/sync/tripSyncManager.test.ts b/client/tests/unit/sync/tripSyncManager.test.ts new file mode 100644 index 00000000..f72c23ac --- /dev/null +++ b/client/tests/unit/sync/tripSyncManager.test.ts @@ -0,0 +1,270 @@ +/** + * tripSyncManager unit tests. + * + * Covers: trip filtering (shouldCache/isStale), bundle fetch → Dexie upsert, + * stale trip eviction, offline guard, file blob caching. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import 'fake-indexeddb/auto'; +import { server } from '../../helpers/msw/server'; +import { http, HttpResponse } from 'msw'; +import { tripSyncManager } from '../../../src/sync/tripSyncManager'; +import { offlineDb, clearAll, upsertTrip } from '../../../src/db/offlineDb'; +import { + buildTrip, + buildDay, + buildPlace, + buildPackingItem, + buildTodoItem, + buildBudgetItem, + buildReservation, + buildTripFile, +} from '../../helpers/factories'; + +// Helper to get today ± N days as YYYY-MM-DD +function dateOffset(days: number): string { + const d = new Date(); + d.setDate(d.getDate() + days); + return d.toISOString().slice(0, 10); +} + +function makeBundle(tripId: number) { + const trip = buildTrip({ id: tripId, end_date: dateOffset(3) }); + return { + trip, + days: [buildDay({ trip_id: tripId, assignments: [], notes_items: [] })], + places: [buildPlace({ trip_id: tripId })], + packingItems: [buildPackingItem({ trip_id: tripId })], + todoItems: [buildTodoItem({ trip_id: tripId })], + budgetItems: [buildBudgetItem({ trip_id: tripId })], + reservations: [buildReservation({ trip_id: tripId })], + files: [buildTripFile({ trip_id: tripId, url: `/api/trips/${tripId}/files/99/download`, mime_type: 'application/pdf' })], + }; +} + +beforeEach(async () => { + await clearAll(); + tripSyncManager._resetSyncing(); + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }); + // Stub fetch for blob caching (used by cacheFilesForTrip) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + blob: async () => new Blob(['data'], { type: 'application/pdf' }), + })); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +// ── offline guard ───────────────────────────────────────────────────────────── + +describe('tripSyncManager.syncAll — offline guard', () => { + it('does nothing when offline', async () => { + Object.defineProperty(navigator, 'onLine', { value: false }); + + let listed = false; + server.use( + http.get('/api/trips', () => { listed = true; return HttpResponse.json({ trips: [] }); }), + ); + + await tripSyncManager.syncAll(); + expect(listed).toBe(false); + }); +}); + +// ── trip filtering ───────────────────────────────────────────────────────────── + +describe('tripSyncManager.syncAll — trip filtering', () => { + it('caches ongoing trips (end_date >= today)', async () => { + const tripId = 100; + const bundle = makeBundle(tripId); + + server.use( + http.get('/api/trips', () => + HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(2) })] }), + ), + http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)), + ); + + await tripSyncManager.syncAll(); + + const cached = await offlineDb.trips.get(tripId); + expect(cached).toBeDefined(); + expect(cached!.id).toBe(tripId); + }); + + it('caches trips with no end_date', async () => { + const tripId = 101; + const bundle = makeBundle(tripId); + const trip = buildTrip({ id: tripId, end_date: null as unknown as string }); + + server.use( + http.get('/api/trips', () => HttpResponse.json({ trips: [trip] })), + http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json({ ...bundle, trip })), + ); + + await tripSyncManager.syncAll(); + expect(await offlineDb.trips.get(tripId)).toBeDefined(); + }); + + it('does not cache past trips (end_date < today)', async () => { + const tripId = 102; + + server.use( + http.get('/api/trips', () => + HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(-1) })] }), + ), + ); + + // Bundle should NOT be called for past trips + let bundleCalled = false; + server.use( + http.get(`/api/trips/${tripId}/bundle`, () => { + bundleCalled = true; + return HttpResponse.json({}); + }), + ); + + await tripSyncManager.syncAll(); + expect(bundleCalled).toBe(false); + expect(await offlineDb.trips.get(tripId)).toBeUndefined(); + }); +}); + +// ── stale eviction ───────────────────────────────────────────────────────────── + +describe('tripSyncManager.syncAll — stale eviction', () => { + it('evicts trips that ended more than 7 days ago', async () => { + const staleId = 200; + // Seed Dexie as if previously cached + await upsertTrip(buildTrip({ id: staleId, end_date: dateOffset(-8) })); + + server.use( + http.get('/api/trips', () => + HttpResponse.json({ trips: [buildTrip({ id: staleId, end_date: dateOffset(-8) })] }), + ), + ); + + await tripSyncManager.syncAll(); + expect(await offlineDb.trips.get(staleId)).toBeUndefined(); + }); + + it('does NOT evict trips that ended exactly 6 days ago', async () => { + const recentId = 201; + const bundle = makeBundle(recentId); + const trip = buildTrip({ id: recentId, end_date: dateOffset(-6) }); + + server.use( + http.get('/api/trips', () => HttpResponse.json({ trips: [trip] })), + http.get(`/api/trips/${recentId}/bundle`, () => HttpResponse.json({ ...bundle, trip })), + ); + + await tripSyncManager.syncAll(); + // end_date = -6 days: still within 7d window, but < today so not cached + // i.e., shouldCache is false (end_date < today) so won't be fetched + // but also isStale is false (end_date = -6 >= cutoff -7), so won't be evicted + // → trip should simply not appear in Dexie (not cached, not evicted pre-seeded data) + expect(await offlineDb.trips.get(recentId)).toBeUndefined(); + }); +}); + +// ── bundle upsert ────────────────────────────────────────────────────────────── + +describe('tripSyncManager.syncAll — bundle upsert', () => { + it('writes all bundle entities to Dexie', async () => { + const tripId = 300; + const bundle = makeBundle(tripId); + + server.use( + http.get('/api/trips', () => + HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(5) })] }), + ), + http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)), + ); + + await tripSyncManager.syncAll(); + + expect(await offlineDb.trips.get(tripId)).toBeDefined(); + expect(await offlineDb.days.where('trip_id').equals(tripId).count()).toBe(1); + expect(await offlineDb.places.where('trip_id').equals(tripId).count()).toBe(1); + expect(await offlineDb.packingItems.where('trip_id').equals(tripId).count()).toBe(1); + expect(await offlineDb.todoItems.where('trip_id').equals(tripId).count()).toBe(1); + expect(await offlineDb.budgetItems.where('trip_id').equals(tripId).count()).toBe(1); + expect(await offlineDb.reservations.where('trip_id').equals(tripId).count()).toBe(1); + expect(await offlineDb.tripFiles.where('trip_id').equals(tripId).count()).toBe(1); + }); + + it('writes syncMeta with lastSyncedAt', async () => { + const tripId = 301; + const bundle = makeBundle(tripId); + + server.use( + http.get('/api/trips', () => + HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(5) })] }), + ), + http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)), + ); + + const before = Date.now(); + await tripSyncManager.syncAll(); + const after = Date.now(); + + const meta = await offlineDb.syncMeta.get(tripId); + expect(meta).toBeDefined(); + expect(meta!.lastSyncedAt).toBeGreaterThanOrEqual(before); + expect(meta!.lastSyncedAt).toBeLessThanOrEqual(after); + }); +}); + +// ── file blob caching ────────────────────────────────────────────────────────── + +describe('tripSyncManager — file blob caching', () => { + it('caches non-photo files after bundle sync', async () => { + const tripId = 400; + const bundle = makeBundle(tripId); + + server.use( + http.get('/api/trips', () => + HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(5) })] }), + ), + http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)), + ); + + await tripSyncManager.syncAll(); + + // Give fire-and-forget a tick + await new Promise(r => setTimeout(r, 50)); + + const cached = await offlineDb.blobCache.toArray(); + expect(cached.length).toBeGreaterThan(0); + expect(cached[0].url).toContain('/download'); + }); + + it('does not cache photo files (image/* MIME)', async () => { + const tripId = 401; + const photoFile = buildTripFile({ + trip_id: tripId, + mime_type: 'image/jpeg', + url: `/api/trips/${tripId}/files/77/download`, + }); + const bundle = { + ...makeBundle(tripId), + files: [photoFile], + }; + + server.use( + http.get('/api/trips', () => + HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(5) })] }), + ), + http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)), + ); + + await tripSyncManager.syncAll(); + await new Promise(r => setTimeout(r, 50)); + + const cached = await offlineDb.blobCache.toArray(); + expect(cached.length).toBe(0); + }); +}); diff --git a/client/tests/unit/tripStore.test.ts b/client/tests/unit/tripStore.test.ts index bf0009eb..8d35c9eb 100644 --- a/client/tests/unit/tripStore.test.ts +++ b/client/tests/unit/tripStore.test.ts @@ -14,6 +14,7 @@ vi.mock('../../src/api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn(), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), })); beforeEach(() => { diff --git a/server/package-lock.json b/server/package-lock.json index b3460c0b..b0c1c5d9 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -130,9 +130,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -146,9 +146,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -162,9 +162,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -178,9 +178,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -194,9 +194,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -210,9 +210,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -226,9 +226,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -242,9 +242,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -258,9 +258,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -274,9 +274,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -290,9 +290,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -306,9 +306,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -322,9 +322,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -338,9 +338,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -354,9 +354,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -370,9 +370,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -386,9 +386,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -402,9 +402,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -418,9 +418,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -434,9 +434,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -450,9 +450,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -466,9 +466,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -482,9 +482,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -498,9 +498,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -514,9 +514,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -530,9 +530,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -546,9 +546,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -661,9 +661,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -710,9 +710,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", - "integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -787,9 +787,9 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", "engines": { "node": ">=18" @@ -808,23 +808,6 @@ "node": ">=6.6.0" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/@modelcontextprotocol/sdk/node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -960,12 +943,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -975,21 +952,6 @@ "node": ">= 0.6" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/@modelcontextprotocol/sdk/node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -1225,6 +1187,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1239,6 +1204,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1253,6 +1221,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1267,6 +1238,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1281,6 +1255,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1295,6 +1272,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1309,6 +1289,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1323,6 +1306,9 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1337,6 +1323,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1351,6 +1340,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1365,6 +1357,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1379,6 +1374,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1393,6 +1391,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1659,13 +1660,13 @@ } }, "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/node-cron": { @@ -1837,31 +1838,6 @@ } } }, - "node_modules/@vitest/coverage-v8/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@vitest/coverage-v8/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -2165,14 +2141,10 @@ } }, "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" }, "node_modules/bare-events": { "version": "2.8.2", @@ -2189,9 +2161,9 @@ } }, "node_modules/bare-fs": { - "version": "4.5.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", - "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.0.tgz", + "integrity": "sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA==", "license": "Apache-2.0", "dependencies": { "bare-events": "^2.5.4", @@ -2213,9 +2185,9 @@ } }, "node_modules/bare-os": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.0.tgz", - "integrity": "sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==", + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz", + "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==", "license": "Apache-2.0", "engines": { "bare": ">=1.14.0" @@ -2231,19 +2203,23 @@ } }, "node_modules/bare-stream": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz", - "integrity": "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", + "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", "license": "Apache-2.0", "dependencies": { - "streamx": "^2.21.0", + "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { + "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, "bare-buffer": { "optional": true }, @@ -2253,9 +2229,9 @@ } }, "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", "license": "Apache-2.0", "dependencies": { "bare-path": "^3.0.0" @@ -2288,9 +2264,9 @@ "license": "MIT" }, "node_modules/better-sqlite3": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", - "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -2364,17 +2340,43 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/body-parser/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">= 0.8" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" } }, "node_modules/braces": { @@ -2686,18 +2688,12 @@ "node": ">= 0.8.0" } }, - "node_modules/cookie-parser/node_modules/cookie-signature": { + "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, "node_modules/cookiejar": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", @@ -2768,12 +2764,20 @@ } }, "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "ms": "2.0.0" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/decamelize": { @@ -3039,9 +3043,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -3051,32 +3055,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escape-html": { @@ -3200,9 +3204,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", "license": "MIT", "dependencies": { "ip-address": "10.1.0" @@ -3217,6 +3221,21 @@ "express": ">= 4.11" } }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3268,9 +3287,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.10", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.10.tgz", - "integrity": "sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==", + "version": "5.5.12", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.12.tgz", + "integrity": "sha512-nUR0q8PPfoA/svPM43Gup7vLOZWppaNrYgGmrVqrAVJa7cOH4hMG6FX9M4mQ8dZA1/ObGZHzES7Ed88hxEBSJg==", "funding": [ { "type": "github", @@ -3280,8 +3299,8 @@ "license": "MIT", "dependencies": { "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.2.1", - "strnum": "^2.2.2" + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -3324,6 +3343,21 @@ "node": ">= 0.8" } }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -3553,33 +3587,6 @@ "node": ">= 6" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3599,13 +3606,13 @@ "license": "ISC" }, "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/has-symbols": { @@ -3871,29 +3878,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-report/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/istanbul-lib-source-maps": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", @@ -3909,31 +3893,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -4026,12 +3985,6 @@ "npm": ">=6" } }, - "node_modules/jsonwebtoken/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -4289,19 +4242,15 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=10" } }, "node_modules/minimist": { @@ -4330,9 +4279,9 @@ "license": "MIT" }, "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/multer": { @@ -4453,30 +4402,67 @@ "url": "https://opencollective.com/nodemon" } }, - "node_modules/nodemon/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": "18 || 20 || >=22" } }, - "node_modules/nodemon/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -4602,9 +4588,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz", - "integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "funding": [ { "type": "github", @@ -4704,9 +4690,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", "dev": true, "funding": [ { @@ -4837,18 +4823,34 @@ } }, "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", + "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/rc": { @@ -4889,33 +4891,6 @@ "minimatch": "^5.1.0" } }, - "node_modules/readdir-glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -5023,33 +4998,10 @@ "node": ">= 18" } }, - "node_modules/router/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/router/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/router/node_modules/path-to-regexp": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", - "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -5118,10 +5070,19 @@ "node": ">= 0.8.0" } }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/serve-static": { @@ -5192,13 +5153,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -5364,9 +5325,9 @@ } }, "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -5469,9 +5430,9 @@ "license": "MIT" }, "node_modules/strnum": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", - "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", "funding": [ { "type": "github", @@ -5501,24 +5462,6 @@ "node": ">=14.18.0" } }, - "node_modules/superagent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/superagent/node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -5532,13 +5475,6 @@ "node": ">=4.0.0" } }, - "node_modules/superagent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/supertest": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", @@ -5565,16 +5501,16 @@ } }, "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/tar-fs": { @@ -5642,20 +5578,13 @@ } }, "node_modules/test-exclude/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/test-exclude/node_modules/glob": { @@ -5696,6 +5625,35 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/text-decoder": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", @@ -5728,14 +5686,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -5908,18 +5866,18 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "license": "MIT", "engines": { "node": ">=20.18.1" } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" }, @@ -6089,31 +6047,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/vite-node/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/vite-node/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -6218,31 +6151,6 @@ } } }, - "node_modules/vitest/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -6334,9 +6242,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 4238cf48..d41d36ab 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1578,6 +1578,22 @@ function runMigrations(db: Database.Database): void { () => { try { db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); } catch {} }, + // Migration 100: Idempotency keys for offline mutation replay + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS idempotency_keys ( + key TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + method TEXT NOT NULL, + path TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_body TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (key, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at); + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/index.ts b/server/src/index.ts index a51dde6c..91417455 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -48,6 +48,7 @@ const server = app.listen(PORT, () => { scheduler.startTripReminders(); scheduler.startVersionCheck(); scheduler.startDemoReset(); + scheduler.startIdempotencyCleanup(); const { startTokenCleanup } = require('./services/ephemeralTokens'); startTokenCleanup(); import('./websocket').then(({ setupWebSocket }) => { diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index d30c01c3..d2dddf39 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken'; import { db } from '../db/database'; import { JWT_SECRET } from '../config'; import { AuthRequest, OptionalAuthRequest, User } from '../types'; +import { applyIdempotency } from './idempotency'; export function extractToken(req: Request): string | null { // Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients) @@ -38,7 +39,7 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void => return; } (req as AuthRequest).user = user; - next(); + applyIdempotency(req, res, next, user.id); }; /** Like `authenticate` but rejects requests that don't carry an httpOnly session cookie. diff --git a/server/src/middleware/idempotency.ts b/server/src/middleware/idempotency.ts new file mode 100644 index 00000000..57e68989 --- /dev/null +++ b/server/src/middleware/idempotency.ts @@ -0,0 +1,59 @@ +import { Request, Response, NextFunction } from 'express'; +import { db } from '../db/database'; + +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +interface IdempotencyRow { + status_code: number; + response_body: string; +} + +/** + * Called from within `authenticate` after req.user is set. + * + * For mutating requests carrying X-Idempotency-Key: + * - If (key, userId) already stored: replays the cached response. + * - Otherwise: wraps res.json to capture and store a successful response. + * + * Storing happens in idempotency_keys (24h TTL, cleaned by scheduler). + */ +export function applyIdempotency(req: Request, res: Response, next: NextFunction, userId: number): void { + if (!MUTATING_METHODS.has(req.method)) { + next(); + return; + } + + const key = req.headers['x-idempotency-key'] as string | undefined; + if (!key) { + next(); + return; + } + + // Return cached response if key already processed for this user + const existing = db.prepare( + 'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ?' + ).get(key, userId) as IdempotencyRow | undefined; + + if (existing) { + res.status(existing.status_code).json(JSON.parse(existing.response_body)); + return; + } + + // Wrap res.json to capture the response on first successful execution + const originalJson = res.json.bind(res); + res.json = function (body: unknown): Response { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + db.prepare( + `INSERT OR IGNORE INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(key, userId, req.method, req.path, res.statusCode, JSON.stringify(body), Math.floor(Date.now() / 1000)); + } catch { + // Non-fatal: if storage fails, the request still succeeds + } + } + return originalJson(body); + }; + + next(); +} diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts index cf1f2fc4..9882e1f5 100644 --- a/server/src/routes/trips.ts +++ b/server/src/routes/trips.ts @@ -29,6 +29,13 @@ import { ValidationError, TRIP_SELECT, } from '../services/tripService'; +import { listDays } from '../services/dayService'; +import { listPlaces } from '../services/placeService'; +import { listItems as listPackingItems } from '../services/packingService'; +import { listItems as listTodoItems } from '../services/todoService'; +import { listBudgetItems } from '../services/budgetService'; +import { listReservations } from '../services/reservationService'; +import { listFiles } from '../services/fileService'; const router = express.Router(); @@ -294,6 +301,36 @@ router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response res.json({ success: true }); }); +// ── Offline bundle ──────────────────────────────────────────────────────── +// Returns all trip sub-collections in a single request for offline caching. + +router.get('/:id/bundle', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const tripId = req.params.id; + + const trip = getTrip(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const { days } = listDays(tripId); + const places = listPlaces(String(tripId), {}); + const packingItems = listPackingItems(tripId); + const todoItems = listTodoItems(tripId); + const budgetItems = listBudgetItems(tripId); + const reservations = listReservations(tripId); + const files = listFiles(tripId, false); + + res.json({ + trip, + days, + places, + packingItems, + todoItems, + budgetItems, + reservations, + files, + }); +}); + // ── ICS calendar export ─────────────────────────────────────────────────── router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => { diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts index 8385aaaa..4e2cee9a 100644 --- a/server/src/scheduler.ts +++ b/server/src/scheduler.ts @@ -230,11 +230,35 @@ function startVersionCheck(): void { }, { timezone: tz }); } +// Idempotency key cleanup: nightly at 3 AM — delete keys older than 24 hours +let idempotencyCleanupTask: ScheduledTask | null = null; + +function startIdempotencyCleanup(): void { + if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; } + + const tz = process.env.TZ || 'UTC'; + idempotencyCleanupTask = cron.schedule('0 3 * * *', () => { + try { + const { db } = require('./db/database'); + const cutoff = Math.floor(Date.now() / 1000) - 86400; + const result = db.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff); + if (result.changes > 0) { + const { logInfo: li } = require('./services/auditLog'); + li(`Idempotency cleanup: removed ${result.changes} expired key(s)`); + } + } catch (err: unknown) { + const { logError: le } = require('./services/auditLog'); + le(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`); + } + }, { timezone: tz }); +} + function stop(): void { if (currentTask) { currentTask.stop(); currentTask = null; } if (demoTask) { demoTask.stop(); demoTask = null; } if (reminderTask) { reminderTask.stop(); reminderTask = null; } if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; } + if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; } } -export { start, stop, startDemoReset, startTripReminders, startVersionCheck, loadSettings, saveSettings, VALID_INTERVALS }; +export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, loadSettings, saveSettings, VALID_INTERVALS }; diff --git a/server/tests/integration/trips.test.ts b/server/tests/integration/trips.test.ts index cc37148c..fc410ccb 100644 --- a/server/tests/integration/trips.test.ts +++ b/server/tests/integration/trips.test.ts @@ -892,3 +892,75 @@ describe('Copy trip with data', () => { expect(newNotes[0].text).toBe('Pack early!'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Bundle endpoint — GET /api/trips/:id/bundle +// ───────────────────────────────────────────────────────────────────────────── + +describe('Trip bundle', () => { + it('BUNDLE-001 — returns all sub-collections for owned trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2026-07-01', end_date: '2026-07-03' }); + + const res = await request(app) + .get(`/api/trips/${trip.id}/bundle`) + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.trip).toBeDefined(); + expect(res.body.trip.id).toBe(trip.id); + expect(Array.isArray(res.body.days)).toBe(true); + expect(res.body.days).toHaveLength(3); + expect(Array.isArray(res.body.places)).toBe(true); + expect(Array.isArray(res.body.packingItems)).toBe(true); + expect(Array.isArray(res.body.todoItems)).toBe(true); + expect(Array.isArray(res.body.budgetItems)).toBe(true); + expect(Array.isArray(res.body.reservations)).toBe(true); + expect(Array.isArray(res.body.files)).toBe(true); + }); + + it('BUNDLE-002 — returns 404 for trip that does not exist', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .get('/api/trips/999999/bundle') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(404); + }); + + it('BUNDLE-003 — returns 404 when user has no access to trip', async () => { + const { user: owner } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + + const res = await request(app) + .get(`/api/trips/${trip.id}/bundle`) + .set('Cookie', authCookie(other.id)); + + expect(res.status).toBe(404); + }); + + it('BUNDLE-004 — members can fetch bundle', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id); + + const res = await request(app) + .get(`/api/trips/${trip.id}/bundle`) + .set('Cookie', authCookie(member.id)); + + expect(res.status).toBe(200); + expect(res.body.trip.id).toBe(trip.id); + }); + + it('BUNDLE-005 — returns 401 when unauthenticated', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app).get(`/api/trips/${trip.id}/bundle`); + + expect(res.status).toBe(401); + }); +}); diff --git a/server/tests/unit/middleware/idempotency.test.ts b/server/tests/unit/middleware/idempotency.test.ts new file mode 100644 index 00000000..237939d0 --- /dev/null +++ b/server/tests/unit/middleware/idempotency.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── In-memory store + DB mock using vi.hoisted ──────────────────────────────── +const { rows, dbMock } = vi.hoisted(() => { + const rows: Record = {}; + + const dbMock = { + db: { + prepare: vi.fn((sql: string) => ({ + get: vi.fn((...args: unknown[]) => { + const [key, userId] = args; + return rows[`${key}:${userId}`] ?? undefined; + }), + run: vi.fn((...args: unknown[]) => { + const [key, userId, , , status_code, response_body] = args as [string, number, string, string, number, string]; + const k = `${key}:${userId}`; + if (!rows[k]) rows[k] = { status_code, response_body }; + }), + })), + }, + }; + + return { rows, dbMock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); + +import { applyIdempotency } from '../../../src/middleware/idempotency'; +import type { Request, Response, NextFunction } from 'express'; + +function makeReq(method = 'POST', headers: Record = {}): Request { + return { method, path: '/api/test', headers } as unknown as Request; +} + +function makeRes(statusCode = 200): Response { + const ctx = { status: statusCode }; + const res = { + get statusCode() { return ctx.status; }, + status(code: number) { ctx.status = code; return res; }, + json: vi.fn((_body: unknown) => res), + } as unknown as Response; + return res; +} + +beforeEach(() => { + Object.keys(rows).forEach(k => delete rows[k]); + vi.clearAllMocks(); +}); + +describe('applyIdempotency', () => { + it('calls next() for GET requests', () => { + const req = makeReq('GET', { 'x-idempotency-key': 'key1' }); + const res = makeRes(); + const next = vi.fn(); + applyIdempotency(req, res, next, 1); + expect(next).toHaveBeenCalledOnce(); + }); + + it('calls next() when header is absent for POST', () => { + const req = makeReq('POST', {}); + const res = makeRes(); + const next = vi.fn(); + applyIdempotency(req, res, next, 1); + expect(next).toHaveBeenCalledOnce(); + }); + + it('replays cached response when key+user already stored', () => { + rows['cached-key:42'] = { status_code: 201, response_body: JSON.stringify({ id: 99 }) }; + const req = makeReq('POST', { 'x-idempotency-key': 'cached-key' }); + const res = makeRes(); + const next = vi.fn(); + applyIdempotency(req, res, next, 42); + expect(next).not.toHaveBeenCalled(); + expect(res.json as ReturnType).toHaveBeenCalledWith({ id: 99 }); + }); + + it('different user same key does NOT replay', () => { + rows['cached-key:1'] = { status_code: 200, response_body: JSON.stringify({ ok: true }) }; + const req = makeReq('POST', { 'x-idempotency-key': 'cached-key' }); + const res = makeRes(); + const next = vi.fn(); + applyIdempotency(req, res, next, 99); // different user + expect(next).toHaveBeenCalledOnce(); + }); + + it('stores 2xx response on first execution via wrapped res.json', () => { + const req = makeReq('POST', { 'x-idempotency-key': 'new-key' }); + const res = makeRes(201); + const next = vi.fn(() => { + // Simulate handler calling res.json + (res.json as ReturnType)({ id: 5 }); + }); + applyIdempotency(req, res, next, 7); + expect(next).toHaveBeenCalledOnce(); + expect(rows['new-key:7']).toBeDefined(); + expect(rows['new-key:7'].status_code).toBe(201); + expect(JSON.parse(rows['new-key:7'].response_body)).toEqual({ id: 5 }); + }); + + it('does NOT store 4xx responses', () => { + const req = makeReq('POST', { 'x-idempotency-key': 'fail-key' }); + const res = makeRes(422); + const next = vi.fn(() => { + (res.json as ReturnType)({ error: 'Invalid' }); + }); + applyIdempotency(req, res, next, 3); + expect(rows['fail-key:3']).toBeUndefined(); + }); + + it('handles PUT, PATCH, and DELETE the same as POST', () => { + for (const method of ['PUT', 'PATCH', 'DELETE'] as const) { + const req = makeReq(method, { 'x-idempotency-key': `key-${method}` }); + const res = makeRes(200); + const next = vi.fn(); + applyIdempotency(req, res, next, 1); + expect(next).toHaveBeenCalled(); + vi.clearAllMocks(); + } + }); +});