From b315a331c400346bf10c2c949d5b023c2cef8d00 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:58:08 +0400 Subject: [PATCH 01/48] Read the API origin and sign-in settings from the environment - BASE_URL now comes from VITE_API_URL (calculator-app: NEXT_PUBLIC_API_URL), defaulting to https://api.policyengine.org, so staging builds can use the staging API. - next.config.ts maps NEXT_PUBLIC_API_URL, NEXT_PUBLIC_AUTH_ENABLED and NEXT_PUBLIC_AUTH0_{DOMAIN,CLIENT_ID,AUDIENCE} (or their VITE_* spellings) onto import.meta.env.VITE_* for the shared app code. - Add @auth0/auth0-react to app and calculator-app. - Document the calculator environment in calculator-app/.env.example. Co-Authored-By: Claude Opus 5.5 --- app/package.json | 3 ++- app/src/constants.ts | 15 +++++++++--- bun.lock | 28 ++++++++++++++++++--- calculator-app/.env.example | 35 +++++++++++++++++++++++++++ calculator-app/.gitignore | 1 + calculator-app/next.config.ts | 28 +++++++++++++++++++++ calculator-app/package.json | 1 + calculator-app/src/vite-env-shim.d.ts | 5 ++++ 8 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 calculator-app/.env.example diff --git a/app/package.json b/app/package.json index 1aec39bfe..f583b6b47 100644 --- a/app/package.json +++ b/app/package.json @@ -35,7 +35,7 @@ "chromatic": "VITE_APP_MODE=website chromatic --only-changed --build-script-name='storybook:build'" }, "dependencies": { - "policyengine-household-wizard": "^0.1.0", + "@auth0/auth0-react": "2.27.0", "@policyengine/ui-kit": "^0.4.0", "@reduxjs/toolkit": "^2.8.2", "@tabler/icons-react": "^3.34.0", @@ -53,6 +53,7 @@ "html-to-image": "^1.11.13", "jsonp": "^0.2.1", "lucide-react": "^0.575.0", + "policyengine-household-wizard": "^0.1.0", "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/app/src/constants.ts b/app/src/constants.ts index d08a8f708..a797cb5ab 100644 --- a/app/src/constants.ts +++ b/app/src/constants.ts @@ -1,5 +1,12 @@ export const FOREVER = '2100-12-31'; -export const BASE_URL = 'https://api.policyengine.org'; +// PolicyEngine API origin. Builds set VITE_API_URL (Vite) or +// NEXT_PUBLIC_API_URL (calculator-app, mapped onto VITE_API_URL in +// next.config.ts) so staging talks to the staging API; production falls +// back to the public API. +export const BASE_URL = (import.meta.env.VITE_API_URL || 'https://api.policyengine.org').replace( + /\/+$/, + '' +); export const CURRENT_YEAR = '2026'; // Certified default US microdata dataset URI, pinned to the revision that @@ -47,7 +54,9 @@ export function getParamDefinitionDate(year?: string): string { } /** - * Mock user ID used for anonymous/unauthenticated users - * TODO: Replace with actual user ID from auth context when authentication is implemented + * User ID that localStorage associations are filed under. Signed-out + * visitors (and every country without account storage) use it; it is + * never sent to the API. Signed-in users get their PolicyEngine user ID + * from useCurrentUserId() instead. */ export const MOCK_USER_ID = 'anonymous'; diff --git a/bun.lock b/bun.lock index c61dfc7c8..ce1ab66a1 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "name": "policyengine-app-v2", "version": "0.0.0", "dependencies": { + "@auth0/auth0-react": "2.27.0", "@policyengine/ui-kit": "^0.4.0", "@reduxjs/toolkit": "^2.8.2", "@tabler/icons-react": "^3.34.0", @@ -106,6 +107,7 @@ "version": "0.1.0", "dependencies": { "@anthropic-ai/sdk": "^0.116.0", + "@auth0/auth0-react": "2.27.0", "@neondatabase/serverless": "^1.1.0", "@normy/react-query": "^0.20.0", "@policyengine/ui-kit": "^0.4.0", @@ -203,6 +205,12 @@ "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + "@auth0/auth0-auth-js": ["@auth0/auth0-auth-js@1.16.1", "", { "dependencies": { "jose": "^6.0.8", "openid-client": "^6.8.0" } }, "sha512-dtH12GiKEZ+YGnwUe0LTe7gJtEEAvhE5ztUL3i8iXN7D3siXe/MUssQq735UlXJzasXNqZOQUkMbv1VMXMZj+g=="], + + "@auth0/auth0-react": ["@auth0/auth0-react@2.27.0", "", { "dependencies": { "@auth0/auth0-spa-js": "^2.27.0" }, "peerDependencies": { "react": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1", "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-SVDiQM1Eatv+zLzy7qyxMdcETdbGVOqXR+zaBXbMyX32xUzTNIppgcHFoD0AFLqVQrBZgTHX4ujj9D9+sfCXsQ=="], + + "@auth0/auth0-spa-js": ["@auth0/auth0-spa-js@2.28.3", "", { "dependencies": { "@auth0/auth0-auth-js": "^1.16.1", "browser-tabs-lock": "^1.3.0", "dpop": "^2.1.1", "es-cookie": "~1.3.2" } }, "sha512-W4bqoPdzjakJdtzKiOcWT5yMLciR9jeaHHwGydAKRpAkTD80ld2tGDUVL+sTpzaty8i3SL7lakgERiowcl0WCw=="], + "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], "@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="], @@ -1139,6 +1147,8 @@ "browser-assert": ["browser-assert@1.2.1", "", {}, "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ=="], + "browser-tabs-lock": ["browser-tabs-lock@1.3.0", "", { "dependencies": { "lodash": ">=4.17.21" } }, "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw=="], + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -1367,6 +1377,8 @@ "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dpop": ["dpop@2.1.2", "", {}, "sha512-hN3XjjCcgYWmNL8DFAV1Z3alXIvLolvjis189NI/U4k4Iu/m4rBeUYObbIjnRzQYDQv4zMaXXskjVDqSkmyh7Q=="], + "draw-svg-path": ["draw-svg-path@1.0.0", "", { "dependencies": { "abs-svg-path": "~0.1.1", "normalize-svg-path": "~0.1.0" } }, "sha512-P8j3IHxcgRMcY6sDzr0QvJDLzBnJJqpTG33UZ2Pvp8rw0apCHhJCWqYprqrXjrgHnJ6tuhP1iTJSAodPDHxwkg=="], "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], @@ -1407,6 +1419,8 @@ "es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="], + "es-cookie": ["es-cookie@1.3.2", "", {}, "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -1837,6 +1851,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1909,6 +1925,8 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.truncate": ["lodash.truncate@4.4.2", "", {}, "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw=="], @@ -2113,6 +2131,8 @@ "nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], + "oauth4webapi": ["oauth4webapi@3.8.8", "", {}, "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -2133,6 +2153,8 @@ "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + "openid-client": ["openid-client@6.8.8", "", { "dependencies": { "jose": "^6.2.12", "oauth4webapi": "^3.8.8" } }, "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], @@ -2269,7 +2291,7 @@ "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], @@ -2501,8 +2523,6 @@ "stylelint-scss": ["stylelint-scss@6.14.0", "", { "dependencies": { "css-tree": "^3.0.1", "is-plain-object": "^5.0.0", "known-css-properties": "^0.37.0", "mdn-data": "^2.25.0", "postcss-media-query-parser": "^0.2.3", "postcss-resolve-nested-selector": "^0.1.6", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "stylelint": "^16.8.2" } }, "sha512-ZKmHMZolxeuYsnB+PCYrTpFce0/QWX9i9gh0hPXzp73WjuIMqUpzdQaBCrKoLWh6XtCFSaNDErkMPqdjy1/8aA=="], - "sugarss": ["sugarss@4.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-WCjS5NfuVJjkQzK10s8WOBY+hhDxxNt/N6ZaGwxFZ+wN3/lKKFSaaKUNecULcTTvE4urLcKaZFQD8vO0mOZujw=="], - "supercluster": ["supercluster@7.1.5", "", { "dependencies": { "kdbush": "^3.0.0" } }, "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg=="], "superscript-text": ["superscript-text@1.0.0", "", {}, "sha512-gwu8l5MtRZ6koO0icVTlmN5pm7Dhh1+Xpe9O4x6ObMAsW+3jPbW14d1DsBq1F4wiI+WOFjXF35pslgec/G8yCQ=="], @@ -2987,7 +3007,7 @@ "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-docgen/doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], diff --git a/calculator-app/.env.example b/calculator-app/.env.example new file mode 100644 index 000000000..cdee42f43 --- /dev/null +++ b/calculator-app/.env.example @@ -0,0 +1,35 @@ +# calculator-app environment (copy to .env.local for local development). +# NEXT_PUBLIC_* values are inlined at build time; next.config.ts maps the +# API and sign-in ones onto import.meta.env.VITE_* for the shared app code, +# so the VITE_* spellings work here too. + +# --- PolicyEngine API -------------------------------------------------------- +# Origin of the PolicyEngine API (v1 artifacts and the signed-in /v2 routes). +# Defaults to https://api.policyengine.org. Staging builds must point at the +# staging API so staging artifacts never land in production. +# NEXT_PUBLIC_API_URL=https:// + +# --- Sign-in (Auth0 social sign-on) ----------------------------------------- +# Sign-in is inert unless NEXT_PUBLIC_AUTH_ENABLED=true AND all three Auth0 +# values are set; otherwise the app behaves exactly as it does without +# sign-in (everything saved in localStorage). +# NEXT_PUBLIC_AUTH_ENABLED=true +# Auth0 (custom) domain, e.g. auth.policyengine.org +# NEXT_PUBLIC_AUTH0_DOMAIN= +# Client ID of the SPA application for this environment +# NEXT_PUBLIC_AUTH0_CLIENT_ID= +# Identifier of the PolicyEngine API resource server, e.g. https://api.policyengine.org/ +# NEXT_PUBLIC_AUTH0_AUDIENCE= +# +# Auth0 application settings this app relies on: +# Allowed callback URLs: /us/callback, /uk/callback, +# /ca/callback, /ng/callback, /il/callback +# Allowed logout URLs and web origins: /, +# Refresh-token rotation on; the API resource server allows offline access. + +# --- Flagship shell ----------------------------------------------------------- +# NEXT_PUBLIC_FLAGSHIP_SHELL=true + +# --- Tracker bill feed (read-only, public anon key) -------------------------- +# NEXT_PUBLIC_TRACKER_SUPABASE_URL=https://ffgngqlgfsvqartilful.supabase.co +# NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY= diff --git a/calculator-app/.gitignore b/calculator-app/.gitignore index 878e2584e..5f31746d5 100644 --- a/calculator-app/.gitignore +++ b/calculator-app/.gitignore @@ -4,3 +4,4 @@ next-env.d.ts tsconfig.tsbuildinfo .vercel .env* +!.env.example diff --git a/calculator-app/next.config.ts b/calculator-app/next.config.ts index c90d58142..3a8a6727a 100644 --- a/calculator-app/next.config.ts +++ b/calculator-app/next.config.ts @@ -10,6 +10,17 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const WEBSITE_ORIGIN = process.env.NEXT_PUBLIC_WEBSITE_URL || "https://policyengine.org"; +/** The first of the given build-time environment variables that is set. */ +function envValue(...names: string[]): string { + for (const name of names) { + const value = process.env[name]; + if (value) { + return value; + } + } + return ""; +} + const nextConfig: NextConfig = { // Compile TypeScript files from ../app/src/ (the shared Vite codebase) experimental: { @@ -51,6 +62,23 @@ const nextConfig: NextConfig = { "import.meta.env.VITE_FLAGSHIP_SHELL": JSON.stringify( process.env.NEXT_PUBLIC_FLAGSHIP_SHELL || "", ), + // Sign-in and the PolicyEngine API origin. Shared code reads the + // VITE_* names; either spelling may be set on the deployment. + "import.meta.env.VITE_API_URL": JSON.stringify( + envValue("NEXT_PUBLIC_API_URL", "VITE_API_URL"), + ), + "import.meta.env.VITE_AUTH_ENABLED": JSON.stringify( + envValue("NEXT_PUBLIC_AUTH_ENABLED", "VITE_AUTH_ENABLED"), + ), + "import.meta.env.VITE_AUTH0_DOMAIN": JSON.stringify( + envValue("NEXT_PUBLIC_AUTH0_DOMAIN", "VITE_AUTH0_DOMAIN"), + ), + "import.meta.env.VITE_AUTH0_CLIENT_ID": JSON.stringify( + envValue("NEXT_PUBLIC_AUTH0_CLIENT_ID", "VITE_AUTH0_CLIENT_ID"), + ), + "import.meta.env.VITE_AUTH0_AUDIENCE": JSON.stringify( + envValue("NEXT_PUBLIC_AUTH0_AUDIENCE", "VITE_AUTH0_AUDIENCE"), + ), }), ); diff --git a/calculator-app/package.json b/calculator-app/package.json index abcf6e17b..074b5df42 100644 --- a/calculator-app/package.json +++ b/calculator-app/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.116.0", + "@auth0/auth0-react": "2.27.0", "@neondatabase/serverless": "^1.1.0", "@normy/react-query": "^0.20.0", "@policyengine/ui-kit": "^0.4.0", diff --git a/calculator-app/src/vite-env-shim.d.ts b/calculator-app/src/vite-env-shim.d.ts index 7c03e4029..56a1c61de 100644 --- a/calculator-app/src/vite-env-shim.d.ts +++ b/calculator-app/src/vite-env-shim.d.ts @@ -14,6 +14,11 @@ interface ImportMetaEnv { readonly VITE_FLAGSHIP_SHELL?: string; readonly VITE_FLAGSHIP_OVERRIDE?: string; readonly VITE_STORE_BACKEND?: string; + readonly VITE_API_URL?: string; + readonly VITE_AUTH_ENABLED?: string; + readonly VITE_AUTH0_DOMAIN?: string; + readonly VITE_AUTH0_CLIENT_ID?: string; + readonly VITE_AUTH0_AUDIENCE?: string; } interface ImportMeta { From 886645c91e11f0385c8fff4b2fafd323ffd77e38 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:59:38 +0400 Subject: [PATCH 02/48] Add Auth0 sign-in and save associations to the account when signed in Sign-in (inert unless NEXT_PUBLIC_AUTH_ENABLED=true and Auth0 is configured): - libs/auth: AuthProvider wraps @auth0/auth0-react (memory cache, rotating refresh tokens with fallback, audience + offline_access) and loads the PolicyEngine user from GET /v2/me for US/UK; useAuthStatus() exposes status, user, signIn, signOut and getAccessToken. Without the provider, or with sign-in off, status is 'disabled'. - Mount the provider in [countryId]/providers.tsx and add the /[countryId]/callback route that completes sign-in and returns to appState.returnTo (same-origin paths only). Account storage: - v2UserClient: bearer-token fetch wrapper for ${BASE_URL}/v2/..., parsing the {status, message, result} envelope and throwing V2ApiError(status). - useAssociationBackend(): API stores only when signed in with a loaded PolicyEngine user AND the country is US or UK; otherwise localStorage, unchanged. Queries wait while the session is restored, and query keys carry the PolicyEngine user ID (or 'anonymous') so caches split. - Rewrite ApiPolicyStore/ApiHouseholdStore/ApiSimulationStore/ApiReportStore and their adapters for /v2/user-{policies,households,simulations,reports}: numeric legacy_*_id <-> string IDs, the association UUID as id, list/get/create/update/delete, never a user_id. The API report store has no createWithId (shared-report saves POST {legacy_report_id}), and local sur- IDs resolve through this browser's import map or fall back to localStorage so old links keep working. - Replace every hard-coded isLoggedIn = false and the local simulation store in createReportSimulations; pass the session's user ID instead of MOCK_USER_ID. Saved geographies stay in localStorage for everyone; drop the unused ApiGeographicStore and dead association payload types. Co-Authored-By: Claude Opus 5.5 --- app/src/adapters/UserGeographicAdapter.ts | 42 -- app/src/adapters/UserHouseholdAdapter.ts | 64 ++- app/src/adapters/UserPolicyAdapter.ts | 71 +-- app/src/adapters/UserReportAdapter.ts | 62 ++- app/src/adapters/UserSimulationAdapter.ts | 61 ++- app/src/adapters/index.ts | 1 - app/src/api/geographicAssociation.ts | 96 +--- app/src/api/householdAssociation.ts | 124 ++--- app/src/api/policyAssociation.ts | 115 ++--- app/src/api/report.ts | 13 +- app/src/api/reportAssociation.ts | 175 +++---- app/src/api/simulationAssociation.ts | 126 ++---- app/src/api/v2Associations.ts | 138 ++++++ app/src/api/v2Me.ts | 51 +++ app/src/api/v2UserClient.ts | 174 +++++++ app/src/hooks/useCreateHousehold.ts | 9 +- app/src/hooks/useCreatePolicy.ts | 9 +- app/src/hooks/useCreateReport.ts | 7 +- app/src/hooks/useCreateSimulation.ts | 9 +- app/src/hooks/useSaveSharedReport.ts | 28 +- app/src/hooks/useUserGeographic.ts | 47 +- app/src/hooks/useUserHousehold.ts | 28 +- app/src/hooks/useUserPolicy.ts | 38 +- app/src/hooks/useUserReportAssociations.ts | 69 ++- .../hooks/useUserSimulationAssociations.ts | 51 ++- app/src/libs/auth/AuthContext.ts | 54 +++ app/src/libs/auth/AuthProvider.tsx | 186 ++++++++ app/src/libs/auth/authConfig.ts | 72 +++ app/src/libs/auth/importedIds.ts | 69 +++ app/src/libs/auth/useAssociationBackend.ts | 88 ++++ app/src/pages/AuthCallback.page.tsx | 70 +++ app/src/pages/Policies.page.tsx | 5 +- app/src/pages/Populations.page.tsx | 4 +- app/src/pages/Reports.page.tsx | 4 +- app/src/pages/Simulations.page.tsx | 4 +- .../hooks/useModifyReportSubmission.ts | 17 +- .../hooks/useReportIngredientAvailability.ts | 4 +- .../hooks/useReportSubmission.ts | 8 + .../hooks/useSimulationCanvas.ts | 4 +- .../modals/HouseholdCreationModal.tsx | 4 +- .../modals/PolicyBrowseModal.tsx | 4 +- .../modals/PolicyCreationModal.tsx | 4 +- .../modals/PopulationBrowseModal.tsx | 4 +- .../utils/createReportSimulations.ts | 11 +- .../views/policy/PolicyExistingView.tsx | 4 +- .../population/PopulationExistingView.tsx | 4 +- .../simulation/SimulationPolicySetupView.tsx | 4 +- .../SimulationPopulationSetupView.tsx | 4 +- .../simulation/SimulationPathwayWrapper.tsx | 4 +- .../adapters/UserReportAdapterMocks.ts | 50 -- app/src/tests/fixtures/adapters/index.ts | 5 - .../fixtures/adapters/userAssociationMocks.ts | 149 ------ app/src/tests/fixtures/api/v2UserMocks.ts | 156 +++++++ app/src/tests/fixtures/index.ts | 1 - .../tests/fixtures/libs/auth/authMocks.tsx | 54 +++ .../adapters/UserHouseholdAdapter.test.ts | 36 ++ .../unit/adapters/UserPolicyAdapter.test.ts | 211 +++------ .../unit/adapters/UserReportAdapter.test.ts | 136 ++---- .../adapters/UserSimulationAdapter.test.ts | 194 ++------ .../unit/api/geographicAssociation.test.ts | 208 +-------- .../unit/api/householdAssociation.test.ts | 208 +-------- .../tests/unit/api/policyAssociation.test.ts | 203 +-------- .../tests/unit/api/reportAssociation.test.ts | 321 +------------ .../unit/api/simulationAssociation.test.ts | 176 +------- .../unit/api/v2AssociationStores.test.ts | 426 ++++++++++++++++++ app/src/tests/unit/api/v2UserClient.test.ts | 134 ++++++ .../tests/unit/hooks/useCreateReport.test.tsx | 4 + .../unit/hooks/useUserGeographic.test.tsx | 78 ++-- .../unit/libs/auth/AuthProvider.test.tsx | 251 +++++++++++ .../tests/unit/libs/auth/authConfig.test.ts | 47 ++ .../libs/auth/useAssociationBackend.test.tsx | 132 ++++++ .../hooks/useModifyReportSubmission.test.tsx | 3 + app/src/types/metadata/userPolicyMetadata.ts | 27 -- .../payloads/UserPolicyCreationPayload.ts | 15 - .../payloads/UserReportCreationPayload.ts | 12 - .../payloads/UserSimulationCreationPayload.ts | 12 - app/src/types/payloads/index.ts | 3 - app/src/types/v2User.ts | 242 ++++++++++ .../src/app/[countryId]/callback/page.tsx | 11 + .../src/app/[countryId]/providers.tsx | 15 +- 80 files changed, 3193 insertions(+), 2571 deletions(-) delete mode 100644 app/src/adapters/UserGeographicAdapter.ts create mode 100644 app/src/api/v2Associations.ts create mode 100644 app/src/api/v2Me.ts create mode 100644 app/src/api/v2UserClient.ts create mode 100644 app/src/libs/auth/AuthContext.ts create mode 100644 app/src/libs/auth/AuthProvider.tsx create mode 100644 app/src/libs/auth/authConfig.ts create mode 100644 app/src/libs/auth/importedIds.ts create mode 100644 app/src/libs/auth/useAssociationBackend.ts create mode 100644 app/src/pages/AuthCallback.page.tsx delete mode 100644 app/src/tests/fixtures/adapters/UserReportAdapterMocks.ts delete mode 100644 app/src/tests/fixtures/adapters/index.ts delete mode 100644 app/src/tests/fixtures/adapters/userAssociationMocks.ts create mode 100644 app/src/tests/fixtures/api/v2UserMocks.ts create mode 100644 app/src/tests/fixtures/libs/auth/authMocks.tsx create mode 100644 app/src/tests/unit/adapters/UserHouseholdAdapter.test.ts create mode 100644 app/src/tests/unit/api/v2AssociationStores.test.ts create mode 100644 app/src/tests/unit/api/v2UserClient.test.ts create mode 100644 app/src/tests/unit/libs/auth/AuthProvider.test.tsx create mode 100644 app/src/tests/unit/libs/auth/authConfig.test.ts create mode 100644 app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx delete mode 100644 app/src/types/metadata/userPolicyMetadata.ts delete mode 100644 app/src/types/payloads/UserPolicyCreationPayload.ts delete mode 100644 app/src/types/payloads/UserReportCreationPayload.ts delete mode 100644 app/src/types/payloads/UserSimulationCreationPayload.ts create mode 100644 app/src/types/v2User.ts create mode 100644 calculator-app/src/app/[countryId]/callback/page.tsx diff --git a/app/src/adapters/UserGeographicAdapter.ts b/app/src/adapters/UserGeographicAdapter.ts deleted file mode 100644 index 4cc69e700..000000000 --- a/app/src/adapters/UserGeographicAdapter.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { UserGeographyPopulation } from '@/types/ingredients/UserPopulation'; - -/** - * Adapter for converting between UserGeographyPopulation and API formats - */ -export class UserGeographicAdapter { - /** - * Convert UserGeographyPopulation to API creation payload - * Note: This endpoint doesn't exist yet - */ - static toCreationPayload(population: UserGeographyPopulation): any { - return { - user_id: population.userId, - geography_id: population.geographyId, - country_id: population.countryId, - label: population.label, - scope: population.scope, - created_at: population.createdAt, - updated_at: population.updatedAt || new Date().toISOString(), - }; - } - - /** - * Convert API response to UserGeographyPopulation - * Explicitly coerces IDs to strings to handle JSON.parse type mismatches - * Note: API endpoint doesn't exist yet - */ - static fromApiResponse(apiData: any): UserGeographyPopulation { - return { - type: 'geography' as const, - id: String(apiData.geography_id), - userId: String(apiData.user_id), - geographyId: String(apiData.geography_id), - countryId: apiData.country_id, - scope: apiData.scope || 'national', - label: apiData.label, - createdAt: apiData.created_at, - updatedAt: apiData.updated_at, - isCreated: true, - }; - } -} diff --git a/app/src/adapters/UserHouseholdAdapter.ts b/app/src/adapters/UserHouseholdAdapter.ts index 7d8a87b54..afb1cf009 100644 --- a/app/src/adapters/UserHouseholdAdapter.ts +++ b/app/src/adapters/UserHouseholdAdapter.ts @@ -1,39 +1,53 @@ +import { toLegacyId } from '@/api/v2Associations'; import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; +import type { + AssociationCountryId, + UserHouseholdCreateBody, + UserHouseholdItem, + UserHouseholdPatchBody, +} from '@/types/v2User'; /** - * Adapter for converting between UserHouseholdPopulation and API formats + * Converts between UserHouseholdPopulation and the `/v2/user-households` + * wire format. `householdId` is the v1 household ID (`legacy_household_id`). */ export class UserHouseholdAdapter { - /** - * Convert UserHouseholdPopulation to API creation payload - * Note: This endpoint doesn't exist yet - */ - static toCreationPayload(userHousehold: UserHouseholdPopulation): any { + static toCreationPayload( + userHousehold: Pick, + countryId: AssociationCountryId + ): UserHouseholdCreateBody { return { - user_id: userHousehold.userId, - household_id: userHousehold.householdId, - country_id: userHousehold.countryId, - label: userHousehold.label, - created_at: userHousehold.createdAt, - updated_at: userHousehold.updatedAt || new Date().toISOString(), + country_id: countryId, + legacy_household_id: toLegacyId(userHousehold.householdId, 'household'), + ...(userHousehold.label ? { name: userHousehold.label } : {}), }; } - /** - * Convert API response to UserHouseholdPopulation - * Note: This endpoint doesn't exist yet - * Explicitly coerces IDs to strings to handle JSON.parse type mismatches - */ - static fromApiResponse(apiData: any): UserHouseholdPopulation { + static toUpdatePayload(updates: Partial): UserHouseholdPatchBody { + const body: UserHouseholdPatchBody = {}; + if ('label' in updates) { + body.name = updates.label || null; + } + if (updates.householdId !== undefined) { + body.legacy_household_id = toLegacyId(updates.householdId, 'household'); + } + return body; + } + + static hasLegacyHousehold(item: UserHouseholdItem): boolean { + return item.legacy_household_id !== null && item.legacy_household_id !== undefined; + } + + static fromApiItem(item: UserHouseholdItem): UserHouseholdPopulation { return { type: 'household' as const, - id: String(apiData.household_id), - userId: String(apiData.user_id), - householdId: String(apiData.household_id), - countryId: apiData.country_id, - label: apiData.label, - createdAt: apiData.created_at, - updatedAt: apiData.updated_at, + id: item.id, + userId: item.user_id, + householdId: String(item.legacy_household_id), + countryId: item.country_id, + label: item.name ?? undefined, + createdAt: item.created_at, + updatedAt: item.updated_at, isCreated: true, }; } diff --git a/app/src/adapters/UserPolicyAdapter.ts b/app/src/adapters/UserPolicyAdapter.ts index ea9024fea..8e46717c2 100644 --- a/app/src/adapters/UserPolicyAdapter.ts +++ b/app/src/adapters/UserPolicyAdapter.ts @@ -1,45 +1,54 @@ +import { toLegacyId } from '@/api/v2Associations'; import { UserPolicy } from '@/types/ingredients/UserPolicy'; -import { - UserPolicyCreationMetadata, - UserPolicyMetadata, -} from '@/types/metadata/userPolicyMetadata'; +import type { + AssociationCountryId, + UserPolicyCreateBody, + UserPolicyItem, + UserPolicyPatchBody, +} from '@/types/v2User'; /** - * Adapter for converting between UserPolicy and API formats + * Converts between UserPolicy and the `/v2/user-policies` wire format. + * The association's `id` is the server UUID; `policyId` is the v1 policy + * ID (`legacy_policy_id`) as a string. No user ID is ever sent. */ export class UserPolicyAdapter { - /** - * Convert UserPolicy to API creation payload - * Handles camelCase to snake_case conversion - * Note: API endpoint doesn't exist yet - */ static toCreationPayload( - userPolicy: Omit - ): UserPolicyCreationMetadata { + userPolicy: Pick, + countryId: AssociationCountryId + ): UserPolicyCreateBody { return { - user_id: String(userPolicy.userId), - policy_id: String(userPolicy.policyId), - country_id: userPolicy.countryId, - label: userPolicy.label, - updated_at: userPolicy.updatedAt || new Date().toISOString(), + country_id: countryId, + legacy_policy_id: toLegacyId(userPolicy.policyId, 'policy'), + ...(userPolicy.label ? { name: userPolicy.label } : {}), }; } - /** - * Convert API response to UserPolicy - * Handles snake_case to camelCase conversion - * Explicitly coerces IDs to strings to handle JSON.parse type mismatches - * Note: API endpoint doesn't exist yet - */ - static fromApiResponse(apiData: UserPolicyMetadata): UserPolicy { + static toUpdatePayload(updates: Partial): UserPolicyPatchBody { + const body: UserPolicyPatchBody = {}; + if ('label' in updates) { + body.name = updates.label || null; + } + if (updates.policyId !== undefined) { + body.legacy_policy_id = toLegacyId(updates.policyId, 'policy'); + } + return body; + } + + /** Rows without a v1 policy (v2-only) cannot be shown by this app yet. */ + static hasLegacyPolicy(item: UserPolicyItem): boolean { + return item.legacy_policy_id !== null && item.legacy_policy_id !== undefined; + } + + static fromApiItem(item: UserPolicyItem): UserPolicy { return { - id: String(apiData.policy_id), - userId: String(apiData.user_id), - policyId: String(apiData.policy_id), - countryId: apiData.country_id, - label: apiData.label ?? undefined, - createdAt: apiData.created_at, - updatedAt: apiData.updated_at, + id: item.id, + userId: item.user_id, + policyId: String(item.legacy_policy_id), + countryId: item.country_id, + label: item.name ?? undefined, + createdAt: item.created_at, + updatedAt: item.updated_at, isCreated: true, }; } diff --git a/app/src/adapters/UserReportAdapter.ts b/app/src/adapters/UserReportAdapter.ts index 057ad9400..61db77e03 100644 --- a/app/src/adapters/UserReportAdapter.ts +++ b/app/src/adapters/UserReportAdapter.ts @@ -1,37 +1,53 @@ +import { toLegacyId } from '@/api/v2Associations'; import { UserReport } from '@/types/ingredients/UserReport'; -import { UserReportCreationPayload } from '@/types/payloads'; +import type { + AssociationCountryId, + UserReportCreateBody, + UserReportItem, + UserReportPatchBody, +} from '@/types/v2User'; /** - * Adapter for converting between UserReport and API formats + * Converts between UserReport and the `/v2/user-reports` wire format. + * `reportId` is the requested v1 report ID (`legacy_report_id`). The v3 + * fields on the same rows are mapped by the flagship report store. */ export class UserReportAdapter { - /** - * Convert UserReport to API creation payload - */ static toCreationPayload( - userReport: Omit - ): UserReportCreationPayload { + userReport: Pick, + countryId: AssociationCountryId + ): UserReportCreateBody { return { - userId: String(userReport.userId), - reportId: String(userReport.reportId), - countryId: userReport.countryId, - label: userReport.label, - updatedAt: userReport.updatedAt || new Date().toISOString(), + country_id: countryId, + legacy_report_id: toLegacyId(userReport.reportId, 'report'), + ...(userReport.label ? { name: userReport.label } : {}), }; } - /** - * Convert API response to UserReport - */ - static fromApiResponse(apiData: any): UserReport { + static toUpdatePayload(updates: Partial): UserReportPatchBody { + const body: UserReportPatchBody = {}; + if ('label' in updates) { + body.name = updates.label || null; + } + if (updates.reportId !== undefined) { + body.legacy_report_id = toLegacyId(updates.reportId, 'report'); + } + return body; + } + + static hasLegacyReport(item: UserReportItem): boolean { + return item.legacy_report_id !== null && item.legacy_report_id !== undefined; + } + + static fromApiItem(item: UserReportItem): UserReport { return { - id: apiData.id, - userId: apiData.userId, - reportId: apiData.reportId, - countryId: apiData.countryId, - label: apiData.label, - createdAt: apiData.createdAt, - updatedAt: apiData.updatedAt, + id: item.id, + userId: item.user_id, + reportId: String(item.legacy_report_id), + countryId: item.country_id, + label: item.name ?? undefined, + createdAt: item.created_at, + updatedAt: item.updated_at, isCreated: true, }; } diff --git a/app/src/adapters/UserSimulationAdapter.ts b/app/src/adapters/UserSimulationAdapter.ts index def2435bf..d0e0ffc4c 100644 --- a/app/src/adapters/UserSimulationAdapter.ts +++ b/app/src/adapters/UserSimulationAdapter.ts @@ -1,37 +1,52 @@ +import { toLegacyId } from '@/api/v2Associations'; import { UserSimulation } from '@/types/ingredients/UserSimulation'; -import { UserSimulationCreationPayload } from '@/types/payloads'; +import type { + AssociationCountryId, + UserSimulationCreateBody, + UserSimulationItem, + UserSimulationPatchBody, +} from '@/types/v2User'; /** - * Adapter for converting between UserSimulation and API formats + * Converts between UserSimulation and the `/v2/user-simulations` wire + * format. `simulationId` is the v1 simulation ID (`legacy_simulation_id`). */ export class UserSimulationAdapter { - /** - * Convert UserSimulation to API creation payload - */ static toCreationPayload( - userSimulation: Omit - ): UserSimulationCreationPayload { + userSimulation: Pick, + countryId: AssociationCountryId + ): UserSimulationCreateBody { return { - userId: userSimulation.userId.toString(), - simulationId: userSimulation.simulationId.toString(), - countryId: userSimulation.countryId, - label: userSimulation.label, - updatedAt: userSimulation.updatedAt || new Date().toISOString(), + country_id: countryId, + legacy_simulation_id: toLegacyId(userSimulation.simulationId, 'simulation'), + ...(userSimulation.label ? { name: userSimulation.label } : {}), }; } - /** - * Convert API response to UserSimulation - */ - static fromApiResponse(apiData: any): UserSimulation { + static toUpdatePayload(updates: Partial): UserSimulationPatchBody { + const body: UserSimulationPatchBody = {}; + if ('label' in updates) { + body.name = updates.label || null; + } + if (updates.simulationId !== undefined) { + body.legacy_simulation_id = toLegacyId(updates.simulationId, 'simulation'); + } + return body; + } + + static hasLegacySimulation(item: UserSimulationItem): boolean { + return item.legacy_simulation_id !== null && item.legacy_simulation_id !== undefined; + } + + static fromApiItem(item: UserSimulationItem): UserSimulation { return { - id: apiData.id, - userId: apiData.userId, - simulationId: apiData.simulationId, - countryId: apiData.countryId, - label: apiData.label, - createdAt: apiData.createdAt, - updatedAt: apiData.updatedAt, + id: item.id, + userId: item.user_id, + simulationId: String(item.legacy_simulation_id), + countryId: item.country_id, + label: item.name ?? undefined, + createdAt: item.created_at, + updatedAt: item.updated_at, isCreated: true, }; } diff --git a/app/src/adapters/index.ts b/app/src/adapters/index.ts index 55e1dda5a..a0e70397c 100644 --- a/app/src/adapters/index.ts +++ b/app/src/adapters/index.ts @@ -8,7 +8,6 @@ export { UserReportAdapter } from './UserReportAdapter'; export { UserPolicyAdapter } from './UserPolicyAdapter'; export { UserSimulationAdapter } from './UserSimulationAdapter'; export { UserHouseholdAdapter } from './UserHouseholdAdapter'; -export { UserGeographicAdapter } from './UserGeographicAdapter'; // Conversion Helpers export { diff --git a/app/src/api/geographicAssociation.ts b/app/src/api/geographicAssociation.ts index e2b3b5e08..70e8abd69 100644 --- a/app/src/api/geographicAssociation.ts +++ b/app/src/api/geographicAssociation.ts @@ -1,6 +1,9 @@ -import { UserGeographicAdapter } from '@/adapters/UserGeographicAdapter'; import { UserGeographyPopulation } from '@/types/ingredients/UserPopulation'; +/** + * Saved geographies live in this browser's localStorage for everyone, + * signed in or not; there is no account table for them. + */ export interface UserGeographicStore { create: (population: UserGeographyPopulation) => Promise; findByUser: (userId: string, countryId?: string) => Promise; @@ -14,97 +17,6 @@ export interface UserGeographicStore { // delete(userId: string, geographyId: string): Promise; } -export class ApiGeographicStore implements UserGeographicStore { - // TODO: Modify value to match to-be-created API endpoint structure - private readonly BASE_URL = '/api/user-geographic-associations'; - - async create(population: UserGeographyPopulation): Promise { - const payload = UserGeographicAdapter.toCreationPayload(population); - - const response = await fetch(`${this.BASE_URL}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error('Failed to create geographic association'); - } - - const apiResponse = await response.json(); - return UserGeographicAdapter.fromApiResponse(apiResponse); - } - - async findByUser(userId: string, countryId?: string): Promise { - const response = await fetch(`${this.BASE_URL}/user/${userId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - if (!response.ok) { - throw new Error('Failed to fetch user associations'); - } - - const apiResponses = await response.json(); - - // Convert each API response to UserGeographyPopulation and filter by country if specified - const geographies = apiResponses.map((apiData: any) => - UserGeographicAdapter.fromApiResponse(apiData) - ); - return countryId - ? geographies.filter((g: UserGeographyPopulation) => g.countryId === countryId) - : geographies; - } - - async findById(userId: string, geographyId: string): Promise { - const response = await fetch(`${this.BASE_URL}/${userId}/${geographyId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - - if (response.status === 404) { - return null; - } - - if (!response.ok) { - throw new Error('Failed to fetch association'); - } - - const apiData = await response.json(); - return UserGeographicAdapter.fromApiResponse(apiData); - } - - async update( - _userId: string, - _geographyId: string, - _updates: Partial - ): Promise { - // TODO: Implement when backend API endpoint is available - // Expected endpoint: PUT /api/user-geographic-associations/:userId/:geographyId - // Expected payload: UserGeographicUpdatePayload (to be created) - - console.warn( - '[ApiGeographicStore.update] API endpoint not yet implemented. ' + - 'This method will be activated when user authentication is added.' - ); - - throw new Error( - 'Geographic population updates via API are not yet supported. ' + - 'Please ensure you are using localStorage mode.' - ); - } - - // Not yet implemented, but keeping for future use - /* - async delete(userId: string, geographyId: string): Promise { - const response = await fetch(`/api/user-geographic-associations/${userId}/${geographyId}`, { - method: 'DELETE', - }); - - if !response.ok) { - throw new Error('Failed to delete association'); - } - } - */ -} - export class LocalStorageGeographicStore implements UserGeographicStore { private readonly STORAGE_KEY = 'user-geographic-associations'; diff --git a/app/src/api/householdAssociation.ts b/app/src/api/householdAssociation.ts index c6f5614a5..4d0bc7a32 100644 --- a/app/src/api/householdAssociation.ts +++ b/app/src/api/householdAssociation.ts @@ -1,5 +1,7 @@ import { UserHouseholdAdapter } from '@/adapters/UserHouseholdAdapter'; +import { AccountAssociationRoutes, isLegacyId, type ApiStoreContext } from '@/api/v2Associations'; import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; +import { isAssociationCountry, type UserHouseholdItem } from '@/types/v2User'; export interface UserHouseholdStore { create: (association: UserHouseholdPopulation) => Promise; @@ -9,95 +11,65 @@ export interface UserHouseholdStore { userHouseholdId: string, updates: Partial ) => Promise; - // The below are not yet implemented, but keeping for future use - // delete(userId: string, householdId: string): Promise; + delete: (userHouseholdId: string) => Promise; } +/** + * Account storage: `/v2/user-households` on the PolicyEngine API. The + * server stamps the signed-in user onto each row; `userId` arguments are + * never sent. + */ export class ApiHouseholdStore implements UserHouseholdStore { - // TODO: Modify value to match to-be-created API endpoint structure - private readonly BASE_URL = '/api/user-household-associations'; + private readonly routes: AccountAssociationRoutes; - async create(association: UserHouseholdPopulation): Promise { - const payload = UserHouseholdAdapter.toCreationPayload(association); - - const response = await fetch(`${this.BASE_URL}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error('Failed to create household association'); - } + constructor(context: ApiStoreContext) { + this.routes = new AccountAssociationRoutes(context, 'user-households', 'UserHousehold'); + } - const apiResponse = await response.json(); - return UserHouseholdAdapter.fromApiResponse(apiResponse); + async create(association: UserHouseholdPopulation): Promise { + const countryId = isAssociationCountry(association.countryId) + ? association.countryId + : this.routes.countryId; + const item = await this.routes.create( + UserHouseholdAdapter.toCreationPayload(association, countryId) + ); + return UserHouseholdAdapter.fromApiItem(item); } - async findByUser(userId: string, countryId?: string): Promise { - const url = countryId - ? `${this.BASE_URL}/user/${userId}?country_id=${countryId}` - : `${this.BASE_URL}/user/${userId}`; - - const response = await fetch(url, { - headers: { 'Content-Type': 'application/json' }, - }); - if (!response.ok) { - throw new Error('Failed to fetch user households'); + async findByUser(_userId: string, countryId?: string): Promise { + if (countryId && countryId !== this.routes.countryId) { + return []; } - - const apiResponses = await response.json(); - return apiResponses.map((apiData: any) => UserHouseholdAdapter.fromApiResponse(apiData)); + const items = await this.routes.list(); + return items + .filter(UserHouseholdAdapter.hasLegacyHousehold) + .map(UserHouseholdAdapter.fromApiItem); } - async findById(userId: string, householdId: string): Promise { - const response = await fetch(`${this.BASE_URL}/${userId}/${householdId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - - if (response.status === 404) { + async findById(_userId: string, householdId: string): Promise { + if (!isLegacyId(householdId)) { return null; } - - if (!response.ok) { - throw new Error('Failed to fetch association'); - } - - const apiData = await response.json(); - return UserHouseholdAdapter.fromApiResponse(apiData); + const item = await this.routes.findFirst({ legacy_household_id: householdId }); + return item && UserHouseholdAdapter.hasLegacyHousehold(item) + ? UserHouseholdAdapter.fromApiItem(item) + : null; } async update( - _userHouseholdId: string, - _updates: Partial + userHouseholdId: string, + updates: Partial ): Promise { - // TODO: Implement when backend API endpoint is available - // Expected endpoint: PUT /api/user-household-associations/:userHouseholdId - // Expected payload: UserHouseholdUpdatePayload (to be created) - - console.warn( - '[ApiHouseholdStore.update] API endpoint not yet implemented. ' + - 'This method will be activated when user authentication is added.' - ); - - throw new Error( - 'Household updates via API are not yet supported. ' + - 'Please ensure you are using localStorage mode.' + const item = await this.routes.patch( + userHouseholdId, + UserHouseholdAdapter.toUpdatePayload(updates) ); + return UserHouseholdAdapter.fromApiItem(item); } - // Not yet implemented, but keeping for future use - /* - async delete(userId: string, householdId: string): Promise { - const response = await fetch(`/api/user-population-households/${userId}/${householdId}`, { - method: 'DELETE', - }); - - if (!response.ok) { - throw new Error('Failed to delete association'); - } + delete(userHouseholdId: string): Promise { + return this.routes.delete(userHouseholdId); } - */ } export class LocalStorageHouseholdStore implements UserHouseholdStore { @@ -165,19 +137,9 @@ export class LocalStorageHouseholdStore implements UserHouseholdStore { return updated; } - // Not yet implemented, but keeping for future use - /* - async delete(userId: string, householdId: string): Promise { - const households = this.getStoredHouseholds(); - const filtered = households.filter(a => !(a.userId === userId && a.householdId === householdId)); - - if (filtered.length === households.length) { - throw new Error('Association not found'); - } - - this.setStoredHouseholds(filtered); + async delete(userHouseholdId: string): Promise { + this.setStoredHouseholds(this.getStoredHouseholds().filter((h) => h.id !== userHouseholdId)); } - */ private getStoredHouseholds(): UserHouseholdPopulation[] { try { diff --git a/app/src/api/policyAssociation.ts b/app/src/api/policyAssociation.ts index dc073aac0..034884ed8 100644 --- a/app/src/api/policyAssociation.ts +++ b/app/src/api/policyAssociation.ts @@ -1,5 +1,6 @@ import { UserPolicyAdapter } from '@/adapters/UserPolicyAdapter'; -import { UserPolicyCreationPayload } from '@/types/payloads'; +import { AccountAssociationRoutes, isLegacyId, type ApiStoreContext } from '@/api/v2Associations'; +import { isAssociationCountry, type UserPolicyItem } from '@/types/v2User'; import { UserPolicy } from '../types/ingredients/UserPolicy'; export interface UserPolicyStore { @@ -7,91 +8,56 @@ export interface UserPolicyStore { findByUser: (userId: string, countryId?: string) => Promise; findById: (userId: string, policyId: string) => Promise; update: (userPolicyId: string, updates: Partial) => Promise; - // The below are not yet implemented, but keeping for future use - // delete(userPolicyId: string): Promise; + delete: (userPolicyId: string) => Promise; } +/** + * Account storage: `/v2/user-policies` on the PolicyEngine API. The server + * stamps the signed-in user onto each row; the `userId` arguments exist + * for the localStorage interface and are never sent. + */ export class ApiPolicyStore implements UserPolicyStore { - // TODO: Modify value to match to-be-created API endpoint structure - private readonly BASE_URL = '/api/user-policy-associations'; + private readonly routes: AccountAssociationRoutes; - async create(policy: Omit): Promise { - const payload: UserPolicyCreationPayload = UserPolicyAdapter.toCreationPayload(policy); - - const response = await fetch(`${this.BASE_URL}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error('Failed to create policy association'); - } + constructor(context: ApiStoreContext) { + this.routes = new AccountAssociationRoutes(context, 'user-policies', 'UserPolicy'); + } - const apiResponse = await response.json(); - return UserPolicyAdapter.fromApiResponse(apiResponse); + async create(policy: Omit): Promise { + const countryId = isAssociationCountry(policy.countryId) + ? policy.countryId + : this.routes.countryId; + const item = await this.routes.create(UserPolicyAdapter.toCreationPayload(policy, countryId)); + return UserPolicyAdapter.fromApiItem(item); } - async findByUser(userId: string, countryId?: string): Promise { - const response = await fetch(`${this.BASE_URL}/user/${userId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - if (!response.ok) { - throw new Error('Failed to fetch user associations'); + async findByUser(_userId: string, countryId?: string): Promise { + if (countryId && countryId !== this.routes.countryId) { + return []; } - - const apiResponses = await response.json(); - - // Convert each API response to UserPolicy and filter by country if specified - const policies = apiResponses.map((apiData: any) => UserPolicyAdapter.fromApiResponse(apiData)); - return countryId ? policies.filter((p: UserPolicy) => p.countryId === countryId) : policies; + const items = await this.routes.list(); + return items.filter(UserPolicyAdapter.hasLegacyPolicy).map(UserPolicyAdapter.fromApiItem); } - async findById(userId: string, policyId: string): Promise { - const response = await fetch(`${this.BASE_URL}/${userId}/${policyId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - - if (response.status === 404) { + /** The association for a v1 policy ID (the oldest, as localStorage returns the first). */ + async findById(_userId: string, policyId: string): Promise { + if (!isLegacyId(policyId)) { return null; } - - if (!response.ok) { - throw new Error('Failed to fetch association'); - } - - const apiData = await response.json(); - return UserPolicyAdapter.fromApiResponse(apiData); + const item = await this.routes.findFirst({ legacy_policy_id: policyId }); + return item && UserPolicyAdapter.hasLegacyPolicy(item) + ? UserPolicyAdapter.fromApiItem(item) + : null; } - async update(_userPolicyId: string, _updates: Partial): Promise { - // TODO: Implement when backend API endpoint is available - // Expected endpoint: PUT /api/user-policy-associations/:userPolicyId - // Expected payload: UserPolicyUpdatePayload (to be created) - - console.warn( - '[ApiPolicyStore.update] API endpoint not yet implemented. ' + - 'This method will be activated when user authentication is added.' - ); - - throw new Error( - 'Policy updates via API are not yet supported. ' + - 'Please ensure you are using localStorage mode.' - ); + async update(userPolicyId: string, updates: Partial): Promise { + const item = await this.routes.patch(userPolicyId, UserPolicyAdapter.toUpdatePayload(updates)); + return UserPolicyAdapter.fromApiItem(item); } - // Not yet implemented, but keeping for future use - /* - async delete(userId: string, policyId: string): Promise { - const response = await fetch(`/api/user-policy-associations/${userId}/${policyId}`, { - method: 'DELETE', - }); - - if (!response.ok) { - throw new Error('Failed to delete association'); - } + delete(userPolicyId: string): Promise { + return this.routes.delete(userPolicyId); } - */ } export class LocalStoragePolicyStore implements UserPolicyStore { @@ -182,14 +148,7 @@ export class LocalStoragePolicyStore implements UserPolicyStore { return updated; } - // Not yet implemented, but keeping for future use - /* - async delete(userId: string, policyId: string): Promise { - const policies = this.getStoredPolicies(); - const filtered = policies.filter( - a => !(a.userId === userId && a.policyId === policyId) - ); - this.setStoredPolicies(filtered); + async delete(userPolicyId: string): Promise { + this.setStoredPolicies(this.getStoredPolicies().filter((p) => p.id !== userPolicyId)); } - */ } diff --git a/app/src/api/report.ts b/app/src/api/report.ts index e7af68fef..1e542ad9b 100644 --- a/app/src/api/report.ts +++ b/app/src/api/report.ts @@ -1,5 +1,5 @@ import { ReportAdapter } from '@/adapters/ReportAdapter'; -import { ApiReportStore, LocalStorageReportStore } from '@/api/reportAssociation'; +import { LocalStorageReportStore, type UserReportStore } from '@/api/reportAssociation'; import { BASE_URL } from '@/constants'; import { countryIds } from '@/libs/countries'; import { Report } from '@/types/ingredients/Report'; @@ -129,6 +129,11 @@ export interface CreateReportWithAssociationParams { payload: ReportCreationPayload; userId: string | number; label?: string; + /** + * Where the association is saved: the account store when signed in + * (see useUserReportStore), localStorage otherwise (the default). + */ + reportStore?: UserReportStore; } /** @@ -161,10 +166,8 @@ export async function createReportAndAssociateWithUser( // 2. Convert to domain model (ensures id is string, proper field names) const report = ReportAdapter.fromMetadata(reportMetadata); - // 3. Determine which store to use (localStorage vs API) - // TODO: Get isLoggedIn from auth context - const isLoggedIn = false; - const reportStore = isLoggedIn ? new ApiReportStore() : new LocalStorageReportStore(); + // 3. Save the association where the caller's session keeps saved work + const reportStore = params.reportStore ?? new LocalStorageReportStore(); // 4. Create UserReport association const userReport = await reportStore.create({ diff --git a/app/src/api/reportAssociation.ts b/app/src/api/reportAssociation.ts index 547316e64..bdda738a8 100644 --- a/app/src/api/reportAssociation.ts +++ b/app/src/api/reportAssociation.ts @@ -1,136 +1,98 @@ import { UserReportAdapter } from '@/adapters/UserReportAdapter'; -import { UserReportCreationPayload } from '@/types/payloads'; +import { + accountAssociationId, + AccountAssociationRoutes, + isLegacyId, + type ApiStoreContext, +} from '@/api/v2Associations'; +import { isAssociationCountry, type UserReportItem } from '@/types/v2User'; import { UserReport } from '../types/ingredients/UserReport'; export interface UserReportStore { create: (report: Omit) => Promise; - createWithId: (report: Omit) => Promise; + /** + * Saves under a caller-chosen ID (shared-report saves in localStorage). + * Account storage assigns its own IDs, so the API store omits it. + */ + createWithId?: (report: Omit) => Promise; findByUser: (userId: string, countryId?: string) => Promise; findById: (userId: string, reportId: string) => Promise; findByUserReportId: (userReportId: string) => Promise; update: (userReportId: string, updates: Partial) => Promise; - // The below are not yet implemented, but keeping for future use - // delete(userReportId: string): Promise; + delete: (userReportId: string) => Promise; } +/** + * Account storage: `/v2/user-reports` on the PolicyEngine API. The server + * stamps the signed-in user onto each row; `userId` arguments are never + * sent. + * + * Report links are URL-addressable, so local IDs (`sur-…`) keep working + * after sign-in: imported ones resolve to their account UUID, and ones + * never imported are read from (and updated in) this browser's + * localStorage as before. + */ export class ApiReportStore implements UserReportStore { - // TODO: Modify value to match to-be-created API endpoint structure - private readonly BASE_URL = '/api/user-report-associations'; + private readonly routes: AccountAssociationRoutes; - async create(report: Omit): Promise { - const payload: UserReportCreationPayload = UserReportAdapter.toCreationPayload(report); - - const response = await fetch(`${this.BASE_URL}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error('Failed to create report association'); - } - - const apiResponse = await response.json(); - return UserReportAdapter.fromApiResponse(apiResponse); + constructor( + private readonly context: ApiStoreContext, + private readonly local: UserReportStore = new LocalStorageReportStore() + ) { + this.routes = new AccountAssociationRoutes(context, 'user-reports', 'UserReport'); } - // Added for interface consistency; may be removed when user auth is implemented - async createWithId(report: Omit): Promise { - // For API store, we pass the ID to the backend and let it handle idempotency - const payload: UserReportCreationPayload = UserReportAdapter.toCreationPayload(report); - - const response = await fetch(`${this.BASE_URL}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...payload, id: report.id }), - }); - - if (!response.ok) { - throw new Error('Failed to create report association'); - } - - const apiResponse = await response.json(); - return UserReportAdapter.fromApiResponse(apiResponse); + async create(report: Omit): Promise { + const countryId = isAssociationCountry(report.countryId) + ? report.countryId + : this.routes.countryId; + const item = await this.routes.create(UserReportAdapter.toCreationPayload(report, countryId)); + return UserReportAdapter.fromApiItem(item); } - async findByUser(userId: string, countryId?: string): Promise { - const response = await fetch(`${this.BASE_URL}/user/${userId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - if (!response.ok) { - throw new Error('Failed to fetch user associations'); + async findByUser(_userId: string, countryId?: string): Promise { + if (countryId && countryId !== this.routes.countryId) { + return []; } - - const apiResponses = await response.json(); - - // Convert each API response to UserReport and filter by country if specified - const reports = apiResponses.map((apiData: any) => UserReportAdapter.fromApiResponse(apiData)); - return countryId ? reports.filter((r: UserReport) => r.countryId === countryId) : reports; + const items = await this.routes.list(); + return items.filter(UserReportAdapter.hasLegacyReport).map(UserReportAdapter.fromApiItem); } - async findById(userId: string, reportId: string): Promise { - const response = await fetch(`${this.BASE_URL}/${userId}/${reportId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - - if (response.status === 404) { + /** The association for a v1 report ID (the oldest, as localStorage returns the first). */ + async findById(_userId: string, reportId: string): Promise { + if (!isLegacyId(reportId)) { return null; } - - if (!response.ok) { - throw new Error('Failed to fetch association'); - } - - const apiData = await response.json(); - return UserReportAdapter.fromApiResponse(apiData); + const item = await this.routes.findFirst({ legacy_report_id: reportId }); + return item && UserReportAdapter.hasLegacyReport(item) + ? UserReportAdapter.fromApiItem(item) + : null; } - // Note: This method relies on as-of-yet unimplemented API endpoint; will alter once available async findByUserReportId(userReportId: string): Promise { - const response = await fetch(`${this.BASE_URL}/${userReportId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - - if (response.status === 404) { - return null; - } - - if (!response.ok) { - throw new Error('Failed to fetch user report'); + if (!accountAssociationId(this.context, userReportId)) { + return this.local.findByUserReportId(userReportId); } - - const apiData = await response.json(); - return UserReportAdapter.fromApiResponse(apiData); + const item = await this.routes.get(userReportId); + return item && UserReportAdapter.hasLegacyReport(item) + ? UserReportAdapter.fromApiItem(item) + : null; } - async update(_userReportId: string, _updates: Partial): Promise { - // TODO: Implement when backend API endpoint is available - // Expected endpoint: PUT /api/user-report-associations/:userReportId - // Expected payload: UserReportUpdatePayload (to be created) - - console.warn( - '[ApiReportStore.update] API endpoint not yet implemented. ' + - 'This method will be activated when user authentication is added.' - ); - - throw new Error( - 'Report updates via API are not yet supported. ' + - 'Please ensure you are using localStorage mode.' - ); + async update(userReportId: string, updates: Partial): Promise { + if (!accountAssociationId(this.context, userReportId)) { + return this.local.update(userReportId, updates); + } + const item = await this.routes.patch(userReportId, UserReportAdapter.toUpdatePayload(updates)); + return UserReportAdapter.fromApiItem(item); } - // Not yet implemented, but keeping for future use - /* - async delete(userId: string, reportId: string): Promise { - const response = await fetch(`/api/user-report-associations/${userId}/${reportId}`, { - method: 'DELETE', - }); - - if (!response.ok) { - throw new Error('Failed to delete association'); + delete(userReportId: string): Promise { + if (!accountAssociationId(this.context, userReportId)) { + return this.local.delete(userReportId); } + return this.routes.delete(userReportId); } - */ } export class LocalStorageReportStore implements UserReportStore { @@ -231,14 +193,7 @@ export class LocalStorageReportStore implements UserReportStore { return updated; } - // Not yet implemented, but keeping for future use - /* - async delete(userId: string, reportId: string): Promise { - const reports = this.getStoredReports(); - const filtered = reports.filter( - a => !(a.userId === userId && a.reportId === reportId) - ); - this.setStoredReports(filtered); + async delete(userReportId: string): Promise { + this.setStoredReports(this.getStoredReports().filter((r) => r.id !== userReportId)); } - */ } diff --git a/app/src/api/simulationAssociation.ts b/app/src/api/simulationAssociation.ts index 9ba43293f..4ccd473a9 100644 --- a/app/src/api/simulationAssociation.ts +++ b/app/src/api/simulationAssociation.ts @@ -1,5 +1,6 @@ import { UserSimulationAdapter } from '@/adapters/UserSimulationAdapter'; -import { UserSimulationCreationPayload } from '@/types/payloads'; +import { AccountAssociationRoutes, isLegacyId, type ApiStoreContext } from '@/api/v2Associations'; +import { isAssociationCountry, type UserSimulationItem } from '@/types/v2User'; import { UserSimulation } from '../types/ingredients/UserSimulation'; export interface UserSimulationStore { @@ -7,99 +8,65 @@ export interface UserSimulationStore { findByUser: (userId: string, countryId?: string) => Promise; findById: (userId: string, simulationId: string) => Promise; update: (userSimulationId: string, updates: Partial) => Promise; - // The below are not yet implemented, but keeping for future use - // delete(userSimulationId: string): Promise; + delete: (userSimulationId: string) => Promise; } +/** + * Account storage: `/v2/user-simulations` on the PolicyEngine API. The + * server stamps the signed-in user onto each row; `userId` arguments are + * never sent. + */ export class ApiSimulationStore implements UserSimulationStore { - // TODO: Modify value to match to-be-created API endpoint structure - private readonly BASE_URL = '/api/user-simulation-associations'; + private readonly routes: AccountAssociationRoutes; - async create(simulation: Omit): Promise { - const payload: UserSimulationCreationPayload = - UserSimulationAdapter.toCreationPayload(simulation); - - const response = await fetch(`${this.BASE_URL}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error('Failed to create simulation association'); - } - - const apiResponse = await response.json(); - return UserSimulationAdapter.fromApiResponse(apiResponse); + constructor(context: ApiStoreContext) { + this.routes = new AccountAssociationRoutes(context, 'user-simulations', 'UserSimulation'); } - async findByUser(userId: string, countryId?: string): Promise { - const response = await fetch(`${this.BASE_URL}/user/${userId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - if (!response.ok) { - throw new Error('Failed to fetch user associations'); - } - - const apiResponses = await response.json(); - - // Convert each API response to UserSimulation and filter by country if specified - const simulations = apiResponses.map((apiData: any) => - UserSimulationAdapter.fromApiResponse(apiData) + async create(simulation: Omit): Promise { + const countryId = isAssociationCountry(simulation.countryId) + ? simulation.countryId + : this.routes.countryId; + const item = await this.routes.create( + UserSimulationAdapter.toCreationPayload(simulation, countryId) ); - return countryId - ? simulations.filter((s: UserSimulation) => s.countryId === countryId) - : simulations; + return UserSimulationAdapter.fromApiItem(item); } - async findById(userId: string, simulationId: string): Promise { - const response = await fetch(`${this.BASE_URL}/${userId}/${simulationId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - - if (response.status === 404) { - return null; + async findByUser(_userId: string, countryId?: string): Promise { + if (countryId && countryId !== this.routes.countryId) { + return []; } + const items = await this.routes.list(); + return items + .filter(UserSimulationAdapter.hasLegacySimulation) + .map(UserSimulationAdapter.fromApiItem); + } - if (!response.ok) { - throw new Error('Failed to fetch association'); + async findById(_userId: string, simulationId: string): Promise { + if (!isLegacyId(simulationId)) { + return null; } - - const apiData = await response.json(); - return UserSimulationAdapter.fromApiResponse(apiData); + const item = await this.routes.findFirst({ legacy_simulation_id: simulationId }); + return item && UserSimulationAdapter.hasLegacySimulation(item) + ? UserSimulationAdapter.fromApiItem(item) + : null; } async update( - _userSimulationId: string, - _updates: Partial + userSimulationId: string, + updates: Partial ): Promise { - // TODO: Implement when backend API endpoint is available - // Expected endpoint: PUT /api/user-simulation-associations/:userSimulationId - // Expected payload: UserSimulationUpdatePayload (to be created) - - console.warn( - '[ApiSimulationStore.update] API endpoint not yet implemented. ' + - 'This method will be activated when user authentication is added.' - ); - - throw new Error( - 'Simulation updates via API are not yet supported. ' + - 'Please ensure you are using localStorage mode.' + const item = await this.routes.patch( + userSimulationId, + UserSimulationAdapter.toUpdatePayload(updates) ); + return UserSimulationAdapter.fromApiItem(item); } - // Not yet implemented, but keeping for future use - /* - async delete(userId: string, simulationId: string): Promise { - const response = await fetch(`/api/user-simulation-associations/${userId}/${simulationId}`, { - method: 'DELETE', - }); - - if (!response.ok) { - throw new Error('Failed to delete association'); - } + delete(userSimulationId: string): Promise { + return this.routes.delete(userSimulationId); } - */ } export class LocalStorageSimulationStore implements UserSimulationStore { @@ -179,14 +146,7 @@ export class LocalStorageSimulationStore implements UserSimulationStore { return updated; } - // Not yet implemented, but keeping for future use - /* - async delete(userId: string, simulationId: string): Promise { - const simulations = this.getStoredSimulations(); - const filtered = simulations.filter( - a => !(a.userId === userId && a.simulationId === simulationId) - ); - this.setStoredSimulations(filtered); + async delete(userSimulationId: string): Promise { + this.setStoredSimulations(this.getStoredSimulations().filter((s) => s.id !== userSimulationId)); } - */ } diff --git a/app/src/api/v2Associations.ts b/app/src/api/v2Associations.ts new file mode 100644 index 000000000..7fc0bd5b8 --- /dev/null +++ b/app/src/api/v2Associations.ts @@ -0,0 +1,138 @@ +import { isV2ApiError, V2ApiError, type V2Query, type V2UserClient } from '@/api/v2UserClient'; +import { resolveImportedId } from '@/libs/auth/importedIds'; +import type { AssociationCountryId, V2ItemResult, V2PageResult } from '@/types/v2User'; + +/** + * What every account-backed store needs: an authenticated client, the + * country its routes are called with (`country_id`), and the signed-in + * PolicyEngine user (only used to resolve this browser's imported local + * IDs — the server derives the owner from the token). + */ +export interface ApiStoreContext { + client: V2UserClient; + countryId: AssociationCountryId; + userId: string; +} + +/** + * v1 artifact IDs are positive integers on the wire; the app carries them + * as strings. Anything else cannot be saved to an account. + */ +export function isLegacyId(value: unknown): value is string | number { + if (typeof value !== 'string' && typeof value !== 'number') { + return false; + } + const text = String(value).trim(); + return /^\d+$/.test(text) && Number.isSafeInteger(Number(text)) && Number(text) >= 1; +} + +export function toLegacyId(value: string | number | null | undefined, what: string): number { + if (!isLegacyId(value)) { + throw new Error(`Cannot save ${what} ${String(value)}: it has no PolicyEngine API ID`); + } + return Number(String(value).trim()); +} + +/** The account association ID for an ID the UI holds (a UUID or an imported local ID). */ +export function accountAssociationId(context: ApiStoreContext, id: string): string | null { + return resolveImportedId(context.userId, id); +} + +/** Drops undefined fields so a patch carries only what the caller set. */ +export function definedFields(body: T): Partial { + return Object.fromEntries( + Object.entries(body).filter(([, value]) => value !== undefined) + ) as Partial; +} + +/** + * The five operations every `/v2/user-*` resource supports, scoped to the + * store's country. Stores map items to app types; this class owns the + * wire calls and the local-ID → UUID resolution. + */ +export class AccountAssociationRoutes { + constructor( + private readonly context: ApiStoreContext, + private readonly path: string, + private readonly what: string + ) {} + + get countryId(): AssociationCountryId { + return this.context.countryId; + } + + async create(body: TBody): Promise { + const result = await this.context.client.request>('POST', this.path, { + query: { country_id: body.country_id }, + body, + }); + return result.item; + } + + list(filters: V2Query = {}): Promise { + return this.context.client.listAll(this.path, { + ...filters, + country_id: this.context.countryId, + }); + } + + /** The first (oldest) row matching a list filter, or null. */ + async findFirst(filters: V2Query): Promise { + const page = await this.context.client.request>('GET', this.path, { + query: { ...filters, country_id: this.context.countryId, limit: 1 }, + }); + return page.items[0] ?? null; + } + + /** The row with this ID; null when it does not exist or belongs to someone else. */ + async get(id: string): Promise { + const accountId = accountAssociationId(this.context, id); + if (!accountId) { + return null; + } + try { + const result = await this.context.client.request>( + 'GET', + `${this.path}/${encodeURIComponent(accountId)}`, + { query: { country_id: this.context.countryId } } + ); + return result.item; + } catch (error) { + if (isV2ApiError(error, 404)) { + return null; + } + throw error; + } + } + + /** Applies only the supplied fields; an empty patch just reads the row. */ + async patch(id: string, body: object): Promise { + const accountId = this.requireId(id); + const result = await this.context.client.request>( + Object.keys(body).length === 0 ? 'GET' : 'PATCH', + `${this.path}/${encodeURIComponent(accountId)}`, + { + query: { country_id: this.context.countryId }, + ...(Object.keys(body).length === 0 ? {} : { body }), + } + ); + return result.item; + } + + async delete(id: string): Promise { + const accountId = this.requireId(id); + await this.context.client.request( + 'DELETE', + `${this.path}/${encodeURIComponent(accountId)}`, + { query: { country_id: this.context.countryId } } + ); + } + + private requireId(id: string): string { + const accountId = accountAssociationId(this.context, id); + if (!accountId) { + throw new V2ApiError(404, `${this.what} with id ${id} not found`); + } + return accountId; + } +} diff --git a/app/src/api/v2Me.ts b/app/src/api/v2Me.ts new file mode 100644 index 000000000..82b0bd6ae --- /dev/null +++ b/app/src/api/v2Me.ts @@ -0,0 +1,51 @@ +import type { V2UserClient } from '@/api/v2UserClient'; +import type { + AssociationCountryId, + ImportRequestBody, + ImportResult, + MeExportResult, + MeItem, + V2ItemResult, +} from '@/types/v2User'; + +/** + * "Who am I": the PolicyEngine user behind the session. The first call + * provisions the user, with `primary_country` set from `countryId`. + */ +export async function fetchMe( + client: V2UserClient, + countryId: AssociationCountryId +): Promise { + const result = await client.request>('GET', 'me', { + query: { country_id: countryId }, + }); + return result.item; +} + +/** Everything saved to the account, across all countries. */ +export function exportMe( + client: V2UserClient, + countryId: AssociationCountryId +): Promise { + return client.request('GET', 'me/export', { query: { country_id: countryId } }); +} + +/** Deletes the user, their identities, and every association (and the Auth0 user). */ +export async function deleteMe( + client: V2UserClient, + countryId: AssociationCountryId +): Promise { + await client.request('DELETE', 'me', { query: { country_id: countryId } }); +} + +/** Bulk import of this browser's saved items; idempotent per `client_ref`. */ +export function importToAccount( + client: V2UserClient, + countryId: AssociationCountryId, + body: ImportRequestBody +): Promise { + return client.request('POST', 'me/imports', { + query: { country_id: countryId }, + body, + }); +} diff --git a/app/src/api/v2UserClient.ts b/app/src/api/v2UserClient.ts new file mode 100644 index 000000000..bc2517dd1 --- /dev/null +++ b/app/src/api/v2UserClient.ts @@ -0,0 +1,174 @@ +import { BASE_URL } from '@/constants'; +import type { V2Envelope, V2PageResult } from '@/types/v2User'; + +/** + * Fetch wrapper for the PolicyEngine API's signed-in routes + * (`${BASE_URL}/v2/me*` and `/v2/user-*`). Every request carries the + * session's access token as `Authorization: Bearer …`; the server derives + * the user from it, so callers never send a user ID. Responses are the + * API's `{status, message, result}` envelope, unwrapped to `result`. + */ + +/** Largest page the list routes serve. */ +export const V2_MAX_PAGE_SIZE = 500; + +/** A failed signed-in request: HTTP status (0 for a network failure) and the API's message. */ +export class V2ApiError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = 'V2ApiError'; + this.status = status; + } +} + +export function isV2ApiError(error: unknown, status?: number): error is V2ApiError { + return error instanceof V2ApiError && (status === undefined || error.status === status); +} + +export type AccessTokenGetter = () => Promise; + +type QueryValue = string | number | boolean | null | undefined; +export type V2Query = Record; + +export interface V2RequestOptions { + query?: V2Query; + body?: unknown; +} + +export interface V2UserClient { + /** Sends a request and returns the envelope's `result` (undefined for 204). */ + request: ( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + path: string, + options?: V2RequestOptions + ) => Promise; + /** Reads every page of a list route. */ + listAll: (path: string, query?: V2Query) => Promise; +} + +export interface CreateV2UserClientOptions { + getAccessToken: AccessTokenGetter; + baseUrl?: string; + fetchImpl?: typeof fetch; +} + +const STATUS_MESSAGES: Record = { + 400: 'The request was not valid.', + 401: 'Your session has ended. Sign in again to continue.', + 403: 'This account cannot use saved work.', + 404: 'Not found.', + 409: 'This item is managed elsewhere and cannot be changed here.', + 413: 'Too much data in one request.', + 422: 'Something this refers to no longer exists.', + 503: 'Saved work is temporarily unavailable.', +}; + +function queryString(query: V2Query | undefined): string { + if (!query) { + return ''; + } + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + params.set(key, String(value)); + } + } + const text = params.toString(); + return text ? `?${text}` : ''; +} + +async function readEnvelope(response: Response): Promise | null> { + try { + return (await response.json()) as V2Envelope; + } catch { + return null; + } +} + +export function createV2UserClient({ + getAccessToken, + baseUrl, + fetchImpl, +}: CreateV2UserClientOptions): V2UserClient { + async function request( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + path: string, + { query, body }: V2RequestOptions = {} + ): Promise { + let token: string | null; + try { + token = await getAccessToken(); + } catch { + token = null; + } + if (!token) { + throw new V2ApiError(401, STATUS_MESSAGES[401]); + } + + const headers: Record = { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + }; + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + + // Resolved per request so building a client never touches configuration. + const url = `${baseUrl ?? BASE_URL}/v2/${path.replace(/^\/+/, '')}${queryString(query)}`; + let response: Response; + try { + response = await (fetchImpl ?? fetch)(url, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (error) { + throw new V2ApiError( + 0, + error instanceof Error ? `Network error: ${error.message}` : 'Network error' + ); + } + + if (response.status === 204) { + return undefined as R; + } + + const envelope = await readEnvelope(response); + if (!response.ok) { + throw new V2ApiError( + response.status, + envelope?.message || + STATUS_MESSAGES[response.status] || + `Request failed (${response.status})` + ); + } + if (!envelope || envelope.status !== 'ok') { + throw new V2ApiError( + response.status, + envelope?.message || 'Unexpected response from the API' + ); + } + return envelope.result as R; + } + + async function listAll(path: string, query: V2Query = {}): Promise { + const items: T[] = []; + let offset = 0; + // A generous cap keeps a misbehaving server from looping forever. + for (let page = 0; page < 100; page += 1) { + const result = await request>('GET', path, { + query: { ...query, offset, limit: V2_MAX_PAGE_SIZE }, + }); + items.push(...result.items); + if (!result.has_more || result.items.length === 0) { + break; + } + offset += result.items.length; + } + return items; + } + + return { request, listAll }; +} diff --git a/app/src/hooks/useCreateHousehold.ts b/app/src/hooks/useCreateHousehold.ts index b748dd229..b0f863d0e 100644 --- a/app/src/hooks/useCreateHousehold.ts +++ b/app/src/hooks/useCreateHousehold.ts @@ -1,6 +1,6 @@ import { useMutation } from '@tanstack/react-query'; import { createHousehold } from '@/api/household'; -import { MOCK_USER_ID } from '@/constants'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { countryIds } from '@/libs/countries'; import type { HouseholdCreationPayload } from '@/types/payloads'; import { useCreateHouseholdAssociation } from './useUserHousehold'; @@ -11,7 +11,7 @@ interface CreateHouseholdVariables { } export function useCreateHousehold(householdLabel?: string) { - // const user = MOCK_USER_ID; // TODO: Replace with actual user context or auth hook in future + const userId = useCurrentUserId(); const createAssociation = useCreateHouseholdAssociation(); const mutation = useMutation({ @@ -20,9 +20,8 @@ export function useCreateHousehold(householdLabel?: string) { const resolvedLabel = label ?? householdLabel ?? payload.label; try { - // Create association with current user (or anonymous for session storage) - const userId = MOCK_USER_ID; // TODO: Replace with actual user ID retrieval logic and add conditional logic to access user ID - + // File the association under the current user (the account when + // signed in, localStorage otherwise) await createAssociation.mutateAsync({ userId, householdId: data.result.household_id, diff --git a/app/src/hooks/useCreatePolicy.ts b/app/src/hooks/useCreatePolicy.ts index 70d7adcb9..fa53d2646 100644 --- a/app/src/hooks/useCreatePolicy.ts +++ b/app/src/hooks/useCreatePolicy.ts @@ -1,6 +1,6 @@ import { useMutation } from '@tanstack/react-query'; import { createPolicy } from '@/api/policy'; -import { MOCK_USER_ID } from '@/constants'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { PolicyCreationPayload } from '@/types/payloads'; import { useCurrentCountry } from './useCurrentCountry'; import { useCreatePolicyAssociation } from './useUserPolicy'; @@ -12,7 +12,7 @@ interface CreatePolicyVariables { export function useCreatePolicy(policyLabel?: string) { const countryId = useCurrentCountry(); - // const user = MOCK_USER_ID; // TODO: Replace with actual user context or auth hook in future + const userId = useCurrentUserId(); const createAssociation = useCreatePolicyAssociation(); const mutation = useMutation({ @@ -21,9 +21,8 @@ export function useCreatePolicy(policyLabel?: string) { const resolvedLabel = label ?? policyLabel; try { - // Create association with current user (or anonymous for session storage) - const userId = MOCK_USER_ID; // TODO: Replace with actual user ID retrieval logic and add conditional logic to access user ID - + // File the association under the current user (the account when + // signed in, localStorage otherwise) await createAssociation.mutateAsync({ userId, policyId: data.result.policy_id, diff --git a/app/src/hooks/useCreateReport.ts b/app/src/hooks/useCreateReport.ts index aff0c1516..cb84ed5ba 100644 --- a/app/src/hooks/useCreateReport.ts +++ b/app/src/hooks/useCreateReport.ts @@ -1,13 +1,14 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { createReportAndAssociateWithUser, CreateReportWithAssociationResult } from '@/api/report'; -import { MOCK_USER_ID } from '@/constants'; import { useCalcOrchestratorManager } from '@/contexts/CalcOrchestratorContext'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { countryIds } from '@/libs/countries'; import { reportAssociationKeys, reportKeys } from '@/libs/queryKeys'; import type { Household } from '@/models/Household'; import { Geography } from '@/types/ingredients/Geography'; import { Simulation } from '@/types/ingredients/Simulation'; import { ReportCreationPayload } from '@/types/payloads'; +import { useUserReportStore } from './useUserReportAssociations'; interface CreateReportAndBeginCalculationParams { countryId: (typeof countryIds)[number]; @@ -44,7 +45,8 @@ export function useCreateReport(reportLabel?: string) { const queryClient = useQueryClient(); const manager = useCalcOrchestratorManager(); - const userId = MOCK_USER_ID; + const userId = useCurrentUserId(); + const reportStore = useUserReportStore(); const mutation = useMutation({ mutationFn: async ({ @@ -62,6 +64,7 @@ export function useCreateReport(reportLabel?: string) { payload, userId, label: reportLabel, + reportStore, }); if (import.meta.env.DEV) { diff --git a/app/src/hooks/useCreateSimulation.ts b/app/src/hooks/useCreateSimulation.ts index 1fcb8db4b..6b910d455 100644 --- a/app/src/hooks/useCreateSimulation.ts +++ b/app/src/hooks/useCreateSimulation.ts @@ -1,22 +1,21 @@ import { useMutation } from '@tanstack/react-query'; import { createSimulation } from '@/api/simulation'; -import { MOCK_USER_ID } from '@/constants'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { SimulationCreationPayload } from '@/types/payloads'; import { useCreateSimulationAssociation } from './useUserSimulationAssociations'; export function useCreateSimulation(simulationLabel?: string) { const country = useCurrentCountry(); - // const user = MOCK_USER_ID; // TODO: Replace with actual user context or auth hook in future + const userId = useCurrentUserId(); const createAssociation = useCreateSimulationAssociation(); const mutation = useMutation({ mutationFn: (data: SimulationCreationPayload) => createSimulation(country, data), onSuccess: async (data) => { try { - // Create association with current user (or anonymous for session storage) - const userId = MOCK_USER_ID; // TODO: Replace with actual user ID retrieval logic and add conditional logic to access user ID - + // File the association under the current user (the account when + // signed in, localStorage otherwise) await createAssociation.mutateAsync({ userId, simulationId: data.result.simulation_id, diff --git a/app/src/hooks/useSaveSharedReport.ts b/app/src/hooks/useSaveSharedReport.ts index 3fa6d089a..9abd4864a 100644 --- a/app/src/hooks/useSaveSharedReport.ts +++ b/app/src/hooks/useSaveSharedReport.ts @@ -1,14 +1,16 @@ /** - * Hook for saving a shared report to user's localStorage + * Hook for saving a shared report to the user's saved work * * When a user clicks "Save" on a shared report, this hook persists the - * user associations from ReportIngredientsInput to localStorage, making the report - * appear in their reports list. + * user associations from ReportIngredientsInput — to their account when + * signed in (US/UK), to localStorage otherwise — making the report appear + * in their reports list. */ import { useCallback, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { ReportIngredientsInput } from '@/hooks/utils/useFetchReportIngredients'; +import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; import { CountryId } from '@/libs/countries'; import { Household } from '@/models/Household'; import { RootState } from '@/store'; @@ -25,13 +27,15 @@ export type SaveResult = 'success' | 'partial' | 'already_saved' | null; type SharedSaveHouseholdDetails = Household; /** - * Hook for saving a shared report and all its user associations to localStorage + * Hook for saving a shared report and all its user associations * * ReportIngredientsInput already contains user associations with IDs and labels. - * This hook simply persists them to localStorage. + * This hook persists them to the current store. * * Features: - * - Idempotent save using userReportId (won't create duplicates) + * - Idempotent save: by userReportId in localStorage; signed in, by the + * report (`POST /v2/user-reports {legacy_report_id}` only when this + * account has not saved it yet) * - Best-effort saving of ingredients (report save required, others optional) * - Skips current law policies (they're pre-defined, not user-created) */ @@ -42,6 +46,7 @@ export function useSaveSharedReport() { const createHouseholdAssociation = useCreateHouseholdAssociation(); const createGeographicAssociation = useCreateGeographicAssociation(); const reportStore = useUserReportStore(); + const backend = useAssociationBackend(); // Get currentLawId to skip creating associations for current law policies const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); @@ -63,11 +68,16 @@ export function useSaveSharedReport() { _policies: Policy[] = [], _households: SharedSaveHouseholdDetails[] = [] ): Promise => { - const userId = 'anonymous'; // TODO: Replace with auth context + const { userId } = backend; const userReportId = getShareDataUserReportId(shareData); + const findExisting = async () => + (await reportStore.findByUserReportId(userReportId)) ?? + (backend.mode === 'api' + ? await reportStore.findById(userId, shareData.userReport.reportId) + : null); // Idempotency check: see if this report is already saved - const existingReport = await reportStore.findByUserReportId(userReportId); + const existingReport = await findExisting(); if (existingReport) { setResultWithTimeout('already_saved'); return existingReport; @@ -146,7 +156,7 @@ export function useSaveSharedReport() { }); } catch (error) { // Handle race condition: if another save completed first - const existingAfterRace = await reportStore.findByUserReportId(userReportId); + const existingAfterRace = await findExisting(); if (existingAfterRace) { setResultWithTimeout('already_saved'); return existingAfterRace; diff --git a/app/src/hooks/useUserGeographic.ts b/app/src/hooks/useUserGeographic.ts index c9dbd9ffd..5352307a6 100644 --- a/app/src/hooks/useUserGeographic.ts +++ b/app/src/hooks/useUserGeographic.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { ApiGeographicStore, LocalStorageGeographicStore } from '@/api/geographicAssociation'; +import { LocalStorageGeographicStore } from '@/api/geographicAssociation'; +import { MOCK_USER_ID } from '@/constants'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useRegions } from '@/hooks/useRegions'; import { queryConfig } from '@/libs/queryConfig'; @@ -9,46 +10,52 @@ import { buildCanonicalGeography } from '@/models/geography'; import { Geography } from '@/types/ingredients/Geography'; import { UserGeographyPopulation } from '@/types/ingredients/UserPopulation'; -const apiGeographicStore = new ApiGeographicStore(); const localGeographicStore = new LocalStorageGeographicStore(); +/** + * Saved geographies belong to the browser, not the account, so they are + * always filed under the local user: signing in neither hides the ones + * saved while signed out nor files new ones under the account's ID. + */ +const GEOGRAPHY_OWNER = MOCK_USER_ID; + type SavedGeographyAssociationStoreSelection = { - store: ApiGeographicStore | LocalStorageGeographicStore; - config: typeof queryConfig.api | typeof queryConfig.localStorage; + store: LocalStorageGeographicStore; + config: typeof queryConfig.localStorage; }; export const useUserGeographicStore = () => { return useSavedGeographyAssociationStoreForMode().store; }; +// Saved geographies stay in localStorage for everyone, signed in or not: +// there is no account table for them (v3 picks from the canonical region +// list and saves the geography on the simulation instead). export const useSavedGeographyAssociationStoreForMode = - (): SavedGeographyAssociationStoreSelection => { - const isLoggedIn = false; // TODO: Replace with actual auth check in future - return { - store: isLoggedIn ? apiGeographicStore : localGeographicStore, - config: isLoggedIn ? queryConfig.api : queryConfig.localStorage, - }; - }; + (): SavedGeographyAssociationStoreSelection => ({ + store: localGeographicStore, + config: queryConfig.localStorage, + }); // `saved_geographies` controls persisted user geography rows. // Canonical `regions` remain a separate concern for lookup and shadow resolution. -export const useGeographicAssociationsByUser = (userId: string) => { +export const useGeographicAssociationsByUser = (_userId: string) => { const { store, config } = useSavedGeographyAssociationStoreForMode(); const countryId = useCurrentCountry(); return useQuery({ - queryKey: geographicAssociationKeys.byUser(userId, countryId), - queryFn: () => store.findByUser(userId, countryId), + queryKey: geographicAssociationKeys.byUser(GEOGRAPHY_OWNER, countryId), + queryFn: () => store.findByUser(GEOGRAPHY_OWNER, countryId), ...config, }); }; -export const useGeographicAssociation = (userId: string, geographyId: string) => { +export const useGeographicAssociation = (_userId: string, geographyId: string) => { const { store, config } = useSavedGeographyAssociationStoreForMode(); return useQuery({ - queryKey: geographicAssociationKeys.specific(userId, geographyId), - queryFn: () => store.findById(userId, geographyId), + queryKey: geographicAssociationKeys.specific(GEOGRAPHY_OWNER, geographyId), + queryFn: () => store.findById(GEOGRAPHY_OWNER, geographyId), ...config, }); }; @@ -59,7 +66,7 @@ export const useCreateGeographicAssociation = () => { return useMutation({ mutationFn: (population: Omit) => - store.create({ ...population, type: 'geography' as const }), + store.create({ ...population, userId: GEOGRAPHY_OWNER, type: 'geography' as const }), onSuccess: (newPopulation) => { // Invalidate and refetch related queries queryClient.invalidateQueries({ @@ -84,14 +91,14 @@ export const useUpdateGeographicAssociation = () => { return useMutation({ mutationFn: ({ - userId, geographyId, updates, }: { + /** Accepted for symmetry with the other stores; geographies are always local. */ userId: string; geographyId: string; updates: Partial; - }) => store.update(userId, geographyId, updates), + }) => store.update(GEOGRAPHY_OWNER, geographyId, updates), onSuccess: (updatedAssociation) => { // Invalidate all related queries to trigger refetch diff --git a/app/src/hooks/useUserHousehold.ts b/app/src/hooks/useUserHousehold.ts index 59ec3fb5f..172b1b9e5 100644 --- a/app/src/hooks/useUserHousehold.ts +++ b/app/src/hooks/useUserHousehold.ts @@ -1,21 +1,23 @@ -// Import auth hook here in future; for now, mocked out below +import { useMemo } from 'react'; import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { fetchHouseholdById } from '@/api/household'; import type { UserHouseholdStore } from '@/api/householdAssociation'; import { replaceHouseholdBaseForAssociation as replaceHouseholdBaseForAssociationAction } from '@/hooks/household/replaceHouseholdBaseForAssociation'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; import { Household } from '@/models/Household'; import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; import { ApiHouseholdStore, LocalStorageHouseholdStore } from '../api/householdAssociation'; import { queryConfig } from '../libs/queryConfig'; import { householdAssociationKeys, householdKeys } from '../libs/queryKeys'; -const apiHouseholdStore = new ApiHouseholdStore(); const localHouseholdStore = new LocalStorageHouseholdStore(); type HouseholdAssociationStoreSelection = { - store: ApiHouseholdStore | LocalStorageHouseholdStore; + store: UserHouseholdStore; config: typeof queryConfig.api | typeof queryConfig.localStorage; + /** False while the session is being restored; queries wait. */ + ready: boolean; }; type HouseholdAssociationQueryOptions = { @@ -27,23 +29,27 @@ export const useUserHouseholdStore = () => { }; export const useHouseholdAssociationStoreForMode = (): HouseholdAssociationStoreSelection => { - const isLoggedIn = false; // TODO: Replace with actual auth check in future - return { - store: isLoggedIn ? apiHouseholdStore : localHouseholdStore, - config: isLoggedIn ? queryConfig.api : queryConfig.localStorage, - }; + const backend = useAssociationBackend(); + return useMemo( + () => + backend.mode === 'api' + ? { store: new ApiHouseholdStore(backend), config: queryConfig.api, ready: true } + : { store: localHouseholdStore, config: queryConfig.localStorage, ready: backend.ready }, + [backend] + ); }; // This fetches only the user-household associations; see // 'useUserHouseholds' below to also fetch full household details export const useHouseholdAssociationsByUser = (userId: string) => { - const { store, config } = useHouseholdAssociationStoreForMode(); + const { store, config, ready } = useHouseholdAssociationStoreForMode(); const countryId = useCurrentCountry(); return useQuery({ queryKey: householdAssociationKeys.byUser(userId, countryId), queryFn: () => store.findByUser(userId, countryId), ...config, + enabled: ready, }); }; @@ -52,7 +58,7 @@ export const useHouseholdAssociation = ( householdId: string, options?: HouseholdAssociationQueryOptions ) => { - const { store, config } = useHouseholdAssociationStoreForMode(); + const { store, config, ready } = useHouseholdAssociationStoreForMode(); const countryId = useCurrentCountry(); return useQuery({ @@ -61,8 +67,8 @@ export const useHouseholdAssociation = ( const association = await store.findById(userId, householdId); return association?.countryId === countryId ? association : null; }, - enabled: options?.enabled ?? true, ...config, + enabled: ready && (options?.enabled ?? true), }); }; diff --git a/app/src/hooks/useUserPolicy.ts b/app/src/hooks/useUserPolicy.ts index f69547d9e..f18a5997a 100644 --- a/app/src/hooks/useUserPolicy.ts +++ b/app/src/hooks/useUserPolicy.ts @@ -1,22 +1,28 @@ -// Import auth hook here in future; for now, mocked out below +import { useMemo } from 'react'; import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { PolicyAdapter } from '@/adapters'; import { fetchPolicyById } from '@/api/policy'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useParameterMetadata } from '@/hooks/useParameterMetadata'; +import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; import { Policy } from '@/types/ingredients/Policy'; import type { PolicyCreationPayload } from '@/types/payloads'; -import { ApiPolicyStore, LocalStoragePolicyStore } from '../api/policyAssociation'; +import { + ApiPolicyStore, + LocalStoragePolicyStore, + type UserPolicyStore, +} from '../api/policyAssociation'; import { queryConfig } from '../libs/queryConfig'; import { policyAssociationKeys, policyKeys } from '../libs/queryKeys'; import { UserPolicy } from '../types/ingredients/UserPolicy'; -const apiPolicyStore = new ApiPolicyStore(); const localPolicyStore = new LocalStoragePolicyStore(); type PolicyAssociationStoreSelection = { - store: ApiPolicyStore | LocalStoragePolicyStore; + store: UserPolicyStore; config: typeof queryConfig.api | typeof queryConfig.localStorage; + /** False while the session is being restored; queries wait. */ + ready: boolean; }; type PolicyAssociationQueryOptions = { @@ -35,23 +41,31 @@ export const useUserPolicyStore = () => { }; export const usePolicyAssociationStoreForMode = (): PolicyAssociationStoreSelection => { - const isLoggedIn = false; // TODO: Replace with actual auth check in future - return { - store: isLoggedIn ? apiPolicyStore : localPolicyStore, - config: isLoggedIn ? queryConfig.api : queryConfig.localStorage, - }; + const backend = useAssociationBackend(); + return useMemo( + () => + backend.mode === 'api' + ? { + store: new ApiPolicyStore(backend), + config: queryConfig.api, + ready: true, + } + : { store: localPolicyStore, config: queryConfig.localStorage, ready: backend.ready }, + [backend] + ); }; // This fetches only the user-policy associations; see // 'useUserPolicies' below to also fetch full policy details export const usePolicyAssociationsByUser = (userId: string) => { - const { store, config } = usePolicyAssociationStoreForMode(); + const { store, config, ready } = usePolicyAssociationStoreForMode(); const countryId = useCurrentCountry(); return useQuery({ queryKey: policyAssociationKeys.byUser(userId, countryId), queryFn: () => store.findByUser(userId, countryId), ...config, + enabled: ready, }); }; @@ -60,7 +74,7 @@ export const usePolicyAssociation = ( policyId: string, options?: PolicyAssociationQueryOptions ) => { - const { store, config } = usePolicyAssociationStoreForMode(); + const { store, config, ready } = usePolicyAssociationStoreForMode(); const countryId = useCurrentCountry(); return useQuery({ @@ -69,8 +83,8 @@ export const usePolicyAssociation = ( const association = await store.findById(userId, policyId); return association?.countryId === countryId ? association : null; }, - enabled: options?.enabled ?? true, ...config, + enabled: ready && (options?.enabled ?? true), }); }; diff --git a/app/src/hooks/useUserReportAssociations.ts b/app/src/hooks/useUserReportAssociations.ts index de110bb16..6741a9284 100644 --- a/app/src/hooks/useUserReportAssociations.ts +++ b/app/src/hooks/useUserReportAssociations.ts @@ -1,17 +1,45 @@ -// Import auth hook here in future; for now, mocked out below +import { useMemo } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import { ApiReportStore, LocalStorageReportStore } from '../api/reportAssociation'; +import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; +import { + ApiReportStore, + LocalStorageReportStore, + type UserReportStore, +} from '../api/reportAssociation'; import { queryConfig } from '../libs/queryConfig'; import { reportAssociationKeys } from '../libs/queryKeys'; import { UserReport } from '../types/ingredients/UserReport'; -const apiReportStore = new ApiReportStore(); -const localReportStore = new LocalStorageReportStore(); +// Created on first use rather than at import, so modules (and test mocks) +// that load this hook do not construct storage eagerly. +let localReportStore: LocalStorageReportStore | null = null; +const getLocalReportStore = () => (localReportStore ??= new LocalStorageReportStore()); + +type ReportAssociationStoreSelection = { + store: UserReportStore; + config: typeof queryConfig.api | typeof queryConfig.localStorage; + /** False while the session is being restored; queries wait. */ + ready: boolean; +}; + +export const useReportAssociationStoreForMode = (): ReportAssociationStoreSelection => { + const backend = useAssociationBackend(); + return useMemo( + () => + backend.mode === 'api' + ? { + store: new ApiReportStore(backend, getLocalReportStore()), + config: queryConfig.api, + ready: true, + } + : { store: getLocalReportStore(), config: queryConfig.localStorage, ready: backend.ready }, + [backend] + ); +}; export const useUserReportStore = () => { - const isLoggedIn = false; // TODO: Replace with actual auth check in future - return isLoggedIn ? apiReportStore : localReportStore; + return useReportAssociationStoreForMode().store; }; /** @@ -25,42 +53,37 @@ export const useUserReportStore = () => { * For full report data with simulations and outputs, use useUserReports */ export const useReportAssociationsByUser = (userId: string) => { - const store = useUserReportStore(); + const { store, config, ready } = useReportAssociationStoreForMode(); const countryId = useCurrentCountry(); - const isLoggedIn = false; // TODO: Replace with actual auth check in future - // TODO: Should we determine user ID from auth context here? Or pass as arg? - const config = isLoggedIn ? queryConfig.api : queryConfig.localStorage; return useQuery({ queryKey: reportAssociationKeys.byUser(userId, countryId), queryFn: () => store.findByUser(userId, countryId), ...config, + enabled: ready, }); }; export const useReportAssociation = (userId: string, reportId: string) => { - const store = useUserReportStore(); - const isLoggedIn = false; // TODO: Replace with actual auth check in future - const config = isLoggedIn ? queryConfig.api : queryConfig.localStorage; + const { store, config, ready } = useReportAssociationStoreForMode(); return useQuery({ queryKey: reportAssociationKeys.specific(userId, reportId), queryFn: () => store.findById(userId, reportId), ...config, + enabled: ready, }); }; export const useReportAssociationById = (userReportId: string, options?: { enabled?: boolean }) => { - const store = useUserReportStore(); - const isLoggedIn = false; // TODO: Replace with actual auth check in future - const config = isLoggedIn ? queryConfig.api : queryConfig.localStorage; + const { store, config, ready } = useReportAssociationStoreForMode(); const isEnabled = options?.enabled !== false; return useQuery({ queryKey: reportAssociationKeys.byUserReportId(userReportId), queryFn: () => store.findByUserReportId(userReportId), ...config, - enabled: isEnabled, + enabled: ready && isEnabled, }); }; @@ -69,7 +92,8 @@ export const useReportAssociationById = (userReportId: string, options?: { enabl * * Supports two modes: * - Without id: generates a new id (use for new reports) - * - With id: uses the provided id (use for saving shared reports with a specific id) + * - With id: in localStorage, saves under the provided id (shared-report + * saves); account storage always assigns its own id */ export const useCreateReportAssociation = () => { const store = useUserReportStore(); @@ -79,12 +103,13 @@ export const useCreateReportAssociation = () => { mutationFn: ( userReport: Omit | Omit ) => { - // If id is provided, use createWithId for idempotent save - if ('id' in userReport && userReport.id) { + // If id is provided and the store keeps caller IDs, save under it (idempotent) + if ('id' in userReport && userReport.id && store.createWithId) { return store.createWithId(userReport as Omit); } - // Otherwise, generate a new id - return store.create(userReport as Omit); + // Otherwise the store assigns the id + const { id: _id, ...fields } = userReport as Partial; + return store.create(fields as Omit); }, onSuccess: (newAssociation) => { // Invalidate and refetch related queries diff --git a/app/src/hooks/useUserSimulationAssociations.ts b/app/src/hooks/useUserSimulationAssociations.ts index e08f768ae..41c861980 100644 --- a/app/src/hooks/useUserSimulationAssociations.ts +++ b/app/src/hooks/useUserSimulationAssociations.ts @@ -1,17 +1,45 @@ -// Import auth hook here in future; for now, mocked out below +import { useMemo } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import { ApiSimulationStore, LocalStorageSimulationStore } from '../api/simulationAssociation'; +import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; +import { + ApiSimulationStore, + LocalStorageSimulationStore, + type UserSimulationStore, +} from '../api/simulationAssociation'; import { queryConfig } from '../libs/queryConfig'; import { simulationAssociationKeys } from '../libs/queryKeys'; import { UserSimulation } from '../types/ingredients/UserSimulation'; -const apiSimulationStore = new ApiSimulationStore(); -const localSimulationStore = new LocalStorageSimulationStore(); +// Created on first use rather than at import, so modules (and test mocks) +// that load this hook do not construct storage eagerly. +let localSimulationStore: LocalStorageSimulationStore | null = null; +const getLocalSimulationStore = () => (localSimulationStore ??= new LocalStorageSimulationStore()); + +type SimulationAssociationStoreSelection = { + store: UserSimulationStore; + config: typeof queryConfig.api | typeof queryConfig.localStorage; + /** False while the session is being restored; queries wait. */ + ready: boolean; +}; + +export const useSimulationAssociationStoreForMode = (): SimulationAssociationStoreSelection => { + const backend = useAssociationBackend(); + return useMemo( + () => + backend.mode === 'api' + ? { store: new ApiSimulationStore(backend), config: queryConfig.api, ready: true } + : { + store: getLocalSimulationStore(), + config: queryConfig.localStorage, + ready: backend.ready, + }, + [backend] + ); +}; export const useUserSimulationStore = () => { - const isLoggedIn = false; // TODO: Replace with actual auth check in future - return isLoggedIn ? apiSimulationStore : localSimulationStore; + return useSimulationAssociationStoreForMode().store; }; /** @@ -25,28 +53,25 @@ export const useUserSimulationStore = () => { * For full simulation data with policies and households, use useUserSimulations */ export const useSimulationAssociationsByUser = (userId: string) => { - const store = useUserSimulationStore(); + const { store, config, ready } = useSimulationAssociationStoreForMode(); const countryId = useCurrentCountry(); - const isLoggedIn = false; // TODO: Replace with actual auth check in future - // TODO: Should we determine user ID from auth context here? Or pass as arg? - const config = isLoggedIn ? queryConfig.api : queryConfig.localStorage; return useQuery({ queryKey: simulationAssociationKeys.byUser(userId, countryId), queryFn: () => store.findByUser(userId, countryId), ...config, + enabled: ready, }); }; export const useSimulationAssociation = (userId: string, simulationId: string) => { - const store = useUserSimulationStore(); - const isLoggedIn = false; // TODO: Replace with actual auth check in future - const config = isLoggedIn ? queryConfig.api : queryConfig.localStorage; + const { store, config, ready } = useSimulationAssociationStoreForMode(); return useQuery({ queryKey: simulationAssociationKeys.specific(userId, simulationId), queryFn: () => store.findById(userId, simulationId), ...config, + enabled: ready, }); }; diff --git a/app/src/libs/auth/AuthContext.ts b/app/src/libs/auth/AuthContext.ts new file mode 100644 index 000000000..7067d9a47 --- /dev/null +++ b/app/src/libs/auth/AuthContext.ts @@ -0,0 +1,54 @@ +import { createContext, useContext } from 'react'; +import type { MeItem } from '@/types/v2User'; + +/** + * Sign-in state shared with the whole app. This module has no Auth0 + * dependency, so code that only reads the state (stores, menus, the + * legacy Vite build) never bundles the SDK. Without an AuthProvider in + * the tree the state is 'disabled' and the app behaves exactly as it + * does without sign-in. + */ + +export type AuthStatus = 'disabled' | 'loading' | 'anonymous' | 'authenticated'; + +/** Display details from the identity provider (Auth0 ID token claims). */ +export interface AuthProfile { + name: string | null; + email: string | null; + picture: string | null; +} + +export interface AuthContextValue { + status: AuthStatus; + /** The PolicyEngine user (`GET /v2/me`), once loaded; null otherwise. */ + user: MeItem | null; + profile: AuthProfile | null; + /** Why sign-in or loading the PolicyEngine user failed, when it did. */ + error: Error | null; + /** Redirects to the hosted sign-in page; returns to `returnTo` (default: here). */ + signIn: (returnTo?: string) => Promise; + signOut: () => Promise; + /** An access token for the PolicyEngine API, or null when signed out. */ + getAccessToken: () => Promise; +} + +export const DISABLED_AUTH: AuthContextValue = { + status: 'disabled', + user: null, + profile: null, + error: null, + signIn: async () => {}, + signOut: async () => {}, + getAccessToken: async () => null, +}; + +export const AuthContext = createContext(DISABLED_AUTH); + +export function useAuthStatus(): AuthContextValue { + return useContext(AuthContext); +} + +export const authKeys = { + all: ['auth'] as const, + me: (subject: string) => ['auth', 'me', subject] as const, +}; diff --git a/app/src/libs/auth/AuthProvider.tsx b/app/src/libs/auth/AuthProvider.tsx new file mode 100644 index 000000000..32f4e557b --- /dev/null +++ b/app/src/libs/auth/AuthProvider.tsx @@ -0,0 +1,186 @@ +import { useCallback, useMemo, type ReactNode } from 'react'; +import { Auth0Provider, useAuth0, type AppState } from '@auth0/auth0-react'; +import { useQuery } from '@tanstack/react-query'; +import { fetchMe } from '@/api/v2Me'; +import { createV2UserClient } from '@/api/v2UserClient'; +import { useCountryContext } from '@/contexts/CountryContext'; +import { useAppNavigate } from '@/contexts/NavigationContext'; +import { DEFAULT_COUNTRY } from '@/libs/countries'; +import { isAssociationCountry, type MeItem } from '@/types/v2User'; +import { + AUTH_SCOPE, + callbackPath, + getAuth0Config, + isCallbackPath, + safeReturnTo, + type Auth0Config, +} from './authConfig'; +import { AuthContext, authKeys, type AuthContextValue, type AuthStatus } from './AuthContext'; + +/** + * Social sign-on for the calculator. With sign-in off (or Auth0 not + * configured) this renders its children unchanged and the app sees + * status 'disabled'. Otherwise the browser session is managed by + * @auth0/auth0-react: tokens stay in memory and are renewed with + * rotating refresh tokens (falling back to silent re-authentication + * against the Auth0 custom domain after a reload). + * + * Must sit inside the QueryClientProvider, CountryProvider and + * NavigationProvider. + */ +export function AuthProvider({ children }: { children: ReactNode }) { + const config = getAuth0Config(); + if (!config) { + return <>{children}; + } + return {children}; +} + +let redirectHandled = false; + +/** Whether this page load has already finished a sign-in redirect. */ +export function wasSignInRedirectHandled(): boolean { + return redirectHandled; +} + +function Auth0SessionProvider({ config, children }: { config: Auth0Config; children: ReactNode }) { + const countryId = useCountryContext() ?? DEFAULT_COUNTRY; + const nav = useAppNavigate(); + const inBrowser = typeof window !== 'undefined'; + + const onRedirectCallback = useCallback( + (appState?: AppState) => { + redirectHandled = true; + nav.replace(safeReturnTo(appState?.returnTo, `/${countryId}`)); + }, + [nav, countryId] + ); + + return ( + + + {children} + + + ); +} + +function stringClaim(value: unknown): string | null { + return typeof value === 'string' && value ? value : null; +} + +function Auth0SessionBridge({ + config, + countryId, + children, +}: { + config: Auth0Config; + countryId: string; + children: ReactNode; +}) { + const { + isLoading, + isAuthenticated, + error: auth0Error, + user: auth0User, + getAccessTokenSilently, + loginWithRedirect, + logout, + } = useAuth0(); + + const getAccessToken = useCallback(async () => { + try { + const token = await getAccessTokenSilently({ + authorizationParams: { audience: config.audience }, + }); + return token || null; + } catch { + return null; + } + }, [getAccessTokenSilently, config.audience]); + + const subject = stringClaim(auth0User?.sub); + const name = stringClaim(auth0User?.name); + const email = stringClaim(auth0User?.email); + const picture = stringClaim(auth0User?.picture); + + // The PolicyEngine user behind the session. Fetched with the current + // country (us/uk) so a first sign-in provisions with it; elsewhere the + // session is still signed in but saved work stays local. + const meCountry = isAssociationCountry(countryId) ? countryId : null; + const meQuery = useQuery({ + queryKey: authKeys.me(subject ?? ''), + queryFn: () => fetchMe(createV2UserClient({ getAccessToken }), meCountry!), + enabled: isAuthenticated && !!subject && !!meCountry, + staleTime: Infinity, + gcTime: Infinity, + retry: 1, + refetchOnWindowFocus: false, + }); + + let status: AuthStatus; + if (isLoading) { + status = 'loading'; + } else if (!isAuthenticated) { + status = 'anonymous'; + } else if (meCountry && !meQuery.data && meQuery.isPending && meQuery.fetchStatus !== 'idle') { + status = 'loading'; + } else { + status = 'authenticated'; + } + + const user = isAuthenticated ? (meQuery.data ?? null) : null; + const error = auth0Error ?? (isAuthenticated ? meQuery.error : null) ?? null; + + const signIn = useCallback( + async (returnTo?: string) => { + const here = + typeof window !== 'undefined' + ? `${window.location.pathname}${window.location.search}` + : `/${countryId}`; + await loginWithRedirect({ + appState: { returnTo: safeReturnTo(returnTo ?? here, `/${countryId}`) }, + authorizationParams: { + audience: config.audience, + redirect_uri: `${window.location.origin}${callbackPath(countryId)}`, + scope: AUTH_SCOPE, + }, + }); + }, + [loginWithRedirect, countryId, config.audience] + ); + + const signOut = useCallback(async () => { + await logout({ logoutParams: { returnTo: `${window.location.origin}/${countryId}` } }); + }, [logout, countryId]); + + const value = useMemo( + () => ({ + status, + user, + profile: isAuthenticated ? { name, email, picture } : null, + error, + signIn, + signOut, + getAccessToken, + }), + [status, user, isAuthenticated, name, email, picture, error, signIn, signOut, getAccessToken] + ); + + return {children}; +} diff --git a/app/src/libs/auth/authConfig.ts b/app/src/libs/auth/authConfig.ts new file mode 100644 index 000000000..a10cb5b81 --- /dev/null +++ b/app/src/libs/auth/authConfig.ts @@ -0,0 +1,72 @@ +/** + * Sign-in configuration, read from build-time environment variables. + * + * Sign-in is inert unless the deployment sets all of: + * - VITE_AUTH_ENABLED=true (calculator-app: NEXT_PUBLIC_AUTH_ENABLED) + * - VITE_AUTH0_DOMAIN, VITE_AUTH0_CLIENT_ID, VITE_AUTH0_AUDIENCE + * (calculator-app: NEXT_PUBLIC_AUTH0_*) + * + * calculator-app maps the NEXT_PUBLIC_* names onto import.meta.env.VITE_* + * in next.config.ts, so shared code reads one set of names. Values are + * read at call time so tests can stub them. + */ + +export interface Auth0Config { + domain: string; + clientId: string; + /** The PolicyEngine API resource server; access tokens are minted for it. */ + audience: string; +} + +/** Refresh tokens need offline_access; profile and email feed the user menu. */ +export const AUTH_SCOPE = 'openid profile email offline_access'; + +function trimmed(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +export function isAuthFlagOn(): boolean { + return trimmed(import.meta.env.VITE_AUTH_ENABLED) === 'true'; +} + +/** The Auth0 settings, or null when sign-in is off or not fully configured. */ +export function getAuth0Config(): Auth0Config | null { + if (!isAuthFlagOn()) { + return null; + } + const domain = trimmed(import.meta.env.VITE_AUTH0_DOMAIN); + const clientId = trimmed(import.meta.env.VITE_AUTH0_CLIENT_ID); + const audience = trimmed(import.meta.env.VITE_AUTH0_AUDIENCE); + if (!domain || !clientId || !audience) { + return null; + } + return { domain, clientId, audience }; +} + +/** Where Auth0 sends the browser back to: `/{countryId}/callback`. */ +export function callbackPath(countryId: string): string { + return `/${countryId}/callback`; +} + +export function isCallbackPath(pathname: string): boolean { + return /^\/[a-z]{2}\/callback\/?$/.test(pathname); +} + +/** + * A same-origin path to land on after sign-in. Anything that is not a + * plain absolute path (another origin, a protocol-relative URL, the + * callback itself) falls back, so the redirect cannot leave the app. + */ +export function safeReturnTo(returnTo: unknown, fallback: string): string { + if (typeof returnTo !== 'string' || !returnTo.startsWith('/') || returnTo.startsWith('//')) { + return fallback; + } + if (returnTo.includes('\\')) { + return fallback; + } + const pathname = returnTo.split(/[?#]/)[0]; + if (isCallbackPath(pathname)) { + return fallback; + } + return returnTo; +} diff --git a/app/src/libs/auth/importedIds.ts b/app/src/libs/auth/importedIds.ts new file mode 100644 index 000000000..245589b26 --- /dev/null +++ b/app/src/libs/auth/importedIds.ts @@ -0,0 +1,69 @@ +/** + * Where this browser's localStorage items went when they were saved to an + * account: `client_ref` (the local ID, e.g. `sur-…`, `lfr-…`, `rf-…`) → + * the account's association UUID, per PolicyEngine user. Old links that + * carry a local ID keep resolving after sign-in through this map. Items + * the import skipped are remembered too, so the prompt does not offer + * them again. Nothing here deletes local data. + */ + +export const IMPORTED_ID_MAP_KEY = 'pe-imported-id-map'; +export const IMPORT_SKIPPED_KEY = 'pe-import-skipped'; + +type PerUser = Record>; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Association IDs on the server are UUIDs; local IDs never are. */ +export function isUuid(id: unknown): id is string { + return typeof id === 'string' && UUID_RE.test(id); +} + +function readPerUser(key: string): PerUser { + try { + const parsed = JSON.parse(localStorage.getItem(key) ?? '{}'); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function writePerUser(key: string, value: PerUser): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Storage full or unavailable: the import itself still succeeded. + } +} + +export function readImportedIdMap(userId: string): Record { + return { ...(readPerUser(IMPORTED_ID_MAP_KEY)[userId] ?? {}) }; +} + +export function recordImportedIds(userId: string, idMap: Record): void { + const all = readPerUser(IMPORTED_ID_MAP_KEY); + all[userId] = { ...(all[userId] ?? {}), ...idMap }; + writePerUser(IMPORTED_ID_MAP_KEY, all); +} + +export function readImportSkipped(userId: string): Record { + return { ...(readPerUser(IMPORT_SKIPPED_KEY)[userId] ?? {}) }; +} + +export function recordImportSkipped(userId: string, skipped: Record): void { + const all = readPerUser(IMPORT_SKIPPED_KEY); + all[userId] = { ...(all[userId] ?? {}), ...skipped }; + writePerUser(IMPORT_SKIPPED_KEY, all); +} + +/** + * The account association ID for an ID from a link or a local record: + * UUIDs pass through, imported local IDs map to their UUID, anything else + * is null. + */ +export function resolveImportedId(userId: string, id: string): string | null { + if (isUuid(id)) { + return id; + } + return readImportedIdMap(userId)[id] ?? null; +} diff --git a/app/src/libs/auth/useAssociationBackend.ts b/app/src/libs/auth/useAssociationBackend.ts new file mode 100644 index 000000000..a814af42e --- /dev/null +++ b/app/src/libs/auth/useAssociationBackend.ts @@ -0,0 +1,88 @@ +import { useMemo } from 'react'; +import { createV2UserClient, type V2UserClient } from '@/api/v2UserClient'; +import { MOCK_USER_ID } from '@/constants'; +import { useCountryContext } from '@/contexts/CountryContext'; +import { isAssociationCountry, type AssociationCountryId, type MeItem } from '@/types/v2User'; +import { useAuthStatus, type AuthStatus } from './AuthContext'; + +/** + * The single switch between account storage and localStorage. + * + * Saved work goes to the PolicyEngine API only when the user is signed in + * (and their PolicyEngine user has loaded) and the country has account + * storage (US, UK). Everything else — signed out, sign-in disabled, + * Canada, Nigeria, Israel — keeps the localStorage behaviour unchanged. + */ + +export type AssociationBackendMode = 'api' | 'localStorage'; + +export function selectAssociationBackend({ + status, + user, + countryId, +}: { + status: AuthStatus; + user: MeItem | null; + countryId: string | null | undefined; +}): AssociationBackendMode { + return status === 'authenticated' && !!user && isAssociationCountry(countryId) + ? 'api' + : 'localStorage'; +} + +export interface ApiAssociationBackend { + mode: 'api'; + ready: true; + /** The PolicyEngine user ID; query keys carry it so caches split per user. */ + userId: string; + countryId: AssociationCountryId; + client: V2UserClient; +} + +export interface LocalAssociationBackend { + mode: 'localStorage'; + /** False while the session is still being restored; queries wait for it. */ + ready: boolean; + /** MOCK_USER_ID: localStorage records are filed under it. Never sent to the API. */ + userId: string; + countryId: string | null; + client: null; +} + +export type AssociationBackend = ApiAssociationBackend | LocalAssociationBackend; + +/** A PolicyEngine API client bound to the current session's access token. */ +export function useV2UserClient(): V2UserClient { + const { getAccessToken } = useAuthStatus(); + return useMemo(() => createV2UserClient({ getAccessToken }), [getAccessToken]); +} + +export function useAssociationBackend(countryIdOverride?: string | null): AssociationBackend { + const contextCountry = useCountryContext(); + const countryId = countryIdOverride ?? contextCountry ?? null; + const { status, user } = useAuthStatus(); + const client = useV2UserClient(); + const userId = user?.id ?? null; + + return useMemo(() => { + if ( + userId && + isAssociationCountry(countryId) && + selectAssociationBackend({ status, user: { id: userId } as MeItem, countryId }) === 'api' + ) { + return { mode: 'api', ready: true, userId, countryId, client }; + } + return { + mode: 'localStorage', + ready: status !== 'loading', + userId: MOCK_USER_ID, + countryId, + client: null, + }; + }, [status, userId, countryId, client]); +} + +/** The user ID that saved work is filed under right now. */ +export function useCurrentUserId(): string { + return useAssociationBackend().userId; +} diff --git a/app/src/pages/AuthCallback.page.tsx b/app/src/pages/AuthCallback.page.tsx new file mode 100644 index 000000000..c34ca353e --- /dev/null +++ b/app/src/pages/AuthCallback.page.tsx @@ -0,0 +1,70 @@ +import { useEffect } from 'react'; +import { Button, Spinner, Stack, Text, Title } from '@/components/ui'; +import { useAppNavigate } from '@/contexts/NavigationContext'; +import { colors, spacing } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useAuthStatus } from '@/libs/auth/AuthContext'; +import { wasSignInRedirectHandled } from '@/libs/auth/AuthProvider'; + +function hasSignInParams(search: string): boolean { + const params = new URLSearchParams(search); + return params.has('state') && (params.has('code') || params.has('error')); +} + +/** + * Where Auth0 sends the browser after sign-in. The AuthProvider finishes + * the code exchange and navigates to where sign-in started; this page + * shows progress meanwhile, and the error when sign-in did not complete. + */ +export default function AuthCallbackPage() { + const countryId = useCurrentCountry(); + const nav = useAppNavigate(); + const { status, error } = useAuthStatus(); + + useEffect(() => { + if (status === 'disabled') { + nav.replace(`/${countryId}`); + return; + } + // A visit without sign-in parameters (a reload, a bookmark) has nothing to finish. + if ( + status !== 'loading' && + !error && + !wasSignInRedirectHandled() && + !hasSignInParams(window.location.search) + ) { + nav.replace(`/${countryId}`); + } + }, [status, error, countryId, nav]); + + if (error && status !== 'authenticated') { + return ( + + + Sign-in did not complete + + {error.message} + + + ); + } + + return ( + + + Signing you in… + + ); +} diff --git a/app/src/pages/Policies.page.tsx b/app/src/pages/Policies.page.tsx index d2f0bc835..00b590cea 100644 --- a/app/src/pages/Policies.page.tsx +++ b/app/src/pages/Policies.page.tsx @@ -4,11 +4,12 @@ import { ColumnConfig, IngredientRecord, TextValue } from '@/components/columns' import { RenameIngredientModal } from '@/components/common/RenameIngredientModal'; import IngredientReadView from '@/components/IngredientReadView'; import { Stack } from '@/components/ui'; -import { CURRENT_YEAR, MOCK_USER_ID } from '@/constants'; +import { CURRENT_YEAR } from '@/constants'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useDisclosure } from '@/hooks/useDisclosure'; import { useUpdatePolicyAssociation, useUserPolicies } from '@/hooks/useUserPolicy'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import type { EditorMode } from '@/pages/reportBuilder/modals/policyCreation/types'; import { PolicyCreationModal } from '@/pages/reportBuilder/modals/PolicyCreationModal'; import { PolicyStateProps } from '@/types/pathwayState'; @@ -17,7 +18,7 @@ import { formatDate } from '@/utils/dateUtils'; import { getUserPolicyAvailability } from '@/utils/ingredientAvailability'; export default function PoliciesPage() { - const userId = MOCK_USER_ID.toString(); // TODO: Replace with actual user ID retrieval logic + const userId = useCurrentUserId(); const { data, isLoading, isError, error } = useUserPolicies(userId); const nav = useAppNavigate(); const countryId = useCurrentCountry(); diff --git a/app/src/pages/Populations.page.tsx b/app/src/pages/Populations.page.tsx index 0e52a24b4..7a742c647 100644 --- a/app/src/pages/Populations.page.tsx +++ b/app/src/pages/Populations.page.tsx @@ -3,12 +3,12 @@ import { BulletsValue, ColumnConfig, IngredientRecord, TextValue } from '@/compo import { RenameIngredientModal } from '@/components/common/RenameIngredientModal'; import IngredientReadView from '@/components/IngredientReadView'; import { Stack } from '@/components/ui'; -import { MOCK_USER_ID } from '@/constants'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useDisclosure } from '@/hooks/useDisclosure'; import { useUpdateGeographicAssociation, useUserGeographics } from '@/hooks/useUserGeographic'; import { useUpdateHouseholdAssociation, useUserHouseholds } from '@/hooks/useUserHousehold'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { countryIds } from '@/libs/countries'; import { getCountryDisplayName, getGeographyRegionTypeLabel } from '@/models/geography'; import { Household } from '@/models/Household'; @@ -21,7 +21,7 @@ import { } from '@/utils/ingredientAvailability'; export default function PopulationsPage() { - const userId = MOCK_USER_ID.toString(); // TODO: Replace with actual user ID retrieval logic + const userId = useCurrentUserId(); // TODO: Session storage hard-fixes "anonymous" as user ID; this should really just be anything const countryId = useCurrentCountry(); diff --git a/app/src/pages/Reports.page.tsx b/app/src/pages/Reports.page.tsx index ac23e8699..15d449d2c 100644 --- a/app/src/pages/Reports.page.tsx +++ b/app/src/pages/Reports.page.tsx @@ -13,12 +13,12 @@ import IngredientReadView from '@/components/IngredientReadView'; import { MultiSimOutputTypeCell } from '@/components/report/MultiSimReportOutputTypeCell'; import { ReportOutputTypeCell } from '@/components/report/ReportOutputTypeCell'; import { Stack } from '@/components/ui'; -import { MOCK_USER_ID } from '@/constants'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useDisclosure } from '@/hooks/useDisclosure'; import { useUpdateReportAssociation } from '@/hooks/useUserReportAssociations'; import { useUserReports } from '@/hooks/useUserReports'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { RootState } from '@/store'; import { useCacheMonitor } from '@/utils/cacheMonitor'; import { formatDate } from '@/utils/dateUtils'; @@ -26,7 +26,7 @@ import { getReportConfigPath } from '@/utils/reportRouting'; import { CURRENT_LAW_LABEL } from './reportBuilder/currentLaw'; export default function ReportsPage() { - const userId = MOCK_USER_ID.toString(); // TODO: Replace with actual user ID retrieval logic + const userId = useCurrentUserId(); const { data, isLoading, isError, error } = useUserReports(userId); const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); const cacheMonitor = useCacheMonitor(); diff --git a/app/src/pages/Simulations.page.tsx b/app/src/pages/Simulations.page.tsx index 2f10ababb..2dc363906 100644 --- a/app/src/pages/Simulations.page.tsx +++ b/app/src/pages/Simulations.page.tsx @@ -3,16 +3,16 @@ import { ColumnConfig, IngredientRecord, TextValue } from '@/components/columns' import { RenameIngredientModal } from '@/components/common/RenameIngredientModal'; import IngredientReadView from '@/components/IngredientReadView'; import { Stack } from '@/components/ui'; -import { MOCK_USER_ID } from '@/constants'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useDisclosure } from '@/hooks/useDisclosure'; import { useUpdateSimulationAssociation } from '@/hooks/useUserSimulationAssociations'; import { useUserSimulations } from '@/hooks/useUserSimulations'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { formatDate } from '@/utils/dateUtils'; export default function SimulationsPage() { - const userId = MOCK_USER_ID.toString(); // TODO: Replace with actual user ID retrieval logic + const userId = useCurrentUserId(); const { data, isLoading, isError, error } = useUserSimulations(userId); const nav = useAppNavigate(); const countryId = useCurrentCountry(); diff --git a/app/src/pages/reportBuilder/hooks/useModifyReportSubmission.ts b/app/src/pages/reportBuilder/hooks/useModifyReportSubmission.ts index 381932924..2d287e51b 100644 --- a/app/src/pages/reportBuilder/hooks/useModifyReportSubmission.ts +++ b/app/src/pages/reportBuilder/hooks/useModifyReportSubmission.ts @@ -14,9 +14,10 @@ import { useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import { ReportAdapter } from '@/adapters'; import { createReport as createBaseReport, createReportAndAssociateWithUser } from '@/api/report'; -import { MOCK_USER_ID } from '@/constants'; import { useCalcOrchestratorManager } from '@/contexts/CalcOrchestratorContext'; -import { useUpdateReportAssociation } from '@/hooks/useUserReportAssociations'; +import { useUpdateReportAssociation, useUserReportStore } from '@/hooks/useUserReportAssociations'; +import { useUserSimulationStore } from '@/hooks/useUserSimulationAssociations'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { reportAssociationKeys, reportKeys } from '@/libs/queryKeys'; import { RootState } from '@/store'; import { Report } from '@/types/ingredients/Report'; @@ -51,6 +52,9 @@ export function useModifyReportSubmission({ const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); const manager = useCalcOrchestratorManager(); const updateReportAssociation = useUpdateReportAssociation(); + const reportStore = useUserReportStore(); + const simulationStore = useUserSimulationStore(); + const userId = useCurrentUserId(); const queryClient = useQueryClient(); const [submissionError, setSubmissionError] = useState(null); const [isSavingNew, setIsSavingNew] = useState(false); @@ -68,6 +72,8 @@ export function useModifyReportSubmission({ countryId, currentLawId, reportYear: reportState.year, + simulationStore, + userId, }); const reportPayload = ReportAdapter.toCreationPayload({ @@ -78,7 +84,7 @@ export function useModifyReportSubmission({ } as Report); return { simulationIds, simulations, simulationStates, reportPayload }; - }, [reportState, countryId, currentLawId]); + }, [reportState, countryId, currentLawId, simulationStore, userId]); /** * Shared logic: start calculation after a report is created. @@ -166,8 +172,9 @@ export function useModifyReportSubmission({ const result = await createReportAndAssociateWithUser({ countryId, payload: reportPayload, - userId: MOCK_USER_ID, + userId, label: label || undefined, + reportStore, }); queryClient.invalidateQueries({ queryKey: reportKeys.all }); @@ -192,6 +199,8 @@ export function useModifyReportSubmission({ queryClient, startCalculation, onSuccess, + userId, + reportStore, ] ); diff --git a/app/src/pages/reportBuilder/hooks/useReportIngredientAvailability.ts b/app/src/pages/reportBuilder/hooks/useReportIngredientAvailability.ts index 3407d1401..0864c7da9 100644 --- a/app/src/pages/reportBuilder/hooks/useReportIngredientAvailability.ts +++ b/app/src/pages/reportBuilder/hooks/useReportIngredientAvailability.ts @@ -1,9 +1,9 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { MOCK_USER_ID } from '@/constants'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useUserHouseholds } from '@/hooks/useUserHousehold'; import { useUserPolicies } from '@/hooks/useUserPolicy'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import type { RootState } from '@/store'; import { hasRequiredSimulationIngredients, @@ -30,7 +30,7 @@ export function useReportIngredientAvailability(reportState: ReportBuilderState) const spmSelectionError = useReportSPMSelectionError(reportState); const countryId = useCurrentCountry(); const metadata = useSelector((state: RootState) => state.metadata); - const userId = MOCK_USER_ID.toString(); + const userId = useCurrentUserId(); const { data: policies, isLoading: policiesLoading } = useUserPolicies(userId); const { data: households, isLoading: householdsLoading } = useUserHouseholds(userId); diff --git a/app/src/pages/reportBuilder/hooks/useReportSubmission.ts b/app/src/pages/reportBuilder/hooks/useReportSubmission.ts index 0545445fb..7cc8bb183 100644 --- a/app/src/pages/reportBuilder/hooks/useReportSubmission.ts +++ b/app/src/pages/reportBuilder/hooks/useReportSubmission.ts @@ -14,6 +14,8 @@ import { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; import { ReportAdapter } from '@/adapters'; import { useCreateReport } from '@/hooks/useCreateReport'; +import { useUserSimulationStore } from '@/hooks/useUserSimulationAssociations'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { RootState } from '@/store'; import { Report } from '@/types/ingredients/Report'; import { trackReportStarted } from '@/utils/analytics'; @@ -55,6 +57,8 @@ export function useReportSubmission({ const [submissionError, setSubmissionError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const { createReport } = useCreateReport(reportState.label || undefined); + const simulationStore = useUserSimulationStore(); + const userId = useCurrentUserId(); const { isReportConfigured } = useReportIngredientAvailability(reportState); const handleSubmit = useCallback(async () => { @@ -75,6 +79,8 @@ export function useReportSubmission({ countryId, currentLawId, reportYear: reportState.year, + simulationStore, + userId, }); journeyProfiler?.markEnd?.('report-submit-simulations', 'api-call'); @@ -140,6 +146,8 @@ export function useReportSubmission({ currentLawId, createReport, onSuccess, + simulationStore, + userId, ]); return { handleSubmit, isSubmitting, isReportConfigured, submissionError }; diff --git a/app/src/pages/reportBuilder/hooks/useSimulationCanvas.ts b/app/src/pages/reportBuilder/hooks/useSimulationCanvas.ts index 55e2762db..e9c7a1b3f 100644 --- a/app/src/pages/reportBuilder/hooks/useSimulationCanvas.ts +++ b/app/src/pages/reportBuilder/hooks/useSimulationCanvas.ts @@ -12,11 +12,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { geographyUsageStore, householdUsageStore } from '@/api/usageTracking'; -import { MOCK_USER_ID } from '@/constants'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useRegions } from '@/hooks/useRegions'; import { useUserHouseholds } from '@/hooks/useUserHousehold'; import { useUserPolicies } from '@/hooks/useUserPolicy'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { Geography } from '@/types/ingredients/Geography'; import { PolicyStateProps, PopulationStateProps } from '@/types/pathwayState'; import { countPolicyModifications } from '@/utils/countParameterChanges'; @@ -61,7 +61,7 @@ const REGION_LOAD_TIMEOUT_MS = 10_000; export function useSimulationCanvas({ reportState, setReportState }: UseSimulationCanvasArgs) { const countryId = useCurrentCountry() as 'us' | 'uk'; - const userId = MOCK_USER_ID.toString(); + const userId = useCurrentUserId(); const { data: policies, isLoading: policiesLoading, diff --git a/app/src/pages/reportBuilder/modals/HouseholdCreationModal.tsx b/app/src/pages/reportBuilder/modals/HouseholdCreationModal.tsx index 5de15ed5e..8e6b9f57b 100644 --- a/app/src/pages/reportBuilder/modals/HouseholdCreationModal.tsx +++ b/app/src/pages/reportBuilder/modals/HouseholdCreationModal.tsx @@ -8,11 +8,11 @@ import { Group } from '@/components/ui/Group'; import { Spinner } from '@/components/ui/Spinner'; import { Stack } from '@/components/ui/Stack'; import { Text } from '@/components/ui/Text'; -import { MOCK_USER_ID } from '@/constants'; import { colors, spacing } from '@/designTokens'; import { useCreateHousehold } from '@/hooks/useCreateHousehold'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useHouseholdAssociation, useUpdateHouseholdAssociation } from '@/hooks/useUserHousehold'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { getBasicInputFields } from '@/libs/metadataUtils'; import { Household as HouseholdModel } from '@/models/Household'; import { EditableLabel } from '@/pages/reportBuilder/components/EditableLabel'; @@ -49,7 +49,7 @@ export function HouseholdCreationModal({ forceReadOnly = false, }: HouseholdCreationModalProps) { const countryId = useCurrentCountry() as 'us' | 'uk'; - const userId = MOCK_USER_ID.toString(); + const userId = useCurrentUserId(); const metadata = useSelector((state: RootState) => state.metadata); const basicInputFields = useSelector(getBasicInputFields); const updateHouseholdAssociation = useUpdateHouseholdAssociation(); diff --git a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx index bb6e45c55..ebd4ac4c2 100644 --- a/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyBrowseModal.tsx @@ -10,9 +10,9 @@ import { useSelector } from 'react-redux'; import { Group } from '@/components/ui/Group'; import { Stack } from '@/components/ui/Stack'; import { Text } from '@/components/ui/Text'; -import { MOCK_USER_ID } from '@/constants'; import { colors, spacing } from '@/designTokens'; import { useUpdatePolicyAssociation, useUserPolicies } from '@/hooks/useUserPolicy'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { RootState } from '@/store'; import { PolicyStateProps } from '@/types/pathwayState'; import { countPolicyModifications } from '@/utils/countParameterChanges'; @@ -40,7 +40,7 @@ export function PolicyBrowseModal({ onCreateNew, onEditPolicy, }: PolicyBrowseModalProps) { - const userId = MOCK_USER_ID.toString(); + const userId = useCurrentUserId(); const { data: policies, isLoading } = useUserPolicies(userId); const { parameterTree, parameters } = useSelector((state: RootState) => state.metadata); const updatePolicyAssociation = useUpdatePolicyAssociation(); diff --git a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx index e8700cd4e..cc7d66d00 100644 --- a/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx +++ b/app/src/pages/reportBuilder/modals/PolicyCreationModal.tsx @@ -24,11 +24,11 @@ import { Group } from '@/components/ui/Group'; import { Spinner } from '@/components/ui/Spinner'; import { Stack } from '@/components/ui/Stack'; import { Text } from '@/components/ui/Text'; -import { MOCK_USER_ID } from '@/constants'; import { colors, spacing } from '@/designTokens'; import { useCreatePolicy } from '@/hooks/useCreatePolicy'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { usePolicyAssociation, useUpdatePolicyAssociation } from '@/hooks/useUserPolicy'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { getDateRange, selectSearchableParameters } from '@/libs/metadataUtils'; import { EditableLabel } from '@/pages/reportBuilder/components/EditableLabel'; import { ValueSetterMode } from '@/pathways/report/components/valueSetters'; @@ -93,7 +93,7 @@ export function PolicyCreationModal({ forceReadOnly = false, }: PolicyCreationModalProps) { const countryId = useCurrentCountry(); - const userId = MOCK_USER_ID.toString(); + const userId = useCurrentUserId(); // Get metadata from Redux state const metadata = useSelector((state: RootState) => state.metadata); diff --git a/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx index eb16ff44f..fd6677034 100644 --- a/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx @@ -17,11 +17,11 @@ import { UKOutlineIcon, USOutlineIcon } from '@/components/icons/CountryOutlineI import { Group } from '@/components/ui/Group'; import { Stack } from '@/components/ui/Stack'; import { Text } from '@/components/ui/Text'; -import { MOCK_USER_ID } from '@/constants'; import { colors, spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useRegions } from '@/hooks/useRegions'; import { useUserHouseholds } from '@/hooks/useUserHousehold'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { Geography } from '@/types/ingredients/Geography'; import { PopulationStateProps } from '@/types/pathwayState'; import { generateGeographyLabel } from '@/utils/geographyUtils'; @@ -57,7 +57,7 @@ export function PopulationBrowseModal({ allowedPopulationType, }: PopulationBrowseModalProps) { const countryId = useCurrentCountry() as 'us' | 'uk'; - const userId = MOCK_USER_ID.toString(); + const userId = useCurrentUserId(); const { data: households, isLoading: householdsLoading } = useUserHouseholds(userId); const { data: regions = [] } = useRegions(countryId); diff --git a/app/src/pages/reportBuilder/utils/createReportSimulations.ts b/app/src/pages/reportBuilder/utils/createReportSimulations.ts index 22eea69df..25eb89318 100644 --- a/app/src/pages/reportBuilder/utils/createReportSimulations.ts +++ b/app/src/pages/reportBuilder/utils/createReportSimulations.ts @@ -1,7 +1,7 @@ import { SimulationAdapter } from '@/adapters'; import { createHousehold } from '@/api/household'; import { createSimulation } from '@/api/simulation'; -import { LocalStorageSimulationStore } from '@/api/simulationAssociation'; +import { LocalStorageSimulationStore, type UserSimulationStore } from '@/api/simulationAssociation'; import { MOCK_USER_ID } from '@/constants'; import type { Household } from '@/models/Household'; import { Simulation } from '@/types/ingredients/Simulation'; @@ -18,6 +18,10 @@ interface CreateReportSimulationsArgs { countryId: 'us' | 'uk'; currentLawId: number; reportYear?: string; + /** Where simulation associations are saved (useUserSimulationStore); localStorage by default. */ + simulationStore?: UserSimulationStore; + /** Who they are filed under (useCurrentUserId); the local user by default. */ + userId?: string; } interface CreatedReportSimulations { @@ -31,6 +35,8 @@ export async function createReportSimulations({ countryId, currentLawId, reportYear, + simulationStore = new LocalStorageSimulationStore(), + userId = MOCK_USER_ID, }: CreateReportSimulationsArgs): Promise { const populationError = getReportPopulationError(simulationStates); if (populationError) { @@ -81,7 +87,6 @@ export async function createReportSimulations({ }); } - const simulationStore = new LocalStorageSimulationStore(); const simulationIds: string[] = []; const simulations: Simulation[] = []; @@ -114,7 +119,7 @@ export async function createReportSimulations({ } await simulationStore.create({ - userId: MOCK_USER_ID, + userId, simulationId, countryId, label: simulationState.label ?? undefined, diff --git a/app/src/pathways/report/views/policy/PolicyExistingView.tsx b/app/src/pathways/report/views/policy/PolicyExistingView.tsx index 30adf0478..45369a0c2 100644 --- a/app/src/pathways/report/views/policy/PolicyExistingView.tsx +++ b/app/src/pathways/report/views/policy/PolicyExistingView.tsx @@ -6,12 +6,12 @@ import { useState } from 'react'; import PathwayView from '@/components/common/PathwayView'; -import { MOCK_USER_ID } from '@/constants'; import { isPolicyWithAssociation, UserPolicyWithAssociation, useUserPolicies, } from '@/hooks/useUserPolicy'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { Parameter } from '@/types/subIngredients/parameter'; import { getUserPolicyAvailability, isUserPolicySelectable } from '@/utils/ingredientAvailability'; @@ -30,7 +30,7 @@ export default function PolicyExistingView({ onBack, onCancel, }: PolicyExistingViewProps) { - const userId = MOCK_USER_ID.toString(); + const userId = useCurrentUserId(); const { data, isLoading, isError, error } = useUserPolicies(userId); const userPolicies = data || []; diff --git a/app/src/pathways/report/views/population/PopulationExistingView.tsx b/app/src/pathways/report/views/population/PopulationExistingView.tsx index 008715855..27ff4ec32 100644 --- a/app/src/pathways/report/views/population/PopulationExistingView.tsx +++ b/app/src/pathways/report/views/population/PopulationExistingView.tsx @@ -6,7 +6,6 @@ import { useState } from 'react'; import PathwayView from '@/components/common/PathwayView'; -import { MOCK_USER_ID } from '@/constants'; import { isGeographicMetadataWithAssociation, UserGeographicMetadataWithAssociation, @@ -17,6 +16,7 @@ import { UserHouseholdMetadataWithAssociation, useUserHouseholds, } from '@/hooks/useUserHousehold'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { getCountryDisplayName } from '@/models/geography'; import { Household as HouseholdModel } from '@/models/Household'; import { Geography } from '@/types/ingredients/Geography'; @@ -56,7 +56,7 @@ export default function PopulationExistingView({ onBack, onCancel, }: PopulationExistingViewProps) { - const userId = MOCK_USER_ID.toString(); + const userId = useCurrentUserId(); // Fetch household populations const { diff --git a/app/src/pathways/report/views/simulation/SimulationPolicySetupView.tsx b/app/src/pathways/report/views/simulation/SimulationPolicySetupView.tsx index 8bb1b2e75..64c9ec794 100644 --- a/app/src/pathways/report/views/simulation/SimulationPolicySetupView.tsx +++ b/app/src/pathways/report/views/simulation/SimulationPolicySetupView.tsx @@ -6,8 +6,8 @@ import { useState } from 'react'; import PathwayView from '@/components/common/PathwayView'; -import { MOCK_USER_ID } from '@/constants'; import { useUserPolicies } from '@/hooks/useUserPolicy'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; type SetupAction = 'createNew' | 'loadExisting' | 'selectCurrentLaw'; @@ -30,7 +30,7 @@ export default function SimulationPolicySetupView({ onBack, onCancel, }: SimulationPolicySetupViewProps) { - const userId = MOCK_USER_ID.toString(); + const userId = useCurrentUserId(); const { data: userPolicies } = useUserPolicies(userId); const hasExistingPolicies = (userPolicies?.length ?? 0) > 0; diff --git a/app/src/pathways/report/views/simulation/SimulationPopulationSetupView.tsx b/app/src/pathways/report/views/simulation/SimulationPopulationSetupView.tsx index 3d2a008a5..b8bb51f05 100644 --- a/app/src/pathways/report/views/simulation/SimulationPopulationSetupView.tsx +++ b/app/src/pathways/report/views/simulation/SimulationPopulationSetupView.tsx @@ -6,9 +6,9 @@ import { useState } from 'react'; import PathwayView from '@/components/common/PathwayView'; -import { MOCK_USER_ID } from '@/constants'; import { useUserGeographics } from '@/hooks/useUserGeographic'; import { useUserHouseholds } from '@/hooks/useUserHousehold'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { PopulationStateProps, SimulationStateProps } from '@/types/pathwayState'; import { getPopulationLabel, getSimulationLabel } from '@/utils/populationCompatibility'; import { @@ -40,7 +40,7 @@ export default function SimulationPopulationSetupView({ onBack, onCancel, }: SimulationPopulationSetupViewProps) { - const userId = MOCK_USER_ID.toString(); + const userId = useCurrentUserId(); const { data: userHouseholds } = useUserHouseholds(userId); const { data: userGeographics } = useUserGeographics(userId); const hasExistingPopulations = (userHouseholds?.length ?? 0) + (userGeographics?.length ?? 0) > 0; diff --git a/app/src/pathways/simulation/SimulationPathwayWrapper.tsx b/app/src/pathways/simulation/SimulationPathwayWrapper.tsx index 5eacfeabb..40bb08e6e 100644 --- a/app/src/pathways/simulation/SimulationPathwayWrapper.tsx +++ b/app/src/pathways/simulation/SimulationPathwayWrapper.tsx @@ -8,7 +8,6 @@ import { useCallback, useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import FullScreenPortal from '@/components/FullScreenPortal'; -import { MOCK_USER_ID } from '@/constants'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { usePathwayNavigation } from '@/hooks/usePathwayNavigation'; @@ -16,6 +15,7 @@ import { useRegions } from '@/hooks/useRegions'; import { useUserGeographics } from '@/hooks/useUserGeographic'; import { useUserHouseholds } from '@/hooks/useUserHousehold'; import { useUserPolicies } from '@/hooks/useUserPolicy'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { CURRENT_LAW_LABEL } from '@/pages/reportBuilder/currentLaw'; import { RootState } from '@/store'; import { SimulationViewMode } from '@/types/pathwayModes/SimulationViewMode'; @@ -81,7 +81,7 @@ export default function SimulationPathwayWrapper({ onComplete }: SimulationPathw ); // ========== FETCH USER DATA FOR CONDITIONAL NAVIGATION ========== - const userId = MOCK_USER_ID.toString(); + const userId = useCurrentUserId(); const { data: userPolicies } = useUserPolicies(userId); const { data: userHouseholds } = useUserHouseholds(userId); const { data: userGeographics } = useUserGeographics(userId); diff --git a/app/src/tests/fixtures/adapters/UserReportAdapterMocks.ts b/app/src/tests/fixtures/adapters/UserReportAdapterMocks.ts deleted file mode 100644 index c95faae03..000000000 --- a/app/src/tests/fixtures/adapters/UserReportAdapterMocks.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { UserReport } from '@/types/ingredients/UserReport'; - -export const TEST_USER_IDS = { - USER_123: 'user-123', - USER_456: 'user-456', -} as const; - -export const TEST_REPORT_IDS = { - REPORT_456: 'report-456', - REPORT_789: 'report-789', -} as const; - -export const TEST_USER_REPORT_IDS = { - SUR_ABC123: 'sur-abc123', - SUR_XYZ789: 'sur-xyz789', -} as const; - -export const TEST_LABELS = { - TEST_REPORT: 'My Test Report', - ANOTHER_REPORT: 'Another Report', -} as const; - -export const mockUserReportInput = ( - overrides?: Partial> -): Omit => ({ - userId: TEST_USER_IDS.USER_123, - reportId: TEST_REPORT_IDS.REPORT_456, - countryId: 'us', - label: TEST_LABELS.TEST_REPORT, - updatedAt: '2025-01-15T12:00:00Z', - isCreated: true, - ...overrides, -}); - -export const mockUserReport = (overrides?: Partial): UserReport => ({ - ...mockUserReportInput(), - id: TEST_USER_REPORT_IDS.SUR_ABC123, - createdAt: '2025-01-01T00:00:00Z', - ...overrides, -}); - -export const mockApiResponse = (overrides?: any) => ({ - id: TEST_USER_REPORT_IDS.SUR_ABC123, - userId: TEST_USER_IDS.USER_123, - reportId: TEST_REPORT_IDS.REPORT_456, - label: TEST_LABELS.TEST_REPORT, - createdAt: '2025-01-01T00:00:00Z', - updatedAt: '2025-01-15T12:00:00Z', - ...overrides, -}); diff --git a/app/src/tests/fixtures/adapters/index.ts b/app/src/tests/fixtures/adapters/index.ts deleted file mode 100644 index 83a811aff..000000000 --- a/app/src/tests/fixtures/adapters/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Central export for all adapter-related test fixtures - */ - -export * from './userAssociationMocks'; diff --git a/app/src/tests/fixtures/adapters/userAssociationMocks.ts b/app/src/tests/fixtures/adapters/userAssociationMocks.ts deleted file mode 100644 index 689ec08b9..000000000 --- a/app/src/tests/fixtures/adapters/userAssociationMocks.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { UserPolicy } from '@/types/ingredients/UserPolicy'; -import { UserReport } from '@/types/ingredients/UserReport'; -import { UserSimulation } from '@/types/ingredients/UserSimulation'; -import { UserPolicyMetadata } from '@/types/metadata/userPolicyMetadata'; -import { - UserPolicyCreationPayload, - UserReportCreationPayload, - UserSimulationCreationPayload, -} from '@/types/payloads'; -import { - TEST_COUNTRIES, - TEST_LABELS, - TEST_POLICY_IDS, - TEST_REPORT_IDS, - TEST_SIMULATION_IDS, - TEST_TIMESTAMPS, - TEST_USER_IDS, -} from '../constants'; - -// UserPolicy fixtures -export const mockUserPolicyUS: UserPolicy = { - id: TEST_POLICY_IDS.POLICY_789, // UserPolicyAdapter uses policyId as the id - userId: TEST_USER_IDS.USER_123, - policyId: TEST_POLICY_IDS.POLICY_789, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_POLICY, - createdAt: TEST_TIMESTAMPS.CREATED_AT, - updatedAt: TEST_TIMESTAMPS.UPDATED_AT, - isCreated: true, -}; - -export const mockUserPolicyUK: UserPolicy = { - ...mockUserPolicyUS, - id: TEST_POLICY_IDS.POLICY_ABC, - policyId: TEST_POLICY_IDS.POLICY_ABC, - countryId: TEST_COUNTRIES.UK, -}; - -export const mockUserPolicyWithoutOptionalFields: Omit = { - userId: TEST_USER_IDS.USER_123, - policyId: TEST_POLICY_IDS.POLICY_789, - countryId: TEST_COUNTRIES.US, - isCreated: true, -}; - -export const mockUserPolicyCreationPayload: UserPolicyCreationPayload = { - user_id: TEST_USER_IDS.USER_123, - policy_id: TEST_POLICY_IDS.POLICY_789, - country_id: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_POLICY, - updated_at: TEST_TIMESTAMPS.UPDATED_AT, -}; - -export const mockUserPolicyApiResponse: UserPolicyMetadata = { - policy_id: TEST_POLICY_IDS.POLICY_789, - user_id: TEST_USER_IDS.USER_123, - country_id: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_POLICY, - created_at: TEST_TIMESTAMPS.CREATED_AT, - updated_at: TEST_TIMESTAMPS.UPDATED_AT, -}; - -// UserSimulation fixtures -export const mockUserSimulationUS: UserSimulation = { - id: 'us-sim-def', - userId: TEST_USER_IDS.USER_123, - simulationId: TEST_SIMULATION_IDS.SIM_DEF, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_SIMULATION, - createdAt: TEST_TIMESTAMPS.CREATED_AT, - updatedAt: TEST_TIMESTAMPS.UPDATED_AT, - isCreated: true, -}; - -export const mockUserSimulationUK: UserSimulation = { - ...mockUserSimulationUS, - id: 'us-sim-ghi', - simulationId: TEST_SIMULATION_IDS.SIM_GHI, - countryId: TEST_COUNTRIES.UK, -}; - -export const mockUserSimulationWithoutOptionalFields: Omit = { - userId: TEST_USER_IDS.USER_123, - simulationId: TEST_SIMULATION_IDS.SIM_DEF, - countryId: TEST_COUNTRIES.US, - isCreated: true, -}; - -export const mockUserSimulationCreationPayload: UserSimulationCreationPayload = { - userId: TEST_USER_IDS.USER_123, - simulationId: TEST_SIMULATION_IDS.SIM_DEF, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_SIMULATION, - updatedAt: TEST_TIMESTAMPS.UPDATED_AT, -}; - -export const mockUserSimulationApiResponse = { - id: 'us-sim-def', - userId: TEST_USER_IDS.USER_123, - simulationId: TEST_SIMULATION_IDS.SIM_DEF, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_SIMULATION, - createdAt: TEST_TIMESTAMPS.CREATED_AT, - updatedAt: TEST_TIMESTAMPS.UPDATED_AT, -}; - -// UserReport fixtures -export const mockUserReportUS: UserReport = { - id: 'ur-report-jkl', - userId: TEST_USER_IDS.USER_123, - reportId: TEST_REPORT_IDS.REPORT_JKL, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_REPORT, - createdAt: TEST_TIMESTAMPS.CREATED_AT, - updatedAt: TEST_TIMESTAMPS.UPDATED_AT, - isCreated: true, -}; - -export const mockUserReportUK: UserReport = { - ...mockUserReportUS, - id: 'ur-report-mno', - reportId: TEST_REPORT_IDS.REPORT_MNO, - countryId: TEST_COUNTRIES.UK, -}; - -export const mockUserReportWithoutOptionalFields: Omit = { - userId: TEST_USER_IDS.USER_123, - reportId: TEST_REPORT_IDS.REPORT_JKL, - countryId: TEST_COUNTRIES.US, - isCreated: true, -}; - -export const mockUserReportCreationPayload: UserReportCreationPayload = { - userId: TEST_USER_IDS.USER_123, - reportId: TEST_REPORT_IDS.REPORT_JKL, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_REPORT, - updatedAt: TEST_TIMESTAMPS.UPDATED_AT, -}; - -export const mockUserReportApiResponse = { - id: 'ur-report-jkl', - userId: TEST_USER_IDS.USER_123, - reportId: TEST_REPORT_IDS.REPORT_JKL, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_REPORT, - createdAt: TEST_TIMESTAMPS.CREATED_AT, - updatedAt: TEST_TIMESTAMPS.UPDATED_AT, -}; diff --git a/app/src/tests/fixtures/api/v2UserMocks.ts b/app/src/tests/fixtures/api/v2UserMocks.ts new file mode 100644 index 000000000..b0bdddfd3 --- /dev/null +++ b/app/src/tests/fixtures/api/v2UserMocks.ts @@ -0,0 +1,156 @@ +import { vi } from 'vitest'; +import type { ApiStoreContext } from '@/api/v2Associations'; +import { createV2UserClient } from '@/api/v2UserClient'; +import type { + MeItem, + UserHouseholdItem, + UserPolicyItem, + UserReportItem, + UserSimulationItem, +} from '@/types/v2User'; + +export const TEST_API_BASE = 'https://api.test.policyengine.org'; +export const TEST_ACCESS_TOKEN = 'test-access-token'; +export const PE_USER_ID = '7c1e5a8e-2b1f-4c55-9a3e-1f2d3c4b5a69'; +export const OTHER_PE_USER_ID = '0b9a8c7d-6e5f-4a3b-8c2d-1e0f9a8b7c6d'; + +export const ASSOCIATION_IDS = { + POLICY: '11111111-1111-4111-8111-111111111111', + HOUSEHOLD: '22222222-2222-4222-8222-222222222222', + SIMULATION: '33333333-3333-4333-8333-333333333333', + REPORT: '44444444-4444-4444-8444-444444444444', + REFORM: '55555555-5555-4555-8555-555555555555', +} as const; + +export const LEGACY_IDS = { + POLICY: 95762, + HOUSEHOLD: 123, + SIMULATION: 613, + REPORT: 615, +} as const; + +export const TIMESTAMPS = { + CREATED: '2026-10-12T10:00:00+00:00', + UPDATED: '2026-10-12T11:00:00+00:00', +} as const; + +export const mockMeItem: MeItem = { + id: PE_USER_ID, + primary_country: 'us', + first_name: 'Ada', + last_name: 'Lovelace', + email: 'ada@example.org', + created_at: TIMESTAMPS.CREATED, +}; + +const base = { + country_id: 'us' as const, + user_id: PE_USER_ID, + name: null, + description: null, + client_ref: null, + created_at: TIMESTAMPS.CREATED, + updated_at: TIMESTAMPS.UPDATED, +}; + +export function policyItem(overrides: Partial = {}): UserPolicyItem { + return { + ...base, + id: ASSOCIATION_IDS.POLICY, + policy_id: null, + legacy_policy_id: LEGACY_IDS.POLICY, + baseline: null, + provenance: null, + ...overrides, + }; +} + +export function householdItem(overrides: Partial = {}): UserHouseholdItem { + return { + ...base, + id: ASSOCIATION_IDS.HOUSEHOLD, + household_id: null, + legacy_household_id: LEGACY_IDS.HOUSEHOLD, + ...overrides, + }; +} + +export function simulationItem(overrides: Partial = {}): UserSimulationItem { + return { + ...base, + id: ASSOCIATION_IDS.SIMULATION, + simulation_id: null, + legacy_simulation_id: LEGACY_IDS.SIMULATION, + ...overrides, + }; +} + +export function reportItem(overrides: Partial = {}): UserReportItem { + return { + ...base, + id: ASSOCIATION_IDS.REPORT, + report_id: null, + legacy_report_id: LEGACY_IDS.REPORT, + user_policy_id: null, + source_note: null, + provisions: null, + validation: null, + last_run_at: null, + ...overrides, + }; +} + +/** `{"status": "ok", "message": null, "result": …}` as a fetch Response. */ +export function okResponse(result: unknown, status = 200): Response { + return new Response(JSON.stringify({ status: 'ok', message: null, result }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +export function itemResponse(item: unknown, status = 200): Response { + return okResponse({ item }, status); +} + +export function pageResponse(items: unknown[], { offset = 0, hasMore = false } = {}): Response { + return okResponse({ items, offset, limit: 500, has_more: hasMore }); +} + +export function errorResponse(status: number, message: string): Response { + return new Response(JSON.stringify({ status: 'error', message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +export function noContentResponse(): Response { + return new Response(null, { status: 204 }); +} + +/** A fetch mock plus an API store context that uses it. */ +export function createTestApiContext( + countryId: 'us' | 'uk' = 'us', + userId: string = PE_USER_ID +): { fetchMock: ReturnType; context: ApiStoreContext } { + const fetchMock = vi.fn(); + const client = createV2UserClient({ + getAccessToken: async () => TEST_ACCESS_TOKEN, + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + return { fetchMock, context: { client, countryId, userId } }; +} + +/** The (url, init) of the nth fetch call, with the parsed URL and JSON body. */ +export function fetchCall(fetchMock: ReturnType, index = 0) { + const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit]; + const parsed = new URL(url); + return { + url: parsed, + path: parsed.pathname, + query: Object.fromEntries(parsed.searchParams.entries()), + method: init.method, + headers: init.headers as Record, + body: init.body ? JSON.parse(String(init.body)) : undefined, + }; +} diff --git a/app/src/tests/fixtures/index.ts b/app/src/tests/fixtures/index.ts index 0e67a21ea..62f52eb5f 100644 --- a/app/src/tests/fixtures/index.ts +++ b/app/src/tests/fixtures/index.ts @@ -3,4 +3,3 @@ */ export * from './constants'; -export * from './adapters'; diff --git a/app/src/tests/fixtures/libs/auth/authMocks.tsx b/app/src/tests/fixtures/libs/auth/authMocks.tsx new file mode 100644 index 000000000..ed15a17f7 --- /dev/null +++ b/app/src/tests/fixtures/libs/auth/authMocks.tsx @@ -0,0 +1,54 @@ +import { vi } from 'vitest'; +import { AuthContext, type AuthContextValue } from '@/libs/auth/AuthContext'; +import { mockMeItem, TEST_ACCESS_TOKEN } from '@/tests/fixtures/api/v2UserMocks'; + +export const TEST_AUTH0_CONFIG = { + domain: 'auth.test.policyengine.org', + clientId: 'test-client-id', + audience: 'https://api.test.policyengine.org/', +} as const; + +/** Stubs the build-time sign-in environment (undo with vi.unstubAllEnvs()). */ +export function stubAuthEnv( + enabled: boolean, + config: Partial = TEST_AUTH0_CONFIG +): void { + vi.stubEnv('VITE_AUTH_ENABLED', enabled ? 'true' : ''); + vi.stubEnv('VITE_AUTH0_DOMAIN', config.domain ?? ''); + vi.stubEnv('VITE_AUTH0_CLIENT_ID', config.clientId ?? ''); + vi.stubEnv('VITE_AUTH0_AUDIENCE', config.audience ?? ''); +} + +export function makeAuthValue(overrides: Partial = {}): AuthContextValue { + return { + status: 'anonymous', + user: null, + profile: null, + error: null, + signIn: vi.fn().mockResolvedValue(undefined), + signOut: vi.fn().mockResolvedValue(undefined), + getAccessToken: vi.fn().mockResolvedValue(null), + ...overrides, + }; +} + +/** A signed-in session whose PolicyEngine user has loaded. */ +export function signedInAuthValue(overrides: Partial = {}): AuthContextValue { + return makeAuthValue({ + status: 'authenticated', + user: mockMeItem, + profile: { name: 'Ada Lovelace', email: mockMeItem.email, picture: null }, + getAccessToken: vi.fn().mockResolvedValue(TEST_ACCESS_TOKEN), + ...overrides, + }); +} + +export function AuthTestProvider({ + value, + children, +}: { + value: AuthContextValue; + children: React.ReactNode; +}) { + return {children}; +} diff --git a/app/src/tests/unit/adapters/UserHouseholdAdapter.test.ts b/app/src/tests/unit/adapters/UserHouseholdAdapter.test.ts new file mode 100644 index 000000000..f975dc6c8 --- /dev/null +++ b/app/src/tests/unit/adapters/UserHouseholdAdapter.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'vitest'; +import { UserHouseholdAdapter } from '@/adapters/UserHouseholdAdapter'; +import { + ASSOCIATION_IDS, + householdItem, + LEGACY_IDS, + PE_USER_ID, +} from '@/tests/fixtures/api/v2UserMocks'; + +describe('UserHouseholdAdapter', () => { + test('given a household association then the creation payload carries legacy_household_id', () => { + expect( + UserHouseholdAdapter.toCreationPayload( + { householdId: String(LEGACY_IDS.HOUSEHOLD), label: 'Family' }, + 'us' + ) + ).toEqual({ country_id: 'us', legacy_household_id: LEGACY_IDS.HOUSEHOLD, name: 'Family' }); + }); + + test('given a replacement household then the patch repoints legacy_household_id', () => { + expect(UserHouseholdAdapter.toUpdatePayload({ householdId: '456' })).toEqual({ + legacy_household_id: 456, + }); + }); + + test('given an API item then it maps to a household population', () => { + expect(UserHouseholdAdapter.fromApiItem(householdItem({ name: 'Family' }))).toMatchObject({ + type: 'household', + id: ASSOCIATION_IDS.HOUSEHOLD, + userId: PE_USER_ID, + householdId: String(LEGACY_IDS.HOUSEHOLD), + label: 'Family', + isCreated: true, + }); + }); +}); diff --git a/app/src/tests/unit/adapters/UserPolicyAdapter.test.ts b/app/src/tests/unit/adapters/UserPolicyAdapter.test.ts index 24376a223..8ef5bbe7a 100644 --- a/app/src/tests/unit/adapters/UserPolicyAdapter.test.ts +++ b/app/src/tests/unit/adapters/UserPolicyAdapter.test.ts @@ -1,174 +1,71 @@ import { describe, expect, test } from 'vitest'; import { UserPolicyAdapter } from '@/adapters/UserPolicyAdapter'; import { - mockUserPolicyApiResponse, - mockUserPolicyCreationPayload, - mockUserPolicyUS, - mockUserPolicyWithoutOptionalFields, - TEST_COUNTRIES, - TEST_LABELS, - TEST_POLICY_IDS, - TEST_TIMESTAMPS, - TEST_USER_IDS, -} from '@/tests/fixtures'; -import { UserPolicy } from '@/types/ingredients/UserPolicy'; + ASSOCIATION_IDS, + LEGACY_IDS, + PE_USER_ID, + policyItem, + TIMESTAMPS, +} from '@/tests/fixtures/api/v2UserMocks'; describe('UserPolicyAdapter', () => { - describe('toCreationPayload', () => { - test('given UserPolicy with all fields then creates proper payload', () => { - // Given - const userPolicy: Omit = { - userId: TEST_USER_IDS.USER_123, - policyId: TEST_POLICY_IDS.POLICY_789, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_POLICY, - updatedAt: TEST_TIMESTAMPS.UPDATED_AT, - isCreated: true, - }; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result).toEqual(mockUserPolicyCreationPayload); - }); - - test('given UserPolicy without updatedAt then generates timestamp', () => { - // Given - const userPolicy: Omit = { - userId: TEST_USER_IDS.USER_123, - policyId: TEST_POLICY_IDS.POLICY_789, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_POLICY, - isCreated: true, - }; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result.user_id).toBe(TEST_USER_IDS.USER_123); - expect(result.policy_id).toBe(TEST_POLICY_IDS.POLICY_789); - expect(result.country_id).toBe(TEST_COUNTRIES.US); - expect(result.label).toBe(TEST_LABELS.MY_POLICY); - expect(result.updated_at).toBeDefined(); - expect(new Date(result.updated_at as string).toISOString()).toBe(result.updated_at); - }); - - test('given UserPolicy with numeric IDs then converts to strings', () => { - // Given - const userPolicy: Omit = { - userId: 123 as any, - policyId: 456 as any, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_POLICY, - isCreated: true, - }; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result.user_id).toBe('123'); - expect(result.policy_id).toBe('456'); - expect(result.country_id).toBe(TEST_COUNTRIES.US); - }); - - test('given UserPolicy without label then includes undefined label', () => { - // Given - const userPolicy = mockUserPolicyWithoutOptionalFields; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result.label).toBeUndefined(); - expect(result.country_id).toBe(TEST_COUNTRIES.US); - }); - - test('given UserPolicy with UK country then preserves country ID', () => { - // Given - const userPolicy: Omit = { - userId: TEST_USER_IDS.USER_123, - policyId: TEST_POLICY_IDS.POLICY_ABC, - countryId: TEST_COUNTRIES.UK, - label: TEST_LABELS.MY_POLICY, - isCreated: true, - }; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result.country_id).toBe(TEST_COUNTRIES.UK); + test('given a policy association then the creation payload carries the numeric v1 id and no user id', () => { + // When + const payload = UserPolicyAdapter.toCreationPayload( + { policyId: String(LEGACY_IDS.POLICY), label: 'My reform' }, + 'uk' + ); + + // Then + expect(payload).toEqual({ + country_id: 'uk', + legacy_policy_id: LEGACY_IDS.POLICY, + name: 'My reform', }); }); - describe('fromApiResponse', () => { - test('given API response with all fields then creates UserPolicy', () => { - // Given - const apiData = mockUserPolicyApiResponse; - - // When - const result = UserPolicyAdapter.fromApiResponse(apiData); - - // Then - expect(result).toEqual(mockUserPolicyUS); - }); - - test('given API response without optional fields then creates UserPolicy with defaults', () => { - // Given - const apiData = { - policy_id: TEST_POLICY_IDS.POLICY_789, - user_id: TEST_USER_IDS.USER_123, - country_id: TEST_COUNTRIES.US, - }; + test('given no label then the creation payload omits the name', () => { + // When + const payload = UserPolicyAdapter.toCreationPayload({ policyId: '12' }, 'us'); - // When - const result = UserPolicyAdapter.fromApiResponse(apiData); - - // Then - expect(result.id).toBe(TEST_POLICY_IDS.POLICY_789); - expect(result.userId).toBe(TEST_USER_IDS.USER_123); - expect(result.policyId).toBe(TEST_POLICY_IDS.POLICY_789); - expect(result.countryId).toBe(TEST_COUNTRIES.US); - expect(result.label).toBeUndefined(); - expect(result.createdAt).toBeUndefined(); - expect(result.updatedAt).toBeUndefined(); - expect(result.isCreated).toBe(true); - }); + // Then + expect(payload).toEqual({ country_id: 'us', legacy_policy_id: 12 }); + }); - test('given API response with null label then converts to undefined', () => { - // Given - const apiData = { - policy_id: TEST_POLICY_IDS.POLICY_789, - user_id: TEST_USER_IDS.USER_123, - country_id: TEST_COUNTRIES.US, - label: null, - created_at: TEST_TIMESTAMPS.CREATED_AT, - updated_at: TEST_TIMESTAMPS.UPDATED_AT, - }; + test('given a non-numeric policy id then building the payload throws', () => { + expect(() => UserPolicyAdapter.toCreationPayload({ policyId: 'policy-789' }, 'us')).toThrow(); + }); - // When - const result = UserPolicyAdapter.fromApiResponse(apiData); + test('given updates then only supplied fields are patched', () => { + expect(UserPolicyAdapter.toUpdatePayload({ label: 'Renamed' })).toEqual({ name: 'Renamed' }); + expect(UserPolicyAdapter.toUpdatePayload({ policyId: '7' })).toEqual({ legacy_policy_id: 7 }); + expect(UserPolicyAdapter.toUpdatePayload({ label: '' })).toEqual({ name: null }); + expect(UserPolicyAdapter.toUpdatePayload({})).toEqual({}); + }); - // Then - expect(result.label).toBeUndefined(); - expect(result.countryId).toBe(TEST_COUNTRIES.US); + test('given an API item then it maps to a UserPolicy with the UUID as id', () => { + // When + const policy = UserPolicyAdapter.fromApiItem(policyItem({ name: 'My reform' })); + + // Then + expect(policy).toEqual({ + id: ASSOCIATION_IDS.POLICY, + userId: PE_USER_ID, + policyId: String(LEGACY_IDS.POLICY), + countryId: 'us', + label: 'My reform', + createdAt: TIMESTAMPS.CREATED, + updatedAt: TIMESTAMPS.UPDATED, + isCreated: true, }); + }); - test('given API response with UK country then preserves country ID', () => { - // Given - const apiData = { - ...mockUserPolicyApiResponse, - country_id: TEST_COUNTRIES.UK, - }; - - // When - const result = UserPolicyAdapter.fromApiResponse(apiData); + test('given an item without a name then the label is undefined', () => { + expect(UserPolicyAdapter.fromApiItem(policyItem()).label).toBeUndefined(); + }); - // Then - expect(result.countryId).toBe(TEST_COUNTRIES.UK); - }); + test('given a v2-only row then it has no legacy policy', () => { + expect(UserPolicyAdapter.hasLegacyPolicy(policyItem({ legacy_policy_id: null }))).toBe(false); + expect(UserPolicyAdapter.hasLegacyPolicy(policyItem())).toBe(true); }); }); diff --git a/app/src/tests/unit/adapters/UserReportAdapter.test.ts b/app/src/tests/unit/adapters/UserReportAdapter.test.ts index 5ef6d148e..6ea4019bc 100644 --- a/app/src/tests/unit/adapters/UserReportAdapter.test.ts +++ b/app/src/tests/unit/adapters/UserReportAdapter.test.ts @@ -1,117 +1,43 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, test } from 'vitest'; import { UserReportAdapter } from '@/adapters/UserReportAdapter'; import { - mockApiResponse, - mockUserReportInput, - TEST_LABELS, - TEST_REPORT_IDS, - TEST_USER_IDS, - TEST_USER_REPORT_IDS, -} from '@/tests/fixtures/adapters/UserReportAdapterMocks'; + ASSOCIATION_IDS, + LEGACY_IDS, + PE_USER_ID, + reportItem, + TIMESTAMPS, +} from '@/tests/fixtures/api/v2UserMocks'; describe('UserReportAdapter', () => { - describe('toCreationPayload', () => { - it('given user report then converts to API payload', () => { - // Given - const userReport = mockUserReportInput(); - - // When - const payload = UserReportAdapter.toCreationPayload(userReport); - - // Then - expect(payload).toEqual({ - userId: TEST_USER_IDS.USER_123, - reportId: TEST_REPORT_IDS.REPORT_456, - countryId: 'us', - label: TEST_LABELS.TEST_REPORT, - updatedAt: '2025-01-15T12:00:00Z', - }); - }); - - it('given no updatedAt then uses current timestamp', () => { - // Given - const userReport = mockUserReportInput({ updatedAt: undefined }); - - const beforeTime = new Date().toISOString(); - - // When - const payload = UserReportAdapter.toCreationPayload(userReport); - - const afterTime = new Date().toISOString(); - - // Then - expect(payload.updatedAt).toBeDefined(); - expect(payload.updatedAt!.length).toBeGreaterThan(0); - // updatedAt should be between beforeTime and afterTime - expect(payload.updatedAt! >= beforeTime).toBe(true); - expect(payload.updatedAt! <= afterTime).toBe(true); - }); - - it('given numeric IDs then converts to strings', () => { - // Given - const userReport: any = { - userId: 123, - reportId: 456, - label: 'Test', - isCreated: true, - }; - - // When - const payload = UserReportAdapter.toCreationPayload(userReport); - - // Then - expect(payload.userId).toBe('123'); - expect(payload.reportId).toBe('456'); - expect(typeof payload.userId).toBe('string'); - expect(typeof payload.reportId).toBe('string'); - }); + test('given a report association then the creation payload carries legacy_report_id', () => { + expect( + UserReportAdapter.toCreationPayload( + { reportId: String(LEGACY_IDS.REPORT), label: 'Saved report' }, + 'us' + ) + ).toEqual({ country_id: 'us', legacy_report_id: LEGACY_IDS.REPORT, name: 'Saved report' }); }); - describe('fromApiResponse', () => { - it('given API response then converts to UserReport', () => { - // Given - const apiData = mockApiResponse(); - - // When - const userReport = UserReportAdapter.fromApiResponse(apiData); - - // Then - expect(userReport).toEqual({ - id: TEST_USER_REPORT_IDS.SUR_ABC123, - userId: TEST_USER_IDS.USER_123, - reportId: TEST_REPORT_IDS.REPORT_456, - label: TEST_LABELS.TEST_REPORT, - createdAt: '2025-01-01T00:00:00Z', - updatedAt: '2025-01-15T12:00:00Z', - isCreated: true, - }); + test('given a replaced base report then the patch repoints legacy_report_id', () => { + expect(UserReportAdapter.toUpdatePayload({ reportId: '999' })).toEqual({ + legacy_report_id: 999, }); + }); - it('given API response then always sets isCreated to true', () => { - // Given - const apiData = mockApiResponse({ - id: TEST_USER_REPORT_IDS.SUR_XYZ789, - userId: TEST_USER_IDS.USER_456, - reportId: TEST_REPORT_IDS.REPORT_789, - label: TEST_LABELS.ANOTHER_REPORT, - }); - - // When - const userReport = UserReportAdapter.fromApiResponse(apiData); - - // Then - expect(userReport.isCreated).toBe(true); - }); - - it('given missing updatedAt then includes undefined', () => { - // Given - const apiData = mockApiResponse({ updatedAt: undefined }); - - // When - const userReport = UserReportAdapter.fromApiResponse(apiData); + test('given a rename then the patch sets the name', () => { + expect(UserReportAdapter.toUpdatePayload({ label: 'Renamed' })).toEqual({ name: 'Renamed' }); + }); - // Then - expect(userReport.updatedAt).toBeUndefined(); + test('given an API item then it maps to a UserReport with the UUID as id', () => { + expect(UserReportAdapter.fromApiItem(reportItem({ name: 'Saved report' }))).toEqual({ + id: ASSOCIATION_IDS.REPORT, + userId: PE_USER_ID, + reportId: String(LEGACY_IDS.REPORT), + countryId: 'us', + label: 'Saved report', + createdAt: TIMESTAMPS.CREATED, + updatedAt: TIMESTAMPS.UPDATED, + isCreated: true, }); }); }); diff --git a/app/src/tests/unit/adapters/UserSimulationAdapter.test.ts b/app/src/tests/unit/adapters/UserSimulationAdapter.test.ts index 2ddd1dcdb..814fc3ea1 100644 --- a/app/src/tests/unit/adapters/UserSimulationAdapter.test.ts +++ b/app/src/tests/unit/adapters/UserSimulationAdapter.test.ts @@ -1,176 +1,46 @@ import { describe, expect, test } from 'vitest'; import { UserSimulationAdapter } from '@/adapters/UserSimulationAdapter'; import { - mockUserSimulationApiResponse, - mockUserSimulationCreationPayload, - mockUserSimulationUS, - mockUserSimulationWithoutOptionalFields, - TEST_COUNTRIES, - TEST_LABELS, - TEST_SIMULATION_IDS, - TEST_TIMESTAMPS, - TEST_USER_IDS, -} from '@/tests/fixtures'; -import { UserSimulation } from '@/types/ingredients/UserSimulation'; + ASSOCIATION_IDS, + LEGACY_IDS, + PE_USER_ID, + simulationItem, + TIMESTAMPS, +} from '@/tests/fixtures/api/v2UserMocks'; describe('UserSimulationAdapter', () => { - describe('toCreationPayload', () => { - test('given UserSimulation with all fields then creates proper payload', () => { - // Given - const userSimulation: Omit = { - userId: TEST_USER_IDS.USER_123, - simulationId: TEST_SIMULATION_IDS.SIM_DEF, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_SIMULATION, - updatedAt: TEST_TIMESTAMPS.UPDATED_AT, - isCreated: true, - }; - - // When - const result = UserSimulationAdapter.toCreationPayload(userSimulation); - - // Then - expect(result).toEqual(mockUserSimulationCreationPayload); - }); - - test('given UserSimulation without updatedAt then generates timestamp', () => { - // Given - const userSimulation: Omit = { - userId: TEST_USER_IDS.USER_123, - simulationId: TEST_SIMULATION_IDS.SIM_DEF, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_SIMULATION, - isCreated: true, - }; - - // When - const result = UserSimulationAdapter.toCreationPayload(userSimulation); - - // Then - expect(result.userId).toBe(TEST_USER_IDS.USER_123); - expect(result.simulationId).toBe(TEST_SIMULATION_IDS.SIM_DEF); - expect(result.countryId).toBe(TEST_COUNTRIES.US); - expect(result.label).toBe(TEST_LABELS.MY_SIMULATION); - expect(result.updatedAt).toBeDefined(); - expect(new Date(result.updatedAt as string).toISOString()).toBe(result.updatedAt); - }); - - test('given UserSimulation with numeric IDs then converts to strings', () => { - // Given - const userSimulation: Omit = { - userId: 123 as any, - simulationId: 456 as any, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_SIMULATION, - isCreated: true, - }; - - // When - const result = UserSimulationAdapter.toCreationPayload(userSimulation); - - // Then - expect(result.userId).toBe('123'); - expect(result.simulationId).toBe('456'); - expect(result.countryId).toBe(TEST_COUNTRIES.US); - }); - - test('given UserSimulation without label then includes undefined label', () => { - // Given - const userSimulation = mockUserSimulationWithoutOptionalFields; - - // When - const result = UserSimulationAdapter.toCreationPayload(userSimulation); - - // Then - expect(result.label).toBeUndefined(); - expect(result.countryId).toBe(TEST_COUNTRIES.US); - }); - - test('given UserSimulation with UK country then preserves country ID', () => { - // Given - const userSimulation: Omit = { - userId: TEST_USER_IDS.USER_123, - simulationId: TEST_SIMULATION_IDS.SIM_GHI, - countryId: TEST_COUNTRIES.UK, - label: TEST_LABELS.MY_SIMULATION, - isCreated: true, - }; - - // When - const result = UserSimulationAdapter.toCreationPayload(userSimulation); - - // Then - expect(result.countryId).toBe(TEST_COUNTRIES.UK); + test('given a simulation association then the creation payload carries legacy_simulation_id', () => { + expect( + UserSimulationAdapter.toCreationPayload( + { simulationId: String(LEGACY_IDS.SIMULATION), label: 'Reform run' }, + 'uk' + ) + ).toEqual({ + country_id: 'uk', + legacy_simulation_id: LEGACY_IDS.SIMULATION, + name: 'Reform run', }); }); - describe('fromApiResponse', () => { - test('given API response with all fields then creates UserSimulation', () => { - // Given - const apiData = mockUserSimulationApiResponse; - - // When - const result = UserSimulationAdapter.fromApiResponse(apiData); - - // Then - expect(result).toEqual(mockUserSimulationUS); + test('given updates then only supplied fields are patched', () => { + expect(UserSimulationAdapter.toUpdatePayload({ label: 'Renamed' })).toEqual({ + name: 'Renamed', }); - - test('given API response without optional fields then creates UserSimulation with defaults', () => { - // Given - const apiData = { - id: 'us-sim-def', - simulationId: TEST_SIMULATION_IDS.SIM_DEF, - userId: TEST_USER_IDS.USER_123, - countryId: TEST_COUNTRIES.US, - }; - - // When - const result = UserSimulationAdapter.fromApiResponse(apiData); - - // Then - expect(result.id).toBe('us-sim-def'); - expect(result.userId).toBe(TEST_USER_IDS.USER_123); - expect(result.simulationId).toBe(TEST_SIMULATION_IDS.SIM_DEF); - expect(result.countryId).toBe(TEST_COUNTRIES.US); - expect(result.label).toBeUndefined(); - expect(result.createdAt).toBeUndefined(); - expect(result.updatedAt).toBeUndefined(); - expect(result.isCreated).toBe(true); + expect(UserSimulationAdapter.toUpdatePayload({ simulationId: '9' })).toEqual({ + legacy_simulation_id: 9, }); + }); - test('given API response with null label then preserves null', () => { - // Given - const apiData = { - id: 'us-sim-def', - simulationId: TEST_SIMULATION_IDS.SIM_DEF, - userId: TEST_USER_IDS.USER_123, - countryId: TEST_COUNTRIES.US, - label: null, - createdAt: TEST_TIMESTAMPS.CREATED_AT, - updatedAt: TEST_TIMESTAMPS.UPDATED_AT, - }; - - // When - const result = UserSimulationAdapter.fromApiResponse(apiData); - - // Then - expect(result.label).toBeNull(); - expect(result.countryId).toBe(TEST_COUNTRIES.US); - }); - - test('given API response with UK country then preserves country ID', () => { - // Given - const apiData = { - ...mockUserSimulationApiResponse, - countryId: TEST_COUNTRIES.UK, - }; - - // When - const result = UserSimulationAdapter.fromApiResponse(apiData); - - // Then - expect(result.countryId).toBe(TEST_COUNTRIES.UK); + test('given an API item then it maps to a UserSimulation', () => { + expect(UserSimulationAdapter.fromApiItem(simulationItem({ name: 'Reform run' }))).toEqual({ + id: ASSOCIATION_IDS.SIMULATION, + userId: PE_USER_ID, + simulationId: String(LEGACY_IDS.SIMULATION), + countryId: 'us', + label: 'Reform run', + createdAt: TIMESTAMPS.CREATED, + updatedAt: TIMESTAMPS.UPDATED, + isCreated: true, }); }); }); diff --git a/app/src/tests/unit/api/geographicAssociation.test.ts b/app/src/tests/unit/api/geographicAssociation.test.ts index 98a94753f..461ab6f0b 100644 --- a/app/src/tests/unit/api/geographicAssociation.test.ts +++ b/app/src/tests/unit/api/geographicAssociation.test.ts @@ -1,211 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ApiGeographicStore, LocalStorageGeographicStore } from '@/api/geographicAssociation'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LocalStorageGeographicStore } from '@/api/geographicAssociation'; import type { UserGeographyPopulation } from '@/types/ingredients/UserPopulation'; -// Mock fetch -global.fetch = vi.fn(); - -describe('ApiGeographicStore', () => { - let store: ApiGeographicStore; - - const mockPopulation: UserGeographyPopulation = { - userId: 'user-123', - geographyId: 'geo-456', - countryId: 'us', - label: 'Test Geography', - type: 'geography', - scope: 'subnational', - createdAt: '2025-01-01T00:00:00Z', - }; - - const mockApiResponse = { - user_id: 'user-123', - geography_id: 'geo-456', - country_id: 'us', - label: 'Test Geography', - scope: 'subnational', - created_at: '2025-01-01T00:00:00Z', - updated_at: '2025-01-01T00:00:00Z', - }; - - beforeEach(() => { - store = new ApiGeographicStore(); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - describe('create', () => { - it('given valid population then creates geographic association', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: true, - json: async () => mockApiResponse, - }); - - // When - const result = await store.create(mockPopulation); - - // Then - expect(fetch).toHaveBeenCalledWith( - '/api/user-geographic-associations', - expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }) - ); - expect(result).toMatchObject({ - userId: 'user-123', - geographyId: 'geo-456', - countryId: 'us', - label: 'Test Geography', - scope: 'subnational', - }); - }); - - it('given API error then throws error', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: false, - status: 500, - }); - - // When/Then - await expect(store.create(mockPopulation)).rejects.toThrow( - 'Failed to create geographic association' - ); - }); - }); - - describe('findByUser', () => { - it('given valid user ID then fetches user associations', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: true, - json: async () => [mockApiResponse], - }); - - // When - const result = await store.findByUser('user-123'); - - // Then - expect(fetch).toHaveBeenCalledWith( - '/api/user-geographic-associations/user/user-123', - expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, - }) - ); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - userId: 'user-123', - geographyId: 'geo-456', - countryId: 'us', - label: 'Test Geography', - scope: 'subnational', - }); - }); - - it('given API error then throws error', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: false, - status: 500, - }); - - // When/Then - await expect(store.findByUser('user-123')).rejects.toThrow( - 'Failed to fetch user associations' - ); - }); - }); - - describe('findById', () => { - it('given valid IDs then fetches specific association', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: true, - status: 200, - json: async () => mockApiResponse, - }); - - // When - const result = await store.findById('user-123', 'geo-456'); - - // Then - expect(fetch).toHaveBeenCalledWith( - '/api/user-geographic-associations/user-123/geo-456', - expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, - }) - ); - expect(result).toMatchObject({ - userId: 'user-123', - geographyId: 'geo-456', - countryId: 'us', - label: 'Test Geography', - scope: 'subnational', - }); - }); - - it('given 404 response then returns null', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: false, - status: 404, - }); - - // When - const result = await store.findById('user-123', 'nonexistent'); - - // Then - expect(result).toBeNull(); - }); - - it('given other error then throws error', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: false, - status: 500, - }); - - // When/Then - await expect(store.findById('user-123', 'geo-456')).rejects.toThrow( - 'Failed to fetch association' - ); - }); - }); - - describe('update', () => { - it('given update called then throws not supported error', async () => { - // Given & When & Then - await expect(store.update('user-123', 'geo-456', { label: 'Updated Label' })).rejects.toThrow( - 'Please ensure you are using localStorage mode' - ); - }); - - it('given update called then logs warning', async () => { - // Given - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - // When - try { - await store.update('user-123', 'geo-456', { label: 'Updated Label' }); - } catch { - // Expected to throw - } - - // Then - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('API endpoint not yet implemented') - ); - - consoleWarnSpy.mockRestore(); - }); - }); -}); - describe('LocalStorageGeographicStore', () => { let store: LocalStorageGeographicStore; let mockLocalStorage: { [key: string]: string }; diff --git a/app/src/tests/unit/api/householdAssociation.test.ts b/app/src/tests/unit/api/householdAssociation.test.ts index 0f039d0f5..42ff17f3f 100644 --- a/app/src/tests/unit/api/householdAssociation.test.ts +++ b/app/src/tests/unit/api/householdAssociation.test.ts @@ -1,214 +1,10 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { ApiHouseholdStore, LocalStorageHouseholdStore } from '@/api/householdAssociation'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { LocalStorageHouseholdStore } from '@/api/householdAssociation'; import { - mockApiResponse, - mockApiResponseList, mockUserHouseholdPopulation, mockUserHouseholdPopulationList, } from '@/tests/fixtures/api/householdAssociationMocks'; -global.fetch = vi.fn(); - -describe('ApiHouseholdStore', () => { - let store: ApiHouseholdStore; - - beforeEach(() => { - vi.clearAllMocks(); - store = new ApiHouseholdStore(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('create', () => { - test('given valid household association then creates successfully', async () => { - // Given - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue(mockApiResponse), - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - // When - const result = await store.create(mockUserHouseholdPopulation); - - // Then - expect(global.fetch).toHaveBeenCalledWith( - '/api/user-household-associations', - expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }) - ); - expect(result).toMatchObject({ - householdId: '123', - userId: 'user-456', - countryId: 'us', - label: 'My Test Household', - }); - }); - - test('given API returns error then throws error', async () => { - // Given - const mockResponse = { - ok: false, - status: 400, - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - // When/Then - await expect(store.create(mockUserHouseholdPopulation)).rejects.toThrow( - 'Failed to create household association' - ); - }); - - test('given network error then propagates error', async () => { - // Given - const networkError = new Error('Network failure'); - (global.fetch as any).mockRejectedValue(networkError); - - // When/Then - await expect(store.create(mockUserHouseholdPopulation)).rejects.toThrow('Network failure'); - }); - }); - - describe('findByUser', () => { - test('given valid user ID then returns list of households', async () => { - // Given - const userId = 'user-456'; - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue(mockApiResponseList), - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - // When - const result = await store.findByUser(userId); - - // Then - expect(global.fetch).toHaveBeenCalledWith(`/api/user-household-associations/user/${userId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - expect(result).toEqual(mockUserHouseholdPopulationList); - }); - - test('given user with no households then returns empty array', async () => { - // Given - const userId = 'user-with-no-households'; - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue([]), - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - // When - const result = await store.findByUser(userId); - - // Then - expect(result).toEqual([]); - }); - - test('given API returns error then throws error', async () => { - // Given - const userId = 'user-456'; - const mockResponse = { - ok: false, - status: 500, - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - // When/Then - await expect(store.findByUser(userId)).rejects.toThrow('Failed to fetch user households'); - }); - }); - - describe('findById', () => { - test('given valid user and household IDs then returns household', async () => { - // Given - const userId = 'user-456'; - const householdId = 'household-123'; - const mockResponse = { - ok: true, - status: 200, - json: vi.fn().mockResolvedValue(mockApiResponse), - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - // When - const result = await store.findById(userId, householdId); - - // Then - expect(global.fetch).toHaveBeenCalledWith( - `/api/user-household-associations/${userId}/${householdId}`, - { headers: { 'Content-Type': 'application/json' } } - ); - expect(result).toEqual(mockUserHouseholdPopulation); - }); - - test('given non-existent association then returns null', async () => { - // Given - const userId = 'user-456'; - const householdId = 'non-existent'; - const mockResponse = { - ok: false, - status: 404, - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - // When - const result = await store.findById(userId, householdId); - - // Then - expect(result).toBeNull(); - }); - - test('given server error then throws error', async () => { - // Given - const userId = 'user-456'; - const householdId = 'household-123'; - const mockResponse = { - ok: false, - status: 500, - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - // When/Then - await expect(store.findById(userId, householdId)).rejects.toThrow( - 'Failed to fetch association' - ); - }); - }); - - describe('update', () => { - test('given update called then throws not supported error', async () => { - // Given & When & Then - await expect(store.update('suh-abc123', { label: 'Updated Label' })).rejects.toThrow( - 'Please ensure you are using localStorage mode' - ); - }); - - test('given update called then logs warning', async () => { - // Given - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - // When - try { - await store.update('suh-abc123', { label: 'Updated Label' }); - } catch { - // Expected to throw - } - - // Then - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('API endpoint not yet implemented') - ); - - consoleWarnSpy.mockRestore(); - }); - }); -}); - describe('LocalStorageHouseholdStore', () => { let store: LocalStorageHouseholdStore; let mockLocalStorage: { [key: string]: string }; diff --git a/app/src/tests/unit/api/policyAssociation.test.ts b/app/src/tests/unit/api/policyAssociation.test.ts index 532526bca..ef37b3eae 100644 --- a/app/src/tests/unit/api/policyAssociation.test.ts +++ b/app/src/tests/unit/api/policyAssociation.test.ts @@ -1,206 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ApiPolicyStore, LocalStoragePolicyStore } from '@/api/policyAssociation'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LocalStoragePolicyStore } from '@/api/policyAssociation'; import type { UserPolicy } from '@/types/ingredients/UserPolicy'; -// Mock fetch -global.fetch = vi.fn(); - -describe('ApiPolicyStore', () => { - let store: ApiPolicyStore; - - const mockPolicyInput: Omit = { - userId: 'user-123', - policyId: 'policy-456', - countryId: 'us', - label: 'Test Policy', - isCreated: true, - }; - - const mockApiResponse = { - id: 'policy-456', - user_id: 'user-123', - policy_id: 'policy-456', - country_id: 'us', - label: 'Test Policy', - created_at: '2025-01-01T00:00:00Z', - updated_at: '2025-01-01T00:00:00Z', - }; - - beforeEach(() => { - store = new ApiPolicyStore(); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - describe('create', () => { - it('given valid policy then creates policy association', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: true, - json: async () => mockApiResponse, - }); - - // When - const result = await store.create(mockPolicyInput); - - // Then - expect(fetch).toHaveBeenCalledWith( - '/api/user-policy-associations', - expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }) - ); - expect(result).toMatchObject({ - userId: 'user-123', - policyId: 'policy-456', - countryId: 'us', - label: 'Test Policy', - }); - }); - - it('given API error then throws error', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: false, - status: 500, - }); - - // When/Then - await expect(store.create(mockPolicyInput)).rejects.toThrow( - 'Failed to create policy association' - ); - }); - }); - - describe('findByUser', () => { - it('given valid user ID then fetches user policy associations', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: true, - json: async () => [mockApiResponse], - }); - - // When - const result = await store.findByUser('user-123'); - - // Then - expect(fetch).toHaveBeenCalledWith( - '/api/user-policy-associations/user/user-123', - expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, - }) - ); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - userId: 'user-123', - policyId: 'policy-456', - countryId: 'us', - label: 'Test Policy', - }); - }); - - it('given API error then throws error', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: false, - status: 500, - }); - - // When/Then - await expect(store.findByUser('user-123')).rejects.toThrow( - 'Failed to fetch user associations' - ); - }); - }); - - describe('findById', () => { - it('given valid IDs then fetches specific association', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: true, - status: 200, - json: async () => mockApiResponse, - }); - - // When - const result = await store.findById('user-123', 'policy-456'); - - // Then - expect(fetch).toHaveBeenCalledWith( - '/api/user-policy-associations/user-123/policy-456', - expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, - }) - ); - expect(result).toMatchObject({ - userId: 'user-123', - policyId: 'policy-456', - countryId: 'us', - label: 'Test Policy', - }); - }); - - it('given 404 response then returns null', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: false, - status: 404, - }); - - // When - const result = await store.findById('user-123', 'nonexistent'); - - // Then - expect(result).toBeNull(); - }); - - it('given other error then throws error', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: false, - status: 500, - }); - - // When/Then - await expect(store.findById('user-123', 'policy-456')).rejects.toThrow( - 'Failed to fetch association' - ); - }); - }); - - describe('update', () => { - it('given update called then throws not supported error', async () => { - // Given & When & Then - await expect(store.update('sup-abc123', { label: 'Updated Label' })).rejects.toThrow( - 'Please ensure you are using localStorage mode' - ); - }); - - it('given update called then logs warning', async () => { - // Given - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - // When - try { - await store.update('sup-abc123', { label: 'Updated Label' }); - } catch { - // Expected to throw - } - - // Then - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('API endpoint not yet implemented') - ); - - consoleWarnSpy.mockRestore(); - }); - }); -}); - describe('LocalStoragePolicyStore', () => { let store: LocalStoragePolicyStore; let mockLocalStorage: { [key: string]: string }; diff --git a/app/src/tests/unit/api/reportAssociation.test.ts b/app/src/tests/unit/api/reportAssociation.test.ts index d247da2c1..d410098be 100644 --- a/app/src/tests/unit/api/reportAssociation.test.ts +++ b/app/src/tests/unit/api/reportAssociation.test.ts @@ -1,332 +1,15 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { UserReportAdapter } from '@/adapters/UserReportAdapter'; -import { ApiReportStore, LocalStorageReportStore } from '@/api/reportAssociation'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LocalStorageReportStore } from '@/api/reportAssociation'; import { - mockErrorFetchResponse, - mockMultiCountryApiResponses, mockMultiCountryReportList, - mockReport, - mockReportApiResponse, mockReportInput, - mockSuccessFetchResponse, - mockUserReport, TEST_COUNTRIES, TEST_LABELS, TEST_REPORT_IDS, TEST_USER_ID, TEST_USER_IDS, - TEST_USER_REPORT_IDS, } from '@/tests/fixtures/api/reportAssociationMocks'; -// Mock the adapter -vi.mock('@/adapters/UserReportAdapter'); - -// Mock fetch -global.fetch = vi.fn(); - -describe('ApiReportStore', () => { - let store: ApiReportStore; - - beforeEach(() => { - store = new ApiReportStore(); - vi.clearAllMocks(); - (UserReportAdapter.toCreationPayload as any).mockReturnValue(mockReportApiResponse()); - (UserReportAdapter.fromApiResponse as any).mockReturnValue(mockReport()); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - describe('create', () => { - it('given valid report then creates report association', async () => { - (global.fetch as any).mockResolvedValue(mockSuccessFetchResponse(mockReportApiResponse())); - - const result = await store.create(mockReportInput()); - - expect(fetch).toHaveBeenCalledWith( - '/api/user-report-associations', - expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(mockReportApiResponse()), - }) - ); - expect(result).toEqual(mockReport()); - }); - - it('given API error then throws error', async () => { - (global.fetch as any).mockResolvedValue(mockErrorFetchResponse(500)); - - await expect(store.create(mockReportInput())).rejects.toThrow( - 'Failed to create report association' - ); - }); - }); - - describe('findByUser', () => { - it('given valid user ID then fetches user report associations', async () => { - (global.fetch as any).mockResolvedValue(mockSuccessFetchResponse([mockReportApiResponse()])); - - const result = await store.findByUser(TEST_USER_IDS.USER_123); - - expect(fetch).toHaveBeenCalledWith( - `/api/user-report-associations/user/${TEST_USER_IDS.USER_123}`, - expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, - }) - ); - expect(result).toEqual([mockReport()]); - }); - - it('given API error then throws error', async () => { - (global.fetch as any).mockResolvedValue(mockErrorFetchResponse(500)); - - await expect(store.findByUser(TEST_USER_IDS.USER_123)).rejects.toThrow( - 'Failed to fetch user associations' - ); - }); - - test('given countryId filter then returns only matching reports', async () => { - // Given - (UserReportAdapter.fromApiResponse as any).mockImplementation((data: any) => { - return mockMultiCountryReportList.find((r) => r.reportId === data.reportId); - }); - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue(mockMultiCountryApiResponses), - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - // When - const result = await store.findByUser(TEST_USER_ID, TEST_COUNTRIES.US); - - // Then - expect(result).toHaveLength(2); - expect(result.every((r) => r.countryId === TEST_COUNTRIES.US)).toBe(true); - expect(result.map((r) => r.label)).toEqual(['US Report 1', 'US Report 2']); - }); - - test('given countryId filter for UK then returns only UK reports', async () => { - // Given - (UserReportAdapter.fromApiResponse as any).mockImplementation((data: any) => { - return mockMultiCountryReportList.find((r) => r.reportId === data.reportId); - }); - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue(mockMultiCountryApiResponses), - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - // When - const result = await store.findByUser(TEST_USER_ID, TEST_COUNTRIES.UK); - - // Then - expect(result).toHaveLength(1); - expect(result[0].countryId).toBe(TEST_COUNTRIES.UK); - expect(result[0].label).toBe('UK Report 1'); - }); - - test('given no countryId filter then returns all reports', async () => { - // Given - const mockResponse = { - ok: false, - status: 500, - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - await expect(store.findByUser(TEST_USER_IDS.USER_123)).rejects.toThrow( - 'Failed to fetch user associations' - ); - }); - - test('given countryId filter then returns only matching reports', async () => { - // Given - (UserReportAdapter.fromApiResponse as any).mockImplementation((data: any) => { - return mockMultiCountryReportList.find((r) => r.reportId === data.reportId); - }); - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue(mockMultiCountryApiResponses), - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - // When - const result = await store.findByUser(TEST_USER_ID, TEST_COUNTRIES.US); - - // Then - expect(result).toHaveLength(2); - expect(result.every((r) => r.countryId === TEST_COUNTRIES.US)).toBe(true); - expect(result.map((r) => r.label)).toEqual(['US Report 1', 'US Report 2']); - }); - - test('given countryId filter for UK then returns only UK reports', async () => { - // Given - (UserReportAdapter.fromApiResponse as any).mockImplementation((data: any) => { - return mockMultiCountryReportList.find((r) => r.reportId === data.reportId); - }); - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue(mockMultiCountryApiResponses), - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - // When - const result = await store.findByUser(TEST_USER_ID, TEST_COUNTRIES.UK); - - // Then - expect(result).toHaveLength(1); - expect(result[0].countryId).toBe(TEST_COUNTRIES.UK); - expect(result[0].label).toBe('UK Report 1'); - }); - - test('given no countryId filter then returns all reports', async () => { - // Given - (UserReportAdapter.fromApiResponse as any).mockImplementation((data: any) => { - return mockMultiCountryReportList.find((r) => r.reportId === data.reportId); - }); - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue(mockMultiCountryApiResponses), - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - // When - const result = await store.findByUser(TEST_USER_ID); - - // Then - expect(result).toHaveLength(4); - expect(result.map((r) => r.countryId)).toEqual([ - TEST_COUNTRIES.US, - TEST_COUNTRIES.US, - TEST_COUNTRIES.UK, - TEST_COUNTRIES.CA, - ]); - }); - }); - - describe('findById', () => { - test('given valid user and report IDs then returns report', async () => { - // Given - // Mock the adapter to properly convert each API response - (UserReportAdapter.fromApiResponse as any).mockImplementation((apiResponse: any) => ({ - ...mockUserReport, - countryId: apiResponse.countryId || apiResponse.country_id, - reportId: apiResponse.reportId || apiResponse.report_id, - })); - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue(mockMultiCountryApiResponses), - }; - (global.fetch as any).mockResolvedValue(mockResponse); - - // When - const result = await store.findByUser(TEST_USER_ID); - - // Then - expect(result).toHaveLength(4); - expect(result.map((r) => r.countryId)).toEqual([ - TEST_COUNTRIES.US, - TEST_COUNTRIES.US, - TEST_COUNTRIES.UK, - TEST_COUNTRIES.CA, - ]); - }); - }); - - describe('findById', () => { - it('given valid IDs then fetches specific association', async () => { - (global.fetch as any).mockResolvedValue(mockSuccessFetchResponse(mockReportApiResponse())); - - const result = await store.findById(TEST_USER_IDS.USER_123, TEST_REPORT_IDS.REPORT_456); - - expect(fetch).toHaveBeenCalledWith( - `/api/user-report-associations/${TEST_USER_IDS.USER_123}/${TEST_REPORT_IDS.REPORT_456}`, - expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, - }) - ); - expect(result).toEqual(mockReport()); - }); - - it('given 404 response then returns null', async () => { - (global.fetch as any).mockResolvedValue(mockErrorFetchResponse(404)); - - const result = await store.findById(TEST_USER_IDS.USER_123, 'nonexistent'); - - expect(result).toBeNull(); - }); - - it('given other error then throws error', async () => { - (global.fetch as any).mockResolvedValue(mockErrorFetchResponse(500)); - - await expect( - store.findById(TEST_USER_IDS.USER_123, TEST_REPORT_IDS.REPORT_456) - ).rejects.toThrow('Failed to fetch association'); - }); - }); - - describe('findByUserReportId', () => { - it('given valid user report ID then fetches report', async () => { - (global.fetch as any).mockResolvedValue(mockSuccessFetchResponse(mockReportApiResponse())); - - const result = await store.findByUserReportId(TEST_USER_REPORT_IDS.SUR_ABC123); - - expect(fetch).toHaveBeenCalledWith( - `/api/user-report-associations/${TEST_USER_REPORT_IDS.SUR_ABC123}`, - expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, - }) - ); - expect(result).toEqual(mockReport()); - }); - - it('given 404 response then returns null', async () => { - (global.fetch as any).mockResolvedValue(mockErrorFetchResponse(404)); - - const result = await store.findByUserReportId('nonexistent'); - - expect(result).toBeNull(); - }); - - it('given other error then throws error', async () => { - (global.fetch as any).mockResolvedValue(mockErrorFetchResponse(500)); - - await expect(store.findByUserReportId(TEST_USER_REPORT_IDS.SUR_ABC123)).rejects.toThrow( - 'Failed to fetch user report' - ); - }); - }); - - describe('update', () => { - it('given update called then throws not supported error', async () => { - // Given & When & Then - await expect(store.update('sur-abc123', { label: 'Updated Label' })).rejects.toThrow( - 'Please ensure you are using localStorage mode' - ); - }); - - it('given update called then logs warning', async () => { - // Given - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - // When - try { - await store.update('sur-abc123', { label: 'Updated Label' }); - } catch { - // Expected to throw - } - - // Then - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('API endpoint not yet implemented') - ); - - consoleWarnSpy.mockRestore(); - }); - }); -}); - describe('LocalStorageReportStore', () => { let store: LocalStorageReportStore; let mockLocalStorage: { [key: string]: string }; diff --git a/app/src/tests/unit/api/simulationAssociation.test.ts b/app/src/tests/unit/api/simulationAssociation.test.ts index 8e2cff638..1ec5e0d81 100644 --- a/app/src/tests/unit/api/simulationAssociation.test.ts +++ b/app/src/tests/unit/api/simulationAssociation.test.ts @@ -1,184 +1,12 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { UserSimulationAdapter } from '@/adapters/UserSimulationAdapter'; -import { ApiSimulationStore, LocalStorageSimulationStore } from '@/api/simulationAssociation'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LocalStorageSimulationStore } from '@/api/simulationAssociation'; import { - mockErrorFetchResponse, - mockSimulation, - mockSimulationApiResponse, mockSimulationInput, - mockSuccessFetchResponse, TEST_LABELS, TEST_SIM_IDS, TEST_USER_IDS, } from '@/tests/fixtures/api/simulationAssociationMocks'; -// Mock the adapter -vi.mock('@/adapters/UserSimulationAdapter'); - -// Mock fetch -global.fetch = vi.fn(); - -describe('ApiSimulationStore', () => { - let store: ApiSimulationStore; - - beforeEach(() => { - store = new ApiSimulationStore(); - vi.clearAllMocks(); - (UserSimulationAdapter.toCreationPayload as any).mockReturnValue(mockSimulationApiResponse()); - (UserSimulationAdapter.fromApiResponse as any).mockReturnValue(mockSimulation()); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - describe('create', () => { - it('given valid simulation then creates simulation association', async () => { - // Given - (global.fetch as any).mockResolvedValue( - mockSuccessFetchResponse(mockSimulationApiResponse()) - ); - - // When - const result = await store.create(mockSimulationInput()); - - // Then - expect(fetch).toHaveBeenCalledWith( - '/api/user-simulation-associations', - expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(mockSimulationApiResponse()), - }) - ); - expect(result).toEqual(mockSimulation()); - }); - - it('given API error then throws error', async () => { - // Given - (global.fetch as any).mockResolvedValue(mockErrorFetchResponse(500)); - - // When/Then - await expect(store.create(mockSimulationInput())).rejects.toThrow( - 'Failed to create simulation association' - ); - }); - }); - - describe('findByUser', () => { - it('given valid user ID then fetches user simulation associations', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: true, - json: async () => [mockSimulationApiResponse()], - }); - - // When - const result = await store.findByUser(TEST_USER_IDS.USER_123); - - // Then - expect(fetch).toHaveBeenCalledWith( - '/api/user-simulation-associations/user/user-123', - expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, - }) - ); - expect(result).toEqual([mockSimulation()]); - }); - - it('given API error then throws error', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: false, - status: 500, - }); - - // When/Then - await expect(store.findByUser(TEST_USER_IDS.USER_123)).rejects.toThrow( - 'Failed to fetch user associations' - ); - }); - }); - - describe('findById', () => { - it('given valid IDs then fetches specific association', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: true, - status: 200, - json: async () => mockSimulationApiResponse(), - }); - - // When - const result = await store.findById(TEST_USER_IDS.USER_123, TEST_SIM_IDS.SIM_456); - - // Then - expect(fetch).toHaveBeenCalledWith( - '/api/user-simulation-associations/user-123/sim-456', - expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, - }) - ); - expect(result).toEqual(mockSimulation()); - }); - - it('given 404 response then returns null', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: false, - status: 404, - }); - - // When - const result = await store.findById(TEST_USER_IDS.USER_123, 'nonexistent'); - - // Then - expect(result).toBeNull(); - }); - - it('given other error then throws error', async () => { - // Given - (global.fetch as any).mockResolvedValue({ - ok: false, - status: 500, - }); - - // When/Then - await expect(store.findById(TEST_USER_IDS.USER_123, TEST_SIM_IDS.SIM_456)).rejects.toThrow( - 'Failed to fetch association' - ); - }); - }); - - describe('update', () => { - it('given update called then throws not supported error', async () => { - // Given & When & Then - await expect(store.update('sus-abc123', { label: 'Updated Label' })).rejects.toThrow( - 'Please ensure you are using localStorage mode' - ); - }); - - it('given update called then logs warning', async () => { - // Given - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - // When - try { - await store.update('sus-abc123', { label: 'Updated Label' }); - } catch { - // Expected to throw - } - - // Then - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('API endpoint not yet implemented') - ); - - consoleWarnSpy.mockRestore(); - }); - }); -}); - describe('LocalStorageSimulationStore', () => { let store: LocalStorageSimulationStore; let mockLocalStorage: { [key: string]: string }; diff --git a/app/src/tests/unit/api/v2AssociationStores.test.ts b/app/src/tests/unit/api/v2AssociationStores.test.ts new file mode 100644 index 000000000..5914ca3f0 --- /dev/null +++ b/app/src/tests/unit/api/v2AssociationStores.test.ts @@ -0,0 +1,426 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { ApiHouseholdStore } from '@/api/householdAssociation'; +import { ApiPolicyStore } from '@/api/policyAssociation'; +import { + ApiReportStore, + LocalStorageReportStore, + type UserReportStore, +} from '@/api/reportAssociation'; +import { ApiSimulationStore } from '@/api/simulationAssociation'; +import { MOCK_USER_ID } from '@/constants'; +import { recordImportedIds } from '@/libs/auth/importedIds'; +import { + ASSOCIATION_IDS, + createTestApiContext, + errorResponse, + fetchCall, + householdItem, + itemResponse, + LEGACY_IDS, + noContentResponse, + pageResponse, + PE_USER_ID, + policyItem, + reportItem, + simulationItem, + TEST_ACCESS_TOKEN, + TIMESTAMPS, +} from '@/tests/fixtures/api/v2UserMocks'; + +beforeEach(() => { + localStorage.clear(); +}); + +describe('ApiPolicyStore', () => { + test('given a new association then it posts the v1 policy id and name without a user id', async () => { + // Given + const { fetchMock, context } = createTestApiContext('us'); + fetchMock.mockResolvedValue(itemResponse(policyItem({ name: 'CTC expansion' }), 201)); + const store = new ApiPolicyStore(context); + + // When + const created = await store.create({ + userId: MOCK_USER_ID, + policyId: String(LEGACY_IDS.POLICY), + countryId: 'us', + label: 'CTC expansion', + }); + + // Then + const call = fetchCall(fetchMock); + expect(call.method).toBe('POST'); + expect(call.path).toBe('/v2/user-policies'); + expect(call.query).toEqual({ country_id: 'us' }); + expect(call.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(call.body).toEqual({ + country_id: 'us', + legacy_policy_id: LEGACY_IDS.POLICY, + name: 'CTC expansion', + }); + expect(created).toEqual({ + id: ASSOCIATION_IDS.POLICY, + userId: PE_USER_ID, + policyId: String(LEGACY_IDS.POLICY), + countryId: 'us', + label: 'CTC expansion', + createdAt: TIMESTAMPS.CREATED, + updatedAt: TIMESTAMPS.UPDATED, + isCreated: true, + }); + }); + + test('given a non-numeric policy id then create refuses before calling the API', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + const store = new ApiPolicyStore(context); + + // When / Then + await expect( + store.create({ userId: MOCK_USER_ID, policyId: 'current-law', countryId: 'us' }) + ).rejects.toThrow(/no PolicyEngine API ID/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given rows then findByUser lists the country and skips rows without a v1 policy', async () => { + // Given + const { fetchMock, context } = createTestApiContext('uk'); + fetchMock.mockResolvedValue( + pageResponse([ + policyItem({ country_id: 'uk' }), + policyItem({ id: 'v2-only', legacy_policy_id: null, policy_id: 'abc' }), + ]) + ); + const store = new ApiPolicyStore(context); + + // When + const policies = await store.findByUser(PE_USER_ID, 'uk'); + + // Then + expect(fetchCall(fetchMock).query).toMatchObject({ country_id: 'uk' }); + expect(policies.map((policy) => policy.id)).toEqual([ASSOCIATION_IDS.POLICY]); + }); + + test('given another country then findByUser returns nothing without a request', async () => { + // Given + const { fetchMock, context } = createTestApiContext('us'); + const store = new ApiPolicyStore(context); + + // When + const policies = await store.findByUser(PE_USER_ID, 'ca'); + + // Then + expect(policies).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given a v1 policy id then findById filters by legacy_policy_id', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(pageResponse([policyItem()])); + const store = new ApiPolicyStore(context); + + // When + const found = await store.findById(PE_USER_ID, String(LEGACY_IDS.POLICY)); + + // Then + expect(fetchCall(fetchMock).query).toEqual({ + country_id: 'us', + legacy_policy_id: String(LEGACY_IDS.POLICY), + limit: '1', + }); + expect(found?.id).toBe(ASSOCIATION_IDS.POLICY); + }); + + test('given a rename and repoint then update patches only those fields', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(policyItem({ name: 'Renamed', legacy_policy_id: 7 }))); + const store = new ApiPolicyStore(context); + + // When + const updated = await store.update(ASSOCIATION_IDS.POLICY, { label: 'Renamed', policyId: '7' }); + + // Then + const call = fetchCall(fetchMock); + expect(call.method).toBe('PATCH'); + expect(call.path).toBe(`/v2/user-policies/${ASSOCIATION_IDS.POLICY}`); + expect(call.body).toEqual({ name: 'Renamed', legacy_policy_id: 7 }); + expect(updated.policyId).toBe('7'); + }); + + test('given an empty update then it reads the row instead of patching', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(policyItem())); + const store = new ApiPolicyStore(context); + + // When + await store.update(ASSOCIATION_IDS.POLICY, {}); + + // Then + expect(fetchCall(fetchMock).method).toBe('GET'); + expect(fetchCall(fetchMock).body).toBeUndefined(); + }); + + test('given an imported local id then update targets its account row', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + recordImportedIds(PE_USER_ID, { 'sup-local1': ASSOCIATION_IDS.POLICY }); + fetchMock.mockResolvedValue(itemResponse(policyItem({ name: 'Renamed' }))); + const store = new ApiPolicyStore(context); + + // When + await store.update('sup-local1', { label: 'Renamed' }); + + // Then + expect(fetchCall(fetchMock).path).toBe(`/v2/user-policies/${ASSOCIATION_IDS.POLICY}`); + }); + + test('given an unknown local id then update fails as not found without a request', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + const store = new ApiPolicyStore(context); + + // When / Then + await expect(store.update('sup-unknown', { label: 'x' })).rejects.toMatchObject({ + status: 404, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given a delete then it sends DELETE and accepts 204', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(noContentResponse()); + const store = new ApiPolicyStore(context); + + // When + await store.delete(ASSOCIATION_IDS.POLICY); + + // Then + expect(fetchCall(fetchMock).method).toBe('DELETE'); + expect(fetchCall(fetchMock).query).toEqual({ country_id: 'us' }); + }); +}); + +describe('ApiHouseholdStore', () => { + test('given a household then create posts legacy_household_id', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(householdItem({ name: 'Family of four' }), 201)); + const store = new ApiHouseholdStore(context); + + // When + const created = await store.create({ + type: 'household', + userId: MOCK_USER_ID, + householdId: String(LEGACY_IDS.HOUSEHOLD), + countryId: 'us', + label: 'Family of four', + }); + + // Then + expect(fetchCall(fetchMock).body).toEqual({ + country_id: 'us', + legacy_household_id: LEGACY_IDS.HOUSEHOLD, + name: 'Family of four', + }); + expect(created).toMatchObject({ + type: 'household', + id: ASSOCIATION_IDS.HOUSEHOLD, + householdId: String(LEGACY_IDS.HOUSEHOLD), + label: 'Family of four', + }); + }); + + test('given a replacement household then update repoints legacy_household_id', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(householdItem({ legacy_household_id: 456 }))); + const store = new ApiHouseholdStore(context); + + // When + const updated = await store.update(ASSOCIATION_IDS.HOUSEHOLD, { householdId: '456' }); + + // Then + expect(fetchCall(fetchMock).body).toEqual({ legacy_household_id: 456 }); + expect(updated.householdId).toBe('456'); + }); +}); + +describe('ApiSimulationStore', () => { + test('given a simulation then create posts legacy_simulation_id', async () => { + // Given + const { fetchMock, context } = createTestApiContext('uk'); + fetchMock.mockResolvedValue(itemResponse(simulationItem({ country_id: 'uk' }), 201)); + const store = new ApiSimulationStore(context); + + // When + await store.create({ + userId: MOCK_USER_ID, + simulationId: String(LEGACY_IDS.SIMULATION), + countryId: 'uk', + }); + + // Then + const call = fetchCall(fetchMock); + expect(call.path).toBe('/v2/user-simulations'); + expect(call.query).toEqual({ country_id: 'uk' }); + expect(call.body).toEqual({ country_id: 'uk', legacy_simulation_id: LEGACY_IDS.SIMULATION }); + }); + + test('given rows then findByUser maps legacy ids to simulation ids', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(pageResponse([simulationItem({ name: 'Baseline' })])); + const store = new ApiSimulationStore(context); + + // When + const simulations = await store.findByUser(PE_USER_ID, 'us'); + + // Then + expect(simulations).toEqual([ + expect.objectContaining({ + id: ASSOCIATION_IDS.SIMULATION, + userId: PE_USER_ID, + simulationId: String(LEGACY_IDS.SIMULATION), + label: 'Baseline', + }), + ]); + }); +}); + +describe('ApiReportStore', () => { + test('given a report then create posts legacy_report_id (no caller-chosen id)', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(reportItem({ name: 'Saved report' }), 201)); + const store = new ApiReportStore(context); + + // When + const created = await store.create({ + userId: MOCK_USER_ID, + reportId: String(LEGACY_IDS.REPORT), + countryId: 'us', + label: 'Saved report', + }); + + // Then + expect((store as UserReportStore).createWithId).toBeUndefined(); + expect(fetchCall(fetchMock).body).toEqual({ + country_id: 'us', + legacy_report_id: LEGACY_IDS.REPORT, + name: 'Saved report', + }); + expect(created.id).toBe(ASSOCIATION_IDS.REPORT); + expect(created.reportId).toBe(String(LEGACY_IDS.REPORT)); + }); + + test('given an association uuid then findByUserReportId reads that row', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(reportItem())); + const store = new ApiReportStore(context); + + // When + const found = await store.findByUserReportId(ASSOCIATION_IDS.REPORT); + + // Then + expect(fetchCall(fetchMock).path).toBe(`/v2/user-reports/${ASSOCIATION_IDS.REPORT}`); + expect(found?.reportId).toBe(String(LEGACY_IDS.REPORT)); + }); + + test("given another user's row then findByUserReportId returns null", async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(errorResponse(404, 'Not found')); + const store = new ApiReportStore(context); + + // When + const found = await store.findByUserReportId(ASSOCIATION_IDS.REPORT); + + // Then + expect(found).toBeNull(); + }); + + test('given an imported sur- id then findByUserReportId resolves to the account row', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + recordImportedIds(PE_USER_ID, { 'sur-old1': ASSOCIATION_IDS.REPORT }); + fetchMock.mockResolvedValue(itemResponse(reportItem())); + const store = new ApiReportStore(context); + + // When + const found = await store.findByUserReportId('sur-old1'); + + // Then + expect(fetchCall(fetchMock).path).toBe(`/v2/user-reports/${ASSOCIATION_IDS.REPORT}`); + expect(found?.id).toBe(ASSOCIATION_IDS.REPORT); + }); + + test('given an id imported by a different user then it is not resolved for this user', async () => { + // Given + const { fetchMock, context } = createTestApiContext('us', PE_USER_ID); + recordImportedIds('another-user', { 'sur-old1': ASSOCIATION_IDS.REPORT }); + const store = new ApiReportStore(context); + + // When + const found = await store.findByUserReportId('sur-old1'); + + // Then + expect(found).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given a local sur- id never imported then it is read and updated in localStorage', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + const local = new LocalStorageReportStore(); + const localReport = await local.create({ + userId: MOCK_USER_ID, + reportId: '42', + countryId: 'us', + label: 'Local report', + }); + const store = new ApiReportStore(context, local); + + // When + const found = await store.findByUserReportId(localReport.id); + const renamed = await store.update(localReport.id, { label: 'Renamed locally' }); + + // Then + expect(found?.label).toBe('Local report'); + expect(renamed.label).toBe('Renamed locally'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given a replaced base report then update repoints legacy_report_id', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(reportItem({ legacy_report_id: 999 }))); + const store = new ApiReportStore(context); + + // When + const updated = await store.update(ASSOCIATION_IDS.REPORT, { reportId: '999' }); + + // Then + expect(fetchCall(fetchMock).body).toEqual({ legacy_report_id: 999 }); + expect(updated.reportId).toBe('999'); + }); + + test('given a v1 report id then findById filters by legacy_report_id', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(pageResponse([])); + const store = new ApiReportStore(context); + + // When + const found = await store.findById(PE_USER_ID, String(LEGACY_IDS.REPORT)); + + // Then + expect(found).toBeNull(); + expect(fetchCall(fetchMock).query).toMatchObject({ + legacy_report_id: String(LEGACY_IDS.REPORT), + }); + }); +}); diff --git a/app/src/tests/unit/api/v2UserClient.test.ts b/app/src/tests/unit/api/v2UserClient.test.ts new file mode 100644 index 000000000..65f02464d --- /dev/null +++ b/app/src/tests/unit/api/v2UserClient.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test, vi } from 'vitest'; +import { fetchMe } from '@/api/v2Me'; +import { createV2UserClient, isV2ApiError, V2ApiError } from '@/api/v2UserClient'; +import { + createTestApiContext, + errorResponse, + fetchCall, + itemResponse, + mockMeItem, + noContentResponse, + pageResponse, + policyItem, + TEST_ACCESS_TOKEN, + TEST_API_BASE, +} from '@/tests/fixtures/api/v2UserMocks'; + +describe('v2UserClient', () => { + test('given a request then it carries the bearer token to /v2 on the API base', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(mockMeItem)); + + // When + const me = await fetchMe(context.client, 'us'); + + // Then + const call = fetchCall(fetchMock); + expect(call.url.origin + call.path).toBe(`${TEST_API_BASE}/v2/me`); + expect(call.query).toEqual({ country_id: 'us' }); + expect(call.method).toBe('GET'); + expect(call.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(me).toEqual(mockMeItem); + }); + + test('given a body then it is sent as JSON', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(policyItem(), 201)); + + // When + await context.client.request('POST', 'user-policies', { + query: { country_id: 'us' }, + body: { country_id: 'us', legacy_policy_id: 1 }, + }); + + // Then + const call = fetchCall(fetchMock); + expect(call.headers['Content-Type']).toBe('application/json'); + expect(call.body).toEqual({ country_id: 'us', legacy_policy_id: 1 }); + }); + + test('given an error envelope then it throws a typed error with status and message', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(errorResponse(422, 'legacy_policy_id 9 does not exist')); + + // When + const failure = context.client + .request('GET', 'user-policies') + .catch((caught: unknown) => caught); + + // Then + const error = (await failure) as V2ApiError; + expect(error).toBeInstanceOf(V2ApiError); + expect(error.status).toBe(422); + expect(error.message).toBe('legacy_policy_id 9 does not exist'); + expect(isV2ApiError(error, 422)).toBe(true); + }); + + test('given an error without a body then the message describes the status', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(new Response('', { status: 503 })); + + // When / Then + await expect(context.client.request('GET', 'me')).rejects.toMatchObject({ + status: 503, + message: 'Saved work is temporarily unavailable.', + }); + }); + + test('given 204 then the result is undefined', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(noContentResponse()); + + // When + const result = await context.client.request('DELETE', 'me', { query: { country_id: 'us' } }); + + // Then + expect(result).toBeUndefined(); + }); + + test('given no access token then it fails with 401 without calling the API', async () => { + // Given + const fetchMock = vi.fn(); + const client = createV2UserClient({ + getAccessToken: async () => null, + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + // When / Then + await expect(client.request('GET', 'me')).rejects.toMatchObject({ status: 401 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given a network failure then it throws status 0', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + + // When / Then + await expect(context.client.request('GET', 'me')).rejects.toMatchObject({ status: 0 }); + }); + + test('given several pages then listAll reads until has_more is false', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + const first = policyItem({ id: 'a' }); + const second = policyItem({ id: 'b' }); + fetchMock + .mockResolvedValueOnce(pageResponse([first], { hasMore: true })) + .mockResolvedValueOnce(pageResponse([second], { offset: 1 })); + + // When + const items = await context.client.listAll('user-policies', { country_id: 'uk' }); + + // Then + expect(items).toEqual([first, second]); + expect(fetchCall(fetchMock, 0).query).toEqual({ country_id: 'uk', offset: '0', limit: '500' }); + expect(fetchCall(fetchMock, 1).query).toEqual({ country_id: 'uk', offset: '1', limit: '500' }); + }); +}); diff --git a/app/src/tests/unit/hooks/useCreateReport.test.tsx b/app/src/tests/unit/hooks/useCreateReport.test.tsx index 28a238a25..42392cf69 100644 --- a/app/src/tests/unit/hooks/useCreateReport.test.tsx +++ b/app/src/tests/unit/hooks/useCreateReport.test.tsx @@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { createReportAndAssociateWithUser } from '@/api/report'; +import { LocalStorageReportStore } from '@/api/reportAssociation'; import { useCreateReport } from '@/hooks/useCreateReport'; import { mockReport } from '@/tests/fixtures/adapters/reportMocks'; // Removed - old calculation manager mocks no longer needed @@ -123,6 +124,8 @@ describe('useCreateReport', () => { payload: mockReportCreationPayload, userId: 'anonymous', label: TEST_LABEL, + // Signed out, the association is saved in localStorage + reportStore: expect.any(LocalStorageReportStore), }); }); }); @@ -143,6 +146,7 @@ describe('useCreateReport', () => { payload: mockReportCreationPayload, userId: 'anonymous', label: undefined, + reportStore: expect.any(LocalStorageReportStore), }); }); }); diff --git a/app/src/tests/unit/hooks/useUserGeographic.test.tsx b/app/src/tests/unit/hooks/useUserGeographic.test.tsx index d1f27bf9f..49ece9738 100644 --- a/app/src/tests/unit/hooks/useUserGeographic.test.tsx +++ b/app/src/tests/unit/hooks/useUserGeographic.test.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { LocalStorageGeographicStore } from '@/api/geographicAssociation'; +import { MOCK_USER_ID } from '@/constants'; import { useRegions } from '@/hooks/useRegions'; import { useCreateGeographicAssociation, @@ -24,6 +25,9 @@ import { TEST_LABELS, } from '@/tests/fixtures/hooks/hooksMocks'; +// Saved geographies are always filed under the local user, whoever is signed in. +const LOCAL_USER_ID = MOCK_USER_ID; + // Mock useCurrentCountry hook vi.mock('@/hooks/useCurrentCountry', () => ({ useCurrentCountry: vi.fn(() => 'us'), @@ -197,7 +201,7 @@ describe('useUserGeographic hooks', () => { describe('useGeographicAssociationsByUser', () => { test('given valid user ID when fetching associations then returns list', async () => { // Given - const userId = TEST_IDS.USER_ID; + const userId = LOCAL_USER_ID; // When const { result } = renderHook(() => useGeographicAssociationsByUser(userId), { wrapper }); @@ -219,7 +223,7 @@ describe('useUserGeographic hooks', () => { mockStore.findByUser.mockRejectedValue(error); // When - const { result } = renderHook(() => useGeographicAssociationsByUser(TEST_IDS.USER_ID), { + const { result } = renderHook(() => useGeographicAssociationsByUser(LOCAL_USER_ID), { wrapper, }); @@ -241,7 +245,24 @@ describe('useUserGeographic hooks', () => { }); const mockStore = (LocalStorageGeographicStore as any)(); - expect(mockStore.findByUser).toHaveBeenCalledWith('', 'us'); + expect(mockStore.findByUser).toHaveBeenCalledWith(LOCAL_USER_ID, 'us'); + }); + + test('given a signed-in account user id then geographies are still read for the local user', async () => { + // Given + const accountUserId = '7c1e5a8e-2b1f-4c55-9a3e-1f2d3c4b5a69'; + + // When + const { result } = renderHook(() => useGeographicAssociationsByUser(accountUserId), { + wrapper, + }); + + // Then + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + const mockStore = (LocalStorageGeographicStore as any)(); + expect(mockStore.findByUser).toHaveBeenCalledWith(LOCAL_USER_ID, 'us'); }); test('given user with no associations then returns empty array', async () => { @@ -250,7 +271,7 @@ describe('useUserGeographic hooks', () => { mockStore.findByUser.mockResolvedValue([]); // When - const { result } = renderHook(() => useGeographicAssociationsByUser(TEST_IDS.USER_ID), { + const { result } = renderHook(() => useGeographicAssociationsByUser(LOCAL_USER_ID), { wrapper, }); @@ -266,7 +287,7 @@ describe('useUserGeographic hooks', () => { describe('useGeographicAssociation', () => { test('given valid IDs when fetching specific association then returns data', async () => { // Given - const userId = TEST_IDS.USER_ID; + const userId = LOCAL_USER_ID; const geographyId = TEST_IDS.GEOGRAPHY_ID; // When @@ -290,10 +311,9 @@ describe('useUserGeographic hooks', () => { mockStore.findById.mockResolvedValue(null); // When - const { result } = renderHook( - () => useGeographicAssociation(TEST_IDS.USER_ID, 'non-existent'), - { wrapper } - ); + const { result } = renderHook(() => useGeographicAssociation(LOCAL_USER_ID, 'non-existent'), { + wrapper, + }); // Then await waitFor(() => { @@ -311,7 +331,7 @@ describe('useUserGeographic hooks', () => { // When const { result } = renderHook( - () => useGeographicAssociation(TEST_IDS.USER_ID, TEST_IDS.GEOGRAPHY_ID), + () => useGeographicAssociation(LOCAL_USER_ID, TEST_IDS.GEOGRAPHY_ID), { wrapper } ); @@ -329,7 +349,7 @@ describe('useUserGeographic hooks', () => { // Given const newAssociation = { id: TEST_IDS.GEOGRAPHY_ID, - userId: TEST_IDS.USER_ID, + userId: LOCAL_USER_ID, countryId: GEO_CONSTANTS.COUNTRY_US, scope: GEO_CONSTANTS.TYPE_NATIONAL, geographyId: GEO_CONSTANTS.COUNTRY_US, @@ -352,7 +372,7 @@ describe('useUserGeographic hooks', () => { // Verify cache invalidation expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: QUERY_KEY_PATTERNS.GEO_ASSOCIATION_BY_USER(TEST_IDS.USER_ID), + queryKey: QUERY_KEY_PATTERNS.GEO_ASSOCIATION_BY_USER(LOCAL_USER_ID), }); expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEY_PATTERNS.GEO_ASSOCIATION_BY_GEOGRAPHY(GEO_CONSTANTS.COUNTRY_US), @@ -360,7 +380,7 @@ describe('useUserGeographic hooks', () => { // Verify cache update expect(queryClient.setQueryData).toHaveBeenCalledWith( - QUERY_KEY_PATTERNS.GEO_ASSOCIATION_SPECIFIC(TEST_IDS.USER_ID, GEO_CONSTANTS.COUNTRY_US), + QUERY_KEY_PATTERNS.GEO_ASSOCIATION_SPECIFIC(LOCAL_USER_ID, GEO_CONSTANTS.COUNTRY_US), { ...newAssociation, type: 'geography' } ); }); @@ -383,7 +403,10 @@ describe('useUserGeographic hooks', () => { // Then expect(queryClient.setQueryData).toHaveBeenCalledWith( - QUERY_KEY_PATTERNS.GEO_ASSOCIATION_SPECIFIC(TEST_IDS.USER_ID, GEO_CONSTANTS.REGION_CA), + QUERY_KEY_PATTERNS.GEO_ASSOCIATION_SPECIFIC( + subnationalAssociation.userId, + GEO_CONSTANTS.REGION_CA + ), subnationalAssociation ); }); @@ -399,7 +422,7 @@ describe('useUserGeographic hooks', () => { await expect( result.current.mutateAsync({ id: TEST_IDS.GEOGRAPHY_ID, - userId: TEST_IDS.USER_ID, + userId: LOCAL_USER_ID, countryId: GEO_CONSTANTS.COUNTRY_US, scope: GEO_CONSTANTS.TYPE_NATIONAL, geographyId: GEO_CONSTANTS.COUNTRY_US, @@ -417,7 +440,7 @@ describe('useUserGeographic hooks', () => { const association1 = { id: TEST_IDS.GEOGRAPHY_ID, - userId: TEST_IDS.USER_ID, + userId: LOCAL_USER_ID, countryId: GEO_CONSTANTS.COUNTRY_US, scope: GEO_CONSTANTS.TYPE_NATIONAL, geographyId: GEO_CONSTANTS.COUNTRY_US, @@ -426,7 +449,7 @@ describe('useUserGeographic hooks', () => { const association2 = { id: TEST_IDS.GEOGRAPHY_ID_2, - userId: TEST_IDS.USER_ID, + userId: LOCAL_USER_ID, countryId: GEO_CONSTANTS.COUNTRY_UK, scope: GEO_CONSTANTS.TYPE_NATIONAL, geographyId: GEO_CONSTANTS.COUNTRY_UK, @@ -453,17 +476,18 @@ describe('useUserGeographic hooks', () => { const { result } = renderHook(() => useUpdateGeographicAssociation(), { wrapper }); await result.current.mutateAsync({ - userId: TEST_IDS.USER_ID, + userId: LOCAL_USER_ID, geographyId: GEO_CONSTANTS.COUNTRY_US, updates: { label: 'Renamed Geography' }, }); const mockStore = (LocalStorageGeographicStore as any)(); - expect(mockStore.update).toHaveBeenCalledWith(TEST_IDS.USER_ID, GEO_CONSTANTS.COUNTRY_US, { + expect(mockStore.update).toHaveBeenCalledWith(LOCAL_USER_ID, GEO_CONSTANTS.COUNTRY_US, { label: 'Renamed Geography', }); + // Caches are keyed by the association the store returned. expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ - queryKey: QUERY_KEY_PATTERNS.GEO_ASSOCIATION_BY_USER(TEST_IDS.USER_ID), + queryKey: QUERY_KEY_PATTERNS.GEO_ASSOCIATION_BY_USER(mockUserGeographicAssociation.userId), }); expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEY_PATTERNS.GEO_ASSOCIATION_BY_GEOGRAPHY( @@ -472,7 +496,7 @@ describe('useUserGeographic hooks', () => { }); expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: QUERY_KEY_PATTERNS.GEO_ASSOCIATION_SPECIFIC( - TEST_IDS.USER_ID, + mockUserGeographicAssociation.userId, mockUserGeographicAssociation.geographyId ), }); @@ -482,7 +506,7 @@ describe('useUserGeographic hooks', () => { describe('query configuration', () => { test('given local storage mode then uses local storage config', async () => { // When - const { result } = renderHook(() => useGeographicAssociationsByUser(TEST_IDS.USER_ID), { + const { result } = renderHook(() => useGeographicAssociationsByUser(LOCAL_USER_ID), { wrapper, }); @@ -493,14 +517,14 @@ describe('useUserGeographic hooks', () => { // Local storage should have no stale time const queryState = queryClient.getQueryState( - QUERY_KEY_PATTERNS.GEO_ASSOCIATION_BY_USER(TEST_IDS.USER_ID) + QUERY_KEY_PATTERNS.GEO_ASSOCIATION_BY_USER(LOCAL_USER_ID) ); expect(queryState).toBeDefined(); }); test('given refetch called then fetches fresh data', async () => { // Given - const { result } = renderHook(() => useGeographicAssociationsByUser(TEST_IDS.USER_ID), { + const { result } = renderHook(() => useGeographicAssociationsByUser(LOCAL_USER_ID), { wrapper, }); @@ -526,7 +550,7 @@ describe('useUserGeographic hooks', () => { describe('useUserGeographics', () => { test('given geography associations then it reconstructs canonical geography records', async () => { - const { result } = renderHook(() => useUserGeographics(TEST_IDS.USER_ID), { wrapper }); + const { result } = renderHook(() => useUserGeographics(LOCAL_USER_ID), { wrapper }); await waitFor(() => { expect(result.current.isLoading).toBe(false); @@ -550,7 +574,7 @@ describe('useUserGeographic hooks', () => { error: new Error('regions unavailable'), } as ReturnType); - const { result } = renderHook(() => useUserGeographics(TEST_IDS.USER_ID), { wrapper }); + const { result } = renderHook(() => useUserGeographics(LOCAL_USER_ID), { wrapper }); await waitFor(() => { expect(result.current.isLoading).toBe(false); @@ -579,7 +603,7 @@ describe('useUserGeographic hooks', () => { }, ]); - const { result } = renderHook(() => useUserGeographics(TEST_IDS.USER_ID), { wrapper }); + const { result } = renderHook(() => useUserGeographics(LOCAL_USER_ID), { wrapper }); await waitFor(() => { expect(result.current.isLoading).toBe(false); diff --git a/app/src/tests/unit/libs/auth/AuthProvider.test.tsx b/app/src/tests/unit/libs/auth/AuthProvider.test.tsx new file mode 100644 index 000000000..59734e0e3 --- /dev/null +++ b/app/src/tests/unit/libs/auth/AuthProvider.test.tsx @@ -0,0 +1,251 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { NavigationProvider } from '@/contexts/NavigationContext'; +import { useAuthStatus } from '@/libs/auth/AuthContext'; +import { AuthProvider } from '@/libs/auth/AuthProvider'; +import type { CountryId } from '@/libs/countries'; +import { + errorResponse, + itemResponse, + mockMeItem, + TEST_ACCESS_TOKEN, +} from '@/tests/fixtures/api/v2UserMocks'; +import { stubAuthEnv, TEST_AUTH0_CONFIG } from '@/tests/fixtures/libs/auth/authMocks'; + +const auth0 = vi.hoisted(() => ({ + state: { + isLoading: false, + isAuthenticated: false, + error: undefined as Error | undefined, + user: undefined as Record | undefined, + }, + providerProps: vi.fn(), + getAccessTokenSilently: vi.fn(), + loginWithRedirect: vi.fn(), + logout: vi.fn(), +})); + +vi.mock('@auth0/auth0-react', () => ({ + Auth0Provider: (props: { children: React.ReactNode }) => { + auth0.providerProps(props); + return <>{props.children}; + }, + useAuth0: () => ({ + ...auth0.state, + getAccessTokenSilently: auth0.getAccessTokenSilently, + loginWithRedirect: auth0.loginWithRedirect, + logout: auth0.logout, + }), +})); + +const navigation = { push: vi.fn(), replace: vi.fn(), back: vi.fn() }; +const fetchMock = vi.fn(); + +function Probe() { + const auth = useAuthStatus(); + return ( +
+ {auth.status} + {auth.user?.id ?? 'none'} + +
+ ); +} + +function renderProvider(countryId: CountryId = 'us') { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + + + + + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); + auth0.state.isLoading = false; + auth0.state.isAuthenticated = false; + auth0.state.error = undefined; + auth0.state.user = undefined; + auth0.getAccessTokenSilently.mockResolvedValue(TEST_ACCESS_TOKEN); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe('AuthProvider', () => { + test('given sign-in is off then children render with status disabled and no Auth0', () => { + // Given + stubAuthEnv(false); + + // When + renderProvider(); + + // Then + expect(screen.getByTestId('status')).toHaveTextContent('disabled'); + expect(auth0.providerProps).not.toHaveBeenCalled(); + }); + + test('given the flag is on but Auth0 is not configured then sign-in stays disabled', () => { + // Given + stubAuthEnv(true, { domain: TEST_AUTH0_CONFIG.domain }); + + // When + renderProvider(); + + // Then + expect(screen.getByTestId('status')).toHaveTextContent('disabled'); + expect(auth0.providerProps).not.toHaveBeenCalled(); + }); + + test('given sign-in is on then Auth0 keeps tokens in memory with rotating refresh tokens', () => { + // Given + stubAuthEnv(true); + + // When + renderProvider('uk'); + + // Then + expect(auth0.providerProps).toHaveBeenCalledWith( + expect.objectContaining({ + domain: TEST_AUTH0_CONFIG.domain, + clientId: TEST_AUTH0_CONFIG.clientId, + cacheLocation: 'memory', + useRefreshTokens: true, + useRefreshTokensFallback: true, + authorizationParams: { + audience: TEST_AUTH0_CONFIG.audience, + redirect_uri: `${window.location.origin}/uk/callback`, + scope: 'openid profile email offline_access', + }, + }) + ); + }); + + test('given the session is being restored then status is loading', () => { + // Given + stubAuthEnv(true); + auth0.state.isLoading = true; + + // When + renderProvider(); + + // Then + expect(screen.getByTestId('status')).toHaveTextContent('loading'); + }); + + test('given no session then status is anonymous', () => { + // Given + stubAuthEnv(true); + + // When + renderProvider(); + + // Then + expect(screen.getByTestId('status')).toHaveTextContent('anonymous'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given a session in the US then /v2/me loads the PolicyEngine user with the bearer token', async () => { + // Given + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: 'google-oauth2|1234', email: mockMeItem.email }; + fetchMock.mockResolvedValue(itemResponse(mockMeItem)); + + // When + renderProvider('us'); + + // Then + expect(screen.getByTestId('status')).toHaveTextContent('loading'); + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated')); + expect(screen.getByTestId('user')).toHaveTextContent(mockMeItem.id); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.policyengine.org/v2/me?country_id=us'); + expect(init.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(auth0.getAccessTokenSilently).toHaveBeenCalledWith({ + authorizationParams: { audience: TEST_AUTH0_CONFIG.audience }, + }); + }); + + test('given /v2/me fails then the session is signed in without a PolicyEngine user', async () => { + // Given + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: 'google-oauth2|1234' }; + fetchMock.mockImplementation(async () => errorResponse(503, 'Database unavailable')); + + // When + renderProvider('uk'); + + // Then (after one retry) + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'), { + timeout: 3000, + }); + expect(screen.getByTestId('user')).toHaveTextContent('none'); + }); + + test('given a session in a country without account storage then /v2/me is not called', () => { + // Given + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: 'google-oauth2|1234' }; + + // When + renderProvider('ca'); + + // Then + expect(screen.getByTestId('status')).toHaveTextContent('authenticated'); + expect(screen.getByTestId('user')).toHaveTextContent('none'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given sign in then the redirect returns to the requested page via the country callback', async () => { + // Given + stubAuthEnv(true); + renderProvider('us'); + + // When + screen.getByRole('button', { name: 'Sign in' }).click(); + + // Then + await waitFor(() => + expect(auth0.loginWithRedirect).toHaveBeenCalledWith({ + appState: { returnTo: '/us/reforms?filter=yours' }, + authorizationParams: { + audience: TEST_AUTH0_CONFIG.audience, + redirect_uri: `${window.location.origin}/us/callback`, + scope: 'openid profile email offline_access', + }, + }) + ); + }); + + test('given the redirect completes then it navigates to the saved return path', () => { + // Given + stubAuthEnv(true); + renderProvider('us'); + const { onRedirectCallback } = auth0.providerProps.mock.calls[0][0]; + + // When + onRedirectCallback({ returnTo: '/us/build' }); + onRedirectCallback({ returnTo: 'https://evil.example/' }); + + // Then + expect(navigation.replace).toHaveBeenNthCalledWith(1, '/us/build'); + expect(navigation.replace).toHaveBeenNthCalledWith(2, '/us'); + }); +}); diff --git a/app/src/tests/unit/libs/auth/authConfig.test.ts b/app/src/tests/unit/libs/auth/authConfig.test.ts new file mode 100644 index 000000000..816d2aa1d --- /dev/null +++ b/app/src/tests/unit/libs/auth/authConfig.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { getAuth0Config, isCallbackPath, safeReturnTo } from '@/libs/auth/authConfig'; +import { stubAuthEnv, TEST_AUTH0_CONFIG } from '@/tests/fixtures/libs/auth/authMocks'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('getAuth0Config', () => { + test('given the flag and all settings then it returns the Auth0 config', () => { + stubAuthEnv(true); + expect(getAuth0Config()).toEqual(TEST_AUTH0_CONFIG); + }); + + test('given the flag off then it returns null', () => { + stubAuthEnv(false); + expect(getAuth0Config()).toBeNull(); + }); + + test('given a missing setting then it returns null', () => { + stubAuthEnv(true, { domain: TEST_AUTH0_CONFIG.domain, clientId: TEST_AUTH0_CONFIG.clientId }); + expect(getAuth0Config()).toBeNull(); + }); +}); + +describe('safeReturnTo', () => { + test('given an in-app path then it is kept', () => { + expect(safeReturnTo('/us/reforms?filter=yours', '/us')).toBe('/us/reforms?filter=yours'); + }); + + test.each([ + ['https://evil.example/'], + ['//evil.example/'], + ['/\\evil.example'], + ['/us/callback?code=x'], + [undefined], + ])('given %s then it falls back', (returnTo) => { + expect(safeReturnTo(returnTo, '/us')).toBe('/us'); + }); +}); + +describe('isCallbackPath', () => { + test('given the country callback then it matches', () => { + expect(isCallbackPath('/uk/callback')).toBe(true); + expect(isCallbackPath('/uk/reports')).toBe(false); + }); +}); diff --git a/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx b/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx new file mode 100644 index 000000000..c5be62808 --- /dev/null +++ b/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx @@ -0,0 +1,132 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { ApiHouseholdStore, LocalStorageHouseholdStore } from '@/api/householdAssociation'; +import { ApiPolicyStore, LocalStoragePolicyStore } from '@/api/policyAssociation'; +import { ApiReportStore, LocalStorageReportStore } from '@/api/reportAssociation'; +import { ApiSimulationStore, LocalStorageSimulationStore } from '@/api/simulationAssociation'; +import { MOCK_USER_ID } from '@/constants'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useHouseholdAssociationStoreForMode } from '@/hooks/useUserHousehold'; +import { usePolicyAssociationStoreForMode } from '@/hooks/useUserPolicy'; +import { useReportAssociationStoreForMode } from '@/hooks/useUserReportAssociations'; +import { useSimulationAssociationStoreForMode } from '@/hooks/useUserSimulationAssociations'; +import type { AuthContextValue, AuthStatus } from '@/libs/auth/AuthContext'; +import { + selectAssociationBackend, + useAssociationBackend, + useCurrentUserId, +} from '@/libs/auth/useAssociationBackend'; +import type { CountryId } from '@/libs/countries'; +import { mockMeItem, PE_USER_ID } from '@/tests/fixtures/api/v2UserMocks'; +import { + AuthTestProvider, + makeAuthValue, + signedInAuthValue, +} from '@/tests/fixtures/libs/auth/authMocks'; + +function wrapperFor(auth: AuthContextValue, countryId: CountryId) { + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); +} + +describe('selectAssociationBackend', () => { + const statuses: AuthStatus[] = ['disabled', 'loading', 'anonymous', 'authenticated']; + const countries: CountryId[] = ['us', 'uk', 'ca', 'ng', 'il']; + + test.each(statuses.flatMap((status) => countries.map((country) => [status, country] as const)))( + 'given status %s in %s then account storage is used only when signed in to US or UK', + (status, countryId) => { + const expected = + status === 'authenticated' && (countryId === 'us' || countryId === 'uk') + ? 'api' + : 'localStorage'; + expect(selectAssociationBackend({ status, user: mockMeItem, countryId })).toBe(expected); + } + ); + + test('given a session whose PolicyEngine user has not loaded then localStorage is used', () => { + expect(selectAssociationBackend({ status: 'authenticated', user: null, countryId: 'us' })).toBe( + 'localStorage' + ); + }); +}); + +describe('useAssociationBackend', () => { + test('given a signed-in US session then saved work is filed under the PolicyEngine user', () => { + const { result } = renderHook(() => useAssociationBackend(), { + wrapper: wrapperFor(signedInAuthValue(), 'us'), + }); + + expect(result.current.mode).toBe('api'); + expect(result.current.userId).toBe(PE_USER_ID); + expect(result.current.ready).toBe(true); + }); + + test('given a signed-in Canada session then saved work stays local under the local user', () => { + const { result } = renderHook(() => useCurrentUserId(), { + wrapper: wrapperFor(signedInAuthValue(), 'ca'), + }); + + expect(result.current).toBe(MOCK_USER_ID); + }); + + test('given the session is still loading then queries are told to wait', () => { + const { result } = renderHook(() => useAssociationBackend(), { + wrapper: wrapperFor(makeAuthValue({ status: 'loading' }), 'us'), + }); + + expect(result.current.mode).toBe('localStorage'); + expect(result.current.ready).toBe(false); + }); + + test('given no auth provider then sign-in is disabled and storage is local', () => { + const { result } = renderHook(() => useAssociationBackend('us')); + + expect(result.current.mode).toBe('localStorage'); + expect(result.current.userId).toBe(MOCK_USER_ID); + expect(result.current.ready).toBe(true); + }); +}); + +describe('association store selection', () => { + test('given a signed-in UK session then every association hook uses the account stores', () => { + const wrapper = wrapperFor(signedInAuthValue(), 'uk'); + + expect( + renderHook(() => usePolicyAssociationStoreForMode(), { wrapper }).result.current.store + ).toBeInstanceOf(ApiPolicyStore); + expect( + renderHook(() => useHouseholdAssociationStoreForMode(), { wrapper }).result.current.store + ).toBeInstanceOf(ApiHouseholdStore); + expect( + renderHook(() => useSimulationAssociationStoreForMode(), { wrapper }).result.current.store + ).toBeInstanceOf(ApiSimulationStore); + expect( + renderHook(() => useReportAssociationStoreForMode(), { wrapper }).result.current.store + ).toBeInstanceOf(ApiReportStore); + }); + + test.each([ + ['signed out in the US', makeAuthValue({ status: 'anonymous' }), 'us'], + ['sign-in disabled', makeAuthValue({ status: 'disabled' }), 'uk'], + ['signed in in Nigeria', signedInAuthValue(), 'ng'], + ] as const)('given %s then every association hook uses localStorage', (_, auth, countryId) => { + const wrapper = wrapperFor(auth, countryId); + + expect( + renderHook(() => usePolicyAssociationStoreForMode(), { wrapper }).result.current.store + ).toBeInstanceOf(LocalStoragePolicyStore); + expect( + renderHook(() => useHouseholdAssociationStoreForMode(), { wrapper }).result.current.store + ).toBeInstanceOf(LocalStorageHouseholdStore); + expect( + renderHook(() => useSimulationAssociationStoreForMode(), { wrapper }).result.current.store + ).toBeInstanceOf(LocalStorageSimulationStore); + expect( + renderHook(() => useReportAssociationStoreForMode(), { wrapper }).result.current.store + ).toBeInstanceOf(LocalStorageReportStore); + }); +}); diff --git a/app/src/tests/unit/pages/reportBuilder/hooks/useModifyReportSubmission.test.tsx b/app/src/tests/unit/pages/reportBuilder/hooks/useModifyReportSubmission.test.tsx index 79f52a51f..0674eba57 100644 --- a/app/src/tests/unit/pages/reportBuilder/hooks/useModifyReportSubmission.test.tsx +++ b/app/src/tests/unit/pages/reportBuilder/hooks/useModifyReportSubmission.test.tsx @@ -89,10 +89,13 @@ vi.mock('@/pages/reportBuilder/hooks/useReportIngredientAvailability', () => ({ const mockMutateAsync = vi.fn().mockResolvedValue({}); +const mockReportStore = { create: vi.fn() }; + vi.mock('@/hooks/useUserReportAssociations', () => ({ useUpdateReportAssociation: () => ({ mutateAsync: mockMutateAsync, }), + useUserReportStore: () => mockReportStore, })); vi.mock('@/contexts/CalcOrchestratorContext', () => ({ diff --git a/app/src/types/metadata/userPolicyMetadata.ts b/app/src/types/metadata/userPolicyMetadata.ts deleted file mode 100644 index ec0d1a9c3..000000000 --- a/app/src/types/metadata/userPolicyMetadata.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { countryIds } from '@/libs/countries'; - -/** - * API response format for user policy associations - * Uses snake_case to match API conventions - */ -export interface UserPolicyMetadata { - user_id: string; - policy_id: string; - country_id: (typeof countryIds)[number]; - label?: string | null; - created_at?: string; - updated_at?: string; -} - -/** - * API creation payload format for user policy associations - * Uses snake_case to match API conventions - */ -export interface UserPolicyCreationMetadata { - user_id: string; - policy_id: string; - country_id: (typeof countryIds)[number]; - label?: string | null; - created_at?: string; - updated_at?: string; -} diff --git a/app/src/types/payloads/UserPolicyCreationPayload.ts b/app/src/types/payloads/UserPolicyCreationPayload.ts deleted file mode 100644 index a9c35a353..000000000 --- a/app/src/types/payloads/UserPolicyCreationPayload.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { countryIds } from '@/libs/countries'; - -/** - * Payload format for creating a user-policy association via the API - * Note: This endpoint doesn't exist yet. Currently uses the same format as UserPolicyMetadata. - * In the future, we may create a separate type if the creation payload differs from the response format. - */ -export interface UserPolicyCreationPayload { - user_id: string; - policy_id: string; - country_id: (typeof countryIds)[number]; - label?: string | null; - created_at?: string; - updated_at?: string; -} diff --git a/app/src/types/payloads/UserReportCreationPayload.ts b/app/src/types/payloads/UserReportCreationPayload.ts deleted file mode 100644 index b7f3f3b82..000000000 --- a/app/src/types/payloads/UserReportCreationPayload.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { countryIds } from '@/libs/countries'; - -/** - * Payload format for creating a user-report association via the API - */ -export interface UserReportCreationPayload { - userId: string; - reportId: string; - countryId: (typeof countryIds)[number]; - label?: string; - updatedAt?: string; -} diff --git a/app/src/types/payloads/UserSimulationCreationPayload.ts b/app/src/types/payloads/UserSimulationCreationPayload.ts deleted file mode 100644 index 6e9e45b62..000000000 --- a/app/src/types/payloads/UserSimulationCreationPayload.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { countryIds } from '@/libs/countries'; - -/** - * Payload format for creating a user-simulation association via the API - */ -export interface UserSimulationCreationPayload { - userId: string; - simulationId: string; - countryId: (typeof countryIds)[number]; - label?: string; - updatedAt?: string; -} diff --git a/app/src/types/payloads/index.ts b/app/src/types/payloads/index.ts index 6dbf3f5bc..88c2e8fd4 100644 --- a/app/src/types/payloads/index.ts +++ b/app/src/types/payloads/index.ts @@ -4,6 +4,3 @@ export type { SimulationSetOutputPayload } from './SimulationSetOutputPayload'; export type { HouseholdCreationPayload } from './HouseholdCreationPayload'; export type { ReportCreationPayload } from './ReportCreationPayload'; export type { ReportSetOutputPayload } from './ReportSetOutputPayload'; -export type { UserPolicyCreationPayload } from './UserPolicyCreationPayload'; -export type { UserReportCreationPayload } from './UserReportCreationPayload'; -export type { UserSimulationCreationPayload } from './UserSimulationCreationPayload'; diff --git a/app/src/types/v2User.ts b/app/src/types/v2User.ts new file mode 100644 index 000000000..6921e6f9f --- /dev/null +++ b/app/src/types/v2User.ts @@ -0,0 +1,242 @@ +/** + * Wire types for the PolicyEngine API's signed-in routes (`/v2/me*` and + * `/v2/user-*`). Field names are snake_case, as on the wire. The server + * derives the user from the bearer token, so request bodies never carry a + * `user_id`; responses do. + */ + +/** Countries with account storage. Every other country stays on localStorage. */ +export const ASSOCIATION_COUNTRY_IDS = ['us', 'uk'] as const; +export type AssociationCountryId = (typeof ASSOCIATION_COUNTRY_IDS)[number]; + +export function isAssociationCountry(countryId: unknown): countryId is AssociationCountryId { + return ( + typeof countryId === 'string' && + (ASSOCIATION_COUNTRY_IDS as readonly string[]).includes(countryId) + ); +} + +/** `{"status": "ok", "message": null, "result": }` or `{"status": "error", "message": "…"}`. */ +export interface V2Envelope { + status: 'ok' | 'error'; + message: string | null; + result?: R; +} + +export interface V2ItemResult { + item: T; +} + +export interface V2PageResult { + items: T[]; + offset: number; + limit: number; + has_more: boolean; +} + +export interface MeItem { + /** The PolicyEngine user ID that owns every association. */ + id: string; + primary_country: AssociationCountryId; + first_name: string | null; + last_name: string | null; + email: string | null; + created_at: string; +} + +export interface ProvenanceWire { + source: 'manual' | 'chat' | 'bill' | 'tool'; + ref: string | null; + confidence: 'exact' | 'approximated' | 'partial' | null; +} + +export type BaselineWire = 'current-law' | 'current-policy'; + +interface AssociationItemBase { + id: string; + country_id: AssociationCountryId; + user_id: string; + name: string | null; + description: string | null; + client_ref: string | null; + created_at: string; + updated_at: string; +} + +export interface UserPolicyItem extends AssociationItemBase { + policy_id: string | null; + legacy_policy_id: number | null; + baseline: BaselineWire | null; + provenance: ProvenanceWire | null; +} + +export interface UserHouseholdItem extends AssociationItemBase { + household_id: string | null; + legacy_household_id: number | null; +} + +export interface UserSimulationItem extends AssociationItemBase { + simulation_id: string | null; + legacy_simulation_id: number | null; +} + +export interface ReportIntervalWire { + parameter?: string; + start_date: string; + end_date: string; + value: unknown; +} + +export interface ReportProvisionWire { + path: string; + breadcrumb: string; + unit: string | null; + baseline_value: unknown; + value: unknown; + intervals: ReportIntervalWire[] | null; +} + +export interface UserReportItem extends AssociationItemBase { + report_id: string | null; + legacy_report_id: number | null; + user_policy_id: string | null; + source_note: string | null; + provisions: ReportProvisionWire[] | null; + /** ReportValidationWire on the wire; validated by validationFromWire. */ + validation: unknown | null; + last_run_at: string | null; +} + +/* Create and patch bodies (strict on the server: unknown keys are a 400). */ + +export interface UserPolicyCreateBody { + country_id: AssociationCountryId; + legacy_policy_id: number; + name?: string | null; + description?: string | null; + baseline?: BaselineWire | null; + provenance?: ProvenanceWire | null; + client_ref?: string | null; +} + +export type UserPolicyPatchBody = Partial< + Pick< + UserPolicyCreateBody, + 'legacy_policy_id' | 'name' | 'description' | 'baseline' | 'provenance' + > +>; + +export interface UserHouseholdCreateBody { + country_id: AssociationCountryId; + legacy_household_id: number; + name?: string | null; + description?: string | null; + client_ref?: string | null; +} + +export type UserHouseholdPatchBody = Partial< + Pick +>; + +export interface UserSimulationCreateBody { + country_id: AssociationCountryId; + legacy_simulation_id: number; + name?: string | null; + description?: string | null; + client_ref?: string | null; +} + +export type UserSimulationPatchBody = Partial< + Pick +>; + +export interface UserReportCreateBody { + country_id: AssociationCountryId; + legacy_report_id: number; + name?: string | null; + description?: string | null; + user_policy_id?: string | null; + source_note?: string | null; + provisions?: ReportProvisionWire[] | null; + validation?: unknown | null; + last_run_at?: string | null; + client_ref?: string | null; +} + +export type UserReportPatchBody = Partial< + Pick< + UserReportCreateBody, + | 'legacy_report_id' + | 'name' + | 'description' + | 'user_policy_id' + | 'source_note' + | 'provisions' + | 'validation' + | 'last_run_at' + > +>; + +/* /v2/me/export */ + +export interface MeExportResult { + user: MeItem; + policies: UserPolicyItem[]; + households: UserHouseholdItem[]; + simulations: UserSimulationItem[]; + reports: UserReportItem[]; +} + +/* /v2/me/imports */ + +export interface ImportPolicyItem { + client_ref: string; + legacy_policy_id: number; + name: string | null; + description: string | null; + baseline: BaselineWire | null; + provenance: ProvenanceWire | null; +} + +export interface ImportHouseholdItem { + client_ref: string; + legacy_household_id: number; + name: string | null; + description: string | null; +} + +export interface ImportSimulationItem { + client_ref: string; + legacy_simulation_id: number; + name: string | null; + description: string | null; +} + +export interface ImportReportItem { + client_ref: string; + legacy_report_id: number; + name: string | null; + description: string | null; + user_policy_client_ref: string | null; + source_note: string | null; + provisions: ReportProvisionWire[] | null; + validation: unknown | null; + last_run_at: string | null; +} + +export interface ImportRequestBody { + policies?: ImportPolicyItem[]; + households?: ImportHouseholdItem[]; + simulations?: ImportSimulationItem[]; + reports?: ImportReportItem[]; +} + +export interface ImportSkippedItem { + client_ref: string; + reason: string; +} + +export interface ImportResult { + id_map: Record; + skipped: ImportSkippedItem[]; +} diff --git a/calculator-app/src/app/[countryId]/callback/page.tsx b/calculator-app/src/app/[countryId]/callback/page.tsx new file mode 100644 index 000000000..1c308ab2f --- /dev/null +++ b/calculator-app/src/app/[countryId]/callback/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import AuthCallbackPage from "@/pages/AuthCallback.page"; + +/** + * Auth0 redirect target (`/{countryId}/callback`). The AuthProvider in + * providers.tsx completes the sign-in and returns to where it started. + */ +export default function AuthCallbackRoute() { + return ; +} diff --git a/calculator-app/src/app/[countryId]/providers.tsx b/calculator-app/src/app/[countryId]/providers.tsx index ca43d329d..dcb7655ef 100644 --- a/calculator-app/src/app/[countryId]/providers.tsx +++ b/calculator-app/src/app/[countryId]/providers.tsx @@ -9,6 +9,7 @@ import { AppProvider } from "@/contexts/AppContext"; import { CalcOrchestratorProvider } from "@/contexts/CalcOrchestratorContext"; import { useCurrentCountry } from "@/hooks/useCurrentCountry"; import { useFetchMetadata } from "@/hooks/useMetadata"; +import { AuthProvider } from "@/libs/auth/AuthProvider"; import { setCurrentCountry } from "@/reducers/metadataReducer"; import { AppDispatch, store } from "@/store"; import { cacheMonitor } from "@/utils/cacheMonitor"; @@ -57,11 +58,15 @@ export function CalculatorProviders({ }} > - - - {children} - - + {/* Sign-in is inert (status 'disabled') unless + NEXT_PUBLIC_AUTH_ENABLED=true and Auth0 is configured. */} + + + + {children} + + + From c08e16a0a18b3139e81329246dab49a389c4ae9d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:00:54 +0400 Subject: [PATCH 03/48] Back v3 reforms and reports with the account API; remove the Next database path Signed in (US/UK): - Reforms are /v2/user-policies rows. Saving materializes the parameters as a v1 policy (POST /{country}/policy, which dedupes) and stores {legacy_policy_id, name, baseline, provenance}; parameter edits create a new v1 policy and repoint legacy_policy_id; reading rebuilds parameters from the v1 policy with the app's policy -> parameter conversion. A reform kept only in this browser is added to the account when saved. - Flagship report records are /v2/user-reports rows with the v3 fields (title <-> name, sourceNote, provisions incl. dated intervals, validation, reformId <-> user_policy_id). runFlagshipReport writes exactly one association right after the v1 report exists; the report page reads the account record for provenance on other devices. Signed out (and outside the US and UK) the v3 stores use localStorage exactly as before, now without first trying the never-provisioned /api/reforms and /api/reports routes. Remove those Next route handlers, calculator-app/src/db (Drizzle schema, migrations, postgres-js client), DATABASE_URL, the PGlite route tests, the drizzle/postgres/neon/pglite dependencies and db:* scripts, and the storeBackend switch they relied on. Co-Authored-By: Claude Opus 5.5 --- app/src/adapters/ReformAdapter.ts | 110 ----- app/src/api/flagshipReportStore.ts | 248 +++++------ app/src/api/reformStore.ts | 413 ++++++++++-------- .../components/flagship/ReportAdjustPanel.tsx | 12 +- .../flagship/reform/DraftHeader.tsx | 13 +- app/src/hooks/useFlagshipStores.ts | 68 +++ app/src/hooks/useReportValidationSnapshot.ts | 11 +- app/src/hooks/useRunFlagshipReport.ts | 5 + app/src/libs/flagship/runReport.ts | 66 ++- app/src/libs/storeBackend.ts | 27 -- app/src/pages/flagship/Reforms.page.tsx | 18 +- app/src/pages/flagship/Report.page.tsx | 29 +- .../integration/api/reformRoutes.test.ts | 352 --------------- .../unit/api/flagshipReportStore.test.ts | 195 +++++++-- app/src/tests/unit/api/reformStore.test.ts | 346 ++++++++------- .../unit/libs/flagship/runReport.test.ts | 95 ++++ app/src/tests/unit/libs/storeBackend.test.ts | 35 -- app/src/types/metadata/reformMetadata.ts | 51 --- bun.lock | 251 +++++------ calculator-app/drizzle.config.ts | 11 - calculator-app/package.json | 10 +- .../src/app/api/reforms/[reformId]/route.ts | 165 ------- calculator-app/src/app/api/reforms/route.ts | 127 ------ .../src/app/api/reports/[reportId]/route.ts | 84 ---- calculator-app/src/app/api/reports/route.ts | 116 ----- calculator-app/src/db/README.md | 59 --- calculator-app/src/db/index.ts | 38 -- .../src/db/migrations/0000_create-reforms.sql | 14 - .../src/db/migrations/0001_create-reports.sql | 14 - .../migrations/0002_add-report-validation.sql | 1 - .../src/db/migrations/meta/0000_snapshot.json | 118 ----- .../src/db/migrations/meta/0001_snapshot.json | 215 --------- .../src/db/migrations/meta/0002_snapshot.json | 221 ---------- .../src/db/migrations/meta/_journal.json | 27 -- calculator-app/src/db/schema.ts | 125 ------ calculator-app/src/db/serialize.ts | 36 -- calculator-app/src/vite-env-shim.d.ts | 1 - 37 files changed, 1039 insertions(+), 2688 deletions(-) delete mode 100644 app/src/adapters/ReformAdapter.ts create mode 100644 app/src/hooks/useFlagshipStores.ts delete mode 100644 app/src/libs/storeBackend.ts delete mode 100644 app/src/tests/integration/api/reformRoutes.test.ts delete mode 100644 app/src/tests/unit/libs/storeBackend.test.ts delete mode 100644 app/src/types/metadata/reformMetadata.ts delete mode 100644 calculator-app/drizzle.config.ts delete mode 100644 calculator-app/src/app/api/reforms/[reformId]/route.ts delete mode 100644 calculator-app/src/app/api/reforms/route.ts delete mode 100644 calculator-app/src/app/api/reports/[reportId]/route.ts delete mode 100644 calculator-app/src/app/api/reports/route.ts delete mode 100644 calculator-app/src/db/README.md delete mode 100644 calculator-app/src/db/index.ts delete mode 100644 calculator-app/src/db/migrations/0000_create-reforms.sql delete mode 100644 calculator-app/src/db/migrations/0001_create-reports.sql delete mode 100644 calculator-app/src/db/migrations/0002_add-report-validation.sql delete mode 100644 calculator-app/src/db/migrations/meta/0000_snapshot.json delete mode 100644 calculator-app/src/db/migrations/meta/0001_snapshot.json delete mode 100644 calculator-app/src/db/migrations/meta/0002_snapshot.json delete mode 100644 calculator-app/src/db/migrations/meta/_journal.json delete mode 100644 calculator-app/src/db/schema.ts delete mode 100644 calculator-app/src/db/serialize.ts diff --git a/app/src/adapters/ReformAdapter.ts b/app/src/adapters/ReformAdapter.ts deleted file mode 100644 index 97c7d05cd..000000000 --- a/app/src/adapters/ReformAdapter.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Reform } from '@/types/ingredients/Reform'; -import { - ReformCreationMetadata, - ReformMetadata, - ReformParameterMetadata, - ReformUpdateMetadata, -} from '@/types/metadata/reformMetadata'; -import { Parameter } from '@/types/subIngredients/parameter'; - -/** - * Adapter for converting between Reform and API formats - */ -export class ReformAdapter { - private static parameterToMetadata(parameter: Parameter): ReformParameterMetadata { - return { - name: parameter.name, - values: parameter.values.map((interval) => ({ - start_date: interval.startDate, - end_date: interval.endDate, - value: interval.value, - })), - }; - } - - private static parameterFromMetadata(metadata: ReformParameterMetadata): Parameter { - return { - name: metadata.name, - values: metadata.values.map((interval) => ({ - startDate: interval.start_date, - endDate: interval.end_date, - value: interval.value, - })), - }; - } - - /** - * Convert Reform to API creation payload - * Handles camelCase to snake_case conversion - */ - static toCreationPayload( - reform: Omit - ): ReformCreationMetadata { - return { - user_id: String(reform.userId), - country_id: reform.countryId, - label: reform.label ?? null, - policy_id: reform.policyId ?? null, - parameters: reform.parameters.map(ReformAdapter.parameterToMetadata), - baseline: reform.baseline, - provenance: { - source: reform.provenance.source, - ref: reform.provenance.ref ?? null, - confidence: reform.provenance.confidence ?? null, - }, - }; - } - - /** - * Convert partial Reform updates to API update payload - */ - static toUpdatePayload( - updates: Partial> - ): ReformUpdateMetadata { - const payload: ReformUpdateMetadata = {}; - if (updates.label !== undefined) { - payload.label = updates.label; - } - if (updates.policyId !== undefined) { - payload.policy_id = updates.policyId; - } - if (updates.parameters !== undefined) { - payload.parameters = updates.parameters.map(ReformAdapter.parameterToMetadata); - } - if (updates.baseline !== undefined) { - payload.baseline = updates.baseline; - } - if (updates.provenance !== undefined) { - payload.provenance = { - source: updates.provenance.source, - ref: updates.provenance.ref ?? null, - confidence: updates.provenance.confidence ?? null, - }; - } - return payload; - } - - /** - * Convert API response to Reform - * Handles snake_case to camelCase conversion - * Explicitly coerces IDs to strings to handle JSON.parse type mismatches - */ - static fromApiResponse(apiData: ReformMetadata): Reform { - return { - id: String(apiData.id), - userId: String(apiData.user_id), - countryId: apiData.country_id, - label: apiData.label ?? undefined, - policyId: apiData.policy_id != null ? String(apiData.policy_id) : undefined, - parameters: apiData.parameters.map(ReformAdapter.parameterFromMetadata), - baseline: apiData.baseline, - provenance: { - source: apiData.provenance.source, - ref: apiData.provenance.ref ?? undefined, - confidence: apiData.provenance.confidence ?? undefined, - }, - createdAt: apiData.created_at, - updatedAt: apiData.updated_at, - }; - } -} diff --git a/app/src/api/flagshipReportStore.ts b/app/src/api/flagshipReportStore.ts index d5118d456..c1eea9097 100644 --- a/app/src/api/flagshipReportStore.ts +++ b/app/src/api/flagshipReportStore.ts @@ -1,17 +1,20 @@ +import { AccountAssociationRoutes, isLegacyId, type ApiStoreContext } from '@/api/v2Associations'; +import { resolveImportedId } from '@/libs/auth/importedIds'; import { CountryId } from '@/libs/countries'; import { ReportValidationSnapshot, validationFromWire, validationToWire, } from '@/libs/flagship/reportValidation'; -import { RunReportProvision } from '@/libs/flagship/runReport'; +import type { RunReportProvision } from '@/libs/flagship/runReport'; +import type { ReportProvisionWire, UserReportCreateBody, UserReportItem } from '@/types/v2User'; /** - * Store for flagship report records: a pointer to the computed output - * on the PolicyEngine API plus the provenance snapshot for the report - * header. Central Supabase-backed store first, localStorage fallback - * when it is unconfigured — same resilience pattern as the reform - * store. + * Store for flagship report records: a pointer to the computed output on + * the PolicyEngine API plus the provenance snapshot for the report + * header. Signed out (and outside the US and UK) records live in + * localStorage; signed in, a record is the account's `/v2/user-reports` + * row for the report, carrying the v3 fields. */ export interface FlagshipReportRecord { id: string; @@ -22,7 +25,8 @@ export interface FlagshipReportRecord { title: string | null; sourceNote: string | null; provisions: RunReportProvision[]; - year: string; + /** The simulated year; localStorage records only (the report itself carries it). */ + year?: string; createdAt: string; /** Saved reform this report came from, when known. */ reformId?: string | null; @@ -42,134 +46,125 @@ export interface FlagshipReportStore { saveValidation: (id: string, snapshot: ReportValidationSnapshot) => Promise; } -class ReportStoreUnavailableError extends Error { - constructor() { - super('Report store unavailable'); - this.name = 'ReportStoreUnavailableError'; - } +/** Provisions on the wire: snake_case, dated changes as start_date/end_date. */ +export function provisionsToWire(provisions: RunReportProvision[]): ReportProvisionWire[] { + return provisions.map((provision) => ({ + path: provision.path, + breadcrumb: provision.breadcrumb, + unit: provision.unit ?? null, + baseline_value: provision.baselineValue ?? null, + value: provision.value ?? null, + intervals: + provision.intervals && provision.intervals.length > 0 + ? provision.intervals.map((interval) => ({ + start_date: interval.startDate, + end_date: interval.endDate, + value: interval.value ?? null, + })) + : null, + })); } -function throwIfUnavailable(response: Response): void { - if (response.status === 503 || response.status === 404) { - throw new ReportStoreUnavailableError(); - } +export function provisionsFromWire( + wire: ReportProvisionWire[] | null | undefined +): RunReportProvision[] { + return (wire ?? []).map((provision) => ({ + path: provision.path, + breadcrumb: provision.breadcrumb, + unit: provision.unit ?? null, + baselineValue: provision.baseline_value, + value: provision.value, + ...(provision.intervals && provision.intervals.length > 0 + ? { + intervals: provision.intervals.map((interval) => ({ + startDate: interval.start_date, + endDate: interval.end_date, + value: interval.value as any, + })), + } + : {}), + })); } -function fromMetadata(data: any): FlagshipReportRecord { +export function recordFromItem(item: UserReportItem): FlagshipReportRecord { return { - id: data.id, - userId: data.user_id, - countryId: data.country_id, - apiReportId: data.api_report_id, - title: data.title, - sourceNote: data.source_note, - provisions: (data.provisions ?? []).map((p: any) => ({ - path: p.path, - breadcrumb: p.breadcrumb, - unit: p.unit ?? null, - baselineValue: p.baseline_value, - value: p.value, - })), - year: data.year, - createdAt: data.created_at, - reformId: data.reform_id ?? null, - validation: validationFromWire(data.validation), + id: item.id, + userId: item.user_id, + countryId: item.country_id, + apiReportId: String(item.legacy_report_id), + title: item.name, + sourceNote: item.source_note, + provisions: provisionsFromWire(item.provisions), + createdAt: item.created_at, + reformId: item.user_policy_id, + validation: validationFromWire(item.validation), }; } +/** Flagship report records as the account's `/v2/user-reports` rows. */ export class ApiFlagshipReportStore implements FlagshipReportStore { - private readonly BASE_URL = '/api/reports'; + private readonly routes: AccountAssociationRoutes; + + constructor(private readonly context: ApiStoreContext) { + this.routes = new AccountAssociationRoutes(context, 'user-reports', 'Report'); + } async create(report: NewFlagshipReport): Promise { - const response = await fetch(this.BASE_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - user_id: report.userId, - country_id: report.countryId, - api_report_id: report.apiReportId, - title: report.title, - source_note: report.sourceNote, - provisions: report.provisions.map((p) => ({ - path: p.path, - breadcrumb: p.breadcrumb, - unit: p.unit, - baseline_value: p.baselineValue, - value: p.value, - })), - year: report.year, - reform_id: report.reformId ?? null, - }), - }); - if (!response.ok) { - throwIfUnavailable(response); - throw new Error('Failed to save the report record'); - } - return fromMetadata(await response.json()); + // The reform link must be one of this account's rows: a UUID, or a + // local reform ID this browser imported. + const userPolicyId = report.reformId + ? resolveImportedId(this.context.userId, report.reformId) + : null; + const body: UserReportCreateBody = { + country_id: this.context.countryId, + legacy_report_id: Number(report.apiReportId), + name: report.title || null, + source_note: report.sourceNote || null, + provisions: provisionsToWire(report.provisions), + ...(userPolicyId ? { user_policy_id: userPolicyId } : {}), + last_run_at: new Date().toISOString(), + }; + return recordFromItem(await this.routes.create(body)); } - async findByUser(userId: string, countryId?: string): Promise { - const params = new URLSearchParams({ user_id: userId }); - if (countryId) { - params.set('country_id', countryId); - } - const response = await fetch(`${this.BASE_URL}?${params}`); - if (!response.ok) { - throwIfUnavailable(response); - throw new Error('Failed to load reports'); + async findByUser(_userId: string, countryId?: string): Promise { + if (countryId && countryId !== this.context.countryId) { + return []; } - return ((await response.json()) as any[]).map(fromMetadata); + const items = await this.routes.list(); + return items.filter((item) => isLegacyId(item.legacy_report_id)).map(recordFromItem); } async findById(id: string): Promise { - const response = await fetch(`${this.BASE_URL}/${id}`); - if (response.status === 404) { - return null; - } - if (!response.ok) { - throwIfUnavailable(response); - throw new Error('Failed to load the report'); - } - return fromMetadata(await response.json()); + const item = await this.routes.get(id); + return item && isLegacyId(item.legacy_report_id) ? recordFromItem(item) : null; } async findByApiReportId( - userId: string, + _userId: string, apiReportId: string ): Promise { - const params = new URLSearchParams({ user_id: userId, api_report_id: apiReportId }); - const response = await fetch(`${this.BASE_URL}?${params}`); - if (!response.ok) { - throwIfUnavailable(response); - throw new Error('Failed to load the report'); + if (!isLegacyId(apiReportId)) { + return null; } - const records = ((await response.json()) as any[]).map(fromMetadata); - return records[0] ?? null; + const item = await this.routes.findFirst({ legacy_report_id: apiReportId }); + return item ? recordFromItem(item) : null; } async saveValidation( id: string, snapshot: ReportValidationSnapshot ): Promise { - const response = await fetch(`${this.BASE_URL}/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ validation: validationToWire(snapshot) }), - }); - if (!response.ok) { - throwIfUnavailable(response); - throw new Error('Failed to save the validation snapshot'); - } - return fromMetadata(await response.json()); + return recordFromItem(await this.routes.patch(id, { validation: validationToWire(snapshot) })); } } -const LOCAL_KEY = 'pe-flagship-reports'; +export const FLAGSHIP_REPORTS_KEY = 'pe-flagship-reports'; export class LocalStorageFlagshipReportStore implements FlagshipReportStore { private read(): FlagshipReportRecord[] { try { - return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]'); + return JSON.parse(localStorage.getItem(FLAGSHIP_REPORTS_KEY) ?? '[]'); } catch { return []; } @@ -177,7 +172,7 @@ export class LocalStorageFlagshipReportStore implements FlagshipReportStore { private write(records: FlagshipReportRecord[]): void { try { - localStorage.setItem(LOCAL_KEY, JSON.stringify(records)); + localStorage.setItem(FLAGSHIP_REPORTS_KEY, JSON.stringify(records)); } catch { // Reports simply won't persist locally. } @@ -230,54 +225,7 @@ export class LocalStorageFlagshipReportStore implements FlagshipReportStore { } } -let centralUnavailable = false; - -class ResilientFlagshipReportStore implements FlagshipReportStore { - constructor( - private readonly api: FlagshipReportStore = new ApiFlagshipReportStore(), - private readonly local: FlagshipReportStore = new LocalStorageFlagshipReportStore() - ) {} - - private async withFallback(run: (store: FlagshipReportStore) => Promise): Promise { - if (!centralUnavailable) { - try { - return await run(this.api); - } catch (error) { - if (!(error instanceof ReportStoreUnavailableError)) { - throw error; - } - centralUnavailable = true; - } - } - return run(this.local); - } - - create(report: NewFlagshipReport): Promise { - return this.withFallback((store) => store.create(report)); - } - - findByUser(userId: string, countryId?: string): Promise { - return this.withFallback((store) => store.findByUser(userId, countryId)); - } - - findById(id: string): Promise { - return this.withFallback((store) => store.findById(id)); - } - - findByApiReportId(userId: string, apiReportId: string): Promise { - return this.withFallback((store) => store.findByApiReportId(userId, apiReportId)); - } - - saveValidation(id: string, snapshot: ReportValidationSnapshot): Promise { - return this.withFallback((store) => store.saveValidation(id, snapshot)); - } -} - -/** Test hook: reset the remembered central-store availability. */ -export function resetFlagshipReportStoreAvailability(): void { - centralUnavailable = false; -} - -export function getFlagshipReportStore(): FlagshipReportStore { - return new ResilientFlagshipReportStore(); +/** The report store for a session: the account's when given one, else localStorage. */ +export function getFlagshipReportStore(account?: ApiStoreContext | null): FlagshipReportStore { + return account ? new ApiFlagshipReportStore(account) : new LocalStorageFlagshipReportStore(); } diff --git a/app/src/api/reformStore.ts b/app/src/api/reformStore.ts index a7db9c92a..c2280c630 100644 --- a/app/src/api/reformStore.ts +++ b/app/src/api/reformStore.ts @@ -1,15 +1,33 @@ -import { ReformAdapter } from '@/adapters/ReformAdapter'; -import { getStoreBackend } from '@/libs/storeBackend'; -import { Reform } from '@/types/ingredients/Reform'; -import { ReformMetadata } from '@/types/metadata/reformMetadata'; +import { convertDateRangeMapToValueIntervals } from '@/adapters/conversionHelpers'; +import { fetchPolicyById } from '@/api/policy'; +import { + accountAssociationId, + AccountAssociationRoutes, + isLegacyId, + type ApiStoreContext, +} from '@/api/v2Associations'; +import { V2ApiError } from '@/api/v2UserClient'; +import { createReformPolicy } from '@/libs/flagship/runReport'; +import { Reform, ReformBaseline, ReformProvenance } from '@/types/ingredients/Reform'; +import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; +import type { PolicyMetadataParams } from '@/types/metadata/policyMetadata'; +import { Parameter } from '@/types/subIngredients/parameter'; +import { ValueInterval } from '@/types/subIngredients/valueInterval'; +import type { + ProvenanceWire, + UserPolicyCreateBody, + UserPolicyItem, + UserPolicyPatchBody, +} from '@/types/v2User'; /** - * Store for the flagship app's canonical reform objects. + * Store for the flagship app's reform objects. * - * Reforms are user-owned, mutable content persisted centrally (unlike - * canonical Policy records, which live in the main PolicyEngine API and - * are immutable). The API implementation talks to /api/reforms, served - * by Next.js route handlers backed by Postgres. + * Signed out (and outside the US and UK) reforms live in localStorage. + * Signed in, a reform is a row in the account's `/v2/user-policies`: its + * parameters are materialized as a v1 policy (`POST /{country}/policy`, + * which dedupes identical ones), and the row carries the policy ID, the + * name, the baseline, and the provenance. */ export interface ReformStore { create: (reform: Omit) => Promise; @@ -22,125 +40,9 @@ export interface ReformStore { delete: (reformId: string) => Promise; } -/** - * The central store reported itself unconfigured (503, e.g. no - * DATABASE_URL) or absent (collection-level 404). Distinct from real - * failures so the resilient store knows falling back is safe. - */ -export class ReformStoreUnavailableError extends Error { - constructor() { - super('Reform store unavailable'); - this.name = 'ReformStoreUnavailableError'; - } -} - -function throwIfUnavailable(response: Response): void { - if (response.status === 503 || response.status === 404) { - throw new ReformStoreUnavailableError(); - } -} - -export class ApiReformStore implements ReformStore { - private readonly BASE_URL = '/api/reforms'; - - async create(reform: Omit): Promise { - const payload = ReformAdapter.toCreationPayload(reform); - - const response = await fetch(this.BASE_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throwIfUnavailable(response); - throw new Error('Failed to create reform'); - } - - const apiData: ReformMetadata = await response.json(); - return ReformAdapter.fromApiResponse(apiData); - } - - async findByUser(userId: string, countryId?: string): Promise { - const params = new URLSearchParams({ user_id: userId }); - if (countryId) { - params.set('country_id', countryId); - } - - const response = await fetch(`${this.BASE_URL}?${params.toString()}`, { - headers: { 'Content-Type': 'application/json' }, - }); - - if (!response.ok) { - throwIfUnavailable(response); - throw new Error('Failed to fetch reforms'); - } - - const apiData: ReformMetadata[] = await response.json(); - return apiData.map((item) => ReformAdapter.fromApiResponse(item)); - } - - async findById(reformId: string): Promise { - const response = await fetch(`${this.BASE_URL}/${reformId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - - // Item-level 404 legitimately means "not found", so only 503 - // signals an unconfigured store here. - if (response.status === 503) { - throw new ReformStoreUnavailableError(); - } - - if (response.status === 404) { - return null; - } - - if (!response.ok) { - throw new Error('Failed to fetch reform'); - } - - const apiData: ReformMetadata = await response.json(); - return ReformAdapter.fromApiResponse(apiData); - } - - async update( - reformId: string, - updates: Partial> - ): Promise { - const payload = ReformAdapter.toUpdatePayload(updates); - - const response = await fetch(`${this.BASE_URL}/${reformId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (response.status === 503) { - throw new ReformStoreUnavailableError(); - } - - if (!response.ok) { - throw new Error('Failed to update reform'); - } - - const apiData: ReformMetadata = await response.json(); - return ReformAdapter.fromApiResponse(apiData); - } - - async delete(reformId: string): Promise { - const response = await fetch(`${this.BASE_URL}/${reformId}`, { - method: 'DELETE', - }); - - if (response.status === 503) { - throw new ReformStoreUnavailableError(); - } - - if (!response.ok) { - throw new Error('Failed to delete reform'); - } - } -} +export type ReformUpdates = Partial< + Omit +>; export class LocalStorageReformStore implements ReformStore { private readonly STORAGE_KEY = 'reforms'; @@ -175,10 +77,7 @@ export class LocalStorageReformStore implements ReformStore { return this.getStoredReforms().find((r) => r.id === reformId) || null; } - async update( - reformId: string, - updates: Partial> - ): Promise { + async update(reformId: string, updates: ReformUpdates): Promise { const reforms = this.getStoredReforms(); const index = reforms.findIndex((r) => r.id === reformId); @@ -218,83 +117,217 @@ export class LocalStorageReformStore implements ReformStore { } } -// Once the central store reports itself unavailable, stay on -// localStorage for the rest of the session rather than hammering it. -let centralStoreUnavailable = false; +/** A v1 policy's date-range map back to dated values, without metadata coercion. */ +function rawIntervals(dateValueMap: Record): ValueInterval[] { + return Object.entries(dateValueMap).map(([dateRange, value]) => { + const [startDate, endDate] = dateRange.split('.'); + return { startDate, endDate, value: value as ValueInterval['value'] }; + }); +} + +/** + * A v1 policy's `policy_json` as reform parameters. Uses the app's + * policy → parameter conversion (with value coercion) when the parameter's + * metadata is loaded, and the stored values as-is otherwise. + */ +export function parametersFromPolicyJson( + policyJson: PolicyMetadataParams, + parameterMetadata?: ParameterMetadataCollection | null +): Parameter[] { + return Object.entries(policyJson ?? {}).map(([name, dateValueMap]) => { + let values: ValueInterval[]; + try { + values = parameterMetadata?.[name] + ? convertDateRangeMapToValueIntervals(name, dateValueMap, parameterMetadata) + : rawIntervals(dateValueMap); + } catch { + values = rawIntervals(dateValueMap); + } + return { name, values }; + }); +} + +function provenanceToWire(provenance: ReformProvenance): ProvenanceWire { + return { + source: provenance.source, + ref: provenance.ref ?? null, + confidence: provenance.confidence ?? null, + }; +} + +function provenanceFromWire(wire: ProvenanceWire | null): ReformProvenance { + if (!wire) { + return { source: 'manual' }; + } + return { + source: wire.source, + ...(wire.ref ? { ref: wire.ref } : {}), + ...(wire.confidence ? { confidence: wire.confidence } : {}), + }; +} + +export interface ApiReformStoreOptions { + /** Current parameter metadata, for coercing values read back from v1 policies. */ + getParameterMetadata?: () => ParameterMetadataCollection | null | undefined; + /** Materializes parameters as a v1 policy; returns its ID. */ + materializePolicy?: ( + countryId: string, + label: string | null | undefined, + parameters: Parameter[] + ) => Promise; + /** Reads a v1 policy's parameter map. */ + fetchPolicyJson?: (countryId: string, policyId: string) => Promise; +} + +/** The v1 policy for a set of parameters, via the run bridge's policy creation. */ +export function materializeReformPolicy( + countryId: string, + label: string | null | undefined, + parameters: Parameter[] +): Promise { + return createReformPolicy( + countryId as Reform['countryId'], + label ?? '', + parameters.map((parameter) => ({ + path: parameter.name, + value: parameter.values[0]?.value, + intervals: parameter.values, + })) + ); +} -/** Test hook: reset the session-level availability flag. */ -export function resetReformStoreAvailabilityForTesting(): void { - centralStoreUnavailable = false; +async function fetchV1PolicyJson( + countryId: string, + policyId: string +): Promise { + return (await fetchPolicyById(countryId, policyId)).policy_json ?? {}; } /** - * Tries the central store first and falls back to localStorage when it - * is unconfigured (503) or absent — so the flagship shell works before - * the database is provisioned. Real failures (500s, validation) still - * surface to the caller. + * Reforms in the account (`/v2/user-policies`). Parameter edits create a + * new v1 policy and repoint the row (`legacy_policy_id`); a rename only + * patches the name. */ -export class ResilientReformStore implements ReformStore { +export class ApiReformStore implements ReformStore { + private readonly routes: AccountAssociationRoutes; + private readonly getParameterMetadata: NonNullable; + private readonly materializePolicy: NonNullable; + private readonly fetchPolicyJson: NonNullable; + constructor( - private readonly api: ReformStore = new ApiReformStore(), - private readonly local: ReformStore = new LocalStorageReformStore() - ) {} + private readonly context: ApiStoreContext, + options: ApiReformStoreOptions = {} + ) { + this.routes = new AccountAssociationRoutes(context, 'user-policies', 'Reform'); + this.getParameterMetadata = options.getParameterMetadata ?? (() => null); + this.materializePolicy = options.materializePolicy ?? materializeReformPolicy; + this.fetchPolicyJson = options.fetchPolicyJson ?? fetchV1PolicyJson; + } - private async withFallback( - viaApi: (store: ReformStore) => Promise, - viaLocal: (store: ReformStore) => Promise - ): Promise { - if (!centralStoreUnavailable) { - try { - return await viaApi(this.api); - } catch (error) { - if (!(error instanceof ReformStoreUnavailableError)) { - throw error; - } - centralStoreUnavailable = true; - } + async create(reform: Omit): Promise { + const legacyPolicyId = isLegacyId(reform.policyId) + ? Number(reform.policyId) + : await this.materializePolicy(this.context.countryId, reform.label, reform.parameters); + const body: UserPolicyCreateBody = { + country_id: this.context.countryId, + legacy_policy_id: legacyPolicyId, + ...(reform.label ? { name: reform.label } : {}), + baseline: reform.baseline, + provenance: provenanceToWire(reform.provenance), + }; + const item = await this.routes.create(body); + return this.toReform(item, reform.parameters); + } + + async findByUser(_userId: string, countryId?: string): Promise { + if (countryId && countryId !== this.context.countryId) { + return []; } - return viaLocal(this.local); + const items = (await this.routes.list()).filter((item) => isLegacyId(item.legacy_policy_id)); + return Promise.all(items.map((item) => this.toReform(item))); } - create(reform: Omit): Promise { - return this.withFallback( - (store) => store.create(reform), - (store) => store.create(reform) - ); + async findById(reformId: string): Promise { + const item = await this.routes.get(reformId); + return item && isLegacyId(item.legacy_policy_id) ? this.toReform(item) : null; } - findByUser(userId: string, countryId?: string): Promise { - return this.withFallback( - (store) => store.findByUser(userId, countryId), - (store) => store.findByUser(userId, countryId) - ); + async update(reformId: string, updates: ReformUpdates): Promise { + if (!accountAssociationId(this.context, reformId)) { + return this.saveLocalReform(reformId, updates); + } + const body: UserPolicyPatchBody = {}; + if (updates.label !== undefined) { + body.name = updates.label || null; + } + if (updates.baseline !== undefined) { + body.baseline = updates.baseline; + } + if (updates.provenance !== undefined) { + body.provenance = provenanceToWire(updates.provenance); + } + if (updates.parameters !== undefined) { + body.legacy_policy_id = await this.materializePolicy( + this.context.countryId, + updates.label, + updates.parameters + ); + } else if (updates.policyId !== undefined && isLegacyId(updates.policyId)) { + body.legacy_policy_id = Number(updates.policyId); + } + const item = await this.routes.patch(reformId, body); + return this.toReform(item, updates.parameters); } - findById(reformId: string): Promise { - return this.withFallback( - (store) => store.findById(reformId), - (store) => store.findById(reformId) - ); + delete(reformId: string): Promise { + return this.routes.delete(reformId); } - update( - reformId: string, - updates: Partial> - ): Promise { - return this.withFallback( - (store) => store.update(reformId, updates), - (store) => store.update(reformId, updates) - ); + /** + * A reform that exists only in this browser (a draft begun while signed + * out, never imported): saving it while signed in adds it to the account. + */ + private async saveLocalReform(reformId: string, updates: ReformUpdates): Promise { + const local = await new LocalStorageReformStore().findById(reformId); + if (!local) { + throw new V2ApiError(404, `Reform with id ${reformId} not found`); + } + const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...fields } = local; + return this.create({ + ...fields, + ...updates, + userId: this.context.userId, + policyId: updates.parameters ? null : fields.policyId, + }); } - delete(reformId: string): Promise { - return this.withFallback( - (store) => store.delete(reformId), - (store) => store.delete(reformId) - ); + private async toReform(item: UserPolicyItem, knownParameters?: Parameter[]): Promise { + const policyId = String(item.legacy_policy_id); + const parameters = + knownParameters ?? + parametersFromPolicyJson( + await this.fetchPolicyJson(item.country_id, policyId), + this.getParameterMetadata() + ); + return { + id: item.id, + userId: item.user_id, + countryId: item.country_id, + label: item.name ?? undefined, + policyId, + parameters, + baseline: (item.baseline ?? 'current-law') as ReformBaseline, + provenance: provenanceFromWire(item.provenance), + createdAt: item.created_at, + updatedAt: item.updated_at, + }; } } -export function getReformStore(): ReformStore { - return getStoreBackend() === 'api' ? new ResilientReformStore() : new LocalStorageReformStore(); +/** The reform store for a session: the account's when given one, else localStorage. */ +export function getReformStore( + account?: ApiStoreContext | null, + options?: ApiReformStoreOptions +): ReformStore { + return account ? new ApiReformStore(account, options) : new LocalStorageReformStore(); } diff --git a/app/src/components/flagship/ReportAdjustPanel.tsx b/app/src/components/flagship/ReportAdjustPanel.tsx index 81aed92f5..fc77e71fb 100644 --- a/app/src/components/flagship/ReportAdjustPanel.tsx +++ b/app/src/components/flagship/ReportAdjustPanel.tsx @@ -1,12 +1,13 @@ import { useState } from 'react'; import { IconChartBar, IconX } from '@tabler/icons-react'; import { useQueryClient } from '@tanstack/react-query'; -import { getReformStore } from '@/api/reformStore'; import { Button, Stack, Text } from '@/components/ui'; -import { CURRENT_YEAR, FOREVER, MOCK_USER_ID } from '@/constants'; +import { CURRENT_YEAR, FOREVER } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useReformStore } from '@/hooks/useFlagshipStores'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { RunReportProvision } from '@/libs/flagship/runReport'; import { Reform } from '@/types/ingredients/Reform'; import { formatCompactBreadcrumb } from '@/utils/parameterLabels'; @@ -49,6 +50,8 @@ export default function ReportAdjustPanel({ }: ReportAdjustPanelProps) { const runReport = useRunFlagshipReport(); const countryId = useCurrentCountry(); + const store = useReformStore(); + const userId = useCurrentUserId(); const queryClient = useQueryClient(); const [removed, setRemoved] = useState>(new Set()); const [reconcileError, setReconcileError] = useState(null); @@ -77,8 +80,7 @@ export default function ReportAdjustPanel({ // Reconcile with saved reforms: reuse an exact match, otherwise // save the adjusted set as its own reform. - const store = getReformStore(); - const existing = await store.findByUser(MOCK_USER_ID, countryId); + const existing = await store.findByUser(userId, countryId); const match = existing.find((reform) => reformMatches(reform, adjusted)); let reformId: string; @@ -88,7 +90,7 @@ export default function ReportAdjustPanel({ runTitle = match.label || `${title} (adjusted)`; } else { const created = await store.create({ - userId: MOCK_USER_ID, + userId, countryId, label: `${title} (adjusted)`, parameters: adjusted.map((provision) => ({ diff --git a/app/src/components/flagship/reform/DraftHeader.tsx b/app/src/components/flagship/reform/DraftHeader.tsx index 160fca147..8aee233f0 100644 --- a/app/src/components/flagship/reform/DraftHeader.tsx +++ b/app/src/components/flagship/reform/DraftHeader.tsx @@ -1,7 +1,6 @@ import { IconCalendarEvent, IconChartBar, IconUsers } from '@tabler/icons-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import { getReformStore } from '@/api/reformStore'; import { Badge, Button, @@ -11,10 +10,11 @@ import { SelectTrigger, SelectValue, } from '@/components/ui'; -import { MOCK_USER_ID } from '@/constants'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; +import { useReformStore } from '@/hooks/useFlagshipStores'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { clearDraftReform, DraftReform, @@ -112,6 +112,8 @@ export default function DraftHeader({ draft }: { draft: DraftReform }) { const nav = useAppNavigate(); const queryClient = useQueryClient(); const runReport = useRunFlagshipReport(); + const reformStore = useReformStore(); + const userId = useCurrentUserId(); const taxYears = useSelector(getTaxYears); const parameters = useSelector((state: RootState) => state.metadata.parameters); const currentLawAt = (path: string, at: number) => { @@ -123,16 +125,15 @@ export default function DraftHeader({ draft }: { draft: DraftReform }) { const saveMutation = useMutation({ mutationFn: async () => { - const store = getReformStore(); - const payload = draftToReform(draft, MOCK_USER_ID); + const payload = draftToReform(draft, userId); if (draft.editingReformId) { - return store.update(draft.editingReformId, { + return reformStore.update(draft.editingReformId, { label: payload.label, parameters: payload.parameters, provenance: payload.provenance, }); } - return store.create(payload); + return reformStore.create(payload); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['reforms'] }); diff --git a/app/src/hooks/useFlagshipStores.ts b/app/src/hooks/useFlagshipStores.ts new file mode 100644 index 000000000..67a77d910 --- /dev/null +++ b/app/src/hooks/useFlagshipStores.ts @@ -0,0 +1,68 @@ +import { useMemo, useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useSelector } from 'react-redux'; +import { getFlagshipReportStore, type FlagshipReportStore } from '@/api/flagshipReportStore'; +import { getReformStore, type ReformStore } from '@/api/reformStore'; +import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; +import type { RootState } from '@/store'; + +/** + * The flagship (v3) stores for the current session: the account's when + * signed in (US/UK), localStorage otherwise. + */ + +export function useReformStore(): ReformStore { + const backend = useAssociationBackend(); + const parameters = useSelector((state: RootState) => state.metadata?.parameters); + // Read through a ref so metadata arriving later does not rebuild the store. + const parametersRef = useRef(parameters); + parametersRef.current = parameters; + + return useMemo( + () => + getReformStore(backend.mode === 'api' ? backend : null, { + getParameterMetadata: () => parametersRef.current, + }), + [backend] + ); +} + +export interface FlagshipReportStoreSelection { + store: FlagshipReportStore; + /** True when records go to the account (one `/v2/user-reports` row per run). */ + isAccount: boolean; + /** The user ID records are filed under. */ + userId: string; + /** False while the session is being restored. */ + ready: boolean; +} + +export function useFlagshipReportStore(): FlagshipReportStoreSelection { + const backend = useAssociationBackend(); + return useMemo( + () => ({ + store: getFlagshipReportStore(backend.mode === 'api' ? backend : null), + isAccount: backend.mode === 'api', + userId: backend.userId, + ready: backend.ready, + }), + [backend] + ); +} + +/** + * The account's record for an API report — its title, source note and + * provisions — so a report opened on another device shows the same + * header. Signed-in (US/UK) sessions only; null otherwise. + */ +export function useAccountReportRecord(apiReportId: string | undefined) { + const { store, isAccount, userId, ready } = useFlagshipReportStore(); + const { data } = useQuery({ + queryKey: ['flagship-report-record', userId, apiReportId ?? ''], + queryFn: () => store.findByApiReportId(userId, apiReportId!), + enabled: isAccount && ready && !!apiReportId, + staleTime: 5 * 60 * 1000, + retry: 1, + }); + return isAccount ? (data ?? null) : null; +} diff --git a/app/src/hooks/useReportValidationSnapshot.ts b/app/src/hooks/useReportValidationSnapshot.ts index 422b51dde..b9b2e56d3 100644 --- a/app/src/hooks/useReportValidationSnapshot.ts +++ b/app/src/hooks/useReportValidationSnapshot.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { getFlagshipReportStore } from '@/api/flagshipReportStore'; +import { useFlagshipReportStore } from '@/hooks/useFlagshipStores'; import { isLiveValidationSettled, LiveValidation, @@ -26,6 +26,7 @@ export function useReportValidationSnapshot( apiReportId: string | undefined, live: LiveValidation ): ReportValidationState { + const { store, ready } = useFlagshipReportStore(); const [snapshot, setSnapshot] = useState(null); const [recordId, setRecordId] = useState(null); const pinned = useRef(false); @@ -35,10 +36,10 @@ export function useReportValidationSnapshot( pinned.current = false; setSnapshot(null); setRecordId(null); - if (!apiReportId) { + if (!apiReportId || !ready) { return; } - getFlagshipReportStore() + store .findByApiReportId(userId, apiReportId) .then((record) => { if (cancelled || !record) { @@ -56,7 +57,7 @@ export function useReportValidationSnapshot( return () => { cancelled = true; }; - }, [userId, apiReportId]); + }, [store, ready, userId, apiReportId]); const settled = isLiveValidationSettled(live); useEffect(() => { @@ -65,7 +66,7 @@ export function useReportValidationSnapshot( } pinned.current = true; const fresh = snapshotFromLive(live); - getFlagshipReportStore() + store .saveValidation(recordId, fresh) .then((record) => setSnapshot(record.validation ?? fresh)) .catch(() => { diff --git a/app/src/hooks/useRunFlagshipReport.ts b/app/src/hooks/useRunFlagshipReport.ts index cf8dbe2d2..f8c3aa963 100644 --- a/app/src/hooks/useRunFlagshipReport.ts +++ b/app/src/hooks/useRunFlagshipReport.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useFlagshipReportStore } from '@/hooks/useFlagshipStores'; import { markDraftRun } from '@/libs/draftReform'; import { runFlagshipReport, RunReportProvision } from '@/libs/flagship/runReport'; import { RootState } from '@/store'; @@ -15,6 +16,7 @@ export function useRunFlagshipReport() { const nav = useAppNavigate(); const countryId = useCurrentCountry(); const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); + const reportStore = useFlagshipReportStore(); const [isRunning, setIsRunning] = useState(false); const [error, setError] = useState(null); @@ -55,6 +57,9 @@ export function useRunFlagshipReport() { currentLawId: Number(currentLawId), reformId, year, + account: reportStore.isAccount + ? { reportStore: reportStore.store, userId: reportStore.userId } + : null, }); if (fromDraft) { markDraftRun(userReportId); diff --git a/app/src/libs/flagship/runReport.ts b/app/src/libs/flagship/runReport.ts index 52a5affc7..1d0e0c279 100644 --- a/app/src/libs/flagship/runReport.ts +++ b/app/src/libs/flagship/runReport.ts @@ -1,5 +1,6 @@ +import type { FlagshipReportStore } from '@/api/flagshipReportStore'; import { createPolicy } from '@/api/policy'; -import { createReportAndAssociateWithUser } from '@/api/report'; +import { createReport, createReportAndAssociateWithUser } from '@/api/report'; import { createSimulation } from '@/api/simulation'; import { CURRENT_YEAR, FOREVER, MOCK_USER_ID } from '@/constants'; import { CountryId } from '@/libs/countries'; @@ -32,11 +33,11 @@ export interface FlagshipReportMeta { createdAt: string; } -const META_KEY = 'pe-flagship-report-meta'; +export const REPORT_META_KEY = 'pe-flagship-report-meta'; function readAllMeta(): Record { try { - return JSON.parse(localStorage.getItem(META_KEY) ?? '{}'); + return JSON.parse(localStorage.getItem(REPORT_META_KEY) ?? '{}'); } catch { return {}; } @@ -44,7 +45,10 @@ function readAllMeta(): Record { export function saveReportMeta(userReportId: string, meta: FlagshipReportMeta): void { try { - localStorage.setItem(META_KEY, JSON.stringify({ ...readAllMeta(), [userReportId]: meta })); + localStorage.setItem( + REPORT_META_KEY, + JSON.stringify({ ...readAllMeta(), [userReportId]: meta }) + ); } catch { // Provenance header simply won't show if storage is unavailable. } @@ -65,6 +69,13 @@ export interface RunFlagshipReportArgs { reformId?: string | null; /** The year to simulate; the current year when unset. */ year?: number; + /** + * Signed in (US/UK): the account's report store and user. The run then + * writes exactly one `/v2/user-reports` row — with the title, source + * note, provisions and reform link — instead of the localStorage + * association and record. + */ + account?: { reportStore: FlagshipReportStore; userId: string } | null; } /** @@ -108,6 +119,7 @@ export async function runFlagshipReport({ currentLawId, reformId, year = Number(CURRENT_YEAR), + account, }: RunFlagshipReportArgs): Promise { if (provisions.length === 0) { throw new Error('Cannot run a report with no provisions'); @@ -128,15 +140,42 @@ export async function runFlagshipReport({ }), ]); + const payload = { + simulation_1_id: Number(baseline.result.simulation_id), + simulation_2_id: Number(reform.result.simulation_id), + year: String(year), + }; + + if (account) { + // Signed in: the account's row is the report's only association. It is + // written right after the v1 report exists, and a failure fails the + // run, so a report that opens is a report that was saved. + const reportMetadata = await createReport(countryId, payload); + const reportId = String(reportMetadata.id); + saveReportMeta(reportId, { + title, + sourceNote, + provisions, + createdAt: new Date().toISOString(), + }); + await account.reportStore.create({ + userId: account.userId, + countryId, + apiReportId: reportId, + title: title || null, + sourceNote: sourceNote || null, + provisions, + year: String(year), + reformId: reformId ?? null, + }); + return reportId; + } + const { metadata } = await createReportAndAssociateWithUser({ countryId, userId: MOCK_USER_ID, label: title || undefined, - payload: { - simulation_1_id: Number(baseline.result.simulation_id), - simulation_2_id: Number(reform.result.simulation_id), - year: String(year), - }, + payload, }); const reportId = String(metadata.baseReportId); @@ -147,12 +186,11 @@ export async function runFlagshipReport({ createdAt: new Date().toISOString(), }); - // Record the report centrally (pointer + provenance; never outputs). - // Best-effort: the run already succeeded, and the store falls back to - // localStorage when the database is unconfigured. + // Record the report locally (pointer + provenance; never outputs). + // Best-effort: the run already succeeded. try { - const { getFlagshipReportStore } = await import('@/api/flagshipReportStore'); - await getFlagshipReportStore().create({ + const { LocalStorageFlagshipReportStore } = await import('@/api/flagshipReportStore'); + await new LocalStorageFlagshipReportStore().create({ userId: MOCK_USER_ID, countryId, apiReportId: String(metadata.baseReportId), diff --git a/app/src/libs/storeBackend.ts b/app/src/libs/storeBackend.ts deleted file mode 100644 index 48323f0e8..000000000 --- a/app/src/libs/storeBackend.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Store Backend Selection - * - * Centralized logic for determining whether association data should be - * read from the v2 API or localStorage. - * - * All user-ingredient associations (reports, simulations, policies, - * households) are stored in the v2 API. The only thing in localStorage - * is the anonymous user UUID. - * - * Failure mode: if the v2 API is unreachable, callers will receive - * unhandled fetch errors. There is currently no automatic fallback to - * localStorage. To temporarily switch backends during an incident, set - * the VITE_STORE_BACKEND environment variable to 'localStorage'. - */ - -export type StoreBackend = 'api' | 'localStorage'; - -export function getStoreBackend(): StoreBackend { - const envOverride = ( - typeof import.meta !== 'undefined' ? import.meta.env?.VITE_STORE_BACKEND : undefined - ) as string | undefined; - if (envOverride === 'api' || envOverride === 'localStorage') { - return envOverride; - } - return 'api'; -} diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index 7a8a0d793..4b31d25cb 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -14,19 +14,20 @@ import { import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import { TrackedBill } from '@/api/billFeed'; -import { getReformStore } from '@/api/reformStore'; import ProvisionList from '@/components/flagship/ProvisionList'; import StatTile from '@/components/flagship/StatTile'; import ValueInput from '@/components/flagship/ValueInput'; import WorkspaceLayout from '@/components/flagship/WorkspaceLayout'; import { Button, Spinner, Stack, Text, Title } from '@/components/ui'; -import { FOREVER, MOCK_USER_ID } from '@/constants'; +import { FOREVER } from '@/constants'; import { useAppLocation } from '@/contexts/LocationContext'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useReformStore } from '@/hooks/useFlagshipStores'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; import { useTrackedBills } from '@/hooks/useTrackedBills'; +import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; import { addDraftProvision, clearDraftReform, @@ -236,6 +237,8 @@ export default function ReformsPage() { const [editedValues, setEditedValues] = useState>({}); const runReport = useRunFlagshipReport(); + const reformStore = useReformStore(); + const { userId, ready: storeReady } = useAssociationBackend(); const { bills, isConfigured: billsConfigured, @@ -248,15 +251,16 @@ export default function ReformsPage() { isPending, isError, } = useQuery({ - queryKey: ['reforms', MOCK_USER_ID, countryId], - queryFn: () => getReformStore().findByUser(MOCK_USER_ID, countryId), + queryKey: ['reforms', userId, countryId], + queryFn: () => reformStore.findByUser(userId, countryId), + enabled: storeReady, }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ['reforms'] }); const saveMutation = useMutation({ mutationFn: (reform: Reform) => - getReformStore().update(reform.id!, { + reformStore.update(reform.id!, { label: editedLabel || null, parameters: reform.parameters.map((parameter) => ({ name: parameter.name, @@ -273,7 +277,7 @@ export default function ReformsPage() { }); const deleteMutation = useMutation({ - mutationFn: (reformId: string) => getReformStore().delete(reformId), + mutationFn: (reformId: string) => reformStore.delete(reformId), onSuccess: () => { setSelectedId(null); invalidate(); @@ -282,7 +286,7 @@ export default function ReformsPage() { const duplicateMutation = useMutation({ mutationFn: (reform: Reform) => - getReformStore().create({ + reformStore.create({ userId: reform.userId, countryId: reform.countryId, label: `${reform.label || 'Untitled reform'} (copy)`, diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 0f4de99e0..1d8028f08 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -9,10 +9,10 @@ import ReportAdjustPanel from '@/components/flagship/ReportAdjustPanel'; import { ReportUnresolvable } from '@/components/flagship/ReportComputing'; import { trackRecordPrograms, useModelTrackRecord } from '@/components/flagship/ValidationPanel'; import { Spinner, Stack, Text, Title } from '@/components/ui'; -import { MOCK_USER_ID } from '@/constants'; import { colors, spacing } from '@/designTokens'; import { useCalculationStatus } from '@/hooks/useCalculationStatus'; import { isApiReportId, useFlagshipReport } from '@/hooks/useFlagshipReport'; +import { useAccountReportRecord, useFlagshipReportStore } from '@/hooks/useFlagshipStores'; import { useReportValidationSnapshot } from '@/hooks/useReportValidationSnapshot'; import { useStartCalculationOnLoad } from '@/hooks/useStartCalculationOnLoad'; import { clearDraftAfterRun, loadProvisionsIntoDraft } from '@/libs/draftReform'; @@ -41,13 +41,27 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep // A local association id from another browser resolves to nothing. const unresolvable = !isLoading && !report && !!userReportId && !isApiReportId(userReportId); const parameters = useSelector((state: RootState) => state.metadata.parameters); - // Provenance: the local stash from the run, else rebuilt from the reform - // policy so a shared link validates like the original. + const { userId: storeUserId } = useFlagshipReportStore(); + const apiReportId = + report?.id !== undefined && report?.id !== null ? String(report.id) : undefined; + // Signed in, the account's record carries the provenance to every device. + const accountRecord = useAccountReportRecord(apiReportId); + // Provenance: the local stash from the run, else the account's record, + // else rebuilt from the reform policy so a shared link validates like + // the original. const reformPolicy = policies.find((policy) => policy.id === simulations?.[1]?.policyId); // Without metadata the rebuilt provisions would show raw paths and no // baseline, so they count as still loading until it arrives. const meta = readReportMeta(userReportId) ?? + (accountRecord && accountRecord.provisions.length > 0 + ? { + title: accountRecord.title ?? '', + sourceNote: accountRecord.sourceNote ?? '', + provisions: accountRecord.provisions, + createdAt: accountRecord.createdAt, + } + : null) ?? (parameters ? provenanceFromPolicy(reformPolicy, parameters, report?.label) : null); const calcStatus = useCalculationStatus(report?.id || '', 'report'); @@ -106,11 +120,10 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const trackRecord = useModelTrackRecord(provisionPaths); const calibration = useCalibrationMatches(provisionPaths, region); // The store keys records by the API report id as a string. - const pin = useReportValidationSnapshot( - MOCK_USER_ID, - report?.id !== undefined && report?.id !== null ? String(report.id) : undefined, - { calibration, programs: trackRecordPrograms(trackRecord) } - ); + const pin = useReportValidationSnapshot(storeUserId, apiReportId, { + calibration, + programs: trackRecordPrograms(trackRecord), + }); const title = meta?.title || report?.label || 'Impact report'; diff --git a/app/src/tests/integration/api/reformRoutes.test.ts b/app/src/tests/integration/api/reformRoutes.test.ts deleted file mode 100644 index b5b834180..000000000 --- a/app/src/tests/integration/api/reformRoutes.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -// @vitest-environment node -// -// Integration tests for the /api/reforms route handlers, executed against a -// real in-memory Postgres (PGlite) with the checked-in Drizzle migration -// applied — so schema, SQL, wire format, and handler logic are all exercised -// exactly as deployed. -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { PGlite } from '@electric-sql/pglite'; -import { drizzle } from 'drizzle-orm/pglite'; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { ReformAdapter } from '@/adapters/ReformAdapter'; -import type { Reform } from '@/types/ingredients/Reform'; -import type { ReformMetadata } from '@/types/metadata/reformMetadata'; -// Route handlers + db module live in calculator-app; imported relatively -// since they are outside the @/ alias root. -import { - DELETE as deleteReform, - GET as getReform, - PATCH as patchReform, -} from '../../../../../calculator-app/src/app/api/reforms/[reformId]/route'; -import { - POST as createReform, - GET as listReforms, -} from '../../../../../calculator-app/src/app/api/reforms/route'; -import { setDbForTesting } from '../../../../../calculator-app/src/db'; -import * as schema from '../../../../../calculator-app/src/db/schema'; - -const MIGRATIONS_DIR = path.resolve(__dirname, '../../../../../calculator-app/src/db/migrations'); - -let client: PGlite; - -beforeAll(async () => { - // The routes 404 on flag-off deployments; these tests exercise the - // flag-on behavior. - process.env.NEXT_PUBLIC_FLAGSHIP_SHELL = 'true'; - client = new PGlite(); - const migrationSql = readFileSync(path.join(MIGRATIONS_DIR, '0000_create-reforms.sql'), 'utf8'); - await client.exec(migrationSql); - setDbForTesting(drizzle(client, { schema }) as any); -}); - -afterAll(async () => { - setDbForTesting(null); - await client.close(); -}); - -beforeEach(async () => { - await client.exec('DELETE FROM reforms;'); -}); - -const VALID_PAYLOAD = { - user_id: 'user-1', - country_id: 'us', - label: 'CTC to $3,600 for children under 6', - policy_id: null, - parameters: [ - { - name: 'gov.irs.credits.ctc.amount.base[0]', - values: [{ start_date: '2026-01-01', end_date: '2026-12-31', value: 3600 }], - }, - ], - baseline: 'current-law', - provenance: { source: 'chat', ref: 'session-1', confidence: 'exact' }, -}; - -function postRequest(body: unknown): Request { - return new Request('http://test/api/reforms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: typeof body === 'string' ? body : JSON.stringify(body), - }); -} - -function listRequest(query: string): Request { - return new Request(`http://test/api/reforms?${query}`); -} - -function itemContext(reformId: string) { - return { params: Promise.resolve({ reformId }) }; -} - -function patchRequest(body: unknown): Request { - return new Request('http://test/api/reforms/x', { - method: 'PATCH', - body: JSON.stringify(body), - }); -} - -async function createAndParse(payload: unknown = VALID_PAYLOAD): Promise { - const response = await createReform(postRequest(payload)); - expect(response.status).toBe(201); - return response.json(); -} - -describe('POST /api/reforms', () => { - it('given a valid payload then creates and returns the reform in wire format', async () => { - const created = await createAndParse(); - - expect(created.id).toMatch(/^[0-9a-f-]{36}$/); - expect(created.user_id).toBe('user-1'); - expect(created.country_id).toBe('us'); - expect(created.parameters).toEqual(VALID_PAYLOAD.parameters); - expect(created.provenance).toEqual(VALID_PAYLOAD.provenance); - expect(new Date(created.created_at!).getTime()).not.toBeNaN(); - }); - - it('given the created response then it round-trips through ReformAdapter unchanged', async () => { - const created = await createAndParse(); - - const reform: Reform = ReformAdapter.fromApiResponse(created); - const backToWire = ReformAdapter.toCreationPayload(reform); - - expect(backToWire.parameters).toEqual(created.parameters); - expect(backToWire.provenance).toEqual(created.provenance); - expect(backToWire.baseline).toBe(created.baseline); - }); - - it.each([ - ['missing user_id', { ...VALID_PAYLOAD, user_id: undefined }], - ['empty user_id', { ...VALID_PAYLOAD, user_id: '' }], - ['invalid country', { ...VALID_PAYLOAD, country_id: 'fr' }], - ['parameters not an array', { ...VALID_PAYLOAD, parameters: 'oops' }], - ['parameter without name', { ...VALID_PAYLOAD, parameters: [{ values: [] }] }], - ['parameter without values', { ...VALID_PAYLOAD, parameters: [{ name: 'x' }] }], - ['invalid baseline', { ...VALID_PAYLOAD, baseline: 'utopia' }], - ['missing provenance', { ...VALID_PAYLOAD, provenance: undefined }], - ['invalid provenance source', { ...VALID_PAYLOAD, provenance: { source: 'dream' } }], - ])('given %s then returns 400', async (_name, payload) => { - const response = await createReform(postRequest(payload)); - expect(response.status).toBe(400); - }); - - it('given malformed JSON then returns 400', async () => { - const response = await createReform(postRequest('{not json')); - expect(response.status).toBe(400); - }); - - it('given a SQL-injection-shaped label then it is stored literally', async () => { - const label = `'; DROP TABLE reforms; --`; - const created = await createAndParse({ ...VALID_PAYLOAD, label }); - - expect(created.label).toBe(label); - - // Table still exists and holds the row - const list = await listReforms(listRequest('user_id=user-1')); - expect((await list.json()) as ReformMetadata[]).toHaveLength(1); - }); - - it('given a unicode label with emoji then it round-trips intact', async () => { - const label = 'Crédit d’impôt 子育て支援 🎯 £/€'; - const created = await createAndParse({ ...VALID_PAYLOAD, label }); - expect(created.label).toBe(label); - }); - - it('given an empty parameters array (a "current law" reform) then it is accepted', async () => { - const created = await createAndParse({ ...VALID_PAYLOAD, parameters: [] }); - expect(created.parameters).toEqual([]); - }); - - it('given 500 parameters with mixed value types then all round-trip', async () => { - const parameters = Array.from({ length: 500 }, (_, i) => ({ - name: `gov.test.parameter_${i}`, - values: [ - { - start_date: '2026-01-01', - end_date: '2100-12-31', - value: i % 3 === 0 ? i * 1.5 : i % 3 === 1 ? String(i) : i % 2 === 0, - }, - ], - })); - - const created = await createAndParse({ ...VALID_PAYLOAD, parameters }); - expect(created.parameters).toHaveLength(500); - expect(created.parameters[0].values[0].value).toBe(0); - expect(created.parameters[1].values[0].value).toBe('1'); - }); -}); - -describe('GET /api/reforms', () => { - it('given no user_id then returns 400', async () => { - const response = await listReforms(listRequest('')); - expect(response.status).toBe(400); - }); - - it('given two users then each only sees their own reforms', async () => { - await createAndParse({ ...VALID_PAYLOAD, user_id: 'alice' }); - await createAndParse({ ...VALID_PAYLOAD, user_id: 'bob' }); - - const aliceList = (await ( - await listReforms(listRequest('user_id=alice')) - ).json()) as ReformMetadata[]; - - expect(aliceList).toHaveLength(1); - expect(aliceList[0].user_id).toBe('alice'); - }); - - it('given a country filter then only that country is returned', async () => { - await createAndParse(); - await createAndParse({ ...VALID_PAYLOAD, country_id: 'uk' }); - - const ukOnly = (await ( - await listReforms(listRequest('user_id=user-1&country_id=uk')) - ).json()) as ReformMetadata[]; - - expect(ukOnly).toHaveLength(1); - expect(ukOnly[0].country_id).toBe('uk'); - }); - - it('given several reforms then they are ordered most recently updated first', async () => { - const first = await createAndParse({ ...VALID_PAYLOAD, label: 'first' }); - await createAndParse({ ...VALID_PAYLOAD, label: 'second' }); - await patchReform(patchRequest({ label: 'first-updated' }), itemContext(first.id)); - - const list = (await ( - await listReforms(listRequest('user_id=user-1')) - ).json()) as ReformMetadata[]; - - expect(list.map((r) => r.label)).toEqual(['first-updated', 'second']); - }); -}); - -describe('GET/PATCH/DELETE /api/reforms/:id', () => { - it('given a created reform then GET returns it by id', async () => { - const created = await createAndParse(); - - const response = await getReform(new Request('http://test'), itemContext(created.id)); - - expect(response.status).toBe(200); - expect(((await response.json()) as ReformMetadata).id).toBe(created.id); - }); - - it.each([ - ['a random UUID', '00000000-0000-4000-8000-000000000000'], - ['a non-UUID id', 'not-a-uuid'], - ['a path-traversal id', '../reforms'], - ])('given %s then GET returns 404', async (_name, reformId) => { - const response = await getReform(new Request('http://test'), itemContext(reformId)); - expect(response.status).toBe(404); - }); - - it('given label, parameters, baseline, and provenance updates then PATCH applies them all', async () => { - const created = await createAndParse(); - - const response = await patchReform( - patchRequest({ - label: 'Bill-derived version', - parameters: [], - baseline: 'current-policy', - provenance: { source: 'bill', ref: 'ut-hb-106', confidence: 'approximated' }, - }), - itemContext(created.id) - ); - - expect(response.status).toBe(200); - const updated = (await response.json()) as ReformMetadata; - expect(updated.label).toBe('Bill-derived version'); - expect(updated.parameters).toEqual([]); - expect(updated.baseline).toBe('current-policy'); - expect(updated.provenance.source).toBe('bill'); - expect(new Date(updated.updated_at!).getTime()).toBeGreaterThanOrEqual( - new Date(created.updated_at!).getTime() - ); - }); - - it('given a policy_id update then PATCH links the materialized policy', async () => { - const created = await createAndParse(); - - const response = await patchReform( - patchRequest({ policy_id: 'pol-999' }), - itemContext(created.id) - ); - - expect(((await response.json()) as ReformMetadata).policy_id).toBe('pol-999'); - }); - - it.each([ - ['an empty body', {}], - ['an invalid baseline', { baseline: 'utopia' }], - ['an invalid provenance source', { provenance: { source: 'dream' } }], - ['non-array parameters', { parameters: 'oops' }], - ])('given %s then PATCH returns 400', async (_name, body) => { - const created = await createAndParse(); - const response = await patchReform(patchRequest(body), itemContext(created.id)); - expect(response.status).toBe(400); - }); - - it('given unknown fields only then PATCH returns 400 rather than silently succeeding', async () => { - const created = await createAndParse(); - const response = await patchReform( - patchRequest({ user_id: 'mallory', country_id: 'uk' }), - itemContext(created.id) - ); - expect(response.status).toBe(400); - }); - - it('given a deleted reform then it 404s afterwards', async () => { - const created = await createAndParse(); - - const deleteResponse = await deleteReform(new Request('http://test'), itemContext(created.id)); - expect(deleteResponse.status).toBe(200); - - const getResponse = await getReform(new Request('http://test'), itemContext(created.id)); - expect(getResponse.status).toBe(404); - }); - - it('given a nonexistent reform then DELETE returns 404', async () => { - const response = await deleteReform( - new Request('http://test'), - itemContext('00000000-0000-4000-8000-000000000000') - ); - expect(response.status).toBe(404); - }); -}); - -describe('degraded mode (no database configured)', () => { - it('given no injected db and no DATABASE_URL then all handlers return 503', async () => { - setDbForTesting(null); - const previousUrl = process.env.DATABASE_URL; - delete process.env.DATABASE_URL; - - try { - expect((await createReform(postRequest(VALID_PAYLOAD))).status).toBe(503); - expect((await listReforms(listRequest('user_id=user-1'))).status).toBe(503); - expect((await getReform(new Request('http://test'), itemContext('x'))).status).toBe(503); - expect((await patchReform(patchRequest({ label: 'x' }), itemContext('x'))).status).toBe(503); - expect((await deleteReform(new Request('http://test'), itemContext('x'))).status).toBe(503); - } finally { - if (previousUrl !== undefined) { - process.env.DATABASE_URL = previousUrl; - } - setDbForTesting(drizzle(client, { schema }) as any); - } - }); -}); - -describe('flag-off deployments (production leak gate)', () => { - it('given the flagship flag is off then every handler 404s before touching the db', async () => { - const previous = process.env.NEXT_PUBLIC_FLAGSHIP_SHELL; - process.env.NEXT_PUBLIC_FLAGSHIP_SHELL = ''; - - try { - expect((await createReform(postRequest(VALID_PAYLOAD))).status).toBe(404); - expect((await listReforms(listRequest('user_id=user-1'))).status).toBe(404); - expect((await getReform(new Request('http://test'), itemContext('x'))).status).toBe(404); - expect((await patchReform(patchRequest({ label: 'x' }), itemContext('x'))).status).toBe(404); - expect((await deleteReform(new Request('http://test'), itemContext('x'))).status).toBe(404); - } finally { - process.env.NEXT_PUBLIC_FLAGSHIP_SHELL = previous; - } - }); -}); diff --git a/app/src/tests/unit/api/flagshipReportStore.test.ts b/app/src/tests/unit/api/flagshipReportStore.test.ts index f98ef076c..c386612d0 100644 --- a/app/src/tests/unit/api/flagshipReportStore.test.ts +++ b/app/src/tests/unit/api/flagshipReportStore.test.ts @@ -1,10 +1,23 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test } from 'vitest'; import { ApiFlagshipReportStore, + getFlagshipReportStore, LocalStorageFlagshipReportStore, NewFlagshipReport, + provisionsFromWire, + provisionsToWire, } from '@/api/flagshipReportStore'; +import { recordImportedIds } from '@/libs/auth/importedIds'; import { validationToWire } from '@/libs/flagship/reportValidation'; +import { + ASSOCIATION_IDS, + createTestApiContext, + fetchCall, + itemResponse, + pageResponse, + PE_USER_ID, + reportItem, +} from '@/tests/fixtures/api/v2UserMocks'; import { mockReportValidationSnapshot } from '@/tests/fixtures/libs/flagship/reportValidationMocks'; const USER_ID = 'user-123'; @@ -64,54 +77,158 @@ describe('LocalStorageFlagshipReportStore', () => { }); }); -describe('ApiFlagshipReportStore', () => { - const fetchMock = vi.fn(); +const DATED_PROVISION = { + path: 'gov.irs.credits.ctc.amount.base[0].amount', + breadcrumb: 'IRS → Credits → CTC → Amount', + unit: 'currency-USD', + baselineValue: 2200, + value: 2500, + intervals: [ + { startDate: '2026-01-01', endDate: '2026-12-31', value: 2500 }, + { startDate: '2027-01-01', endDate: '2100-12-31', value: 3000 }, + ], +}; - beforeEach(() => { - vi.stubGlobal('fetch', fetchMock); - fetchMock.mockReset(); +const DATED_PROVISION_WIRE = { + path: 'gov.irs.credits.ctc.amount.base[0].amount', + breadcrumb: 'IRS → Credits → CTC → Amount', + unit: 'currency-USD', + baseline_value: 2200, + value: 2500, + intervals: [ + { start_date: '2026-01-01', end_date: '2026-12-31', value: 2500 }, + { start_date: '2027-01-01', end_date: '2100-12-31', value: 3000 }, + ], +}; + +describe('provisions wire mapping', () => { + test('given dated provisions then they map to snake_case with start_date and end_date', () => { + expect(provisionsToWire([DATED_PROVISION])).toEqual([DATED_PROVISION_WIRE]); }); - afterEach(() => { - vi.unstubAllGlobals(); + test('given a single-value provision then intervals are null on the wire', () => { + expect(provisionsToWire(newReport.provisions)[0].intervals).toBeNull(); + }); + + test('given wire provisions then they map back to camelCase', () => { + expect(provisionsFromWire([DATED_PROVISION_WIRE])).toEqual([DATED_PROVISION]); + expect(provisionsFromWire(null)).toEqual([]); + }); +}); + +describe('ApiFlagshipReportStore', () => { + beforeEach(() => { + localStorage.clear(); }); - const storedRow = { - id: 'rp-1', - user_id: USER_ID, - country_id: 'us', - api_report_id: API_REPORT_ID, - title: 'CTC to $2,500', - source_note: 'Hand-built draft', - provisions: [], - reform_id: null, - year: '2026', - validation: validationToWire(mockReportValidationSnapshot), - created_at: '2026-09-07T00:00:00.000Z', - }; - - test('given an API report id then the list route is queried for it and the pin decoded', async () => { - fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => [storedRow] }); - const store = new ApiFlagshipReportStore(); - - const record = await store.findByApiReportId(USER_ID, API_REPORT_ID); - - expect(fetchMock).toHaveBeenCalledWith( - `/api/reports?user_id=${USER_ID}&api_report_id=${API_REPORT_ID}` + test('given a run then one /v2/user-reports row carries the v3 fields', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue( + itemResponse( + reportItem({ + legacy_report_id: Number(API_REPORT_ID), + name: 'CTC to $2,500', + source_note: 'Hand-built draft', + provisions: provisionsToWire(newReport.provisions), + user_policy_id: ASSOCIATION_IDS.REFORM, + }), + 201 + ) ); - expect(record?.validation).toEqual(mockReportValidationSnapshot); + const store = new ApiFlagshipReportStore(context); + + // When + const record = await store.create({ ...newReport, reformId: ASSOCIATION_IDS.REFORM }); + + // Then + const call = fetchCall(fetchMock); + expect(call.path).toBe('/v2/user-reports'); + expect(call.body).toEqual({ + country_id: 'us', + legacy_report_id: Number(API_REPORT_ID), + name: 'CTC to $2,500', + source_note: 'Hand-built draft', + provisions: provisionsToWire(newReport.provisions), + user_policy_id: ASSOCIATION_IDS.REFORM, + last_run_at: expect.any(String), + }); + expect(call.body).not.toHaveProperty('user_id'); + expect(call.body).not.toHaveProperty('year'); + expect(record).toMatchObject({ + id: ASSOCIATION_IDS.REPORT, + userId: PE_USER_ID, + apiReportId: API_REPORT_ID, + title: 'CTC to $2,500', + sourceNote: 'Hand-built draft', + reformId: ASSOCIATION_IDS.REFORM, + provisions: newReport.provisions, + }); + }); + + test('given a local reform id then the link uses its imported account id, or is left out', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(async () => itemResponse(reportItem(), 201)); + recordImportedIds(PE_USER_ID, { 'rf-imported': ASSOCIATION_IDS.REFORM }); + const store = new ApiFlagshipReportStore(context); + + // When + await store.create({ ...newReport, reformId: 'rf-imported' }); + await store.create({ ...newReport, reformId: 'rf-never-imported' }); + + // Then + expect(fetchCall(fetchMock, 0).body.user_policy_id).toBe(ASSOCIATION_IDS.REFORM); + expect(fetchCall(fetchMock, 1).body).not.toHaveProperty('user_policy_id'); }); - test('given a snapshot then it is PATCHed in wire form', async () => { - fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => storedRow }); - const store = new ApiFlagshipReportStore(); + test('given an API report id then findByApiReportId filters by legacy_report_id', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue( + pageResponse([reportItem({ legacy_report_id: Number(API_REPORT_ID) })]) + ); + const store = new ApiFlagshipReportStore(context); - await store.saveValidation('rp-1', mockReportValidationSnapshot); + // When + const record = await store.findByApiReportId(PE_USER_ID, API_REPORT_ID); - expect(fetchMock).toHaveBeenCalledWith('/api/reports/rp-1', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ validation: validationToWire(mockReportValidationSnapshot) }), + // Then + expect(fetchCall(fetchMock).query).toEqual({ + country_id: 'us', + legacy_report_id: API_REPORT_ID, + limit: '1', }); + expect(record?.apiReportId).toBe(API_REPORT_ID); + }); + + test('given a snapshot then saveValidation patches the validation wire format', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + const wire = validationToWire(mockReportValidationSnapshot); + fetchMock.mockResolvedValue(itemResponse(reportItem({ validation: wire }))); + const store = new ApiFlagshipReportStore(context); + + // When + const record = await store.saveValidation(ASSOCIATION_IDS.REPORT, mockReportValidationSnapshot); + + // Then + const call = fetchCall(fetchMock); + expect(call.method).toBe('PATCH'); + expect(call.path).toBe(`/v2/user-reports/${ASSOCIATION_IDS.REPORT}`); + expect(call.body).toEqual({ validation: wire }); + expect(record.validation).toEqual(mockReportValidationSnapshot); + }); +}); + +describe('getFlagshipReportStore', () => { + test('given no account then records stay in localStorage', () => { + expect(getFlagshipReportStore(null)).toBeInstanceOf(LocalStorageFlagshipReportStore); + }); + + test('given an account then records go to the account', () => { + expect(getFlagshipReportStore(createTestApiContext().context)).toBeInstanceOf( + ApiFlagshipReportStore + ); }); }); diff --git a/app/src/tests/unit/api/reformStore.test.ts b/app/src/tests/unit/api/reformStore.test.ts index d9bdd3560..7d089049f 100644 --- a/app/src/tests/unit/api/reformStore.test.ts +++ b/app/src/tests/unit/api/reformStore.test.ts @@ -1,16 +1,21 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ReformAdapter } from '@/adapters/ReformAdapter'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiReformStore, + getReformStore, LocalStorageReformStore, - resetReformStoreAvailabilityForTesting, - ResilientReformStore, + parametersFromPolicyJson, } from '@/api/reformStore'; +import { + ASSOCIATION_IDS, + createTestApiContext, + fetchCall, + itemResponse, + noContentResponse, + pageResponse, + PE_USER_ID, + policyItem, +} from '@/tests/fixtures/api/v2UserMocks'; import type { Reform } from '@/types/ingredients/Reform'; -import type { ReformMetadata } from '@/types/metadata/reformMetadata'; - -// Mock fetch -global.fetch = vi.fn(); const mockReformInput: Omit = { userId: 'user-123', @@ -26,142 +31,199 @@ const mockReformInput: Omit = { provenance: { source: 'chat', ref: 'session-1', confidence: 'exact' }, }; -const mockApiResponse: ReformMetadata = { - id: 'rf-1', - user_id: 'user-123', - country_id: 'us', - label: 'CTC to $3,600 for children under 6', - policy_id: null, - parameters: [ - { - name: 'gov.irs.credits.ctc.amount.base[0]', - values: [{ start_date: '2026-01-01', end_date: '2026-12-31', value: 3600 }], - }, - ], - baseline: 'current-law', - provenance: { source: 'chat', ref: 'session-1', confidence: 'exact' }, - created_at: '2026-08-01T00:00:00Z', - updated_at: '2026-08-01T00:00:00Z', +const MATERIALIZED_POLICY_ID = 95762; +const REPLACEMENT_POLICY_ID = 95800; + +const POLICY_JSON = { + 'gov.irs.credits.ctc.amount.base[0]': { '2026-01-01.2026-12-31': 3600 }, }; -describe('ReformAdapter', () => { - it('given a reform, when converted to a creation payload, then keys are snake_case', () => { - const payload = ReformAdapter.toCreationPayload(mockReformInput); +function apiReformStore(countryId: 'us' | 'uk' = 'us') { + const { fetchMock, context } = createTestApiContext(countryId); + const materializePolicy = vi.fn().mockResolvedValue(MATERIALIZED_POLICY_ID); + const fetchPolicyJson = vi.fn().mockResolvedValue(POLICY_JSON); + const store = new ApiReformStore(context, { materializePolicy, fetchPolicyJson }); + return { store, fetchMock, materializePolicy, fetchPolicyJson }; +} + +const reformRow = policyItem({ + id: ASSOCIATION_IDS.REFORM, + legacy_policy_id: MATERIALIZED_POLICY_ID, + name: 'CTC to $3,600 for children under 6', + baseline: 'current-law', + provenance: { source: 'chat', ref: 'session-1', confidence: 'exact' }, +}); - expect(payload.user_id).toBe('user-123'); - expect(payload.country_id).toBe('us'); - expect(payload.parameters[0].values[0]).toEqual({ - start_date: '2026-01-01', - end_date: '2026-12-31', - value: 3600, - }); - expect(payload.provenance).toEqual({ source: 'chat', ref: 'session-1', confidence: 'exact' }); +describe('ApiReformStore', () => { + beforeEach(() => { + localStorage.clear(); }); - it('given an API response, when converted to a Reform, then keys are camelCase and ids are strings', () => { - const reform = ReformAdapter.fromApiResponse(mockApiResponse); + it('given a reform, when created, then its parameters become a v1 policy and the row links to it', async () => { + // Given + const { store, fetchMock, materializePolicy } = apiReformStore(); + fetchMock.mockResolvedValue(itemResponse(reformRow, 201)); + + // When + const created = await store.create(mockReformInput); - expect(reform.id).toBe('rf-1'); - expect(reform.userId).toBe('user-123'); - expect(reform.policyId).toBeUndefined(); - expect(reform.parameters[0].values[0]).toEqual({ - startDate: '2026-01-01', - endDate: '2026-12-31', - value: 3600, + // Then + expect(materializePolicy).toHaveBeenCalledWith( + 'us', + mockReformInput.label, + mockReformInput.parameters + ); + const call = fetchCall(fetchMock); + expect(call.path).toBe('/v2/user-policies'); + expect(call.body).toEqual({ + country_id: 'us', + legacy_policy_id: MATERIALIZED_POLICY_ID, + name: 'CTC to $3,600 for children under 6', + baseline: 'current-law', + provenance: { source: 'chat', ref: 'session-1', confidence: 'exact' }, + }); + expect(created).toMatchObject({ + id: ASSOCIATION_IDS.REFORM, + userId: PE_USER_ID, + policyId: String(MATERIALIZED_POLICY_ID), + parameters: mockReformInput.parameters, }); - expect(reform.provenance.source).toBe('chat'); }); - it('given partial updates, when converted to an update payload, then only provided fields are included', () => { - const payload = ReformAdapter.toUpdatePayload({ label: 'Renamed' }); + it('given a reform already materialized, when created, then no new policy is made', async () => { + // Given + const { store, fetchMock, materializePolicy } = apiReformStore(); + fetchMock.mockResolvedValue(itemResponse(reformRow, 201)); + + // When + await store.create({ ...mockReformInput, policyId: '42' }); - expect(payload).toEqual({ label: 'Renamed' }); + // Then + expect(materializePolicy).not.toHaveBeenCalled(); + expect(fetchCall(fetchMock).body.legacy_policy_id).toBe(42); }); -}); -describe('ApiReformStore', () => { - let store: ApiReformStore; + it('given saved rows, when listed, then parameters are rebuilt from each v1 policy', async () => { + // Given + const { store, fetchMock, fetchPolicyJson } = apiReformStore(); + fetchMock.mockResolvedValue( + pageResponse([reformRow, policyItem({ id: 'v2-only', legacy_policy_id: null })]) + ); - beforeEach(() => { - store = new ApiReformStore(); - vi.clearAllMocks(); - }); + // When + const reforms = await store.findByUser(PE_USER_ID, 'us'); - afterEach(() => { - vi.resetAllMocks(); + // Then + expect(fetchPolicyJson).toHaveBeenCalledWith('us', String(MATERIALIZED_POLICY_ID)); + expect(reforms).toHaveLength(1); + expect(reforms[0].parameters).toEqual(mockReformInput.parameters); + expect(reforms[0].provenance).toEqual({ + source: 'chat', + ref: 'session-1', + confidence: 'exact', + }); }); - it('given a reform, when created, then POSTs snake_case payload and returns the parsed reform', async () => { - (global.fetch as any).mockResolvedValueOnce({ - ok: true, - json: async () => mockApiResponse, - }); + it('given a row without provenance, when read, then it reads as a hand-built current-law reform', async () => { + // Given + const { store, fetchMock } = apiReformStore(); + fetchMock.mockResolvedValue(itemResponse(policyItem({ id: ASSOCIATION_IDS.REFORM }))); - const result = await store.create(mockReformInput); + // When + const reform = await store.findById(ASSOCIATION_IDS.REFORM); - expect(global.fetch).toHaveBeenCalledWith( - '/api/reforms', - expect.objectContaining({ method: 'POST' }) - ); - const body = JSON.parse((global.fetch as any).mock.calls[0][1].body); - expect(body.user_id).toBe('user-123'); - expect(result.id).toBe('rf-1'); + // Then + expect(reform?.baseline).toBe('current-law'); + expect(reform?.provenance).toEqual({ source: 'manual' }); }); - it('given a user id and country, when finding by user, then passes both as query params', async () => { - (global.fetch as any).mockResolvedValueOnce({ - ok: true, - json: async () => [mockApiResponse], - }); - - const result = await store.findByUser('user-123', 'us'); - - expect(global.fetch).toHaveBeenCalledWith( - '/api/reforms?user_id=user-123&country_id=us', - expect.anything() + it('given parameter edits, when updated, then a new v1 policy is made and the row repointed', async () => { + // Given + const { store, fetchMock, materializePolicy } = apiReformStore(); + materializePolicy.mockResolvedValue(REPLACEMENT_POLICY_ID); + fetchMock.mockResolvedValue( + itemResponse({ ...reformRow, name: 'Renamed', legacy_policy_id: REPLACEMENT_POLICY_ID }) ); - expect(result).toHaveLength(1); - expect(result[0].userId).toBe('user-123'); + const parameters = [ + { + name: 'gov.irs.credits.ctc.amount.base[0]', + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 4000 }], + }, + ]; + + // When + const updated = await store.update(ASSOCIATION_IDS.REFORM, { label: 'Renamed', parameters }); + + // Then + const call = fetchCall(fetchMock); + expect(call.method).toBe('PATCH'); + expect(call.path).toBe(`/v2/user-policies/${ASSOCIATION_IDS.REFORM}`); + expect(call.body).toEqual({ name: 'Renamed', legacy_policy_id: REPLACEMENT_POLICY_ID }); + expect(updated.parameters).toEqual(parameters); + expect(updated.policyId).toBe(String(REPLACEMENT_POLICY_ID)); }); - it('given a missing reform, when finding by id, then returns null on 404', async () => { - (global.fetch as any).mockResolvedValueOnce({ ok: false, status: 404 }); + it('given only a rename, when updated, then no policy is made and only the name is patched', async () => { + // Given + const { store, fetchMock, materializePolicy } = apiReformStore(); + fetchMock.mockResolvedValue(itemResponse({ ...reformRow, name: 'Renamed' })); - const result = await store.findById('rf-missing'); + // When + await store.update(ASSOCIATION_IDS.REFORM, { label: 'Renamed' }); - expect(result).toBeNull(); + // Then + expect(materializePolicy).not.toHaveBeenCalled(); + expect(fetchCall(fetchMock).body).toEqual({ name: 'Renamed' }); }); - it('given a failing API, when creating, then throws', async () => { - (global.fetch as any).mockResolvedValueOnce({ ok: false, status: 500 }); - - await expect(store.create(mockReformInput)).rejects.toThrow('Failed to create reform'); + it('given a reform kept only in this browser, when saved signed in, then it is added to the account', async () => { + // Given + const local = await new LocalStorageReformStore().create(mockReformInput); + const { store, fetchMock, materializePolicy } = apiReformStore(); + fetchMock.mockResolvedValue(itemResponse({ ...reformRow, name: 'Renamed' }, 201)); + + // When + const saved = await store.update(local.id!, { label: 'Renamed' }); + + // Then + expect(materializePolicy).toHaveBeenCalledWith('us', 'Renamed', mockReformInput.parameters); + const call = fetchCall(fetchMock); + expect(call.method).toBe('POST'); + expect(call.body).toMatchObject({ name: 'Renamed', legacy_policy_id: MATERIALIZED_POLICY_ID }); + expect(saved.id).toBe(ASSOCIATION_IDS.REFORM); }); - it('given updates, when updating, then PATCHes and returns the parsed reform', async () => { - (global.fetch as any).mockResolvedValueOnce({ - ok: true, - json: async () => ({ ...mockApiResponse, label: 'Renamed' }), - }); + it('given a reform, when deleted, then its row is deleted', async () => { + // Given + const { store, fetchMock } = apiReformStore(); + fetchMock.mockResolvedValue(noContentResponse()); - const result = await store.update('rf-1', { label: 'Renamed' }); + // When + await store.delete(ASSOCIATION_IDS.REFORM); - expect(global.fetch).toHaveBeenCalledWith( - '/api/reforms/rf-1', - expect.objectContaining({ method: 'PATCH' }) - ); - expect(result.label).toBe('Renamed'); + // Then + expect(fetchCall(fetchMock).method).toBe('DELETE'); }); +}); - it('given a reform id, when deleting, then issues DELETE', async () => { - (global.fetch as any).mockResolvedValueOnce({ ok: true }); +describe('parametersFromPolicyJson', () => { + it('given no metadata, then the stored values are used as-is', () => { + expect(parametersFromPolicyJson({ 'gov.example': { '2026-01-01.2100-12-31': true } })).toEqual([ + { + name: 'gov.example', + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: true }], + }, + ]); + }); +}); - await store.delete('rf-1'); +describe('getReformStore', () => { + it('given no account, then reforms stay in localStorage', () => { + expect(getReformStore(null)).toBeInstanceOf(LocalStorageReformStore); + }); - expect(global.fetch).toHaveBeenCalledWith( - '/api/reforms/rf-1', - expect.objectContaining({ method: 'DELETE' }) - ); + it('given an account, then reforms go to the account', () => { + expect(getReformStore(createTestApiContext().context)).toBeInstanceOf(ApiReformStore); }); }); @@ -218,69 +280,3 @@ describe('LocalStorageReformStore', () => { expect(await store.findById(created.id!)).toBeNull(); }); }); - -describe('ResilientReformStore', () => { - let store: ResilientReformStore; - - beforeEach(() => { - store = new ResilientReformStore(); - localStorage.clear(); - vi.clearAllMocks(); - resetReformStoreAvailabilityForTesting(); - }); - - it('given the API is healthy, when creating, then the central store is used', async () => { - (global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => mockApiResponse }); - - const result = await store.create(mockReformInput); - - expect(result.id).toBe('rf-1'); - expect(await new LocalStorageReformStore().findByUser('user-123')).toHaveLength(0); - }); - - it('given the API returns 503, when creating, then it falls back to localStorage', async () => { - (global.fetch as any).mockResolvedValueOnce({ ok: false, status: 503 }); - - const result = await store.create(mockReformInput); - - expect(result.id).toMatch(/^rf-/); - expect(await new LocalStorageReformStore().findByUser('user-123')).toHaveLength(1); - }); - - it('given a prior 503, when reading later, then localStorage is used without retrying the API', async () => { - (global.fetch as any).mockResolvedValueOnce({ ok: false, status: 503 }); - await store.create(mockReformInput); - (global.fetch as any).mockClear(); - - const reforms = await store.findByUser('user-123', 'us'); - - expect(reforms).toHaveLength(1); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('given the collection endpoint is absent (404), when listing, then it falls back to localStorage', async () => { - (global.fetch as any).mockResolvedValueOnce({ ok: false, status: 404 }); - - const reforms = await store.findByUser('user-123'); - - expect(reforms).toEqual([]); - }); - - it('given a real server error (500), when creating, then it surfaces instead of falling back', async () => { - (global.fetch as any).mockResolvedValueOnce({ ok: false, status: 500 }); - - await expect(store.create(mockReformInput)).rejects.toThrow('Failed to create reform'); - expect(await new LocalStorageReformStore().findByUser('user-123')).toHaveLength(0); - }); - - it('given a fallback save then update and delete keep working against localStorage', async () => { - (global.fetch as any).mockResolvedValueOnce({ ok: false, status: 503 }); - const created = await store.create(mockReformInput); - - const updated = await store.update(created.id!, { label: 'Renamed' }); - expect(updated.label).toBe('Renamed'); - - await store.delete(created.id!); - expect(await store.findById(created.id!)).toBeNull(); - }); -}); diff --git a/app/src/tests/unit/libs/flagship/runReport.test.ts b/app/src/tests/unit/libs/flagship/runReport.test.ts index bdcf2646d..caf561632 100644 --- a/app/src/tests/unit/libs/flagship/runReport.test.ts +++ b/app/src/tests/unit/libs/flagship/runReport.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { FlagshipReportStore } from '@/api/flagshipReportStore'; import { readReportMeta, runFlagshipReport } from '@/libs/flagship/runReport'; const mockCreatePolicy = vi.fn(); const mockCreateSimulation = vi.fn(); const mockCreateReportAndAssociate = vi.fn(); +const mockCreateReport = vi.fn(); vi.mock('@/api/policy', () => ({ createPolicy: (...args: any[]) => mockCreatePolicy(...args), @@ -13,8 +15,12 @@ vi.mock('@/api/simulation', () => ({ })); vi.mock('@/api/report', () => ({ createReportAndAssociateWithUser: (...args: any[]) => mockCreateReportAndAssociate(...args), + createReport: (...args: any[]) => mockCreateReport(...args), })); +const ACCOUNT_USER_ID = '7c1e5a8e-2b1f-4c55-9a3e-1f2d3c4b5a69'; +const REFORM_ROW_ID = '55555555-5555-4555-8555-555555555555'; + const PROVISION = { path: 'gov.irs.credits.ctc.amount.base[0].amount', breadcrumb: 'IRS → Credits → Child tax credit → Base amount', @@ -34,6 +40,7 @@ describe('runFlagshipReport', () => { mockCreateReportAndAssociate.mockResolvedValue({ metadata: { baseReportId: '55', userReportId: 'sur-abc', countryId: 'us' }, }); + mockCreateReport.mockResolvedValue({ id: 56, country_id: 'us', year: '2026' }); }); test('starts both simulations before either finishes and waits for both before creating the report', async () => { @@ -128,4 +135,92 @@ describe('runFlagshipReport', () => { ).rejects.toThrow(/no provisions/); expect(mockCreatePolicy).not.toHaveBeenCalled(); }); + + test('given a signed-out run then the local association and local record are written', async () => { + // When + await runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: 'Federal · Introduced', + provisions: [PROVISION], + currentLawId: 2, + reformId: 'rf-local1', + }); + + // Then + expect(mockCreateReportAndAssociate).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'anonymous', label: 'CTC expansion' }) + ); + expect(mockCreateReport).not.toHaveBeenCalled(); + const records = JSON.parse(localStorage.getItem('pe-flagship-reports') ?? '[]'); + expect(records).toEqual([ + expect.objectContaining({ + userId: 'anonymous', + apiReportId: '55', + title: 'CTC expansion', + reformId: 'rf-local1', + }), + ]); + }); + + test('given a signed-in run then exactly one account association is written', async () => { + // Given + const accountStore = { + create: vi.fn().mockResolvedValue({ id: 'row-1' }), + } as unknown as FlagshipReportStore; + + // When + const reportId = await runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: 'Federal · Introduced', + provisions: [PROVISION], + currentLawId: 2, + reformId: REFORM_ROW_ID, + year: 2027, + account: { reportStore: accountStore, userId: ACCOUNT_USER_ID }, + }); + + // Then + expect(reportId).toBe('56'); + expect(mockCreateReport).toHaveBeenCalledWith('us', { + simulation_1_id: 101, + simulation_2_id: 102, + year: '2027', + }); + expect(mockCreateReportAndAssociate).not.toHaveBeenCalled(); + expect(accountStore.create).toHaveBeenCalledTimes(1); + expect(accountStore.create).toHaveBeenCalledWith({ + userId: ACCOUNT_USER_ID, + countryId: 'us', + apiReportId: '56', + title: 'CTC expansion', + sourceNote: 'Federal · Introduced', + provisions: [PROVISION], + year: '2027', + reformId: REFORM_ROW_ID, + }); + expect(localStorage.getItem('pe-flagship-reports')).toBeNull(); + expect(localStorage.getItem('user-report-associations')).toBeNull(); + expect(readReportMeta('56')?.title).toBe('CTC expansion'); + }); + + test('given the account association fails then the signed-in run fails', async () => { + // Given + const accountStore = { + create: vi.fn().mockRejectedValue(new Error('Saved work is temporarily unavailable.')), + } as unknown as FlagshipReportStore; + + // When / Then + await expect( + runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: '', + provisions: [PROVISION], + currentLawId: 2, + account: { reportStore: accountStore, userId: ACCOUNT_USER_ID }, + }) + ).rejects.toThrow('Saved work is temporarily unavailable.'); + }); }); diff --git a/app/src/tests/unit/libs/storeBackend.test.ts b/app/src/tests/unit/libs/storeBackend.test.ts deleted file mode 100644 index baf4165f5..000000000 --- a/app/src/tests/unit/libs/storeBackend.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { getStoreBackend } from '@/libs/storeBackend'; - -vi.stubGlobal('fetch', vi.fn()); - -describe('getStoreBackend', () => { - beforeEach(() => { - vi.mocked(fetch).mockReset(); - }); - - test("given the function is called then it returns 'api'", () => { - // Given / When - const result = getStoreBackend(); - - // Then - expect(result).toBe('api'); - }); - - test('given multiple invocations then it consistently returns api', () => { - // Given / When - const results = [getStoreBackend(), getStoreBackend(), getStoreBackend()]; - - // Then - expect(results).toEqual(['api', 'api', 'api']); - }); - - test('given the return type then it satisfies the StoreBackend union', () => { - // Given / When - const result = getStoreBackend(); - - // Then - const validBackends = ['api', 'localStorage']; - expect(validBackends).toContain(result); - }); -}); diff --git a/app/src/types/metadata/reformMetadata.ts b/app/src/types/metadata/reformMetadata.ts deleted file mode 100644 index 1dc2cd512..000000000 --- a/app/src/types/metadata/reformMetadata.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { countryIds } from '@/libs/countries'; -import { ReformBaseline, ReformConfidence, ReformSource } from '@/types/ingredients/Reform'; - -/** - * Wire format for reform parameters. - * Uses snake_case to match API conventions. - */ -export interface ReformParameterMetadata { - name: string; - values: { - start_date: string; - end_date: string; - value: any; - }[]; -} - -export interface ReformProvenanceMetadata { - source: ReformSource; - ref?: string | null; - confidence?: ReformConfidence | null; -} - -/** - * API response format for reforms - * Uses snake_case to match API conventions - */ -export interface ReformMetadata { - id: string; - user_id: string; - country_id: (typeof countryIds)[number]; - label?: string | null; - policy_id?: string | null; - parameters: ReformParameterMetadata[]; - baseline: ReformBaseline; - provenance: ReformProvenanceMetadata; - created_at?: string; - updated_at?: string; -} - -/** - * API creation payload format for reforms - * Uses snake_case to match API conventions - */ -export type ReformCreationMetadata = Omit; - -/** - * API update payload format for reforms; all content fields optional - */ -export type ReformUpdateMetadata = Partial< - Omit ->; diff --git a/bun.lock b/bun.lock index ce1ab66a1..546190b34 100644 --- a/bun.lock +++ b/bun.lock @@ -108,7 +108,6 @@ "dependencies": { "@anthropic-ai/sdk": "^0.116.0", "@auth0/auth0-react": "2.27.0", - "@neondatabase/serverless": "^1.1.0", "@normy/react-query": "^0.20.0", "@policyengine/ui-kit": "^0.4.0", "@reduxjs/toolkit": "^2.8.2", @@ -119,13 +118,11 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "dayjs": "^1.11.13", - "drizzle-orm": "^0.45.2", "framer-motion": "^12.23.24", "fuse.js": "^7.0.0", "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", "next": "^15.3.3", - "postgres": "^3.4.9", "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -139,7 +136,6 @@ "tailwindcss": "^4.2.0", }, "devDependencies": { - "@electric-sql/pglite": "^0.5.4", "@tailwindcss/postcss": "^4.1.8", "@types/node": "^22.15.0", "@types/react": "^19.1.0", @@ -147,7 +143,6 @@ "@types/react-plotly.js": "^2.6.3", "@types/react-simple-maps": "^3.0.6", "@types/react-syntax-highlighter": "^15.5.0", - "drizzle-kit": "^0.31.10", "postcss": "^8.5.4", "typescript": "^5.8.0", }, @@ -275,8 +270,6 @@ "@csstools/selector-specificity": ["@csstools/selector-specificity@5.0.0", "", { "peerDependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw=="], - "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@dual-bundle/import-meta-resolve": ["@dual-bundle/import-meta-resolve@4.2.1", "", {}, "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg=="], "@edge-runtime/format": ["@edge-runtime/format@2.2.1", "", {}, "sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g=="], @@ -289,69 +282,63 @@ "@edge-runtime/vm": ["@edge-runtime/vm@3.2.0", "", { "dependencies": { "@edge-runtime/primitives": "4.1.0" } }, "sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw=="], - "@electric-sql/pglite": ["@electric-sql/pglite@0.5.4", "", {}, "sha512-yYZUyyXrHU7tPlCjwZQJ6hIG9DscdCCn7Uk0mYKwC1FeHX286AbcmFveMiRBEak8e9iPupjsoVImN3yJZVed2g=="], - "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], - "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], - - "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -491,8 +478,6 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], - "@neondatabase/serverless": ["@neondatabase/serverless@1.1.0", "", {}, "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q=="], - "@next/env": ["@next/env@15.5.12", "", {}, "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.14", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-ogBjgsFrPPz19abP3VwcYSahbkUOMMvJjxCOYWYndw+PydeMuLuB4XrvNkNutFrTjC9St2KFULRdKID8Sd/CMQ=="], @@ -1381,10 +1366,6 @@ "draw-svg-path": ["draw-svg-path@1.0.0", "", { "dependencies": { "abs-svg-path": "~0.1.1", "normalize-svg-path": "~0.1.0" } }, "sha512-P8j3IHxcgRMcY6sDzr0QvJDLzBnJJqpTG33UZ2Pvp8rw0apCHhJCWqYprqrXjrgHnJ6tuhP1iTJSAodPDHxwkg=="], - "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], - - "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], - "dtype": ["dtype@2.0.0", "", {}, "sha512-s2YVcLKdFGS0hpFqJaTwscsyt0E8nNFdmo73Ocd81xNPj4URI4rj6D60A+vFMIw7BXWlb4yRkEwfBqcZzPGiZg=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -1447,7 +1428,7 @@ "es6-weak-map": ["es6-weak-map@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.46", "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.1" } }, "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], @@ -2243,8 +2224,6 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], - "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], - "potpack": ["potpack@1.0.2", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -2443,8 +2422,6 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], - "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], @@ -2801,8 +2778,6 @@ "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -2837,6 +2812,8 @@ "@storybook/builder-vite/@storybook/csf-plugin": ["@storybook/csf-plugin@8.6.15", "", { "dependencies": { "unplugin": "^1.3.1" }, "peerDependencies": { "storybook": "^8.6.15" } }, "sha512-ZLz/mtOoE1Jj2lE4pK3U7MmYrv5+lot3mGtwxGb832tcABMc97j9O+reCVxZYc7DeFbBuuEdMT9rBL/O3kXYmw=="], + "@storybook/core/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -2867,8 +2844,6 @@ "@vercel/node/@types/node": ["@types/node@20.11.0", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ=="], - "@vercel/node/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], - "@vercel/node/typescript": ["typescript@4.9.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g=="], "@vercel/static-config/ajv": ["ajv@8.6.3", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw=="], @@ -3059,61 +3034,71 @@ "victory-vendor/d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + "@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + "@plotly/d3-sankey-circular/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + "@plotly/d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + "@storybook/core/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + "@storybook/core/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + "@storybook/core/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + "@storybook/core/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + "@storybook/core/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + "@storybook/core/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + "@storybook/core/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + "@storybook/core/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + "@storybook/core/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + "@storybook/core/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + "@storybook/core/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + "@storybook/core/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + "@storybook/core/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + "@storybook/core/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + "@storybook/core/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + "@storybook/core/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@storybook/core/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "@storybook/core/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@storybook/core/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - "@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "@storybook/core/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - "@plotly/d3-sankey-circular/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "@storybook/core/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "@plotly/d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "@storybook/core/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@storybook/core/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@storybook/core/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@storybook/core/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@storybook/core/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.0", "", { "os": "android", "cpu": "arm64" }, "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw=="], @@ -3147,58 +3132,6 @@ "@vercel/node/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "@vercel/node/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], - - "@vercel/node/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], - - "@vercel/node/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="], - - "@vercel/node/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="], - - "@vercel/node/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="], - - "@vercel/node/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="], - - "@vercel/node/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="], - - "@vercel/node/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="], - - "@vercel/node/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="], - - "@vercel/node/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="], - - "@vercel/node/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="], - - "@vercel/node/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="], - - "@vercel/node/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="], - - "@vercel/node/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="], - - "@vercel/node/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="], - - "@vercel/node/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="], - - "@vercel/node/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="], - - "@vercel/node/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="], - - "@vercel/node/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="], - - "@vercel/node/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="], - - "@vercel/node/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="], - - "@vercel/node/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="], - - "@vercel/node/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="], - - "@vercel/node/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="], - - "@vercel/node/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="], - - "@vercel/node/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], - "@vercel/static-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "color-normalize/color-rgba/color-parse": ["color-parse@1.4.3", "", { "dependencies": { "color-name": "^1.0.0" } }, "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A=="], @@ -3305,6 +3238,58 @@ "victory-vendor/@types/d3-interpolate/@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], diff --git a/calculator-app/drizzle.config.ts b/calculator-app/drizzle.config.ts deleted file mode 100644 index c8118a30b..000000000 --- a/calculator-app/drizzle.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "drizzle-kit"; - -export default defineConfig({ - dialect: "postgresql", - schema: "./src/db/schema.ts", - out: "./src/db/migrations", - dbCredentials: { - // Only needed for push/migrate/studio; `generate` works without it. - url: process.env.DATABASE_URL ?? "", - }, -}); diff --git a/calculator-app/package.json b/calculator-app/package.json index 074b5df42..03ecfb7c4 100644 --- a/calculator-app/package.json +++ b/calculator-app/package.json @@ -6,15 +6,11 @@ "dev": "sh -c 'next dev --port ${PORT:-3001}'", "build": "next build", "start": "next start", - "typecheck": "tsc --noEmit", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio" + "typecheck": "tsc --noEmit" }, "dependencies": { "@anthropic-ai/sdk": "^0.116.0", "@auth0/auth0-react": "2.27.0", - "@neondatabase/serverless": "^1.1.0", "@normy/react-query": "^0.20.0", "@policyengine/ui-kit": "^0.4.0", "@reduxjs/toolkit": "^2.8.2", @@ -25,13 +21,11 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "dayjs": "^1.11.13", - "drizzle-orm": "^0.45.2", "framer-motion": "^12.23.24", "fuse.js": "^7.0.0", "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", "next": "^15.3.3", - "postgres": "^3.4.9", "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -45,7 +39,6 @@ "tailwindcss": "^4.2.0" }, "devDependencies": { - "@electric-sql/pglite": "^0.5.4", "@tailwindcss/postcss": "^4.1.8", "@types/node": "^22.15.0", "@types/react": "^19.1.0", @@ -53,7 +46,6 @@ "@types/react-plotly.js": "^2.6.3", "@types/react-simple-maps": "^3.0.6", "@types/react-syntax-highlighter": "^15.5.0", - "drizzle-kit": "^0.31.10", "postcss": "^8.5.4", "typescript": "^5.8.0" } diff --git a/calculator-app/src/app/api/reforms/[reformId]/route.ts b/calculator-app/src/app/api/reforms/[reformId]/route.ts deleted file mode 100644 index 6bf0fcedf..000000000 --- a/calculator-app/src/app/api/reforms/[reformId]/route.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { - flagshipApiDisabledResponse, - isFlagshipApiEnabled, -} from "@/libs/flagship/apiGate"; -import { eq } from "drizzle-orm"; -import { reformBaselines, reformSources } from "@/types/ingredients/Reform"; -import type { ReformUpdateMetadata } from "@/types/metadata/reformMetadata"; -import { getDb, isDbConfigured } from "../../../../db"; -import { reforms } from "../../../../db/schema"; -import { reformRowToMetadata } from "../../../../db/serialize"; - -// Handlers use Web-standard Request/Response (not next/server): these files -// are also typechecked inside app/tsconfig.json's program, and importing -// next/server would pull Next's React typings into the Vite app's graph. - -type RouteContext = { params: Promise<{ reformId: string }> }; - -const UUID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -function dbUnavailable(): Response { - return json({ error: "Reform store is not configured" }, 503); -} - -function notFound(): Response { - return json({ error: "Reform not found" }, 404); -} - -export async function GET( - _request: Request, - context: RouteContext, -): Promise { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return dbUnavailable(); - } - - const { reformId } = await context.params; - if (!UUID_PATTERN.test(reformId)) { - return notFound(); - } - - const [row] = await getDb() - .select() - .from(reforms) - .where(eq(reforms.id, reformId)); - if (!row) { - return notFound(); - } - - return json(reformRowToMetadata(row)); -} - -export async function PATCH( - request: Request, - context: RouteContext, -): Promise { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return dbUnavailable(); - } - - const { reformId } = await context.params; - if (!UUID_PATTERN.test(reformId)) { - return notFound(); - } - - let payload: ReformUpdateMetadata; - try { - payload = await request.json(); - } catch { - return json({ error: "Invalid JSON body" }, 400); - } - - const updates: Partial = {}; - if (payload.label !== undefined) { - updates.label = payload.label; - } - if (payload.policy_id !== undefined) { - updates.policyId = payload.policy_id; - } - if (payload.parameters !== undefined) { - if (!Array.isArray(payload.parameters)) { - return json({ error: "parameters must be an array" }, 400); - } - updates.parameters = payload.parameters; - } - if (payload.baseline !== undefined) { - if (!reformBaselines.includes(payload.baseline)) { - return json( - { error: `baseline must be one of: ${reformBaselines.join(", ")}` }, - 400, - ); - } - updates.baseline = payload.baseline; - } - if (payload.provenance !== undefined) { - if ( - !payload.provenance || - !reformSources.includes(payload.provenance.source) - ) { - return json( - { - error: `provenance.source must be one of: ${reformSources.join(", ")}`, - }, - 400, - ); - } - updates.provenance = payload.provenance; - } - if (Object.keys(updates).length === 0) { - return json({ error: "No updatable fields provided" }, 400); - } - updates.updatedAt = new Date(); - - const [row] = await getDb() - .update(reforms) - .set(updates) - .where(eq(reforms.id, reformId)) - .returning(); - - if (!row) { - return notFound(); - } - - return json(reformRowToMetadata(row)); -} - -export async function DELETE( - _request: Request, - context: RouteContext, -): Promise { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return dbUnavailable(); - } - - const { reformId } = await context.params; - if (!UUID_PATTERN.test(reformId)) { - return notFound(); - } - - const [row] = await getDb() - .delete(reforms) - .where(eq(reforms.id, reformId)) - .returning(); - if (!row) { - return notFound(); - } - - return json({ deleted: true }); -} diff --git a/calculator-app/src/app/api/reforms/route.ts b/calculator-app/src/app/api/reforms/route.ts deleted file mode 100644 index ce44b0eba..000000000 --- a/calculator-app/src/app/api/reforms/route.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - flagshipApiDisabledResponse, - isFlagshipApiEnabled, -} from "@/libs/flagship/apiGate"; -import { and, desc, eq } from "drizzle-orm"; -import { reformBaselines, reformSources } from "@/types/ingredients/Reform"; -import type { ReformCreationMetadata } from "@/types/metadata/reformMetadata"; -import { countryIds } from "@/libs/countries"; -import { getDb, isDbConfigured } from "../../../db"; -import { reforms } from "../../../db/schema"; -import { reformRowToMetadata } from "../../../db/serialize"; - -// Handlers use Web-standard Request/Response (not next/server): these files -// are also typechecked inside app/tsconfig.json's program, and importing -// next/server would pull Next's React typings into the Vite app's graph. -// -// NOTE: user_id is client-supplied, matching the existing anonymous-UUID -// trust model used by the user-*-association stores. Revisit when real -// authentication lands (flagship phase 2 prerequisite). - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -function dbUnavailable(): Response { - return json({ error: "Reform store is not configured" }, 503); -} - -function validateCreationPayload(payload: any): string | null { - if (!payload || typeof payload !== "object") { - return "Invalid JSON body"; - } - if (typeof payload.user_id !== "string" || !payload.user_id) { - return "user_id is required"; - } - if (!countryIds.includes(payload.country_id)) { - return `country_id must be one of: ${countryIds.join(", ")}`; - } - if (!Array.isArray(payload.parameters)) { - return "parameters must be an array"; - } - for (const parameter of payload.parameters) { - if ( - typeof parameter?.name !== "string" || - !Array.isArray(parameter?.values) - ) { - return "each parameter needs a name and a values array"; - } - } - if (!reformBaselines.includes(payload.baseline)) { - return `baseline must be one of: ${reformBaselines.join(", ")}`; - } - if ( - !payload.provenance || - !reformSources.includes(payload.provenance.source) - ) { - return `provenance.source must be one of: ${reformSources.join(", ")}`; - } - return null; -} - -export async function GET(request: Request): Promise { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return dbUnavailable(); - } - - const { searchParams } = new URL(request.url); - const userId = searchParams.get("user_id"); - if (!userId) { - return json({ error: "user_id is required" }, 400); - } - const countryId = searchParams.get("country_id"); - - const conditions = countryId - ? and(eq(reforms.userId, userId), eq(reforms.countryId, countryId)) - : eq(reforms.userId, userId); - - const rows = await getDb() - .select() - .from(reforms) - .where(conditions) - .orderBy(desc(reforms.updatedAt)); - - return json(rows.map(reformRowToMetadata)); -} - -export async function POST(request: Request): Promise { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return dbUnavailable(); - } - - let payload: ReformCreationMetadata; - try { - payload = await request.json(); - } catch { - return json({ error: "Invalid JSON body" }, 400); - } - - const validationError = validateCreationPayload(payload); - if (validationError) { - return json({ error: validationError }, 400); - } - - const [row] = await getDb() - .insert(reforms) - .values({ - userId: payload.user_id, - countryId: payload.country_id, - label: payload.label ?? null, - policyId: payload.policy_id ?? null, - parameters: payload.parameters, - baseline: payload.baseline, - provenance: payload.provenance, - }) - .returning(); - - return json(reformRowToMetadata(row), 201); -} diff --git a/calculator-app/src/app/api/reports/[reportId]/route.ts b/calculator-app/src/app/api/reports/[reportId]/route.ts deleted file mode 100644 index 4cecd329a..000000000 --- a/calculator-app/src/app/api/reports/[reportId]/route.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - flagshipApiDisabledResponse, - isFlagshipApiEnabled, -} from "@/libs/flagship/apiGate"; -import { eq } from "drizzle-orm"; -import { getDb, isDbConfigured } from "../../../../db"; -import { reports } from "../../../../db/schema"; -import { reportRowToMetadata } from "../../../../db/serialize"; - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -export async function GET( - _request: Request, - { params }: { params: Promise<{ reportId: string }> }, -): Promise { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return json({ error: "Report store is not configured" }, 503); - } - - const { reportId } = await params; - const [row] = await getDb() - .select() - .from(reports) - .where(eq(reports.id, reportId)); - - if (!row) { - return json({ error: "Report not found" }, 404); - } - return json(reportRowToMetadata(row)); -} - -function isValidationPayload(value: any): boolean { - return ( - !!value && - typeof value === "object" && - typeof value.matched_at === "string" && - typeof value.map_model_version === "string" && - (value.calibration === null || typeof value.calibration === "object") && - (value.scorecard === null || typeof value.scorecard === "object") - ); -} - -/** Pins what validation matched the report against. Idempotent. */ -export async function PATCH( - request: Request, - { params }: { params: Promise<{ reportId: string }> }, -): Promise { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return json({ error: "Report store is not configured" }, 503); - } - - let payload: any; - try { - payload = await request.json(); - } catch { - return json({ error: "Invalid JSON body" }, 400); - } - if (!isValidationPayload(payload?.validation)) { - return json({ error: "validation snapshot is required" }, 400); - } - - const { reportId } = await params; - const [row] = await getDb() - .update(reports) - .set({ validation: payload.validation }) - .where(eq(reports.id, reportId)) - .returning(); - - if (!row) { - return json({ error: "Report not found" }, 404); - } - return json(reportRowToMetadata(row)); -} diff --git a/calculator-app/src/app/api/reports/route.ts b/calculator-app/src/app/api/reports/route.ts deleted file mode 100644 index ab03785fc..000000000 --- a/calculator-app/src/app/api/reports/route.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { - flagshipApiDisabledResponse, - isFlagshipApiEnabled, -} from "@/libs/flagship/apiGate"; -import { and, desc, eq } from "drizzle-orm"; -import { countryIds } from "@/libs/countries"; -import { getDb, isDbConfigured } from "../../../db"; -import { reports } from "../../../db/schema"; -import { reportRowToMetadata } from "../../../db/serialize"; - -// Web-standard Request/Response for the same reason as api/reforms: these -// files are typechecked inside app/tsconfig.json's program too. -// -// NOTE: user_id is client-supplied, matching the anonymous trust model of -// the reform store. Revisit when real authentication lands. - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -function dbUnavailable(): Response { - return json({ error: "Report store is not configured" }, 503); -} - -function validateCreationPayload(payload: any): string | null { - if (!payload || typeof payload !== "object") { - return "Invalid JSON body"; - } - if (typeof payload.user_id !== "string" || !payload.user_id) { - return "user_id is required"; - } - if (!countryIds.includes(payload.country_id)) { - return `country_id must be one of: ${countryIds.join(", ")}`; - } - if (typeof payload.api_report_id !== "string" || !payload.api_report_id) { - return "api_report_id is required"; - } - if (!Array.isArray(payload.provisions)) { - return "provisions must be an array"; - } - if (typeof payload.year !== "string" || !payload.year) { - return "year is required"; - } - return null; -} - -export async function GET(request: Request): Promise { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return dbUnavailable(); - } - - const { searchParams } = new URL(request.url); - const userId = searchParams.get("user_id"); - if (!userId) { - return json({ error: "user_id is required" }, 400); - } - const countryId = searchParams.get("country_id"); - const apiReportId = searchParams.get("api_report_id"); - - const conditions = and( - eq(reports.userId, userId), - ...(countryId ? [eq(reports.countryId, countryId)] : []), - ...(apiReportId ? [eq(reports.apiReportId, apiReportId)] : []), - ); - - const rows = await getDb() - .select() - .from(reports) - .where(conditions) - .orderBy(desc(reports.createdAt)); - - return json(rows.map(reportRowToMetadata)); -} - -export async function POST(request: Request): Promise { - if (!isFlagshipApiEnabled()) { - return flagshipApiDisabledResponse(); - } - if (!isDbConfigured()) { - return dbUnavailable(); - } - - let payload: any; - try { - payload = await request.json(); - } catch { - return json({ error: "Invalid JSON body" }, 400); - } - - const validationError = validateCreationPayload(payload); - if (validationError) { - return json({ error: validationError }, 400); - } - - const [row] = await getDb() - .insert(reports) - .values({ - userId: payload.user_id, - countryId: payload.country_id, - apiReportId: payload.api_report_id, - title: payload.title ?? null, - sourceNote: payload.source_note ?? null, - provisions: payload.provisions, - reformId: payload.reform_id ?? null, - year: payload.year, - }) - .returning(); - - return json(reportRowToMetadata(row), 201); -} diff --git a/calculator-app/src/db/README.md b/calculator-app/src/db/README.md deleted file mode 100644 index 4223483a2..000000000 --- a/calculator-app/src/db/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Central reform database - -Postgres store for the flagship app's reform objects, served by the route -handlers in `src/app/api/reforms/`. Schema is managed with Drizzle; checked-in -migrations live in `migrations/`. - -## One-time provisioning (Neon via Vercel marketplace) - -```bash -cd calculator-app -vercel link # link to the calculator Vercel project -vercel integration add neon # provisions DB + sets DATABASE_URL -vercel env pull .env.local --yes # bring DATABASE_URL down for local dev -``` - -## Applying migrations - -`drizzle-kit` does not auto-load `.env.local`, so source it first: - -```bash -source <(grep -v '^#' .env.local | sed 's/^/export /') && bun run db:migrate -``` - -## Day-to-day - -- Change `schema.ts`, then `bun run db:generate` to emit a new migration. -- Never edit generated SQL in `migrations/` by hand. -- Route handlers return 503 when `DATABASE_URL` is unset, so environments - without the database (including current prod) degrade cleanly; the client - falls back via `VITE_STORE_BACKEND=localStorage` if needed. - -## Storage backend (frozen — coordinate with the storage owner) - -The flagship's user-artifact storage (reforms + reports tables in this -schema) is owned by the org storage workstream. To connect it, the -owner provides a Postgres connection string from the existing shared -Supabase infrastructure: - -``` -DATABASE_URL=postgresql://...pooler.supabase.com:6543/postgres -``` - -Then run migrations from calculator-app/: `bun run db:migrate`. - -Without DATABASE_URL, the app runs fully on localStorage fallbacks — -no behavior change. (A temporary `policyengine-app` project used for -beat-1 verification was deleted on 2026-08-06 to avoid parallel -infrastructure; its one-row export lives outside the repo.) - -The tracker bill feed is separate (read-only, public anon key) and -stays configured: - -``` -NEXT_PUBLIC_TRACKER_SUPABASE_URL=https://ffgngqlgfsvqartilful.supabase.co -NEXT_PUBLIC_TRACKER_SUPABASE_ANON_KEY= -``` - -The beat-2 seam (repointing bills into the unified project) is -`app/src/api/billFeed.ts`. diff --git a/calculator-app/src/db/index.ts b/calculator-app/src/db/index.ts deleted file mode 100644 index 43da62744..000000000 --- a/calculator-app/src/db/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { drizzle } from "drizzle-orm/postgres-js"; -import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; -import postgres from "postgres"; -import * as schema from "./schema"; - -/** - * Driver-agnostic database handle: satisfied by postgres-js against - * Supabase in production and by PGlite in integration tests. - */ -export type ReformDb = PgDatabase; - -// Lazy initialization: Next.js evaluates top-level module code at build -// time, so eager init would crash `next build` on projects without the -// database provisioned. -function createDb(): ReformDb { - // Supabase's pooled connection string (transaction mode) does not - // support prepared statements. - const sql = postgres(process.env.DATABASE_URL!, { prepare: false }); - return drizzle(sql, { schema }); -} - -let _db: ReformDb | null = null; - -export function getDb(): ReformDb { - if (!_db) { - _db = createDb(); - } - return _db; -} - -export function isDbConfigured(): boolean { - return _db !== null || Boolean(process.env.DATABASE_URL); -} - -/** Inject a database (e.g. PGlite) for integration tests; pass null to reset. */ -export function setDbForTesting(db: ReformDb | null): void { - _db = db; -} diff --git a/calculator-app/src/db/migrations/0000_create-reforms.sql b/calculator-app/src/db/migrations/0000_create-reforms.sql deleted file mode 100644 index 2ff485d0c..000000000 --- a/calculator-app/src/db/migrations/0000_create-reforms.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE "reforms" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" text NOT NULL, - "country_id" text NOT NULL, - "label" text, - "policy_id" text, - "parameters" jsonb NOT NULL, - "baseline" text DEFAULT 'current-law' NOT NULL, - "provenance" jsonb NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE INDEX "reforms_user_country_idx" ON "reforms" USING btree ("user_id","country_id"); \ No newline at end of file diff --git a/calculator-app/src/db/migrations/0001_create-reports.sql b/calculator-app/src/db/migrations/0001_create-reports.sql deleted file mode 100644 index f3c1d8cda..000000000 --- a/calculator-app/src/db/migrations/0001_create-reports.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE "reports" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" text NOT NULL, - "country_id" text NOT NULL, - "api_report_id" text NOT NULL, - "title" text, - "source_note" text, - "provisions" jsonb NOT NULL, - "reform_id" uuid, - "year" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE INDEX "reports_user_country_idx" ON "reports" USING btree ("user_id","country_id"); \ No newline at end of file diff --git a/calculator-app/src/db/migrations/0002_add-report-validation.sql b/calculator-app/src/db/migrations/0002_add-report-validation.sql deleted file mode 100644 index 790d2d57d..000000000 --- a/calculator-app/src/db/migrations/0002_add-report-validation.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "reports" ADD COLUMN "validation" jsonb; \ No newline at end of file diff --git a/calculator-app/src/db/migrations/meta/0000_snapshot.json b/calculator-app/src/db/migrations/meta/0000_snapshot.json deleted file mode 100644 index 46b026529..000000000 --- a/calculator-app/src/db/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "id": "7749c19b-8fd8-455d-bd5a-82d1a0429a77", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.reforms": { - "name": "reforms", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "country_id": { - "name": "country_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "label": { - "name": "label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "policy_id": { - "name": "policy_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "parameters": { - "name": "parameters", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "baseline": { - "name": "baseline", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'current-law'" - }, - "provenance": { - "name": "provenance", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "reforms_user_country_idx": { - "name": "reforms_user_country_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "country_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/calculator-app/src/db/migrations/meta/0001_snapshot.json b/calculator-app/src/db/migrations/meta/0001_snapshot.json deleted file mode 100644 index 4db91cd54..000000000 --- a/calculator-app/src/db/migrations/meta/0001_snapshot.json +++ /dev/null @@ -1,215 +0,0 @@ -{ - "id": "f41db9f9-c335-4c06-866b-ed0269a64a90", - "prevId": "7749c19b-8fd8-455d-bd5a-82d1a0429a77", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.reforms": { - "name": "reforms", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "country_id": { - "name": "country_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "label": { - "name": "label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "policy_id": { - "name": "policy_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "parameters": { - "name": "parameters", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "baseline": { - "name": "baseline", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'current-law'" - }, - "provenance": { - "name": "provenance", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "reforms_user_country_idx": { - "name": "reforms_user_country_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "country_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.reports": { - "name": "reports", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "country_id": { - "name": "country_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "api_report_id": { - "name": "api_report_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_note": { - "name": "source_note", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provisions": { - "name": "provisions", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "reform_id": { - "name": "reform_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "year": { - "name": "year", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "reports_user_country_idx": { - "name": "reports_user_country_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "country_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/calculator-app/src/db/migrations/meta/0002_snapshot.json b/calculator-app/src/db/migrations/meta/0002_snapshot.json deleted file mode 100644 index 808dc14a4..000000000 --- a/calculator-app/src/db/migrations/meta/0002_snapshot.json +++ /dev/null @@ -1,221 +0,0 @@ -{ - "id": "11a9cc4e-ef2d-4d24-89ed-a1f7d99ca271", - "prevId": "f41db9f9-c335-4c06-866b-ed0269a64a90", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.reforms": { - "name": "reforms", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "country_id": { - "name": "country_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "label": { - "name": "label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "policy_id": { - "name": "policy_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "parameters": { - "name": "parameters", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "baseline": { - "name": "baseline", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'current-law'" - }, - "provenance": { - "name": "provenance", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "reforms_user_country_idx": { - "name": "reforms_user_country_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "country_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.reports": { - "name": "reports", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "country_id": { - "name": "country_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "api_report_id": { - "name": "api_report_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_note": { - "name": "source_note", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provisions": { - "name": "provisions", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "reform_id": { - "name": "reform_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "year": { - "name": "year", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "validation": { - "name": "validation", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "reports_user_country_idx": { - "name": "reports_user_country_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "country_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/calculator-app/src/db/migrations/meta/_journal.json b/calculator-app/src/db/migrations/meta/_journal.json deleted file mode 100644 index e4a9f7d5f..000000000 --- a/calculator-app/src/db/migrations/meta/_journal.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1785726114869, - "tag": "0000_create-reforms", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1785973002089, - "tag": "0001_create-reports", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1788771086146, - "tag": "0002_add-report-validation", - "breakpoints": true - } - ] -} diff --git a/calculator-app/src/db/schema.ts b/calculator-app/src/db/schema.ts deleted file mode 100644 index 20c423e27..000000000 --- a/calculator-app/src/db/schema.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { - index, - jsonb, - pgTable, - text, - timestamp, - uuid, -} from "drizzle-orm/pg-core"; -import type { - ReformParameterMetadata, - ReformProvenanceMetadata, -} from "@/types/metadata/reformMetadata"; - -/** - * Central store for the flagship app's reform objects. - * - * A reform is user-owned, mutable content; the canonical immutable Policy - * lives in the main PolicyEngine API and is linked via policy_id once the - * reform has been materialized for simulation. - */ -export const reforms = pgTable( - "reforms", - { - id: uuid("id").defaultRandom().primaryKey(), - userId: text("user_id").notNull(), - countryId: text("country_id").notNull(), - label: text("label"), - policyId: text("policy_id"), - parameters: jsonb("parameters") - .notNull() - .$type(), - baseline: text("baseline").notNull().default("current-law"), - provenance: jsonb("provenance").notNull().$type(), - createdAt: timestamp("created_at", { withTimezone: true }) - .defaultNow() - .notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .defaultNow() - .notNull(), - }, - (table) => [ - index("reforms_user_country_idx").on(table.userId, table.countryId), - ], -); - -export type ReformRow = typeof reforms.$inferSelect; -export type NewReformRow = typeof reforms.$inferInsert; - -/** - * A generated impact report: a pointer to the computed output on the - * PolicyEngine API plus the provenance snapshot shown in the report - * header. Outputs themselves are never duplicated here — the API caches - * and persists them. - */ -export const reports = pgTable( - "reports", - { - id: uuid("id").defaultRandom().primaryKey(), - userId: text("user_id").notNull(), - countryId: text("country_id").notNull(), - /** Report id on api.policyengine.org — the pointer to the outputs. */ - apiReportId: text("api_report_id").notNull(), - title: text("title"), - /** e.g. "Utah · Enacted" for bills, "Hand-built" for drafts. */ - sourceNote: text("source_note"), - /** Provision snapshot at run time, for the provenance header. */ - provisions: jsonb("provisions") - .notNull() - .$type(), - /** Originating reform row, when the run came from a saved reform. */ - reformId: uuid("reform_id"), - year: text("year").notNull(), - /** - * What validation matched this report against, pinned at match time: - * the populace release and model version behind the calibration - * check and the scorecard programs. Later releases make the stored - * comparison historical, which the report can then say. - */ - validation: jsonb("validation").$type(), - createdAt: timestamp("created_at", { withTimezone: true }) - .defaultNow() - .notNull(), - }, - (table) => [ - index("reports_user_country_idx").on(table.userId, table.countryId), - ], -); - -export interface ReportProvisionMetadata { - path: string; - breadcrumb: string; - unit: string | null; - baseline_value: unknown; - value: unknown; -} - -export interface ReportValidationMatchMetadata { - variable: string; - depth: number; - ring: "primary" | "mechanism" | "downstream"; - target_count: number; - mean_abs_relative_error: number; - worst: { - name: string; - source: string; - geography: string; - relative_error: number | null; - }; -} - -export interface ReportValidationMetadata { - matched_at: string; - /** Model version the dependency map was traced from. */ - map_model_version: string; - calibration: { - release_id: string | null; - geography: string; - reached_count: number; - matches: ReportValidationMatchMetadata[]; - } | null; - scorecard: { programs: string[] } | null; -} - -export type ReportRow = typeof reports.$inferSelect; -export type NewReportRow = typeof reports.$inferInsert; diff --git a/calculator-app/src/db/serialize.ts b/calculator-app/src/db/serialize.ts deleted file mode 100644 index 1b5cf6207..000000000 --- a/calculator-app/src/db/serialize.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ReformMetadata } from "@/types/metadata/reformMetadata"; -import type { ReportRow } from "./schema"; -import type { ReformRow } from "./schema"; - -/** Convert a database row to the snake_case wire format the client stores expect. */ -export function reformRowToMetadata(row: ReformRow): ReformMetadata { - return { - id: row.id, - user_id: row.userId, - country_id: row.countryId as ReformMetadata["country_id"], - label: row.label, - policy_id: row.policyId, - parameters: row.parameters, - baseline: row.baseline as ReformMetadata["baseline"], - provenance: row.provenance, - created_at: row.createdAt.toISOString(), - updated_at: row.updatedAt.toISOString(), - }; -} - -/** Wire format for report rows (snake_case, ISO dates). */ -export function reportRowToMetadata(row: ReportRow) { - return { - id: row.id, - user_id: row.userId, - country_id: row.countryId, - api_report_id: row.apiReportId, - title: row.title, - source_note: row.sourceNote, - provisions: row.provisions, - reform_id: row.reformId, - year: row.year, - validation: row.validation ?? null, - created_at: row.createdAt.toISOString(), - }; -} diff --git a/calculator-app/src/vite-env-shim.d.ts b/calculator-app/src/vite-env-shim.d.ts index 56a1c61de..cacd395eb 100644 --- a/calculator-app/src/vite-env-shim.d.ts +++ b/calculator-app/src/vite-env-shim.d.ts @@ -13,7 +13,6 @@ interface ImportMetaEnv { readonly VITE_CALCULATOR_URL: string; readonly VITE_FLAGSHIP_SHELL?: string; readonly VITE_FLAGSHIP_OVERRIDE?: string; - readonly VITE_STORE_BACKEND?: string; readonly VITE_API_URL?: string; readonly VITE_AUTH_ENABLED?: string; readonly VITE_AUTH0_DOMAIN?: string; From 459dedfbd3a7c4b2ca3ac3b0495275c8cd65733f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:01:23 +0400 Subject: [PATCH 04/48] Add sign-in menu, account page, and first-sign-in import of local work - AuthMenu: "Sign in" when signed out, a user menu (initial, My account, Sign out) when signed in, nothing when sign-in is disabled. Shown in the calculator header (desktop and mobile) and in the flagship top bar, which replaces that header when the flagship shell is on. - Sidebar "My account" links to the account page when sign-in is enabled. - Account page (/[countryId]/account, also replacing the Vite router placeholder): user details, "Download my data" (GET /v2/me/export), "Delete my account" (confirm, DELETE /v2/me, sign out), and saving this device's work to the account. - First sign-in import (US/UK): when the browser holds saved work the account lacks, offer "Save the work on this device to your account?". Gathers the policy/household/simulation/report associations, reforms, flagship records and provenance stash; materializes reforms without a v1 policy; merges a flagship run's sur-/lfr- records into one report; POSTs /v2/me/imports per country in chunks of at most 500 items (and about 1 MB); reports Canada/Nigeria/Israel items as staying on the device; persists the id_map (per user) so old local links resolve; and never deletes local data. "Not now" is remembered. - README: how sign-in and saved work behave, and the variables to set. Co-Authored-By: Claude Opus 5.5 --- README.md | 25 + app/src/CalculatorRouter.tsx | 3 +- app/src/components/Sidebar.tsx | 14 + app/src/components/auth/AuthMenu.tsx | 123 ++++ .../components/auth/LocalDataImportPrompt.tsx | 106 ++++ app/src/components/auth/importSummaryText.ts | 32 ++ .../components/flagship/FlagshipTopBar.tsx | 4 + .../homeHeader/HeaderActionButtons.tsx | 10 +- app/src/components/homeHeader/MobileMenu.tsx | 2 + app/src/hooks/useLocalDataImport.ts | 112 ++++ app/src/libs/auth/localImport.ts | 544 ++++++++++++++++++ app/src/pages/Account.page.tsx | 334 +++++++++++ .../fixtures/libs/auth/localImportMocks.ts | 163 ++++++ .../unit/components/auth/AuthMenu.test.tsx | 67 +++ .../auth/LocalDataImportPrompt.test.tsx | 108 ++++ .../tests/unit/libs/auth/localImport.test.ts | 387 +++++++++++++ .../tests/unit/pages/Account.page.test.tsx | 123 ++++ .../[countryId]/(calculator)/account/page.tsx | 7 + .../src/app/[countryId]/providers.tsx | 2 + 19 files changed, 2164 insertions(+), 2 deletions(-) create mode 100644 app/src/components/auth/AuthMenu.tsx create mode 100644 app/src/components/auth/LocalDataImportPrompt.tsx create mode 100644 app/src/components/auth/importSummaryText.ts create mode 100644 app/src/hooks/useLocalDataImport.ts create mode 100644 app/src/libs/auth/localImport.ts create mode 100644 app/src/pages/Account.page.tsx create mode 100644 app/src/tests/fixtures/libs/auth/localImportMocks.ts create mode 100644 app/src/tests/unit/components/auth/AuthMenu.test.tsx create mode 100644 app/src/tests/unit/components/auth/LocalDataImportPrompt.test.tsx create mode 100644 app/src/tests/unit/libs/auth/localImport.test.ts create mode 100644 app/src/tests/unit/pages/Account.page.test.tsx create mode 100644 calculator-app/src/app/[countryId]/(calculator)/account/page.tsx diff --git a/README.md b/README.md index a638141c3..4f7ff08f8 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,31 @@ Format code: make format ``` +## Sign-in and saved work (calculator) + +The calculator can sign users in with social sign-on (Auth0) and save their +policies, households, simulations, reports and reforms to their PolicyEngine +account through the PolicyEngine API (`/v2/me` and `/v2/user-*`). It is off +unless all of these are set at build time (see `calculator-app/.env.example`): + +| Variable | Purpose | +| --- | --- | +| `NEXT_PUBLIC_AUTH_ENABLED` | `true` turns sign-in on | +| `NEXT_PUBLIC_AUTH0_DOMAIN` | Auth0 (custom) domain | +| `NEXT_PUBLIC_AUTH0_CLIENT_ID` | SPA application for the environment | +| `NEXT_PUBLIC_AUTH0_AUDIENCE` | PolicyEngine API resource server identifier | +| `NEXT_PUBLIC_API_URL` | PolicyEngine API origin (default `https://api.policyengine.org`); set to the staging API on staging | + +The legacy Vite build reads the same settings as `VITE_AUTH_ENABLED`, +`VITE_AUTH0_*` and `VITE_API_URL`, but does not mount the sign-in provider. + +- Signed out, and in every country without account storage (Canada, + Nigeria, Israel), saved work stays in the browser's localStorage, as before. +- Signed in on the US or UK calculator, saved work goes to the account. The + first sign-in offers to copy this browser's saved work to the account; + nothing is deleted from the device. +- Saved geographies always stay in localStorage. + ## Project structure ``` diff --git a/app/src/CalculatorRouter.tsx b/app/src/CalculatorRouter.tsx index af1cfd00c..d3d350cbf 100644 --- a/app/src/CalculatorRouter.tsx +++ b/app/src/CalculatorRouter.tsx @@ -13,6 +13,7 @@ import { import PathwayLayout from './components/PathwayLayout'; import StandardLayout from './components/StandardLayout'; import { isFlagshipShellEnabled } from './libs/featureFlags'; +import AccountPage from './pages/Account.page'; import BillReportPage from './pages/flagship/BillReport.page'; import BuildPage from './pages/flagship/Build.page'; import ReformsPage from './pages/flagship/Reforms.page'; @@ -173,7 +174,7 @@ const router = createBrowserRouter( }, { path: 'account', - element:
Account settings page
, + element: , }, ], }, diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index fc213af35..37a96dcfe 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -8,6 +8,7 @@ import { IconMail, IconPlus, IconScale, + IconUserCircle, IconUsers, } from '@tabler/icons-react'; import { Button } from '@/components/ui'; @@ -15,6 +16,7 @@ import { WEBSITE_URL } from '@/constants'; import { useAppLocation } from '@/contexts/LocationContext'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useAuthStatus } from '@/libs/auth/AuthContext'; import { colors, typography } from '../designTokens'; import SidebarDivider from './sidebar/SidebarDivider'; import SidebarNavItem from './sidebar/SidebarNavItem'; @@ -28,6 +30,7 @@ export default function Sidebar({ isOpen = true }: SidebarProps) { const location = useAppLocation(); const nav = useAppNavigate(); const countryId = useCurrentCountry(); + const { status: authStatus } = useAuthStatus(); // All internal navigation paths include the country prefix for consistency with v1 app const navItems = [ @@ -68,6 +71,17 @@ export default function Sidebar({ isOpen = true }: SidebarProps) { ]; const accountItems = [ + // The account page exists only where sign-in is enabled. + ...(authStatus === 'disabled' + ? [] + : [ + { + label: 'Account settings', + icon: IconUserCircle, + path: `/${countryId}/account`, + external: false, + }, + ]), { label: 'Contact support', icon: IconMail, diff --git a/app/src/components/auth/AuthMenu.tsx b/app/src/components/auth/AuthMenu.tsx new file mode 100644 index 000000000..e12d5a6b3 --- /dev/null +++ b/app/src/components/auth/AuthMenu.tsx @@ -0,0 +1,123 @@ +import { IconLogout, IconUserCircle } from '@tabler/icons-react'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui'; +import { useAppNavigate } from '@/contexts/NavigationContext'; +import { colors, typography } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useAuthStatus, type AuthContextValue } from '@/libs/auth/AuthContext'; + +interface AuthMenuProps { + /** 'inverse' for the teal calculator header; 'default' for light bars. */ + variant?: 'default' | 'inverse'; +} + +/** The name or email to show for the signed-in user. */ +export function accountDisplayName(auth: Pick): string { + const fullName = [auth.user?.first_name, auth.user?.last_name].filter(Boolean).join(' '); + return fullName || auth.profile?.name || auth.user?.email || auth.profile?.email || 'Account'; +} + +function initialOf(name: string): string { + return (name.trim()[0] ?? '?').toUpperCase(); +} + +/** + * Header control for sign-in: a "Sign in" button when signed out, the + * user menu (My account, Sign out) when signed in, and nothing when + * sign-in is disabled or the session is still being restored. + */ +export default function AuthMenu({ variant = 'default' }: AuthMenuProps) { + const auth = useAuthStatus(); + const countryId = useCurrentCountry(); + const nav = useAppNavigate(); + const inverse = variant === 'inverse'; + + if (auth.status === 'disabled' || auth.status === 'loading') { + return null; + } + + if (auth.status === 'anonymous') { + return ( + + ); + } + + const name = accountDisplayName(auth); + const email = auth.user?.email ?? auth.profile?.email; + + return ( + + + + + + +
{name}
+ {email && email !== name && ( +
+ {email} +
+ )} +
+ + nav.push(`/${countryId}/account`)}> + + My account + + auth.signOut()}> + + Sign out + +
+
+ ); +} diff --git a/app/src/components/auth/LocalDataImportPrompt.tsx b/app/src/components/auth/LocalDataImportPrompt.tsx new file mode 100644 index 000000000..31ca049bf --- /dev/null +++ b/app/src/components/auth/LocalDataImportPrompt.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react'; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Spinner, + Stack, + Text, +} from '@/components/ui'; +import { colors } from '@/designTokens'; +import { useLocalDataImport } from '@/hooks/useLocalDataImport'; +import { importSummaryLines } from './importSummaryText'; + +/** + * Offered once per account after signing in (US/UK), when this browser + * holds saved work the account does not have yet. Saving copies it to + * the account; nothing is deleted from the device. "Not now" is + * remembered; the account page can still save it later. + */ +export default function LocalDataImportPrompt() { + const localImport = useLocalDataImport(); + const [open, setOpen] = useState(false); + + const shouldOffer = + localImport.available && localImport.pendingCount > 0 && !localImport.declined; + + useEffect(() => { + if (shouldOffer) { + setOpen(true); + } + }, [shouldOffer]); + + if (!localImport.available) { + return null; + } + + const running = localImport.state === 'running'; + const finished = localImport.state === 'done' && localImport.summary; + + const close = () => { + if (running) { + return; + } + if (localImport.state === 'idle') { + localImport.decline(); + } + setOpen(false); + }; + + return ( + !next && close()}> + + + + {finished + ? 'Your work is saved to your account' + : 'Save the work on this device to your account?'} + + + {finished + ? 'It now appears wherever you sign in.' + : `This browser has ${localImport.pendingCount} saved item${ + localImport.pendingCount === 1 ? '' : 's' + } — policies, households, simulations, reports and reforms. Saving copies them to your account so they are available on any device. Nothing is removed from this device.`} + + + + {finished && localImport.summary && ( + + {importSummaryLines(localImport.summary).map((line) => ( + + {line} + + ))} + + )} + + {localImport.state === 'error' && ( + + {localImport.error ?? 'The import did not complete.'} Try again, or choose not now. + + )} + + + {finished ? ( + + ) : ( + <> + + + + )} + + + + ); +} diff --git a/app/src/components/auth/importSummaryText.ts b/app/src/components/auth/importSummaryText.ts new file mode 100644 index 000000000..b61ba1693 --- /dev/null +++ b/app/src/components/auth/importSummaryText.ts @@ -0,0 +1,32 @@ +import type { LocalImportSummary } from '@/libs/auth/localImport'; +import { getCountryLabel } from '@/utils/geographyUtils'; + +function items(count: number): string { + return `${count} item${count === 1 ? '' : 's'}`; +} + +/** Plain-language lines describing an import's outcome. */ +export function importSummaryLines(summary: LocalImportSummary): string[] { + const lines = [`Saved ${items(summary.imported)} to your account.`]; + if (summary.skipped.length > 0) { + lines.push( + `${items(summary.skipped.length)} could not be saved because what they refer to no longer exists.` + ); + } + if (summary.invalid.length > 0) { + lines.push(`${items(summary.invalid.length)} could not be saved.`); + } + if (summary.unsupported.length > 0) { + const countries = [ + ...new Set(summary.unsupported.map((note) => getCountryLabel(note.countryId))), + ].join(', '); + lines.push( + `${items(summary.unsupported.length)} from ${countries} stay on this device: accounts store US and UK work only.` + ); + } + if (summary.failed.length > 0) { + lines.push(`${items(summary.failed.length)} did not go through. You can try again later.`); + } + lines.push('Nothing was removed from this device.'); + return lines; +} diff --git a/app/src/components/flagship/FlagshipTopBar.tsx b/app/src/components/flagship/FlagshipTopBar.tsx index 903789865..79852fbcd 100644 --- a/app/src/components/flagship/FlagshipTopBar.tsx +++ b/app/src/components/flagship/FlagshipTopBar.tsx @@ -12,6 +12,7 @@ import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useDraftReform } from '@/libs/draftReform'; +import AuthMenu from '../auth/AuthMenu'; import FlagshipBrand from './FlagshipBrand'; const NAV_ITEMS = [ @@ -173,6 +174,9 @@ export default function FlagshipTopBar() { ))} + + {/* Sign in / user menu; renders nothing unless sign-in is enabled. */} + ); } diff --git a/app/src/components/homeHeader/HeaderActionButtons.tsx b/app/src/components/homeHeader/HeaderActionButtons.tsx index 3907e64b2..5ce3e80be 100644 --- a/app/src/components/homeHeader/HeaderActionButtons.tsx +++ b/app/src/components/homeHeader/HeaderActionButtons.tsx @@ -1,5 +1,13 @@ +import { spacing } from '@/designTokens'; +import AuthMenu from '../auth/AuthMenu'; import CountrySelector from './CountrySelector'; export default function HeaderActionButtons() { - return ; + return ( +
+ + {/* Renders nothing unless sign-in is enabled. */} + +
+ ); } diff --git a/app/src/components/homeHeader/MobileMenu.tsx b/app/src/components/homeHeader/MobileMenu.tsx index 9d62652a3..1b01950d1 100644 --- a/app/src/components/homeHeader/MobileMenu.tsx +++ b/app/src/components/homeHeader/MobileMenu.tsx @@ -1,6 +1,7 @@ import { IconMenu2 } from '@tabler/icons-react'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; +import AuthMenu from '../auth/AuthMenu'; import CountrySelector from './CountrySelector'; import { NavItemSetup } from './NavItem'; @@ -17,6 +18,7 @@ export default function MobileMenu({ opened, onOpen, onClose, navItems }: Mobile {/* Mobile Burger Menu with Country Selector */}
+ + + + + ); + } + + if (!isAssociationCountry(countryId)) { + return ( + + + Accounts store work for the US and UK calculators. Work in the{' '} + {getCountryLabel(countryId)} calculator stays on this device. Manage your account from{' '} + the US calculator or{' '} + the UK calculator. + +
+ +
+
+ ); + } + + const user = auth.user; + if (!user) { + return ( + + + We could not load your PolicyEngine account + {auth.error ? `: ${auth.error.message}` : '.'} + +
+ +
+
+ ); + } + + const handleExport = async () => { + setExporting(true); + setExportError(null); + try { + downloadJson(await exportMe(client, countryId), EXPORT_FILE_NAME); + } catch (error) { + setExportError(error instanceof Error ? error.message : 'The download did not complete.'); + } finally { + setExporting(false); + } + }; + + const handleDelete = async () => { + setDeleting(true); + setDeleteError(null); + try { + await deleteMe(client, countryId); + forgetImportState(user.id); + await auth.signOut(); + } catch (error) { + setDeleteError(error instanceof Error ? error.message : 'The account was not deleted.'); + setDeleting(false); + } + }; + + return ( + + + + {accountDisplayName(auth)} + Signed in with your social account. + + + + + + + + + + + + {localImport.available && (localImport.pendingCount > 0 || localImport.summary) && ( + + + Work saved on this device + + {localImport.pendingCount > 0 + ? `This browser has ${localImport.pendingCount} saved item${ + localImport.pendingCount === 1 ? '' : 's' + } that are not in your account yet. Saving copies them; nothing is removed from this device.` + : 'Everything this browser can save is in your account.'} + + + + + {localImport.summary && + importSummaryLines(localImport.summary).map((line) => ( + + {line} + + ))} + {localImport.state === 'error' && ( + + {localImport.error} + + )} + {localImport.pendingCount > 0 && ( +
+ +
+ )} +
+
+
+ )} + + + + Download my data + + A JSON file with your account and everything saved to it, in every country. + + + + +
+ +
+ {exportError && ( + + {exportError} + + )} +
+
+
+ + + + Delete my account + + Permanently deletes your account and everything saved to it. Work saved only on this + device stays on this device. + + + + + + + + !open && !deleting && setConfirmingDelete(false)} + > + + + Delete your account? + + This permanently deletes your PolicyEngine account and every policy, household, + simulation, report and reform saved to it. It cannot be undone. + + + {deleteError && ( + + {deleteError} + + )} + + + + + + +
+ ); +} diff --git a/app/src/tests/fixtures/libs/auth/localImportMocks.ts b/app/src/tests/fixtures/libs/auth/localImportMocks.ts new file mode 100644 index 000000000..b5f6b4cb7 --- /dev/null +++ b/app/src/tests/fixtures/libs/auth/localImportMocks.ts @@ -0,0 +1,163 @@ +import { LOCAL_STORAGE_KEYS } from '@/libs/auth/localImport'; +import type { Reform } from '@/types/ingredients/Reform'; + +export const LOCAL_USER = 'anonymous'; + +export const LOCAL_IDS = { + POLICY_US: 'sup-us1', + POLICY_CA: 'sup-ca1', + POLICY_INVALID: 'sup-bad1', + HOUSEHOLD_UK: 'suh-uk1', + SIMULATION_US: 'sus-us1', + REPORT_US: 'sur-us1', + REPORT_UK: 'sur-uk1', + FLAGSHIP_MERGED: 'lfr-merged1', + FLAGSHIP_ALONE: 'lfr-alone1', + REFORM_US: 'rf-us1', + REFORM_NG: 'rf-ng1', +} as const; + +export const V1_IDS = { + POLICY_US: '95762', + POLICY_CA: '300', + HOUSEHOLD_UK: '123', + SIMULATION_US: '613', + REPORT_US: '615', + REPORT_UK: '700', + REPORT_FLAGSHIP_ALONE: '801', + MATERIALIZED_REFORM: 95800, +} as const; + +export const PROVISION = { + path: 'gov.irs.credits.ctc.amount.base[0].amount', + breadcrumb: 'IRS → Credits → CTC → Amount', + unit: 'currency-USD', + baselineValue: 2200, + value: 2500, +}; + +export const localReformUS: Reform = { + id: LOCAL_IDS.REFORM_US, + userId: LOCAL_USER, + countryId: 'us', + label: 'CTC to $2,500', + parameters: [ + { + name: PROVISION.path, + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 2500 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'manual' }, + createdAt: '2026-09-01T00:00:00Z', + updatedAt: '2026-09-01T00:00:00Z', +}; + +/** Writes a representative browser: US/UK/Canada items, a flagship run, reforms. */ +export function seedLocalData(): void { + const set = (key: string, value: unknown) => localStorage.setItem(key, JSON.stringify(value)); + set(LOCAL_STORAGE_KEYS.policies, [ + { + id: LOCAL_IDS.POLICY_US, + userId: LOCAL_USER, + policyId: V1_IDS.POLICY_US, + countryId: 'us', + label: 'My policy', + }, + { + id: LOCAL_IDS.POLICY_CA, + userId: LOCAL_USER, + policyId: V1_IDS.POLICY_CA, + countryId: 'ca', + label: 'Canada policy', + }, + { id: LOCAL_IDS.POLICY_INVALID, userId: LOCAL_USER, policyId: 'current-law', countryId: 'us' }, + ]); + set(LOCAL_STORAGE_KEYS.households, [ + { + type: 'household', + id: LOCAL_IDS.HOUSEHOLD_UK, + userId: LOCAL_USER, + householdId: V1_IDS.HOUSEHOLD_UK, + countryId: 'uk', + label: 'Family', + }, + ]); + set(LOCAL_STORAGE_KEYS.simulations, [ + { + id: LOCAL_IDS.SIMULATION_US, + userId: LOCAL_USER, + simulationId: V1_IDS.SIMULATION_US, + countryId: 'us', + }, + ]); + set(LOCAL_STORAGE_KEYS.reports, [ + { + id: LOCAL_IDS.REPORT_US, + userId: LOCAL_USER, + reportId: V1_IDS.REPORT_US, + countryId: 'us', + label: 'CTC to $2,500', + }, + { + id: LOCAL_IDS.REPORT_UK, + userId: LOCAL_USER, + reportId: V1_IDS.REPORT_UK, + countryId: 'uk', + label: 'UK report', + }, + ]); + set(LOCAL_STORAGE_KEYS.reforms, [ + localReformUS, + { ...localReformUS, id: LOCAL_IDS.REFORM_NG, countryId: 'ng' }, + ]); + set(LOCAL_STORAGE_KEYS.flagshipReports, [ + { + id: LOCAL_IDS.FLAGSHIP_MERGED, + userId: LOCAL_USER, + countryId: 'us', + apiReportId: V1_IDS.REPORT_US, + title: 'CTC to $2,500', + sourceNote: 'Hand-built draft', + provisions: [PROVISION], + year: '2026', + createdAt: '2026-09-02T00:00:00Z', + reformId: LOCAL_IDS.REFORM_US, + }, + { + id: LOCAL_IDS.FLAGSHIP_ALONE, + userId: LOCAL_USER, + countryId: 'us', + apiReportId: V1_IDS.REPORT_FLAGSHIP_ALONE, + title: 'Bill report', + sourceNote: 'Utah · Introduced', + provisions: [], + year: '2026', + createdAt: '2026-09-03T00:00:00Z', + reformId: null, + }, + ]); + set(LOCAL_STORAGE_KEYS.reportMeta, { + [V1_IDS.REPORT_FLAGSHIP_ALONE]: { + title: 'Bill report', + sourceNote: 'Utah · Introduced', + provisions: [PROVISION], + createdAt: '2026-09-03T00:00:00Z', + }, + }); +} + +/** A browser with many saved simulations, for chunking. */ +export function seedManySimulations(count: number): void { + localStorage.setItem( + LOCAL_STORAGE_KEYS.simulations, + JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + id: `sus-${index}`, + userId: LOCAL_USER, + simulationId: String(index + 1), + countryId: 'us', + })) + ) + ); +} diff --git a/app/src/tests/unit/components/auth/AuthMenu.test.tsx b/app/src/tests/unit/components/auth/AuthMenu.test.tsx new file mode 100644 index 000000000..62f7e0083 --- /dev/null +++ b/app/src/tests/unit/components/auth/AuthMenu.test.tsx @@ -0,0 +1,67 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import AuthMenu from '@/components/auth/AuthMenu'; +import HeaderActionButtons from '@/components/homeHeader/HeaderActionButtons'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import { mockMeItem } from '@/tests/fixtures/api/v2UserMocks'; +import { + AuthTestProvider, + makeAuthValue, + signedInAuthValue, +} from '@/tests/fixtures/libs/auth/authMocks'; + +function renderMenu(auth: AuthContextValue) { + return render( + + + + ); +} + +describe('AuthMenu', () => { + test('given sign-in is disabled then the header shows no sign-in control', () => { + // When + render(); + + // Then + expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /country selector/i })).toBeInTheDocument(); + }); + + test('given the session is loading then nothing is shown', () => { + // When + renderMenu(makeAuthValue({ status: 'loading' })); + + // Then + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + test('given a signed-out visitor then "Sign in" starts sign-in', async () => { + // Given + const user = userEvent.setup(); + const auth = makeAuthValue({ status: 'anonymous' }); + renderMenu(auth); + + // When + await user.click(screen.getByRole('button', { name: 'Sign in' })); + + // Then + expect(auth.signIn).toHaveBeenCalled(); + }); + + test('given a signed-in user then the menu offers My account and Sign out', async () => { + // Given + const user = userEvent.setup(); + const auth = signedInAuthValue(); + renderMenu(auth); + + // When + await user.click(screen.getByRole('button', { name: /account menu for ada lovelace/i })); + + // Then + expect(screen.getByText(mockMeItem.email!)).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /my account/i })).toBeInTheDocument(); + await user.click(screen.getByRole('menuitem', { name: /sign out/i })); + expect(auth.signOut).toHaveBeenCalled(); + }); +}); diff --git a/app/src/tests/unit/components/auth/LocalDataImportPrompt.test.tsx b/app/src/tests/unit/components/auth/LocalDataImportPrompt.test.tsx new file mode 100644 index 000000000..dc60f0f8c --- /dev/null +++ b/app/src/tests/unit/components/auth/LocalDataImportPrompt.test.tsx @@ -0,0 +1,108 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import LocalDataImportPrompt from '@/components/auth/LocalDataImportPrompt'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import { isImportDeclined } from '@/libs/auth/localImport'; +import { mockMeItem, okResponse, PE_USER_ID } from '@/tests/fixtures/api/v2UserMocks'; +import { + AuthTestProvider, + makeAuthValue, + signedInAuthValue, +} from '@/tests/fixtures/libs/auth/authMocks'; +import { seedManySimulations } from '@/tests/fixtures/libs/auth/localImportMocks'; + +const fetchMock = vi.fn(); + +function renderPrompt(auth: AuthContextValue, countryId = 'us') { + const queryClient = new QueryClient(); + return render( + + + + + , + countryId + ); +} + +beforeEach(() => { + localStorage.clear(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); +}); + +describe('LocalDataImportPrompt', () => { + test('given a signed-in user with local work then the prompt offers to save it', async () => { + // Given + seedManySimulations(2); + + // When + renderPrompt(signedInAuthValue()); + + // Then + expect( + await screen.findByText('Save the work on this device to your account?') + ).toBeInTheDocument(); + expect(screen.getByText(/this browser has 2 saved items/i)).toBeInTheDocument(); + }); + + test('given a signed-out visitor then nothing is offered', () => { + // Given + seedManySimulations(2); + + // When + renderPrompt(makeAuthValue({ status: 'anonymous' })); + + // Then + expect(screen.queryByText(/save the work on this device/i)).not.toBeInTheDocument(); + }); + + test('given a signed-in user in Canada then nothing is offered', () => { + // Given + seedManySimulations(2); + + // When + renderPrompt(signedInAuthValue(), 'ca'); + + // Then + expect(screen.queryByText(/save the work on this device/i)).not.toBeInTheDocument(); + }); + + test('given "Not now" then the choice is remembered', async () => { + // Given + const user = userEvent.setup(); + seedManySimulations(1); + renderPrompt(signedInAuthValue()); + + // When + await user.click(await screen.findByRole('button', { name: 'Not now' })); + + // Then + expect(isImportDeclined(PE_USER_ID)).toBe(true); + }); + + test('given "Save to my account" then the work is imported and summarised', async () => { + // Given + const user = userEvent.setup(); + seedManySimulations(2); + fetchMock.mockImplementation(async () => + okResponse({ id_map: { 'sus-0': 'uuid-0', 'sus-1': 'uuid-1' }, skipped: [] }) + ); + renderPrompt(signedInAuthValue()); + + // When + await user.click(await screen.findByRole('button', { name: 'Save to my account' })); + + // Then + await waitFor(() => + expect(screen.getByText('Your work is saved to your account')).toBeInTheDocument() + ); + expect(screen.getByText('Saved 2 items to your account.')).toBeInTheDocument(); + expect(screen.getByText('Nothing was removed from this device.')).toBeInTheDocument(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.policyengine.org/v2/me/imports?country_id=us'); + expect(init.method).toBe('POST'); + expect(mockMeItem.id).toBe(PE_USER_ID); + }); +}); diff --git a/app/src/tests/unit/libs/auth/localImport.test.ts b/app/src/tests/unit/libs/auth/localImport.test.ts new file mode 100644 index 000000000..472c5e06a --- /dev/null +++ b/app/src/tests/unit/libs/auth/localImport.test.ts @@ -0,0 +1,387 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { readImportedIdMap, readImportSkipped, resolveImportedId } from '@/libs/auth/importedIds'; +import { + chunkImportItems, + countImportableItems, + declineImport, + forgetImportState, + isImportDeclined, + LOCAL_STORAGE_KEYS, + planLocalImport, + runLocalImport, + type PlannedImportItem, +} from '@/libs/auth/localImport'; +import { + createTestApiContext, + errorResponse, + fetchCall, + okResponse, + PE_USER_ID, +} from '@/tests/fixtures/api/v2UserMocks'; +import { + LOCAL_IDS, + localReformUS, + PROVISION, + seedLocalData, + seedManySimulations, + V1_IDS, +} from '@/tests/fixtures/libs/auth/localImportMocks'; + +const ACCOUNT_IDS: Record = { + [LOCAL_IDS.POLICY_US]: 'a0000000-0000-4000-8000-000000000001', + [LOCAL_IDS.REFORM_US]: 'a0000000-0000-4000-8000-000000000002', + [LOCAL_IDS.SIMULATION_US]: 'a0000000-0000-4000-8000-000000000003', + [LOCAL_IDS.REPORT_US]: 'a0000000-0000-4000-8000-000000000004', + [LOCAL_IDS.FLAGSHIP_ALONE]: 'a0000000-0000-4000-8000-000000000005', + [LOCAL_IDS.HOUSEHOLD_UK]: 'a0000000-0000-4000-8000-000000000006', + [LOCAL_IDS.REPORT_UK]: 'a0000000-0000-4000-8000-000000000007', +}; + +/** Answers each import with an id_map for every client_ref it was sent. */ +function importResponder(skip: Record = {}) { + return async (_url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)); + const refs = Object.values(body) + .flat() + .map((item: any) => item.client_ref as string); + return okResponse({ + id_map: Object.fromEntries( + refs.filter((ref) => !skip[ref]).map((ref) => [ref, ACCOUNT_IDS[ref] ?? `uuid-${ref}`]) + ), + skipped: refs + .filter((ref) => skip[ref]) + .map((ref) => ({ client_ref: ref, reason: skip[ref] })), + }); + }; +} + +beforeEach(() => { + localStorage.clear(); +}); + +describe('planLocalImport', () => { + test('given items from several countries then only US and UK are planned and the rest reported', () => { + // Given + seedLocalData(); + + // When + const plan = planLocalImport(PE_USER_ID); + + // Then + expect(plan.items.map((item) => [item.countryId, item.clientRef])).toEqual([ + ['us', LOCAL_IDS.POLICY_US], + ['us', LOCAL_IDS.REFORM_US], + ['uk', LOCAL_IDS.HOUSEHOLD_UK], + ['us', LOCAL_IDS.SIMULATION_US], + ['us', LOCAL_IDS.REPORT_US], + ['uk', LOCAL_IDS.REPORT_UK], + ['us', LOCAL_IDS.FLAGSHIP_ALONE], + ]); + expect(plan.unsupported.map((note) => [note.countryId, note.clientRef])).toEqual([ + ['ca', LOCAL_IDS.POLICY_CA], + ['ng', LOCAL_IDS.REFORM_NG], + ]); + expect(plan.invalid.map((note) => note.clientRef)).toEqual([LOCAL_IDS.POLICY_INVALID]); + }); + + test('given a flagship run then its association, record and provenance become one report', () => { + // Given + seedLocalData(); + + // When + const report = planLocalImport(PE_USER_ID).items.find( + (item) => item.clientRef === LOCAL_IDS.REPORT_US + ); + + // Then + expect(report?.aliases).toEqual([LOCAL_IDS.FLAGSHIP_MERGED]); + expect(report?.body).toEqual({ + client_ref: LOCAL_IDS.REPORT_US, + legacy_report_id: Number(V1_IDS.REPORT_US), + name: 'CTC to $2,500', + description: null, + user_policy_client_ref: LOCAL_IDS.REFORM_US, + source_note: 'Hand-built draft', + provisions: [ + { + path: PROVISION.path, + breadcrumb: PROVISION.breadcrumb, + unit: PROVISION.unit, + baseline_value: PROVISION.baselineValue, + value: PROVISION.value, + intervals: null, + }, + ], + validation: null, + last_run_at: null, + }); + }); + + test('given a flagship record alone then its provenance stash fills the provisions', () => { + // Given + seedLocalData(); + + // When + const report = planLocalImport(PE_USER_ID).items.find( + (item) => item.clientRef === LOCAL_IDS.FLAGSHIP_ALONE + ); + + // Then + expect(report?.body).toMatchObject({ + legacy_report_id: Number(V1_IDS.REPORT_FLAGSHIP_ALONE), + name: 'Bill report', + source_note: 'Utah · Introduced', + provisions: [expect.objectContaining({ path: PROVISION.path })], + }); + }); + + test('given a reform without a v1 policy then it is planned for materialization', () => { + // Given + seedLocalData(); + + // When + const reform = planLocalImport(PE_USER_ID).items.find( + (item) => item.clientRef === LOCAL_IDS.REFORM_US + ); + + // Then + expect(reform?.reform).toEqual(localReformUS); + expect(reform?.body).toMatchObject({ + baseline: 'current-law', + provenance: { source: 'manual', ref: null, confidence: null }, + }); + }); +}); + +describe('chunkImportItems', () => { + const item = (index: number): PlannedImportItem => ({ + kind: 'simulations', + countryId: 'us', + clientRef: `sus-${index}`, + aliases: [], + body: { + client_ref: `sus-${index}`, + legacy_simulation_id: index + 1, + name: null, + description: null, + }, + }); + + test('given 1201 items then requests hold at most 500 each, in order', () => { + const items = Array.from({ length: 1201 }, (_, index) => item(index)); + + const chunks = chunkImportItems(items); + + expect(chunks.map((chunk) => chunk.length)).toEqual([500, 500, 201]); + expect(chunks.flat()).toEqual(items); + }); + + test('given large items then requests also stay under the byte limit', () => { + const items = Array.from({ length: 10 }, (_, index) => item(index)); + const size = new TextEncoder().encode(JSON.stringify(items[0].body)).length + 1; + + const chunks = chunkImportItems(items, { maxBytes: size * 3 }); + + expect(chunks.map((chunk) => chunk.length)).toEqual([3, 3, 3, 1]); + }); +}); + +describe('runLocalImport', () => { + test('given saved work then it is imported per country, with reforms materialized first', async () => { + // Given + seedLocalData(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(importResponder()); + const materialize = vi.fn().mockResolvedValue(V1_IDS.MATERIALIZED_REFORM); + + // When + const summary = await runLocalImport({ + client: context.client, + userId: PE_USER_ID, + materialize, + }); + + // Then + expect(materialize).toHaveBeenCalledWith(localReformUS); + expect(fetchMock).toHaveBeenCalledTimes(2); + const us = fetchCall(fetchMock, 0); + expect(us.path).toBe('/v2/me/imports'); + expect(us.query).toEqual({ country_id: 'us' }); + expect(Object.keys(us.body)).toEqual(['policies', 'simulations', 'reports']); + expect(us.body.policies[1]).toMatchObject({ + client_ref: LOCAL_IDS.REFORM_US, + legacy_policy_id: V1_IDS.MATERIALIZED_REFORM, + }); + expect(JSON.stringify(us.body)).not.toContain('user_id'); + const uk = fetchCall(fetchMock, 1); + expect(uk.query).toEqual({ country_id: 'uk' }); + expect(uk.body).toEqual({ + households: [expect.objectContaining({ client_ref: LOCAL_IDS.HOUSEHOLD_UK })], + reports: [expect.objectContaining({ client_ref: LOCAL_IDS.REPORT_UK })], + }); + expect(summary).toMatchObject({ imported: 7, failed: [], skipped: [] }); + expect(summary.unsupported).toHaveLength(2); + expect(summary.invalid).toHaveLength(1); + }); + + test('given an import then the id_map is kept so old local links resolve, aliases included', async () => { + // Given + seedLocalData(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(importResponder()); + + // When + await runLocalImport({ + client: context.client, + userId: PE_USER_ID, + materialize: async () => V1_IDS.MATERIALIZED_REFORM, + }); + + // Then + expect(resolveImportedId(PE_USER_ID, LOCAL_IDS.REPORT_US)).toBe( + ACCOUNT_IDS[LOCAL_IDS.REPORT_US] + ); + expect(resolveImportedId(PE_USER_ID, LOCAL_IDS.FLAGSHIP_MERGED)).toBe( + ACCOUNT_IDS[LOCAL_IDS.REPORT_US] + ); + expect(resolveImportedId('someone-else', LOCAL_IDS.REPORT_US)).toBeNull(); + expect(countImportableItems(PE_USER_ID)).toBe(0); + }); + + test('given an import then nothing is removed from this device', async () => { + // Given + seedLocalData(); + const before = Object.values(LOCAL_STORAGE_KEYS).map((key) => localStorage.getItem(key)); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(importResponder()); + + // When + await runLocalImport({ + client: context.client, + userId: PE_USER_ID, + materialize: async () => V1_IDS.MATERIALIZED_REFORM, + }); + + // Then + expect(Object.values(LOCAL_STORAGE_KEYS).map((key) => localStorage.getItem(key))).toEqual( + before + ); + }); + + test('given skipped items then they are recorded and not offered again', async () => { + // Given + seedLocalData(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation( + importResponder({ [LOCAL_IDS.SIMULATION_US]: 'unknown_legacy_reference' }) + ); + + // When + const summary = await runLocalImport({ + client: context.client, + userId: PE_USER_ID, + materialize: async () => V1_IDS.MATERIALIZED_REFORM, + }); + + // Then + expect(summary.skipped).toEqual([ + { client_ref: LOCAL_IDS.SIMULATION_US, reason: 'unknown_legacy_reference' }, + ]); + expect(readImportSkipped(PE_USER_ID)).toEqual({ + [LOCAL_IDS.SIMULATION_US]: 'unknown_legacy_reference', + }); + expect(countImportableItems(PE_USER_ID)).toBe(0); + }); + + test('given more than 500 items then they go in several requests', async () => { + // Given + seedManySimulations(600); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(importResponder()); + + // When + const summary = await runLocalImport({ client: context.client, userId: PE_USER_ID }); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchCall(fetchMock, 0).body.simulations).toHaveLength(500); + expect(fetchCall(fetchMock, 1).body.simulations).toHaveLength(100); + expect(summary.imported).toBe(600); + }); + + test('given a failed request then its items are reported and left for a retry', async () => { + // Given + seedManySimulations(600); + const { fetchMock, context } = createTestApiContext(); + fetchMock + .mockImplementationOnce(importResponder()) + .mockImplementationOnce(async () => errorResponse(503, 'Database unavailable')); + + // When + const summary = await runLocalImport({ client: context.client, userId: PE_USER_ID }); + + // Then + expect(summary.imported).toBe(500); + expect(summary.failed).toHaveLength(100); + expect(Object.keys(readImportedIdMap(PE_USER_ID))).toHaveLength(500); + expect(countImportableItems(PE_USER_ID)).toBe(100); + }); + + test('given a reform that cannot be materialized then reports drop their link to it', async () => { + // Given + seedLocalData(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(importResponder()); + + // When + const summary = await runLocalImport({ + client: context.client, + userId: PE_USER_ID, + materialize: async () => { + throw new Error('Failed to create policy'); + }, + }); + + // Then + expect(summary.failed).toEqual([ + expect.objectContaining({ + clientRef: LOCAL_IDS.REFORM_US, + reason: 'Failed to create policy', + }), + ]); + const report = fetchCall(fetchMock, 0).body.reports.find( + (item: any) => item.client_ref === LOCAL_IDS.REPORT_US + ); + expect(report.user_policy_client_ref).toBeNull(); + expect(countImportableItems(PE_USER_ID)).toBe(1); + }); +}); + +describe('import bookkeeping', () => { + test('given "not now" then the prompt is declined for that user only', () => { + declineImport(PE_USER_ID); + + expect(isImportDeclined(PE_USER_ID)).toBe(true); + expect(isImportDeclined('someone-else')).toBe(false); + }); + + test('given a deleted account then its bookkeeping is forgotten', async () => { + // Given + seedLocalData(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(importResponder()); + await runLocalImport({ + client: context.client, + userId: PE_USER_ID, + materialize: async () => V1_IDS.MATERIALIZED_REFORM, + }); + declineImport(PE_USER_ID); + + // When + forgetImportState(PE_USER_ID); + + // Then + expect(readImportedIdMap(PE_USER_ID)).toEqual({}); + expect(isImportDeclined(PE_USER_ID)).toBe(false); + }); +}); diff --git a/app/src/tests/unit/pages/Account.page.test.tsx b/app/src/tests/unit/pages/Account.page.test.tsx new file mode 100644 index 000000000..b75ebe642 --- /dev/null +++ b/app/src/tests/unit/pages/Account.page.test.tsx @@ -0,0 +1,123 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import AccountPage from '@/pages/Account.page'; +import { + mockMeItem, + noContentResponse, + okResponse, + TEST_ACCESS_TOKEN, +} from '@/tests/fixtures/api/v2UserMocks'; +import { + AuthTestProvider, + makeAuthValue, + signedInAuthValue, +} from '@/tests/fixtures/libs/auth/authMocks'; + +const fetchMock = vi.fn(); + +function renderPage(auth: AuthContextValue, countryId = 'us') { + return render( + + + + + , + countryId + ); +} + +beforeEach(() => { + localStorage.clear(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); +}); + +describe('AccountPage', () => { + test('given sign-in is disabled then the page says so', () => { + renderPage(makeAuthValue({ status: 'disabled' })); + + expect(screen.getByText('Signing in is not available here yet.')).toBeInTheDocument(); + }); + + test('given a signed-out visitor then the page offers sign-in', async () => { + // Given + const user = userEvent.setup(); + const auth = makeAuthValue({ status: 'anonymous' }); + renderPage(auth); + + // When + await user.click(screen.getByRole('button', { name: 'Sign in' })); + + // Then + expect(auth.signIn).toHaveBeenCalled(); + }); + + test('given a signed-in user then their account details are shown', () => { + renderPage(signedInAuthValue()); + + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument(); + expect(screen.getByText(mockMeItem.email!)).toBeInTheDocument(); + expect(screen.getByText(mockMeItem.id)).toBeInTheDocument(); + }); + + test('given "Download my data" then the export is fetched with the session token', async () => { + // Given + const user = userEvent.setup(); + const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:export'); + // jsdom has no revokeObjectURL + const revokeObjectURL = vi.fn(); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + writable: true, + value: revokeObjectURL, + }); + fetchMock.mockResolvedValue( + okResponse({ user: mockMeItem, policies: [], households: [], simulations: [], reports: [] }) + ); + renderPage(signedInAuthValue()); + + // When + await user.click(screen.getByRole('button', { name: /download my data/i })); + + // Then + await waitFor(() => expect(createObjectURL).toHaveBeenCalled()); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.policyengine.org/v2/me/export?country_id=us'); + expect(init.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:export'); + }); + + test('given a confirmed deletion then the account is deleted and the user signed out', async () => { + // Given + const user = userEvent.setup(); + const auth = signedInAuthValue(); + fetchMock.mockResolvedValue(noContentResponse()); + renderPage(auth); + + // When + await user.click(screen.getByRole('button', { name: /delete my account/i })); + const confirm = await screen.findByRole('dialog'); + await user.click( + Array.from(confirm.querySelectorAll('button')).find( + (button) => button.textContent === 'Delete my account' + )! + ); + + // Then + await waitFor(() => expect(auth.signOut).toHaveBeenCalled()); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.policyengine.org/v2/me?country_id=us'); + expect(init.method).toBe('DELETE'); + }); + + test('given a country without account storage then the page points to the US and UK', () => { + renderPage(signedInAuthValue(), 'ca'); + + expect( + screen.getByText(/accounts store work for the us and uk calculators/i) + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /delete my account/i })).not.toBeInTheDocument(); + }); +}); diff --git a/calculator-app/src/app/[countryId]/(calculator)/account/page.tsx b/calculator-app/src/app/[countryId]/(calculator)/account/page.tsx new file mode 100644 index 000000000..f9e71471d --- /dev/null +++ b/calculator-app/src/app/[countryId]/(calculator)/account/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import AccountPage from "@/pages/Account.page"; + +export default function AccountRoute() { + return ; +} diff --git a/calculator-app/src/app/[countryId]/providers.tsx b/calculator-app/src/app/[countryId]/providers.tsx index dcb7655ef..86126c751 100644 --- a/calculator-app/src/app/[countryId]/providers.tsx +++ b/calculator-app/src/app/[countryId]/providers.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { QueryNormalizerProvider } from "@normy/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Provider, useDispatch } from "react-redux"; +import LocalDataImportPrompt from "@/components/auth/LocalDataImportPrompt"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AppProvider } from "@/contexts/AppContext"; import { CalcOrchestratorProvider } from "@/contexts/CalcOrchestratorContext"; @@ -64,6 +65,7 @@ export function CalculatorProviders({ {children} + From 1955b261ee24f1d7e90641255acb629f28097bf8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:08:20 +0400 Subject: [PATCH 05/48] List only v3 reforms in the account reform store Account policy rows include policies saved from the legacy builder. v3 reforms always carry provenance and legacy policies never do, so the reform store now reads only rows with provenance. Co-Authored-By: Claude Opus 5.5 --- app/src/api/reformStore.ts | 12 ++++++-- app/src/tests/unit/api/reformStore.test.ts | 32 ++++++++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/app/src/api/reformStore.ts b/app/src/api/reformStore.ts index c2280c630..1813ebad1 100644 --- a/app/src/api/reformStore.ts +++ b/app/src/api/reformStore.ts @@ -155,6 +155,14 @@ function provenanceToWire(provenance: ReformProvenance): ProvenanceWire { }; } +/** + * Whether an account policy row is a v3 reform. Reforms always carry + * provenance; policies saved from the legacy builder never do. + */ +function isAccountReform(item: UserPolicyItem): boolean { + return isLegacyId(item.legacy_policy_id) && item.provenance !== null; +} + function provenanceFromWire(wire: ProvenanceWire | null): ReformProvenance { if (!wire) { return { source: 'manual' }; @@ -243,13 +251,13 @@ export class ApiReformStore implements ReformStore { if (countryId && countryId !== this.context.countryId) { return []; } - const items = (await this.routes.list()).filter((item) => isLegacyId(item.legacy_policy_id)); + const items = (await this.routes.list()).filter(isAccountReform); return Promise.all(items.map((item) => this.toReform(item))); } async findById(reformId: string): Promise { const item = await this.routes.get(reformId); - return item && isLegacyId(item.legacy_policy_id) ? this.toReform(item) : null; + return item && isAccountReform(item) ? this.toReform(item) : null; } async update(reformId: string, updates: ReformUpdates): Promise { diff --git a/app/src/tests/unit/api/reformStore.test.ts b/app/src/tests/unit/api/reformStore.test.ts index 7d089049f..530bbbeb7 100644 --- a/app/src/tests/unit/api/reformStore.test.ts +++ b/app/src/tests/unit/api/reformStore.test.ts @@ -107,7 +107,11 @@ describe('ApiReformStore', () => { // Given const { store, fetchMock, fetchPolicyJson } = apiReformStore(); fetchMock.mockResolvedValue( - pageResponse([reformRow, policyItem({ id: 'v2-only', legacy_policy_id: null })]) + pageResponse([ + reformRow, + policyItem({ id: 'v2-only', legacy_policy_id: null }), + policyItem({ id: 'legacy-builder-policy', provenance: null }), + ]) ); // When @@ -124,10 +128,32 @@ describe('ApiReformStore', () => { }); }); - it('given a row without provenance, when read, then it reads as a hand-built current-law reform', async () => { + it('given a policy saved from the legacy builder (no provenance), when read, then it is not a reform', async () => { // Given const { store, fetchMock } = apiReformStore(); - fetchMock.mockResolvedValue(itemResponse(policyItem({ id: ASSOCIATION_IDS.REFORM }))); + fetchMock.mockResolvedValue( + itemResponse(policyItem({ id: ASSOCIATION_IDS.REFORM, provenance: null })) + ); + + // When + const reform = await store.findById(ASSOCIATION_IDS.REFORM); + + // Then + expect(reform).toBeNull(); + }); + + it('given a reform row without a baseline, when read, then it reads as a current-law reform', async () => { + // Given + const { store, fetchMock } = apiReformStore(); + fetchMock.mockResolvedValue( + itemResponse( + policyItem({ + id: ASSOCIATION_IDS.REFORM, + baseline: null, + provenance: { source: 'manual', ref: null, confidence: null }, + }) + ) + ); // When const reform = await store.findById(ASSOCIATION_IDS.REFORM); From e7bb14b2343247f027571acf59429fe4bfe7f24f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:08:20 +0400 Subject: [PATCH 06/48] Pin Auth0 browser SDK dependencies to releases over two weeks old @auth0/auth0-react 2.27.0 resolved @auth0/auth0-spa-js 2.28.3 (published 2026-10-09) and @auth0/auth0-auth-js 1.16.1 (2026-10-02). Override them to 2.27.0 and 1.15.0, both published in September. Re-locked with the pinned bun 1.2.21. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 9 ++++++--- package.json | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 546190b34..390778744 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "policyengine-monorepo", @@ -191,6 +190,10 @@ }, }, }, + "overrides": { + "@auth0/auth0-auth-js": "1.15.0", + "@auth0/auth0-spa-js": "2.27.0", + }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], @@ -200,11 +203,11 @@ "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], - "@auth0/auth0-auth-js": ["@auth0/auth0-auth-js@1.16.1", "", { "dependencies": { "jose": "^6.0.8", "openid-client": "^6.8.0" } }, "sha512-dtH12GiKEZ+YGnwUe0LTe7gJtEEAvhE5ztUL3i8iXN7D3siXe/MUssQq735UlXJzasXNqZOQUkMbv1VMXMZj+g=="], + "@auth0/auth0-auth-js": ["@auth0/auth0-auth-js@1.15.0", "", { "dependencies": { "jose": "^6.0.8", "openid-client": "^6.8.0" } }, "sha512-obn/Qhxx3eNs2wV6/lLsIDIcEiSJvBEEy15hppB5Me1j2No2toa7KqQcXHBCyGX4mBBdVnxZJBjEX6E4WanYwg=="], "@auth0/auth0-react": ["@auth0/auth0-react@2.27.0", "", { "dependencies": { "@auth0/auth0-spa-js": "^2.27.0" }, "peerDependencies": { "react": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1", "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-SVDiQM1Eatv+zLzy7qyxMdcETdbGVOqXR+zaBXbMyX32xUzTNIppgcHFoD0AFLqVQrBZgTHX4ujj9D9+sfCXsQ=="], - "@auth0/auth0-spa-js": ["@auth0/auth0-spa-js@2.28.3", "", { "dependencies": { "@auth0/auth0-auth-js": "^1.16.1", "browser-tabs-lock": "^1.3.0", "dpop": "^2.1.1", "es-cookie": "~1.3.2" } }, "sha512-W4bqoPdzjakJdtzKiOcWT5yMLciR9jeaHHwGydAKRpAkTD80ld2tGDUVL+sTpzaty8i3SL7lakgERiowcl0WCw=="], + "@auth0/auth0-spa-js": ["@auth0/auth0-spa-js@2.27.0", "", { "dependencies": { "@auth0/auth0-auth-js": "^1.15.0", "browser-tabs-lock": "^1.3.0", "dpop": "^2.1.1", "es-cookie": "~1.3.2" } }, "sha512-E9U4v9IiowT+z26PQYT55ZAU5J1feyEV9umVX+lvnaHl43E3GvGVsTrfukhPLLWnoCRItWa2TxVQ3Svj1S4qyA=="], "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], diff --git a/package.json b/package.json index 67fe9d46e..d50528265 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "jsdom": "^26.1.0", "turbo": "^2.5.3" }, + "overrides": { + "@auth0/auth0-auth-js": "1.15.0", + "@auth0/auth0-spa-js": "2.27.0" + }, "packageManager": "bun@1.2.21", "engines": { "node": ">=22.0.0" From 5c1f0624156fa6c9248decdf332d0e72f385f6b6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:18:24 +0400 Subject: [PATCH 07/48] Ignore import skips that come back without a client_ref The API reports items it cannot parse as skipped with a null client_ref. Keep them in the summary but out of the per-ref skip bookkeeping. Co-Authored-By: Claude Opus 5.5 --- app/src/libs/auth/localImport.ts | 4 ++++ .../tests/unit/libs/auth/localImport.test.ts | 24 +++++++++++++++++++ app/src/types/v2User.ts | 3 ++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/src/libs/auth/localImport.ts b/app/src/libs/auth/localImport.ts index 79a736074..9a114ae53 100644 --- a/app/src/libs/auth/localImport.ts +++ b/app/src/libs/auth/localImport.ts @@ -495,6 +495,10 @@ export async function runLocalImport({ const skippedMap: Record = {}; for (const entry of result.skipped ?? []) { + // Items the server could not parse may come back without a client_ref. + if (entry.client_ref === null) { + continue; + } skippedMap[entry.client_ref] = entry.reason; const item = chunk.find((candidate) => candidate.clientRef === entry.client_ref); item?.aliases.forEach((alias) => { diff --git a/app/src/tests/unit/libs/auth/localImport.test.ts b/app/src/tests/unit/libs/auth/localImport.test.ts index 472c5e06a..73cf35e6a 100644 --- a/app/src/tests/unit/libs/auth/localImport.test.ts +++ b/app/src/tests/unit/libs/auth/localImport.test.ts @@ -293,6 +293,30 @@ describe('runLocalImport', () => { expect(countImportableItems(PE_USER_ID)).toBe(0); }); + test('given a skipped item without a client_ref then it is reported but not recorded', async () => { + // Given + seedLocalData(); + const { fetchMock, context } = createTestApiContext(); + const respond = importResponder(); + fetchMock.mockImplementation(async (url: string, init: RequestInit) => { + const response = await respond(url, init); + const payload = await response.json(); + payload.result.skipped.push({ client_ref: null, reason: 'invalid' }); + return okResponse(payload.result); + }); + + // When + const summary = await runLocalImport({ + client: context.client, + userId: PE_USER_ID, + materialize: async () => V1_IDS.MATERIALIZED_REFORM, + }); + + // Then + expect(summary.skipped).toContainEqual({ client_ref: null, reason: 'invalid' }); + expect(readImportSkipped(PE_USER_ID)).not.toHaveProperty('null'); + }); + test('given more than 500 items then they go in several requests', async () => { // Given seedManySimulations(600); diff --git a/app/src/types/v2User.ts b/app/src/types/v2User.ts index 6921e6f9f..c61dbc30c 100644 --- a/app/src/types/v2User.ts +++ b/app/src/types/v2User.ts @@ -232,7 +232,8 @@ export interface ImportRequestBody { } export interface ImportSkippedItem { - client_ref: string; + /** Null when the server could not parse the item at all. */ + client_ref: string | null; reason: string; } From e26e8d99e1819bf96b0c9ac8ca2ceb8791c6443a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:53:26 +0400 Subject: [PATCH 08/48] Show saved work as loading while the sign-in session is restored Saved-work queries wait until the session is restored, because until then it is not known whether saved work is in the account or in this browser. A waiting TanStack query is neither loading nor holding data, so report pages said "Report not found", flagship sur- links were marked unresolvable, and lists showed their empty state. With sign-in enabled this hit signed-out visitors too. The association hooks now go through useSessionGatedQuery, which reports a query that is waiting for the session as loading. Co-Authored-By: Claude Opus 5.5 --- app/src/hooks/useUserHousehold.ts | 36 ++-- app/src/hooks/useUserPolicy.ts | 36 ++-- app/src/hooks/useUserReportAssociations.ts | 46 +++-- .../hooks/useUserSimulationAssociations.ts | 31 +-- app/src/hooks/utils/useSessionGatedQuery.ts | 38 ++++ .../hooks/utils/useSessionGatedQuery.test.tsx | 177 ++++++++++++++++++ .../savedWorkWhileSessionRestores.test.tsx | 79 ++++++++ 7 files changed, 381 insertions(+), 62 deletions(-) create mode 100644 app/src/hooks/utils/useSessionGatedQuery.ts create mode 100644 app/src/tests/unit/hooks/utils/useSessionGatedQuery.test.tsx create mode 100644 app/src/tests/unit/pages/savedWorkWhileSessionRestores.test.tsx diff --git a/app/src/hooks/useUserHousehold.ts b/app/src/hooks/useUserHousehold.ts index 172b1b9e5..126448037 100644 --- a/app/src/hooks/useUserHousehold.ts +++ b/app/src/hooks/useUserHousehold.ts @@ -1,9 +1,10 @@ import { useMemo } from 'react'; -import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; import { fetchHouseholdById } from '@/api/household'; import type { UserHouseholdStore } from '@/api/householdAssociation'; import { replaceHouseholdBaseForAssociation as replaceHouseholdBaseForAssociationAction } from '@/hooks/household/replaceHouseholdBaseForAssociation'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useSessionGatedQuery } from '@/hooks/utils/useSessionGatedQuery'; import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; import { Household } from '@/models/Household'; import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; @@ -45,12 +46,14 @@ export const useHouseholdAssociationsByUser = (userId: string) => { const { store, config, ready } = useHouseholdAssociationStoreForMode(); const countryId = useCurrentCountry(); - return useQuery({ - queryKey: householdAssociationKeys.byUser(userId, countryId), - queryFn: () => store.findByUser(userId, countryId), - ...config, - enabled: ready, - }); + return useSessionGatedQuery( + { + queryKey: householdAssociationKeys.byUser(userId, countryId), + queryFn: () => store.findByUser(userId, countryId), + ...config, + }, + ready + ); }; export const useHouseholdAssociation = ( @@ -61,15 +64,18 @@ export const useHouseholdAssociation = ( const { store, config, ready } = useHouseholdAssociationStoreForMode(); const countryId = useCurrentCountry(); - return useQuery({ - queryKey: householdAssociationKeys.specific(userId, householdId, countryId), - queryFn: async () => { - const association = await store.findById(userId, householdId); - return association?.countryId === countryId ? association : null; + return useSessionGatedQuery( + { + queryKey: householdAssociationKeys.specific(userId, householdId, countryId), + queryFn: async () => { + const association = await store.findById(userId, householdId); + return association?.countryId === countryId ? association : null; + }, + ...config, + enabled: options?.enabled ?? true, }, - ...config, - enabled: ready && (options?.enabled ?? true), - }); + ready + ); }; export const useCreateHouseholdAssociation = () => { diff --git a/app/src/hooks/useUserPolicy.ts b/app/src/hooks/useUserPolicy.ts index f18a5997a..e1884ff2d 100644 --- a/app/src/hooks/useUserPolicy.ts +++ b/app/src/hooks/useUserPolicy.ts @@ -1,9 +1,10 @@ import { useMemo } from 'react'; -import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; import { PolicyAdapter } from '@/adapters'; import { fetchPolicyById } from '@/api/policy'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useParameterMetadata } from '@/hooks/useParameterMetadata'; +import { useSessionGatedQuery } from '@/hooks/utils/useSessionGatedQuery'; import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; import { Policy } from '@/types/ingredients/Policy'; import type { PolicyCreationPayload } from '@/types/payloads'; @@ -61,12 +62,14 @@ export const usePolicyAssociationsByUser = (userId: string) => { const { store, config, ready } = usePolicyAssociationStoreForMode(); const countryId = useCurrentCountry(); - return useQuery({ - queryKey: policyAssociationKeys.byUser(userId, countryId), - queryFn: () => store.findByUser(userId, countryId), - ...config, - enabled: ready, - }); + return useSessionGatedQuery( + { + queryKey: policyAssociationKeys.byUser(userId, countryId), + queryFn: () => store.findByUser(userId, countryId), + ...config, + }, + ready + ); }; export const usePolicyAssociation = ( @@ -77,15 +80,18 @@ export const usePolicyAssociation = ( const { store, config, ready } = usePolicyAssociationStoreForMode(); const countryId = useCurrentCountry(); - return useQuery({ - queryKey: policyAssociationKeys.specific(userId, policyId, countryId), - queryFn: async () => { - const association = await store.findById(userId, policyId); - return association?.countryId === countryId ? association : null; + return useSessionGatedQuery( + { + queryKey: policyAssociationKeys.specific(userId, policyId, countryId), + queryFn: async () => { + const association = await store.findById(userId, policyId); + return association?.countryId === countryId ? association : null; + }, + ...config, + enabled: options?.enabled ?? true, }, - ...config, - enabled: ready && (options?.enabled ?? true), - }); + ready + ); }; export const useCreatePolicyAssociation = () => { diff --git a/app/src/hooks/useUserReportAssociations.ts b/app/src/hooks/useUserReportAssociations.ts index 6741a9284..38baecc1c 100644 --- a/app/src/hooks/useUserReportAssociations.ts +++ b/app/src/hooks/useUserReportAssociations.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useSessionGatedQuery } from '@/hooks/utils/useSessionGatedQuery'; import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; import { ApiReportStore, @@ -56,35 +57,42 @@ export const useReportAssociationsByUser = (userId: string) => { const { store, config, ready } = useReportAssociationStoreForMode(); const countryId = useCurrentCountry(); - return useQuery({ - queryKey: reportAssociationKeys.byUser(userId, countryId), - queryFn: () => store.findByUser(userId, countryId), - ...config, - enabled: ready, - }); + return useSessionGatedQuery( + { + queryKey: reportAssociationKeys.byUser(userId, countryId), + queryFn: () => store.findByUser(userId, countryId), + ...config, + }, + ready + ); }; export const useReportAssociation = (userId: string, reportId: string) => { const { store, config, ready } = useReportAssociationStoreForMode(); - return useQuery({ - queryKey: reportAssociationKeys.specific(userId, reportId), - queryFn: () => store.findById(userId, reportId), - ...config, - enabled: ready, - }); + return useSessionGatedQuery( + { + queryKey: reportAssociationKeys.specific(userId, reportId), + queryFn: () => store.findById(userId, reportId), + ...config, + }, + ready + ); }; export const useReportAssociationById = (userReportId: string, options?: { enabled?: boolean }) => { const { store, config, ready } = useReportAssociationStoreForMode(); const isEnabled = options?.enabled !== false; - return useQuery({ - queryKey: reportAssociationKeys.byUserReportId(userReportId), - queryFn: () => store.findByUserReportId(userReportId), - ...config, - enabled: ready && isEnabled, - }); + return useSessionGatedQuery( + { + queryKey: reportAssociationKeys.byUserReportId(userReportId), + queryFn: () => store.findByUserReportId(userReportId), + ...config, + enabled: isEnabled, + }, + ready + ); }; /** diff --git a/app/src/hooks/useUserSimulationAssociations.ts b/app/src/hooks/useUserSimulationAssociations.ts index 41c861980..391100850 100644 --- a/app/src/hooks/useUserSimulationAssociations.ts +++ b/app/src/hooks/useUserSimulationAssociations.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useSessionGatedQuery } from '@/hooks/utils/useSessionGatedQuery'; import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; import { ApiSimulationStore, @@ -56,23 +57,27 @@ export const useSimulationAssociationsByUser = (userId: string) => { const { store, config, ready } = useSimulationAssociationStoreForMode(); const countryId = useCurrentCountry(); - return useQuery({ - queryKey: simulationAssociationKeys.byUser(userId, countryId), - queryFn: () => store.findByUser(userId, countryId), - ...config, - enabled: ready, - }); + return useSessionGatedQuery( + { + queryKey: simulationAssociationKeys.byUser(userId, countryId), + queryFn: () => store.findByUser(userId, countryId), + ...config, + }, + ready + ); }; export const useSimulationAssociation = (userId: string, simulationId: string) => { const { store, config, ready } = useSimulationAssociationStoreForMode(); - return useQuery({ - queryKey: simulationAssociationKeys.specific(userId, simulationId), - queryFn: () => store.findById(userId, simulationId), - ...config, - enabled: ready, - }); + return useSessionGatedQuery( + { + queryKey: simulationAssociationKeys.specific(userId, simulationId), + queryFn: () => store.findById(userId, simulationId), + ...config, + }, + ready + ); }; export const useCreateSimulationAssociation = () => { diff --git a/app/src/hooks/utils/useSessionGatedQuery.ts b/app/src/hooks/utils/useSessionGatedQuery.ts new file mode 100644 index 000000000..10f668d0b --- /dev/null +++ b/app/src/hooks/utils/useSessionGatedQuery.ts @@ -0,0 +1,38 @@ +import { + useQuery, + type QueryKey, + type UseQueryOptions, + type UseQueryResult, +} from '@tanstack/react-query'; + +export type SessionGatedQueryOptions< + TQueryFnData, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Omit, 'enabled'> & { + enabled?: boolean; +}; + +/** + * useQuery for saved work (associations, reforms, report records). + * + * Until the sign-in session is restored, nobody knows whether saved work + * lives in the account or in this browser, so the query waits. A waiting + * query has no data and is not fetching, which pages would read as + * "nothing saved" or "not found"; it reports itself as loading instead. + */ +export function useSessionGatedQuery< + TQueryFnData, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: SessionGatedQueryOptions, + sessionReady: boolean +): UseQueryResult { + const wanted = options.enabled ?? true; + const query = useQuery({ ...options, enabled: sessionReady && wanted }); + if (!sessionReady && wanted && query.status === 'pending') { + return { ...query, isLoading: true }; + } + return query; +} diff --git a/app/src/tests/unit/hooks/utils/useSessionGatedQuery.test.tsx b/app/src/tests/unit/hooks/utils/useSessionGatedQuery.test.tsx new file mode 100644 index 000000000..bde47192d --- /dev/null +++ b/app/src/tests/unit/hooks/utils/useSessionGatedQuery.test.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { Provider as ReduxProvider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useFlagshipReport } from '@/hooks/useFlagshipReport'; +import { useHouseholdAssociationsByUser } from '@/hooks/useUserHousehold'; +import { usePolicyAssociationsByUser } from '@/hooks/useUserPolicy'; +import { + useReportAssociationById, + useReportAssociationsByUser, +} from '@/hooks/useUserReportAssociations'; +import { useUserReportById } from '@/hooks/useUserReports'; +import { useSimulationAssociationsByUser } from '@/hooks/useUserSimulationAssociations'; +import { useSessionGatedQuery } from '@/hooks/utils/useSessionGatedQuery'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; +import { store } from '@/store'; +import { AuthTestProvider, makeAuthValue } from '@/tests/fixtures/libs/auth/authMocks'; + +const LOCAL_REPORT_ID = 'sur-local1'; + +function setup(initialAuth: AuthContextValue) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + let auth = initialAuth; + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + + ); + return { + wrapper, + setAuth: (next: AuthContextValue) => { + auth = next; + }, + }; +} + +const restoring = () => makeAuthValue({ status: 'loading' }); +const signedOut = () => makeAuthValue({ status: 'anonymous' }); + +beforeEach(() => { + localStorage.clear(); +}); + +describe('useSessionGatedQuery', () => { + test('given the session is being restored then the query waits and reports loading', () => { + // Given + const { wrapper } = setup(restoring()); + const queryFn = vi.fn().mockResolvedValue(['saved']); + + // When + const { result } = renderHook( + () => useSessionGatedQuery({ queryKey: ['gated'], queryFn }, false), + { wrapper } + ); + + // Then + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + expect(queryFn).not.toHaveBeenCalled(); + }); + + test('given the caller disabled the query then waiting for the session is not reported as loading', () => { + // Given + const { wrapper } = setup(restoring()); + const queryFn = vi.fn(); + + // When + const { result } = renderHook( + () => useSessionGatedQuery({ queryKey: ['gated-off'], queryFn, enabled: false }, false), + { wrapper } + ); + + // Then + expect(result.current.isLoading).toBe(false); + expect(queryFn).not.toHaveBeenCalled(); + }); + + test('given the session is ready then the query runs', async () => { + // Given + const { wrapper } = setup(signedOut()); + const queryFn = vi.fn().mockResolvedValue(['saved']); + + // When + const { result } = renderHook( + () => useSessionGatedQuery({ queryKey: ['gated-ready'], queryFn }, true), + { wrapper } + ); + + // Then + await waitFor(() => expect(result.current.data).toEqual(['saved'])); + expect(queryFn).toHaveBeenCalledTimes(1); + }); +}); + +describe('saved-work hooks while the sign-in session is being restored', () => { + const savedWorkLists: Array<[string, () => { isLoading: boolean; data: unknown }]> = [ + ['policies', () => usePolicyAssociationsByUser(useCurrentUserId())], + ['households', () => useHouseholdAssociationsByUser(useCurrentUserId())], + ['simulations', () => useSimulationAssociationsByUser(useCurrentUserId())], + ['reports', () => useReportAssociationsByUser(useCurrentUserId())], + ]; + + test.each(savedWorkLists)( + 'given saved %s are requested then they load instead of reading as empty', + (_, useList) => { + // Given + const { wrapper } = setup(restoring()); + + // When + const { result } = renderHook(useList, { wrapper }); + + // Then + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + } + ); + + test('given the session settles signed out then the lists resolve from this browser', async () => { + // Given + const { wrapper, setAuth } = setup(restoring()); + const { result, rerender } = renderHook(() => useReportAssociationsByUser(useCurrentUserId()), { + wrapper, + }); + expect(result.current.isLoading).toBe(true); + + // When + setAuth(signedOut()); + rerender(); + + // Then + await waitFor(() => expect(result.current.data).toEqual([])); + expect(result.current.isLoading).toBe(false); + }); + + test('given a report link then the association loads instead of reading as not found', () => { + // Given + const { wrapper } = setup(restoring()); + + // When + const { result } = renderHook(() => useReportAssociationById(LOCAL_REPORT_ID), { wrapper }); + + // Then + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + }); + + test('given a saved report then the report page data is loading, not missing', () => { + // Given + const { wrapper } = setup(restoring()); + + // When + const { result } = renderHook(() => useUserReportById(LOCAL_REPORT_ID), { wrapper }); + + // Then + expect(result.current.isLoading).toBe(true); + expect(result.current.report).toBeUndefined(); + }); + + test('given a flagship sur- link then it is loading, not unresolvable', () => { + // Given + const { wrapper } = setup(restoring()); + + // When + const { result } = renderHook(() => useFlagshipReport(LOCAL_REPORT_ID), { wrapper }); + + // Then + expect(result.current.isLoading).toBe(true); + expect(result.current.report).toBeUndefined(); + }); +}); diff --git a/app/src/tests/unit/pages/savedWorkWhileSessionRestores.test.tsx b/app/src/tests/unit/pages/savedWorkWhileSessionRestores.test.tsx new file mode 100644 index 000000000..a5ec68bc7 --- /dev/null +++ b/app/src/tests/unit/pages/savedWorkWhileSessionRestores.test.tsx @@ -0,0 +1,79 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import ReportOutputPage from '@/pages/ReportOutput.page'; +import ReportsPage from '@/pages/Reports.page'; +import { AuthTestProvider, makeAuthValue } from '@/tests/fixtures/libs/auth/authMocks'; + +/** + * Pages that show saved work, rendered with the real association hooks + * while the sign-in session is still being restored: they must show a + * loading state, never "not found" or an empty list. + */ + +vi.mock('@/pages/report-output/useHouseholdCalculations', () => ({ + useHouseholdCalculations: vi.fn(), +})); + +vi.mock('@/hooks/useCalculationStatus', () => ({ + useCalculationStatus: vi.fn(() => ({ + status: 'initializing', + isInitializing: true, + isPending: false, + isComplete: false, + isError: false, + result: null, + error: null, + })), +})); + +vi.mock('@/hooks/useStartCalculationOnLoad', () => ({ + useStartCalculationOnLoad: vi.fn(), +})); + +const LOCAL_REPORT_ID = 'sur-local1'; + +function renderWithSession(ui: React.ReactNode, auth: AuthContextValue) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + {ui} + + ); +} + +beforeEach(() => { + localStorage.clear(); +}); + +describe('saved work while the sign-in session is being restored', () => { + test('given a report link then the report output page shows loading, not "Report not found"', () => { + // When + renderWithSession( + , + makeAuthValue({ status: 'loading' }) + ); + + // Then + expect(screen.getByText('Loading report...')).toBeInTheDocument(); + expect(screen.queryByText(/Report not found/)).not.toBeInTheDocument(); + }); + + test('given the reports list then it shows loading, not an empty list', () => { + // When + renderWithSession(, makeAuthValue({ status: 'loading' })); + + // Then + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.queryByText('No report found.')).not.toBeInTheDocument(); + }); + + test('given the session settled signed out with nothing saved then the list is empty', async () => { + // When + renderWithSession(, makeAuthValue({ status: 'anonymous' })); + + // Then + expect(await screen.findByText('No report found.')).toBeInTheDocument(); + }); +}); From 06584460176d6a2e1ee7bbbc54d62a5831590c05 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:58:58 +0400 Subject: [PATCH 09/48] Stop saving to this browser when the signed-in account cannot be loaded If GET /v2/me failed (after one retry) the session became 'authenticated' with no PolicyEngine user, so the store switch quietly chose localStorage: the header still showed the user menu, new saves stayed on this device, and nothing retried until a remount. - A US or UK session whose PolicyEngine user could not be loaded is now 'account_error'. It gets the account stores backed by a client that rejects every call with AccountUnavailableError, so lists and reports show an error and saves fail with a clear message instead of going to localStorage. A flagship run is refused up front, as it is while the session is still being restored. - /v2/me retries three times with backoff (1s, 2s, 4s), then again when the window regains focus or the network comes back, and on retryAccount. - The user menu marks the avatar, says the account could not be loaded, and offers "Try again" alongside "Sign out"; the account page offers both too. - selectAssociationBackend takes the user as-is, removing the `{ id } as MeItem` cast. Co-Authored-By: Claude Opus 5.5 --- app/src/components/auth/AuthMenu.tsx | 54 +++++++++- app/src/hooks/useFlagshipStores.ts | 15 ++- app/src/hooks/useRunFlagshipReport.ts | 10 ++ app/src/hooks/useSaveSharedReport.ts | 2 +- app/src/hooks/useUserHousehold.ts | 2 +- app/src/hooks/useUserPolicy.ts | 2 +- app/src/hooks/useUserReportAssociations.ts | 2 +- .../hooks/useUserSimulationAssociations.ts | 2 +- app/src/libs/auth/AuthContext.ts | 14 ++- app/src/libs/auth/AuthProvider.tsx | 98 ++++++++++++++++--- app/src/libs/auth/useAssociationBackend.ts | 91 ++++++++++++++--- app/src/pages/Account.page.tsx | 7 +- .../tests/fixtures/libs/auth/authMocks.tsx | 12 +++ .../unit/components/auth/AuthMenu.test.tsx | 32 ++++++ .../unit/hooks/useRunFlagshipReport.test.tsx | 95 ++++++++++++++++++ .../unit/libs/auth/AuthProvider.test.tsx | 43 ++++++-- .../libs/auth/useAssociationBackend.test.tsx | 59 +++++++++-- .../tests/unit/pages/Account.page.test.tsx | 18 ++++ 18 files changed, 495 insertions(+), 63 deletions(-) create mode 100644 app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx diff --git a/app/src/components/auth/AuthMenu.tsx b/app/src/components/auth/AuthMenu.tsx index e12d5a6b3..cd435ad5a 100644 --- a/app/src/components/auth/AuthMenu.tsx +++ b/app/src/components/auth/AuthMenu.tsx @@ -1,4 +1,4 @@ -import { IconLogout, IconUserCircle } from '@tabler/icons-react'; +import { IconAlertTriangle, IconLogout, IconRefresh, IconUserCircle } from '@tabler/icons-react'; import { Button, DropdownMenu, @@ -31,7 +31,9 @@ function initialOf(name: string): string { /** * Header control for sign-in: a "Sign in" button when signed out, the * user menu (My account, Sign out) when signed in, and nothing when - * sign-in is disabled or the session is still being restored. + * sign-in is disabled or the session is still being restored. When the + * PolicyEngine account could not be loaded, the avatar carries a warning + * and the menu says so, with "Try again" and "Sign out". */ export default function AuthMenu({ variant = 'default' }: AuthMenuProps) { const auth = useAuthStatus(); @@ -66,14 +68,19 @@ export default function AuthMenu({ variant = 'default' }: AuthMenuProps) { const name = accountDisplayName(auth); const email = auth.user?.email ?? auth.profile?.email; + const accountUnavailable = auth.status === 'account_error'; return ( @@ -108,6 +129,31 @@ export default function AuthMenu({ variant = 'default' }: AuthMenuProps) {
)} + {accountUnavailable && ( + <> + +
+ + + We could not load your account, so saved work is unavailable. Nothing is saved until + it loads. + +
+ auth.retryAccount()}> + + Try again + + + )} nav.push(`/${countryId}/account`)}> diff --git a/app/src/hooks/useFlagshipStores.ts b/app/src/hooks/useFlagshipStores.ts index 67a77d910..64ee5c53a 100644 --- a/app/src/hooks/useFlagshipStores.ts +++ b/app/src/hooks/useFlagshipStores.ts @@ -3,12 +3,14 @@ import { useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import { getFlagshipReportStore, type FlagshipReportStore } from '@/api/flagshipReportStore'; import { getReformStore, type ReformStore } from '@/api/reformStore'; -import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; +import { accountStoreContext, useAssociationBackend } from '@/libs/auth/useAssociationBackend'; import type { RootState } from '@/store'; /** * The flagship (v3) stores for the current session: the account's when - * signed in (US/UK), localStorage otherwise. + * signed in (US/UK), localStorage otherwise. A signed-in session whose + * account could not be loaded gets the account stores, whose calls fail, + * rather than localStorage. */ export function useReformStore(): ReformStore { @@ -20,7 +22,7 @@ export function useReformStore(): ReformStore { return useMemo( () => - getReformStore(backend.mode === 'api' ? backend : null, { + getReformStore(accountStoreContext(backend), { getParameterMetadata: () => parametersRef.current, }), [backend] @@ -35,16 +37,19 @@ export interface FlagshipReportStoreSelection { userId: string; /** False while the session is being restored. */ ready: boolean; + /** Set when signed in but the account could not be loaded: nothing can be saved. */ + accountError: Error | null; } export function useFlagshipReportStore(): FlagshipReportStoreSelection { const backend = useAssociationBackend(); return useMemo( () => ({ - store: getFlagshipReportStore(backend.mode === 'api' ? backend : null), - isAccount: backend.mode === 'api', + store: getFlagshipReportStore(accountStoreContext(backend)), + isAccount: backend.mode !== 'localStorage', userId: backend.userId, ready: backend.ready, + accountError: backend.mode === 'unavailable' ? backend.error : null, }), [backend] ); diff --git a/app/src/hooks/useRunFlagshipReport.ts b/app/src/hooks/useRunFlagshipReport.ts index f8c3aa963..e36a71ca2 100644 --- a/app/src/hooks/useRunFlagshipReport.ts +++ b/app/src/hooks/useRunFlagshipReport.ts @@ -46,6 +46,16 @@ export function useRunFlagshipReport() { setError('Model metadata is still loading — try again in a moment.'); return; } + // Signed in, the run's only record is the account's; never run into + // this browser while the account is unknown or could not be loaded. + if (!reportStore.ready) { + setError('Still checking your sign-in — try again in a moment.'); + return; + } + if (reportStore.accountError) { + setError(reportStore.accountError.message); + return; + } setIsRunning(true); setError(null); try { diff --git a/app/src/hooks/useSaveSharedReport.ts b/app/src/hooks/useSaveSharedReport.ts index 9abd4864a..3a1299b9c 100644 --- a/app/src/hooks/useSaveSharedReport.ts +++ b/app/src/hooks/useSaveSharedReport.ts @@ -72,7 +72,7 @@ export function useSaveSharedReport() { const userReportId = getShareDataUserReportId(shareData); const findExisting = async () => (await reportStore.findByUserReportId(userReportId)) ?? - (backend.mode === 'api' + (backend.mode !== 'localStorage' ? await reportStore.findById(userId, shareData.userReport.reportId) : null); diff --git a/app/src/hooks/useUserHousehold.ts b/app/src/hooks/useUserHousehold.ts index 126448037..ee62058b5 100644 --- a/app/src/hooks/useUserHousehold.ts +++ b/app/src/hooks/useUserHousehold.ts @@ -33,7 +33,7 @@ export const useHouseholdAssociationStoreForMode = (): HouseholdAssociationStore const backend = useAssociationBackend(); return useMemo( () => - backend.mode === 'api' + backend.mode !== 'localStorage' ? { store: new ApiHouseholdStore(backend), config: queryConfig.api, ready: true } : { store: localHouseholdStore, config: queryConfig.localStorage, ready: backend.ready }, [backend] diff --git a/app/src/hooks/useUserPolicy.ts b/app/src/hooks/useUserPolicy.ts index e1884ff2d..ee9976d8c 100644 --- a/app/src/hooks/useUserPolicy.ts +++ b/app/src/hooks/useUserPolicy.ts @@ -45,7 +45,7 @@ export const usePolicyAssociationStoreForMode = (): PolicyAssociationStoreSelect const backend = useAssociationBackend(); return useMemo( () => - backend.mode === 'api' + backend.mode !== 'localStorage' ? { store: new ApiPolicyStore(backend), config: queryConfig.api, diff --git a/app/src/hooks/useUserReportAssociations.ts b/app/src/hooks/useUserReportAssociations.ts index 38baecc1c..3acadab63 100644 --- a/app/src/hooks/useUserReportAssociations.ts +++ b/app/src/hooks/useUserReportAssociations.ts @@ -28,7 +28,7 @@ export const useReportAssociationStoreForMode = (): ReportAssociationStoreSelect const backend = useAssociationBackend(); return useMemo( () => - backend.mode === 'api' + backend.mode !== 'localStorage' ? { store: new ApiReportStore(backend, getLocalReportStore()), config: queryConfig.api, diff --git a/app/src/hooks/useUserSimulationAssociations.ts b/app/src/hooks/useUserSimulationAssociations.ts index 391100850..bb4449f18 100644 --- a/app/src/hooks/useUserSimulationAssociations.ts +++ b/app/src/hooks/useUserSimulationAssociations.ts @@ -28,7 +28,7 @@ export const useSimulationAssociationStoreForMode = (): SimulationAssociationSto const backend = useAssociationBackend(); return useMemo( () => - backend.mode === 'api' + backend.mode !== 'localStorage' ? { store: new ApiSimulationStore(backend), config: queryConfig.api, ready: true } : { store: getLocalSimulationStore(), diff --git a/app/src/libs/auth/AuthContext.ts b/app/src/libs/auth/AuthContext.ts index 7067d9a47..097dbd6f1 100644 --- a/app/src/libs/auth/AuthContext.ts +++ b/app/src/libs/auth/AuthContext.ts @@ -9,7 +9,16 @@ import type { MeItem } from '@/types/v2User'; * does without sign-in. */ -export type AuthStatus = 'disabled' | 'loading' | 'anonymous' | 'authenticated'; +/** + * - 'loading': the session (or, in the US and UK, the PolicyEngine user + * behind it) is still being restored. + * - 'authenticated': signed in; in the US and UK the PolicyEngine user + * has loaded. + * - 'account_error': signed in, but the PolicyEngine user could not be + * loaded (US and UK). Saved work is unavailable, never silently kept + * on this device, until `retryAccount` succeeds or the user signs out. + */ +export type AuthStatus = 'disabled' | 'loading' | 'anonymous' | 'authenticated' | 'account_error'; /** Display details from the identity provider (Auth0 ID token claims). */ export interface AuthProfile { @@ -28,6 +37,8 @@ export interface AuthContextValue { /** Redirects to the hosted sign-in page; returns to `returnTo` (default: here). */ signIn: (returnTo?: string) => Promise; signOut: () => Promise; + /** Tries loading the PolicyEngine user again (after 'account_error'). */ + retryAccount: () => void; /** An access token for the PolicyEngine API, or null when signed out. */ getAccessToken: () => Promise; } @@ -39,6 +50,7 @@ export const DISABLED_AUTH: AuthContextValue = { error: null, signIn: async () => {}, signOut: async () => {}, + retryAccount: () => {}, getAccessToken: async () => null, }; diff --git a/app/src/libs/auth/AuthProvider.tsx b/app/src/libs/auth/AuthProvider.tsx index 32f4e557b..ce1520e43 100644 --- a/app/src/libs/auth/AuthProvider.tsx +++ b/app/src/libs/auth/AuthProvider.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, type ReactNode } from 'react'; import { Auth0Provider, useAuth0, type AppState } from '@auth0/auth0-react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import { fetchMe } from '@/api/v2Me'; import { createV2UserClient } from '@/api/v2UserClient'; import { useCountryContext } from '@/contexts/CountryContext'; @@ -84,6 +84,50 @@ function stringClaim(value: unknown): string | null { return typeof value === 'string' && value ? value : null; } +/** Attempts after the first before the PolicyEngine user counts as unavailable. */ +export const ME_RETRIES = 3; + +/** 1s, 2s, 4s between attempts (capped at 8s). */ +export function meRetryDelay(attempt: number): number { + return Math.min(1000 * 2 ** attempt, 8000); +} + +const MISSING_SUBJECT_MESSAGE = 'The sign-in session did not identify a user.'; + +/** + * Where the session stands. In the US and UK a signed-in session is only + * 'authenticated' once its PolicyEngine user has loaded; while that is + * in flight (retries included) it is 'loading', and once it has failed + * it is 'account_error'. + */ +export function sessionStatus({ + isLoading, + isAuthenticated, + hasSubject, + meCountry, + me, +}: { + isLoading: boolean; + isAuthenticated: boolean; + hasSubject: boolean; + meCountry: string | null; + me: Pick, 'data' | 'isError'>; +}): AuthStatus { + if (isLoading) { + return 'loading'; + } + if (!isAuthenticated) { + return 'anonymous'; + } + if (!meCountry || me.data) { + return 'authenticated'; + } + if (!hasSubject || me.isError) { + return 'account_error'; + } + return 'loading'; +} + function Auth0SessionBridge({ config, countryId, @@ -121,7 +165,10 @@ function Auth0SessionBridge({ // The PolicyEngine user behind the session. Fetched with the current // country (us/uk) so a first sign-in provisions with it; elsewhere the - // session is still signed in but saved work stays local. + // session is still signed in but saved work stays local. Until it + // loads, saved work has nowhere to go, so a failure keeps retrying: + // with backoff at first, then whenever the window regains focus or + // the network comes back (and on retryAccount). const meCountry = isAssociationCountry(countryId) ? countryId : null; const meQuery = useQuery({ queryKey: authKeys.me(subject ?? ''), @@ -129,23 +176,29 @@ function Auth0SessionBridge({ enabled: isAuthenticated && !!subject && !!meCountry, staleTime: Infinity, gcTime: Infinity, - retry: 1, - refetchOnWindowFocus: false, + retry: ME_RETRIES, + retryDelay: meRetryDelay, + refetchOnWindowFocus: (query) => query.state.status === 'error', + refetchOnReconnect: (query) => query.state.status === 'error', }); - let status: AuthStatus; - if (isLoading) { - status = 'loading'; - } else if (!isAuthenticated) { - status = 'anonymous'; - } else if (meCountry && !meQuery.data && meQuery.isPending && meQuery.fetchStatus !== 'idle') { - status = 'loading'; - } else { - status = 'authenticated'; - } + const status = sessionStatus({ + isLoading, + isAuthenticated, + hasSubject: !!subject, + meCountry, + me: meQuery, + }); const user = isAuthenticated ? (meQuery.data ?? null) : null; - const error = auth0Error ?? (isAuthenticated ? meQuery.error : null) ?? null; + const accountError = + status === 'account_error' ? (meQuery.error ?? new Error(MISSING_SUBJECT_MESSAGE)) : null; + const error = auth0Error ?? accountError; + + const { refetch: refetchMe } = meQuery; + const retryAccount = useCallback(() => { + void refetchMe(); + }, [refetchMe]); const signIn = useCallback( async (returnTo?: string) => { @@ -177,9 +230,22 @@ function Auth0SessionBridge({ error, signIn, signOut, + retryAccount, getAccessToken, }), - [status, user, isAuthenticated, name, email, picture, error, signIn, signOut, getAccessToken] + [ + status, + user, + isAuthenticated, + name, + email, + picture, + error, + signIn, + signOut, + retryAccount, + getAccessToken, + ] ); return {children}; diff --git a/app/src/libs/auth/useAssociationBackend.ts b/app/src/libs/auth/useAssociationBackend.ts index a814af42e..d854fa086 100644 --- a/app/src/libs/auth/useAssociationBackend.ts +++ b/app/src/libs/auth/useAssociationBackend.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import type { ApiStoreContext } from '@/api/v2Associations'; import { createV2UserClient, type V2UserClient } from '@/api/v2UserClient'; import { MOCK_USER_ID } from '@/constants'; import { useCountryContext } from '@/contexts/CountryContext'; @@ -10,11 +11,14 @@ import { useAuthStatus, type AuthStatus } from './AuthContext'; * * Saved work goes to the PolicyEngine API only when the user is signed in * (and their PolicyEngine user has loaded) and the country has account - * storage (US, UK). Everything else — signed out, sign-in disabled, - * Canada, Nigeria, Israel — keeps the localStorage behaviour unchanged. + * storage (US, UK). Signed out, sign-in disabled, Canada, Nigeria and + * Israel keep the localStorage behaviour unchanged. A signed-in US or UK + * session whose PolicyEngine user could not be loaded is 'unavailable': + * its saved work neither loads nor saves, rather than quietly going to + * this browser while the user appears signed in. */ -export type AssociationBackendMode = 'api' | 'localStorage'; +export type AssociationBackendMode = 'api' | 'unavailable' | 'localStorage'; export function selectAssociationBackend({ status, @@ -22,14 +26,41 @@ export function selectAssociationBackend({ countryId, }: { status: AuthStatus; - user: MeItem | null; + user: Pick | null; countryId: string | null | undefined; }): AssociationBackendMode { - return status === 'authenticated' && !!user && isAssociationCountry(countryId) - ? 'api' - : 'localStorage'; + if (!isAssociationCountry(countryId)) { + return 'localStorage'; + } + if (status === 'authenticated' || status === 'account_error') { + return user ? 'api' : 'unavailable'; + } + return 'localStorage'; } +export const ACCOUNT_UNAVAILABLE_MESSAGE = + 'Your PolicyEngine account could not be loaded. Try again from the account menu, or sign out to keep working on this device.'; + +/** What every account-store call fails with while the account cannot be loaded. */ +export class AccountUnavailableError extends Error { + constructor() { + super(ACCOUNT_UNAVAILABLE_MESSAGE); + this.name = 'AccountUnavailableError'; + } +} + +/** A client that answers every request with `error` and sends nothing. */ +export function createUnavailableClient(error: Error): V2UserClient { + const fail = () => Promise.reject(error); + return { request: fail, listAll: fail }; +} + +/** + * Stands in for the PolicyEngine user ID while none is known; query keys + * carry it, so nothing cached for it is reused once the account loads. + */ +export const UNAVAILABLE_ACCOUNT_USER_ID = 'account-unavailable'; + export interface ApiAssociationBackend { mode: 'api'; ready: true; @@ -39,6 +70,17 @@ export interface ApiAssociationBackend { client: V2UserClient; } +export interface UnavailableAssociationBackend { + mode: 'unavailable'; + /** Queries run, and fail with `error` straight away. */ + ready: true; + userId: typeof UNAVAILABLE_ACCOUNT_USER_ID; + countryId: AssociationCountryId; + /** Rejects every request with `error`. */ + client: V2UserClient; + error: AccountUnavailableError; +} + export interface LocalAssociationBackend { mode: 'localStorage'; /** False while the session is still being restored; queries wait for it. */ @@ -49,7 +91,18 @@ export interface LocalAssociationBackend { client: null; } -export type AssociationBackend = ApiAssociationBackend | LocalAssociationBackend; +export type AssociationBackend = + | ApiAssociationBackend + | UnavailableAssociationBackend + | LocalAssociationBackend; + +/** + * What the account stores are built from: the account when signed in, or + * the unavailable account (whose every call fails), never localStorage. + */ +export function accountStoreContext(backend: AssociationBackend): ApiStoreContext | null { + return backend.mode === 'localStorage' ? null : backend; +} /** A PolicyEngine API client bound to the current session's access token. */ export function useV2UserClient(): V2UserClient { @@ -62,15 +115,23 @@ export function useAssociationBackend(countryIdOverride?: string | null): Associ const countryId = countryIdOverride ?? contextCountry ?? null; const { status, user } = useAuthStatus(); const client = useV2UserClient(); + const mode = selectAssociationBackend({ status, user, countryId }); const userId = user?.id ?? null; return useMemo(() => { - if ( - userId && - isAssociationCountry(countryId) && - selectAssociationBackend({ status, user: { id: userId } as MeItem, countryId }) === 'api' - ) { - return { mode: 'api', ready: true, userId, countryId, client }; + if (mode === 'api' && userId && isAssociationCountry(countryId)) { + return { mode, ready: true, userId, countryId, client }; + } + if (mode === 'unavailable' && isAssociationCountry(countryId)) { + const error = new AccountUnavailableError(); + return { + mode, + ready: true, + userId: UNAVAILABLE_ACCOUNT_USER_ID, + countryId, + client: createUnavailableClient(error), + error, + }; } return { mode: 'localStorage', @@ -79,7 +140,7 @@ export function useAssociationBackend(countryIdOverride?: string | null): Associ countryId, client: null, }; - }, [status, userId, countryId, client]); + }, [mode, status, userId, countryId, client]); } /** The user ID that saved work is filed under right now. */ diff --git a/app/src/pages/Account.page.tsx b/app/src/pages/Account.page.tsx index 3001711f0..564465b8d 100644 --- a/app/src/pages/Account.page.tsx +++ b/app/src/pages/Account.page.tsx @@ -157,11 +157,12 @@ export default function AccountPage() { if (!user) { return ( - + We could not load your PolicyEngine account - {auth.error ? `: ${auth.error.message}` : '.'} + {auth.error ? `: ${auth.error.message}` : '.'} Saved work is unavailable until it loads. -
+
+ diff --git a/app/src/tests/fixtures/libs/auth/authMocks.tsx b/app/src/tests/fixtures/libs/auth/authMocks.tsx index ed15a17f7..548afb2ca 100644 --- a/app/src/tests/fixtures/libs/auth/authMocks.tsx +++ b/app/src/tests/fixtures/libs/auth/authMocks.tsx @@ -27,6 +27,7 @@ export function makeAuthValue(overrides: Partial = {}): AuthCo error: null, signIn: vi.fn().mockResolvedValue(undefined), signOut: vi.fn().mockResolvedValue(undefined), + retryAccount: vi.fn(), getAccessToken: vi.fn().mockResolvedValue(null), ...overrides, }; @@ -43,6 +44,17 @@ export function signedInAuthValue(overrides: Partial = {}): Au }); } +/** Signed in, but the PolicyEngine user could not be loaded. */ +export function accountErrorAuthValue(overrides: Partial = {}): AuthContextValue { + return makeAuthValue({ + status: 'account_error', + profile: { name: 'Ada Lovelace', email: mockMeItem.email, picture: null }, + error: new Error('Saved work is temporarily unavailable.'), + getAccessToken: vi.fn().mockResolvedValue(TEST_ACCESS_TOKEN), + ...overrides, + }); +} + export function AuthTestProvider({ value, children, diff --git a/app/src/tests/unit/components/auth/AuthMenu.test.tsx b/app/src/tests/unit/components/auth/AuthMenu.test.tsx index 62f7e0083..025c9bf69 100644 --- a/app/src/tests/unit/components/auth/AuthMenu.test.tsx +++ b/app/src/tests/unit/components/auth/AuthMenu.test.tsx @@ -5,6 +5,7 @@ import HeaderActionButtons from '@/components/homeHeader/HeaderActionButtons'; import type { AuthContextValue } from '@/libs/auth/AuthContext'; import { mockMeItem } from '@/tests/fixtures/api/v2UserMocks'; import { + accountErrorAuthValue, AuthTestProvider, makeAuthValue, signedInAuthValue, @@ -64,4 +65,35 @@ describe('AuthMenu', () => { await user.click(screen.getByRole('menuitem', { name: /sign out/i })); expect(auth.signOut).toHaveBeenCalled(); }); + + test('given the account could not be loaded then the menu says so and offers Try again', async () => { + // Given + const user = userEvent.setup(); + const auth = accountErrorAuthValue(); + renderMenu(auth); + + // When + await user.click( + screen.getByRole('button', { name: /account menu for ada lovelace \(account not loaded\)/i }) + ); + + // Then + expect(screen.getByRole('alert')).toHaveTextContent(/could not load your account/i); + await user.click(screen.getByRole('menuitem', { name: /try again/i })); + expect(auth.retryAccount).toHaveBeenCalled(); + }); + + test('given the account could not be loaded then Sign out is still offered', async () => { + // Given + const user = userEvent.setup(); + const auth = accountErrorAuthValue(); + renderMenu(auth); + + // When + await user.click(screen.getByRole('button', { name: /account menu for/i })); + await user.click(screen.getByRole('menuitem', { name: /sign out/i })); + + // Then + expect(auth.signOut).toHaveBeenCalled(); + }); }); diff --git a/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx b/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx new file mode 100644 index 000000000..0721d30d4 --- /dev/null +++ b/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx @@ -0,0 +1,95 @@ +import { act, renderHook } from '@testing-library/react'; +import { Provider as ReduxProvider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { NavigationProvider } from '@/contexts/NavigationContext'; +import { useFlagshipReportStore } from '@/hooks/useFlagshipStores'; +import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; +import { + ACCOUNT_UNAVAILABLE_MESSAGE, + AccountUnavailableError, +} from '@/libs/auth/useAssociationBackend'; +import { runFlagshipReport, type RunReportProvision } from '@/libs/flagship/runReport'; +import { store } from '@/store'; + +vi.mock('@/hooks/useFlagshipStores', () => ({ + useFlagshipReportStore: vi.fn(), +})); + +vi.mock('@/libs/flagship/runReport', () => ({ + runFlagshipReport: vi.fn(), +})); + +vi.mock('react-redux', async () => { + const actual = await vi.importActual('react-redux'); + return { ...actual, useSelector: vi.fn(() => 2) }; +}); + +const PROVISIONS: RunReportProvision[] = [ + { + path: 'gov.irs.credits.ctc.amount', + breadcrumb: 'CTC', + unit: null, + baselineValue: 2000, + value: 3000, + }, +]; + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} + +function selection(overrides: Partial> = {}) { + return { + store: {} as ReturnType['store'], + isAccount: true, + userId: 'account-unavailable', + ready: true, + accountError: null, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useRunFlagshipReport', () => { + test('given the account could not be loaded then the run is refused before anything is created', async () => { + // Given + vi.mocked(useFlagshipReportStore).mockReturnValue( + selection({ accountError: new AccountUnavailableError() }) + ); + const { result } = renderHook(() => useRunFlagshipReport(), { wrapper }); + + // When + await act(() => result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS)); + + // Then + expect(runFlagshipReport).not.toHaveBeenCalled(); + expect(result.current.error).toBe(ACCOUNT_UNAVAILABLE_MESSAGE); + }); + + test('given the session is still being restored then the run waits instead of saving locally', async () => { + // Given + vi.mocked(useFlagshipReportStore).mockReturnValue( + selection({ isAccount: false, ready: false }) + ); + const { result } = renderHook(() => useRunFlagshipReport(), { wrapper }); + + // When + await act(() => result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS)); + + // Then + expect(runFlagshipReport).not.toHaveBeenCalled(); + expect(result.current.error).toMatch(/still checking your sign-in/i); + }); +}); diff --git a/app/src/tests/unit/libs/auth/AuthProvider.test.tsx b/app/src/tests/unit/libs/auth/AuthProvider.test.tsx index 59734e0e3..f5bef14f4 100644 --- a/app/src/tests/unit/libs/auth/AuthProvider.test.tsx +++ b/app/src/tests/unit/libs/auth/AuthProvider.test.tsx @@ -1,10 +1,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { CountryProvider } from '@/contexts/CountryContext'; import { NavigationProvider } from '@/contexts/NavigationContext'; import { useAuthStatus } from '@/libs/auth/AuthContext'; -import { AuthProvider } from '@/libs/auth/AuthProvider'; +import { AuthProvider, ME_RETRIES } from '@/libs/auth/AuthProvider'; import type { CountryId } from '@/libs/countries'; import { errorResponse, @@ -49,9 +50,13 @@ function Probe() {
{auth.status} {auth.user?.id ?? 'none'} + {auth.error?.message ?? 'none'} +
); } @@ -181,8 +186,9 @@ describe('AuthProvider', () => { }); }); - test('given /v2/me fails then the session is signed in without a PolicyEngine user', async () => { + test('given /v2/me keeps failing then the session reports an account error, not a signed-in user', async () => { // Given + vi.useFakeTimers({ shouldAdvanceTime: true }); stubAuthEnv(true); auth0.state.isAuthenticated = true; auth0.state.user = { sub: 'google-oauth2|1234' }; @@ -190,12 +196,37 @@ describe('AuthProvider', () => { // When renderProvider('uk'); + expect(screen.getByTestId('status')).toHaveTextContent('loading'); + await vi.advanceTimersByTimeAsync(10_000); - // Then (after one retry) - await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'), { - timeout: 3000, - }); + // Then: every retry was spent before giving up + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('account_error')); + expect(fetchMock).toHaveBeenCalledTimes(1 + ME_RETRIES); expect(screen.getByTestId('user')).toHaveTextContent('none'); + expect(screen.getByTestId('error')).toHaveTextContent('Database unavailable'); + vi.useRealTimers(); + }); + + test('given an account error then retrying loads the PolicyEngine user', async () => { + // Given + vi.useFakeTimers({ shouldAdvanceTime: true }); + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: 'google-oauth2|1234' }; + fetchMock.mockImplementation(async () => errorResponse(503, 'Database unavailable')); + renderProvider('us'); + await vi.advanceTimersByTimeAsync(10_000); + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('account_error')); + vi.useRealTimers(); + + // When + fetchMock.mockImplementation(async () => itemResponse(mockMeItem)); + await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' })); + + // Then + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated')); + expect(screen.getByTestId('user')).toHaveTextContent(mockMeItem.id); + expect(screen.getByTestId('error')).toHaveTextContent('none'); }); test('given a session in a country without account storage then /v2/me is not called', () => { diff --git a/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx b/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx index c5be62808..08656da6a 100644 --- a/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx +++ b/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx @@ -1,5 +1,5 @@ import { renderHook } from '@testing-library/react'; -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; import { ApiHouseholdStore, LocalStorageHouseholdStore } from '@/api/householdAssociation'; import { ApiPolicyStore, LocalStoragePolicyStore } from '@/api/policyAssociation'; import { ApiReportStore, LocalStorageReportStore } from '@/api/reportAssociation'; @@ -12,13 +12,18 @@ import { useReportAssociationStoreForMode } from '@/hooks/useUserReportAssociati import { useSimulationAssociationStoreForMode } from '@/hooks/useUserSimulationAssociations'; import type { AuthContextValue, AuthStatus } from '@/libs/auth/AuthContext'; import { + ACCOUNT_UNAVAILABLE_MESSAGE, + AccountUnavailableError, selectAssociationBackend, + UNAVAILABLE_ACCOUNT_USER_ID, useAssociationBackend, useCurrentUserId, + type AssociationBackendMode, } from '@/libs/auth/useAssociationBackend'; import type { CountryId } from '@/libs/countries'; import { mockMeItem, PE_USER_ID } from '@/tests/fixtures/api/v2UserMocks'; import { + accountErrorAuthValue, AuthTestProvider, makeAuthValue, signedInAuthValue, @@ -33,23 +38,35 @@ function wrapperFor(auth: AuthContextValue, countryId: CountryId) { } describe('selectAssociationBackend', () => { - const statuses: AuthStatus[] = ['disabled', 'loading', 'anonymous', 'authenticated']; + const statuses: AuthStatus[] = [ + 'disabled', + 'loading', + 'anonymous', + 'authenticated', + 'account_error', + ]; const countries: CountryId[] = ['us', 'uk', 'ca', 'ng', 'il']; + const expectedMode: Record = { + disabled: 'localStorage', + loading: 'localStorage', + anonymous: 'localStorage', + authenticated: 'api', + account_error: 'unavailable', + }; test.each(statuses.flatMap((status) => countries.map((country) => [status, country] as const)))( 'given status %s in %s then account storage is used only when signed in to US or UK', (status, countryId) => { + const user = status === 'authenticated' ? mockMeItem : null; const expected = - status === 'authenticated' && (countryId === 'us' || countryId === 'uk') - ? 'api' - : 'localStorage'; - expect(selectAssociationBackend({ status, user: mockMeItem, countryId })).toBe(expected); + countryId === 'us' || countryId === 'uk' ? expectedMode[status] : 'localStorage'; + expect(selectAssociationBackend({ status, user, countryId })).toBe(expected); } ); - test('given a session whose PolicyEngine user has not loaded then localStorage is used', () => { + test('given a US session whose PolicyEngine user is missing then saved work is unavailable, not local', () => { expect(selectAssociationBackend({ status: 'authenticated', user: null, countryId: 'us' })).toBe( - 'localStorage' + 'unavailable' ); }); }); @@ -82,6 +99,32 @@ describe('useAssociationBackend', () => { expect(result.current.ready).toBe(false); }); + test('given the account could not be loaded then saved work neither loads nor saves', async () => { + // Given + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const wrapper = wrapperFor(accountErrorAuthValue(), 'us'); + + // When + const backend = renderHook(() => useAssociationBackend(), { wrapper }).result.current; + const policyStore = renderHook(() => usePolicyAssociationStoreForMode(), { wrapper }).result + .current.store; + + // Then + expect(backend.mode).toBe('unavailable'); + expect(backend.ready).toBe(true); + expect(backend.userId).toBe(UNAVAILABLE_ACCOUNT_USER_ID); + expect(policyStore).toBeInstanceOf(ApiPolicyStore); + await expect(policyStore.findByUser(backend.userId, 'us')).rejects.toBeInstanceOf( + AccountUnavailableError + ); + await expect( + policyStore.create({ userId: backend.userId, policyId: '95762', countryId: 'us' }) + ).rejects.toThrow(ACCOUNT_UNAVAILABLE_MESSAGE); + expect(fetchMock).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + test('given no auth provider then sign-in is disabled and storage is local', () => { const { result } = renderHook(() => useAssociationBackend('us')); diff --git a/app/src/tests/unit/pages/Account.page.test.tsx b/app/src/tests/unit/pages/Account.page.test.tsx index b75ebe642..f1bb2cf1c 100644 --- a/app/src/tests/unit/pages/Account.page.test.tsx +++ b/app/src/tests/unit/pages/Account.page.test.tsx @@ -10,6 +10,7 @@ import { TEST_ACCESS_TOKEN, } from '@/tests/fixtures/api/v2UserMocks'; import { + accountErrorAuthValue, AuthTestProvider, makeAuthValue, signedInAuthValue, @@ -120,4 +121,21 @@ describe('AccountPage', () => { ).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /delete my account/i })).not.toBeInTheDocument(); }); + + test('given the account could not be loaded then the page offers Try again and Sign out', async () => { + // Given + const user = userEvent.setup(); + const auth = accountErrorAuthValue(); + renderPage(auth); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(screen.getByRole('alert')).toHaveTextContent( + /could not load your PolicyEngine account: Saved work is temporarily unavailable/ + ); + expect(auth.retryAccount).toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument(); + }); }); From 910bd3d2e396ea77375430cc90346b4630795f94 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:00:55 +0400 Subject: [PATCH 10/48] Record import items the server did not accept, and stop re-offering An item the import sent that came back in neither id_map nor skipped (the server reports unparseable items without a client_ref) stayed pending, so LocalDataImportPrompt reopened on every visit, and closing it after an import recorded nothing. - Every item the server did not save is now recorded as skipped, with the server's reason or as invalid, and appears once in the summary. - Closing the prompt after an import ran ("Done", or closing after a failure) is remembered like "Not now"; the account page still offers what is left. - The summary only says that what an item refers to no longer exists for unknown_legacy_reference skips; other skips read "could not be saved". Co-Authored-By: Claude Opus 5.5 --- .../components/auth/LocalDataImportPrompt.tsx | 13 ++--- app/src/components/auth/importSummaryText.ts | 15 ++++-- app/src/libs/auth/localImport.ts | 47 +++++++++++++----- .../auth/LocalDataImportPrompt.test.tsx | 46 +++++++++++++++++- .../components/auth/importSummaryText.test.ts | 47 ++++++++++++++++++ .../tests/unit/libs/auth/localImport.test.ts | 48 +++++++++++++++++-- app/src/types/v2User.ts | 8 ++++ 7 files changed, 195 insertions(+), 29 deletions(-) create mode 100644 app/src/tests/unit/components/auth/importSummaryText.test.ts diff --git a/app/src/components/auth/LocalDataImportPrompt.tsx b/app/src/components/auth/LocalDataImportPrompt.tsx index 31ca049bf..ea680867d 100644 --- a/app/src/components/auth/LocalDataImportPrompt.tsx +++ b/app/src/components/auth/LocalDataImportPrompt.tsx @@ -18,8 +18,9 @@ import { importSummaryLines } from './importSummaryText'; /** * Offered once per account after signing in (US/UK), when this browser * holds saved work the account does not have yet. Saving copies it to - * the account; nothing is deleted from the device. "Not now" is - * remembered; the account page can still save it later. + * the account; nothing is deleted from the device. Closing the prompt, + * with "Not now" or after an import, is remembered; the account page can + * still save what is left. */ export default function LocalDataImportPrompt() { const localImport = useLocalDataImport(); @@ -41,13 +42,13 @@ export default function LocalDataImportPrompt() { const running = localImport.state === 'running'; const finished = localImport.state === 'done' && localImport.summary; + // Closing — "Not now", or after an import ran, whatever its outcome — + // stops the prompt for this account; the account page offers the rest. const close = () => { if (running) { return; } - if (localImport.state === 'idle') { - localImport.decline(); - } + localImport.decline(); setOpen(false); }; @@ -87,7 +88,7 @@ export default function LocalDataImportPrompt() { {finished ? ( - + ) : ( <> +
); } @@ -279,4 +298,36 @@ describe('AuthProvider', () => { expect(navigation.replace).toHaveBeenNthCalledWith(1, '/us/build'); expect(navigation.replace).toHaveBeenNthCalledWith(2, '/us'); }); + + test('given sign out then what the session left in this browser is cleared first', async () => { + // Given: a signed-in run's provenance and an account reform loaded for editing + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: 'google-oauth2|1234' }; + fetchMock.mockResolvedValue(itemResponse(mockMeItem)); + mockCreateReport.mockResolvedValue({ id: 56 }); + await runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: '', + provisions: [{ path: 'gov.x', breadcrumb: 'X', unit: null, baselineValue: 1, value: 2 }], + currentLawId: 2, + account: { + reportStore: { create: vi.fn().mockResolvedValue({}) } as unknown as FlagshipReportStore, + userId: mockMeItem.id, + }, + }); + startDraftReform('us', 'manual'); + renderProvider('us'); + + // When + await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' })); + + // Then + expect(readReportMeta('56')).toBeNull(); + expect(getDraftReform()).toBeNull(); + expect(auth0.logout).toHaveBeenCalledWith({ + logoutParams: { returnTo: `${window.location.origin}/us` }, + }); + }); }); diff --git a/app/src/tests/unit/libs/auth/localImport.test.ts b/app/src/tests/unit/libs/auth/localImport.test.ts index 7aa542587..edec7e206 100644 --- a/app/src/tests/unit/libs/auth/localImport.test.ts +++ b/app/src/tests/unit/libs/auth/localImport.test.ts @@ -16,6 +16,7 @@ import { errorResponse, fetchCall, okResponse, + OTHER_PE_USER_ID, PE_USER_ID, } from '@/tests/fixtures/api/v2UserMocks'; import { @@ -446,4 +447,42 @@ describe('import bookkeeping', () => { expect(readImportedIdMap(PE_USER_ID)).toEqual({}); expect(isImportDeclined(PE_USER_ID)).toBe(false); }); + + test('given one account imported this browser then a second person signing in is not offered those items', async () => { + // Given + seedLocalData(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(importResponder()); + await runLocalImport({ + client: context.client, + userId: PE_USER_ID, + materialize: async () => V1_IDS.MATERIALIZED_REFORM, + }); + + // When + const plan = planLocalImport(OTHER_PE_USER_ID); + + // Then: nothing of the first account's is offered, and its links do not resolve + expect(plan.items).toEqual([]); + expect(readImportedIdMap(OTHER_PE_USER_ID)).toEqual({}); + expect(resolveImportedId(OTHER_PE_USER_ID, LOCAL_IDS.REPORT_US)).toBeNull(); + }); + + test('given the importing account was deleted then its items can be offered again', async () => { + // Given + seedLocalData(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(importResponder()); + await runLocalImport({ + client: context.client, + userId: PE_USER_ID, + materialize: async () => V1_IDS.MATERIALIZED_REFORM, + }); + + // When + forgetImportState(PE_USER_ID); + + // Then + expect(countImportableItems(OTHER_PE_USER_ID)).toBe(7); + }); }); diff --git a/app/src/tests/unit/libs/flagship/runReport.test.ts b/app/src/tests/unit/libs/flagship/runReport.test.ts index caf561632..96301e551 100644 --- a/app/src/tests/unit/libs/flagship/runReport.test.ts +++ b/app/src/tests/unit/libs/flagship/runReport.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { FlagshipReportStore } from '@/api/flagshipReportStore'; -import { readReportMeta, runFlagshipReport } from '@/libs/flagship/runReport'; +import { + forgetAccountReportMeta, + readReportMeta, + REPORT_META_KEY, + runFlagshipReport, +} from '@/libs/flagship/runReport'; const mockCreatePolicy = vi.fn(); const mockCreateSimulation = vi.fn(); @@ -205,6 +210,29 @@ describe('runFlagshipReport', () => { expect(readReportMeta('56')?.title).toBe('CTC expansion'); }); + test('given a signed-in run then its provenance stays out of localStorage and goes on sign-out', async () => { + // Given + const accountStore = { + create: vi.fn().mockResolvedValue({ id: 'row-1' }), + } as unknown as FlagshipReportStore; + + // When + await runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: 'Federal · Introduced', + provisions: [PROVISION], + currentLawId: 2, + account: { reportStore: accountStore, userId: ACCOUNT_USER_ID }, + }); + + // Then: the next person on this browser cannot find it + expect(localStorage.getItem(REPORT_META_KEY)).toBeNull(); + expect(readReportMeta('56')?.title).toBe('CTC expansion'); + forgetAccountReportMeta(); + expect(readReportMeta('56')).toBeNull(); + }); + test('given the account association fails then the signed-in run fails', async () => { // Given const accountStore = { From d3c8304242f50f6d565bdeb0c053872eeb77da96 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:08:52 +0400 Subject: [PATCH 13/48] Make the fallback messages for 409 and 422 accurate The client's fallback for a 422 said "Something this refers to no longer exists", but the Public API also answers 422 for request validation, and the 409 fallback said every conflict was "managed elsewhere", although a duplicate client_ref conflicts too. Both now name the likely causes instead of asserting one. (The `{ id } as MeItem` cast went with the account-error change.) Co-Authored-By: Claude Opus 5.5 --- app/src/api/v2UserClient.ts | 9 +++++++-- app/src/tests/unit/api/v2UserClient.test.ts | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/app/src/api/v2UserClient.ts b/app/src/api/v2UserClient.ts index bc2517dd1..b5b0947c6 100644 --- a/app/src/api/v2UserClient.ts +++ b/app/src/api/v2UserClient.ts @@ -54,14 +54,19 @@ export interface CreateV2UserClientOptions { fetchImpl?: typeof fetch; } +/** + * Messages for errors that arrive without one from the API. 409 and 422 + * cover several causes on the server, so their messages name the likely + * ones rather than asserting one. + */ const STATUS_MESSAGES: Record = { 400: 'The request was not valid.', 401: 'Your session has ended. Sign in again to continue.', 403: 'This account cannot use saved work.', 404: 'Not found.', - 409: 'This item is managed elsewhere and cannot be changed here.', + 409: 'This conflicts with what is already saved: it may exist already, or be kept up to date from elsewhere.', 413: 'Too much data in one request.', - 422: 'Something this refers to no longer exists.', + 422: 'This could not be saved: something in it is not valid, or something it refers to may no longer exist.', 503: 'Saved work is temporarily unavailable.', }; diff --git a/app/src/tests/unit/api/v2UserClient.test.ts b/app/src/tests/unit/api/v2UserClient.test.ts index 65f02464d..692b12794 100644 --- a/app/src/tests/unit/api/v2UserClient.test.ts +++ b/app/src/tests/unit/api/v2UserClient.test.ts @@ -67,6 +67,25 @@ describe('v2UserClient', () => { expect(isV2ApiError(error, 422)).toBe(true); }); + test.each([ + [422, /not valid, or something it refers to may no longer exist/], + [409, /conflicts with what is already saved/], + [413, /too much data/i], + ])( + 'given a %s without a message then the fallback does not claim a single cause', + async (status, expected) => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(new Response('', { status })); + + // When / Then + await expect(context.client.request('POST', 'user-policies')).rejects.toMatchObject({ + status, + message: expect.stringMatching(expected), + }); + } + ); + test('given an error without a body then the message describes the status', async () => { // Given const { fetchMock, context } = createTestApiContext(); From 58c425c278fc232f322f56acf1e98b7f1868a555 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:10:38 +0400 Subject: [PATCH 14/48] Name the local-store lookup callbacks Co-Authored-By: Claude Opus 5.5 --- app/src/api/householdAssociation.ts | 2 +- app/src/api/policyAssociation.ts | 2 +- app/src/api/simulationAssociation.ts | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/api/householdAssociation.ts b/app/src/api/householdAssociation.ts index d4990e97c..984b5c4ae 100644 --- a/app/src/api/householdAssociation.ts +++ b/app/src/api/householdAssociation.ts @@ -124,7 +124,7 @@ export class LocalStorageHouseholdStore implements UserHouseholdStore { /** The association by its own ID (`suh-…`). */ async findByUserHouseholdId(userHouseholdId: string): Promise { - return this.getStoredHouseholds().find((h) => h.id === userHouseholdId) ?? null; + return this.getStoredHouseholds().find((household) => household.id === userHouseholdId) ?? null; } async update( diff --git a/app/src/api/policyAssociation.ts b/app/src/api/policyAssociation.ts index 7fe9b5b6d..f65b6a603 100644 --- a/app/src/api/policyAssociation.ts +++ b/app/src/api/policyAssociation.ts @@ -113,7 +113,7 @@ export class LocalStoragePolicyStore implements UserPolicyStore { /** The association by its own ID (`sup-…`). */ async findByUserPolicyId(userPolicyId: string): Promise { - return this.getStoredPolicies().find((p) => p.id === userPolicyId) ?? null; + return this.getStoredPolicies().find((policy) => policy.id === userPolicyId) ?? null; } private getStoredPolicies(): UserPolicy[] { diff --git a/app/src/api/simulationAssociation.ts b/app/src/api/simulationAssociation.ts index 3f8b51c82..f473dd2e3 100644 --- a/app/src/api/simulationAssociation.ts +++ b/app/src/api/simulationAssociation.ts @@ -122,7 +122,9 @@ export class LocalStorageSimulationStore implements UserSimulationStore { /** The association by its own ID (`sus-…`). */ async findByUserSimulationId(userSimulationId: string): Promise { - return this.getStoredSimulations().find((s) => s.id === userSimulationId) ?? null; + return ( + this.getStoredSimulations().find((simulation) => simulation.id === userSimulationId) ?? null + ); } private getStoredSimulations(): UserSimulation[] { From 78fcd1351255aeff0589388993862b52ce007572 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:24:10 +0400 Subject: [PATCH 15/48] Make saved-work writes wait for the sign-in session to be restored While the session was being restored (and /v2/me now retries for up to about 7s), the store hooks handed out the localStorage stores, so a signed-in user's save in that window landed in this browser. - The auth context gains settledSession(): it answers once the status leaves 'loading', with the state the session settled in. useAssociationBackendResolver turns that into the backend for the country. - Every saved-work store the hooks hand out (policies, households, simulations, reports, reforms, flagship report records) now forwards each call to the store for the settled session. That covers the association mutations and the direct writers: the report builder's submission and modify hooks, useCreateReport, ReportAdjustPanel, DraftHeader and the Reforms page. useSaveSharedReport and the flagship run resolve the backend the same way; the run no longer errors while the session restores. - Signed-out visitors wait only for the restore, then save locally. - The v2 client asks for the access token at request time, so a request that waited carries the signed-in user's token. Co-Authored-By: Claude Opus 5.5 --- app/src/api/settledStores.ts | 97 ++++++ app/src/hooks/useFlagshipStores.ts | 64 +++- app/src/hooks/useRunFlagshipReport.ts | 23 +- app/src/hooks/useSaveSharedReport.ts | 7 +- app/src/hooks/useUserHousehold.ts | 33 +- app/src/hooks/useUserPolicy.ts | 37 +- app/src/hooks/useUserReportAssociations.ts | 37 +- .../hooks/useUserSimulationAssociations.ts | 37 +- app/src/libs/auth/AuthContext.ts | 45 ++- app/src/libs/auth/AuthProvider.tsx | 12 +- app/src/libs/auth/useAssociationBackend.ts | 109 ++++-- .../tests/fixtures/libs/auth/authMocks.tsx | 27 +- ...vedWorkWritesWhileSessionRestores.test.tsx | 321 ++++++++++++++++++ .../tests/unit/hooks/useCreateReport.test.tsx | 13 +- .../unit/hooks/useRunFlagshipReport.test.tsx | 95 ++++-- .../libs/auth/useAssociationBackend.test.tsx | 77 +++-- 16 files changed, 856 insertions(+), 178 deletions(-) create mode 100644 app/src/api/settledStores.ts create mode 100644 app/src/tests/unit/hooks/savedWorkWritesWhileSessionRestores.test.tsx diff --git a/app/src/api/settledStores.ts b/app/src/api/settledStores.ts new file mode 100644 index 000000000..41dd0018d --- /dev/null +++ b/app/src/api/settledStores.ts @@ -0,0 +1,97 @@ +import type { FlagshipReportStore } from '@/api/flagshipReportStore'; +import type { UserHouseholdStore } from '@/api/householdAssociation'; +import type { UserPolicyStore } from '@/api/policyAssociation'; +import type { ReformStore } from '@/api/reformStore'; +import type { UserReportStore } from '@/api/reportAssociation'; +import type { UserSimulationStore } from '@/api/simulationAssociation'; + +/** + * Saved-work stores that wait for the sign-in session to be restored. + * + * Each takes a resolver (see useAssociationBackendResolver) and forwards + * every call to the store for how the session settled. Until then which + * store applies is unknown, so nothing is written: a signed-in user's save + * never lands in this browser, and a signed-out visitor's waits only for + * the restore. + */ + +export function deferredPolicyStore(resolve: () => Promise): UserPolicyStore { + return { + create: async (policy) => (await resolve()).create(policy), + findByUser: async (userId, countryId) => (await resolve()).findByUser(userId, countryId), + findById: async (userId, policyId) => (await resolve()).findById(userId, policyId), + update: async (userPolicyId, updates) => (await resolve()).update(userPolicyId, updates), + delete: async (userPolicyId) => (await resolve()).delete(userPolicyId), + }; +} + +export function deferredHouseholdStore( + resolve: () => Promise +): UserHouseholdStore { + return { + create: async (association) => (await resolve()).create(association), + findByUser: async (userId, countryId) => (await resolve()).findByUser(userId, countryId), + findById: async (userId, householdId) => (await resolve()).findById(userId, householdId), + update: async (userHouseholdId, updates) => (await resolve()).update(userHouseholdId, updates), + delete: async (userHouseholdId) => (await resolve()).delete(userHouseholdId), + }; +} + +export function deferredSimulationStore( + resolve: () => Promise +): UserSimulationStore { + return { + create: async (simulation) => (await resolve()).create(simulation), + findByUser: async (userId, countryId) => (await resolve()).findByUser(userId, countryId), + findById: async (userId, simulationId) => (await resolve()).findById(userId, simulationId), + update: async (userSimulationId, updates) => + (await resolve()).update(userSimulationId, updates), + delete: async (userSimulationId) => (await resolve()).delete(userSimulationId), + }; +} + +/** + * `createWithId` saves under the caller's ID where the settled store keeps + * caller IDs (localStorage) and lets the account assign one otherwise. + */ +export function deferredReportStore(resolve: () => Promise): UserReportStore { + return { + create: async (report) => (await resolve()).create(report), + createWithId: async (report) => { + const store = await resolve(); + if (store.createWithId) { + return store.createWithId(report); + } + const { id: _id, ...fields } = report; + return store.create(fields); + }, + findByUser: async (userId, countryId) => (await resolve()).findByUser(userId, countryId), + findById: async (userId, reportId) => (await resolve()).findById(userId, reportId), + findByUserReportId: async (userReportId) => (await resolve()).findByUserReportId(userReportId), + update: async (userReportId, updates) => (await resolve()).update(userReportId, updates), + delete: async (userReportId) => (await resolve()).delete(userReportId), + }; +} + +export function deferredReformStore(resolve: () => Promise): ReformStore { + return { + create: async (reform) => (await resolve()).create(reform), + findByUser: async (userId, countryId) => (await resolve()).findByUser(userId, countryId), + findById: async (reformId) => (await resolve()).findById(reformId), + update: async (reformId, updates) => (await resolve()).update(reformId, updates), + delete: async (reformId) => (await resolve()).delete(reformId), + }; +} + +export function deferredFlagshipReportStore( + resolve: () => Promise +): FlagshipReportStore { + return { + create: async (report) => (await resolve()).create(report), + findByUser: async (userId, countryId) => (await resolve()).findByUser(userId, countryId), + findById: async (id) => (await resolve()).findById(id), + findByApiReportId: async (userId, apiReportId) => + (await resolve()).findByApiReportId(userId, apiReportId), + saveValidation: async (id, snapshot) => (await resolve()).saveValidation(id, snapshot), + }; +} diff --git a/app/src/hooks/useFlagshipStores.ts b/app/src/hooks/useFlagshipStores.ts index 64ee5c53a..7f25b6e92 100644 --- a/app/src/hooks/useFlagshipStores.ts +++ b/app/src/hooks/useFlagshipStores.ts @@ -3,18 +3,33 @@ import { useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import { getFlagshipReportStore, type FlagshipReportStore } from '@/api/flagshipReportStore'; import { getReformStore, type ReformStore } from '@/api/reformStore'; -import { accountStoreContext, useAssociationBackend } from '@/libs/auth/useAssociationBackend'; +import { deferredFlagshipReportStore, deferredReformStore } from '@/api/settledStores'; +import { + accountStoreContext, + useAssociationBackend, + useAssociationBackendResolver, + type AssociationBackend, +} from '@/libs/auth/useAssociationBackend'; import type { RootState } from '@/store'; +import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; /** * The flagship (v3) stores for the current session: the account's when * signed in (US/UK), localStorage otherwise. A signed-in session whose * account could not be loaded gets the account stores, whose calls fail, - * rather than localStorage. + * rather than localStorage. The stores wait for the session to be + * restored, so nothing is written to the wrong one while it is. */ +function accountReformStore( + backend: AssociationBackend, + getParameterMetadata: () => ParameterMetadataCollection | null | undefined +): ReformStore { + return getReformStore(accountStoreContext(backend), { getParameterMetadata }); +} + export function useReformStore(): ReformStore { - const backend = useAssociationBackend(); + const resolveBackend = useAssociationBackendResolver(); const parameters = useSelector((state: RootState) => state.metadata?.parameters); // Read through a ref so metadata arriving later does not rebuild the store. const parametersRef = useRef(parameters); @@ -22,14 +37,36 @@ export function useReformStore(): ReformStore { return useMemo( () => - getReformStore(accountStoreContext(backend), { - getParameterMetadata: () => parametersRef.current, - }), - [backend] + deferredReformStore(async () => + accountReformStore(await resolveBackend(), () => parametersRef.current) + ), + [resolveBackend] ); } +/** Where a flagship run records its report. */ +export interface FlagshipRunDestination { + /** The account's report store and user; null keeps the run in this browser. */ + account: { reportStore: FlagshipReportStore; userId: string } | null; + /** Set when signed in but the account could not be loaded: the run must not start. */ + accountError: Error | null; +} + +function flagshipRunDestination(backend: AssociationBackend): FlagshipRunDestination { + if (backend.mode === 'unavailable') { + return { account: null, accountError: backend.error }; + } + if (backend.mode === 'api') { + return { + account: { reportStore: getFlagshipReportStore(backend), userId: backend.userId }, + accountError: null, + }; + } + return { account: null, accountError: null }; +} + export interface FlagshipReportStoreSelection { + /** Waits for the session to be restored. */ store: FlagshipReportStore; /** True when records go to the account (one `/v2/user-reports` row per run). */ isAccount: boolean; @@ -37,21 +74,24 @@ export interface FlagshipReportStoreSelection { userId: string; /** False while the session is being restored. */ ready: boolean; - /** Set when signed in but the account could not be loaded: nothing can be saved. */ - accountError: Error | null; + /** Where a run records its report, once the session is restored. */ + resolveRunDestination: () => Promise; } export function useFlagshipReportStore(): FlagshipReportStoreSelection { const backend = useAssociationBackend(); + const resolveBackend = useAssociationBackendResolver(); return useMemo( () => ({ - store: getFlagshipReportStore(accountStoreContext(backend)), + store: deferredFlagshipReportStore(async () => + getFlagshipReportStore(accountStoreContext(await resolveBackend())) + ), isAccount: backend.mode !== 'localStorage', userId: backend.userId, ready: backend.ready, - accountError: backend.mode === 'unavailable' ? backend.error : null, + resolveRunDestination: async () => flagshipRunDestination(await resolveBackend()), }), - [backend] + [backend, resolveBackend] ); } diff --git a/app/src/hooks/useRunFlagshipReport.ts b/app/src/hooks/useRunFlagshipReport.ts index e36a71ca2..8d9bf6e95 100644 --- a/app/src/hooks/useRunFlagshipReport.ts +++ b/app/src/hooks/useRunFlagshipReport.ts @@ -46,18 +46,17 @@ export function useRunFlagshipReport() { setError('Model metadata is still loading — try again in a moment.'); return; } - // Signed in, the run's only record is the account's; never run into - // this browser while the account is unknown or could not be loaded. - if (!reportStore.ready) { - setError('Still checking your sign-in — try again in a moment.'); - return; - } - if (reportStore.accountError) { - setError(reportStore.accountError.message); - return; - } setIsRunning(true); setError(null); + // Where the run's record goes is known once the session is restored: + // the account when signed in (never this browser while signing in), + // this browser when signed out. + const destination = await reportStore.resolveRunDestination(); + if (destination.accountError) { + setError(destination.accountError.message); + setIsRunning(false); + return; + } try { const userReportId = await runFlagshipReport({ countryId, @@ -67,9 +66,7 @@ export function useRunFlagshipReport() { currentLawId: Number(currentLawId), reformId, year, - account: reportStore.isAccount - ? { reportStore: reportStore.store, userId: reportStore.userId } - : null, + account: destination.account, }); if (fromDraft) { markDraftRun(userReportId); diff --git a/app/src/hooks/useSaveSharedReport.ts b/app/src/hooks/useSaveSharedReport.ts index 3a1299b9c..5b7a9b543 100644 --- a/app/src/hooks/useSaveSharedReport.ts +++ b/app/src/hooks/useSaveSharedReport.ts @@ -10,7 +10,7 @@ import { useCallback, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { ReportIngredientsInput } from '@/hooks/utils/useFetchReportIngredients'; -import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; +import { useAssociationBackendResolver } from '@/libs/auth/useAssociationBackend'; import { CountryId } from '@/libs/countries'; import { Household } from '@/models/Household'; import { RootState } from '@/store'; @@ -46,7 +46,7 @@ export function useSaveSharedReport() { const createHouseholdAssociation = useCreateHouseholdAssociation(); const createGeographicAssociation = useCreateGeographicAssociation(); const reportStore = useUserReportStore(); - const backend = useAssociationBackend(); + const resolveBackend = useAssociationBackendResolver(); // Get currentLawId to skip creating associations for current law policies const currentLawId = useSelector((state: RootState) => state.metadata.currentLawId); @@ -68,6 +68,9 @@ export function useSaveSharedReport() { _policies: Policy[] = [], _households: SharedSaveHouseholdDetails[] = [] ): Promise => { + // Saved where the session settles: the account when signed in (even if + // the save was clicked while signing in), this browser when signed out. + const backend = await resolveBackend(); const { userId } = backend; const userReportId = getShareDataUserReportId(shareData); const findExisting = async () => diff --git a/app/src/hooks/useUserHousehold.ts b/app/src/hooks/useUserHousehold.ts index ee62058b5..0ee509ba7 100644 --- a/app/src/hooks/useUserHousehold.ts +++ b/app/src/hooks/useUserHousehold.ts @@ -2,10 +2,16 @@ import { useMemo } from 'react'; import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; import { fetchHouseholdById } from '@/api/household'; import type { UserHouseholdStore } from '@/api/householdAssociation'; +import { deferredHouseholdStore } from '@/api/settledStores'; import { replaceHouseholdBaseForAssociation as replaceHouseholdBaseForAssociationAction } from '@/hooks/household/replaceHouseholdBaseForAssociation'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useSessionGatedQuery } from '@/hooks/utils/useSessionGatedQuery'; -import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; +import { + accountStoreContext, + useAssociationBackend, + useAssociationBackendResolver, + type AssociationBackend, +} from '@/libs/auth/useAssociationBackend'; import { Household } from '@/models/Household'; import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; import { ApiHouseholdStore, LocalStorageHouseholdStore } from '../api/householdAssociation'; @@ -14,11 +20,19 @@ import { householdAssociationKeys, householdKeys } from '../libs/queryKeys'; const localHouseholdStore = new LocalStorageHouseholdStore(); +function householdStoreFor(backend: AssociationBackend): UserHouseholdStore { + const account = accountStoreContext(backend); + return account ? new ApiHouseholdStore(account) : localHouseholdStore; +} + type HouseholdAssociationStoreSelection = { + /** Waits for the session to be restored, so writes never go to the wrong store. */ store: UserHouseholdStore; config: typeof queryConfig.api | typeof queryConfig.localStorage; /** False while the session is being restored; queries wait. */ ready: boolean; + /** The store for the session once it is restored; `store` forwards to it. */ + resolveStore: () => Promise; }; type HouseholdAssociationQueryOptions = { @@ -31,13 +45,16 @@ export const useUserHouseholdStore = () => { export const useHouseholdAssociationStoreForMode = (): HouseholdAssociationStoreSelection => { const backend = useAssociationBackend(); - return useMemo( - () => - backend.mode !== 'localStorage' - ? { store: new ApiHouseholdStore(backend), config: queryConfig.api, ready: true } - : { store: localHouseholdStore, config: queryConfig.localStorage, ready: backend.ready }, - [backend] - ); + const resolveBackend = useAssociationBackendResolver(); + return useMemo(() => { + const resolveStore = async () => householdStoreFor(await resolveBackend()); + return { + store: deferredHouseholdStore(resolveStore), + config: backend.mode === 'localStorage' ? queryConfig.localStorage : queryConfig.api, + ready: backend.ready, + resolveStore, + }; + }, [backend, resolveBackend]); }; // This fetches only the user-household associations; see diff --git a/app/src/hooks/useUserPolicy.ts b/app/src/hooks/useUserPolicy.ts index ee9976d8c..b1b5efa8d 100644 --- a/app/src/hooks/useUserPolicy.ts +++ b/app/src/hooks/useUserPolicy.ts @@ -2,10 +2,16 @@ import { useMemo } from 'react'; import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; import { PolicyAdapter } from '@/adapters'; import { fetchPolicyById } from '@/api/policy'; +import { deferredPolicyStore } from '@/api/settledStores'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useParameterMetadata } from '@/hooks/useParameterMetadata'; import { useSessionGatedQuery } from '@/hooks/utils/useSessionGatedQuery'; -import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; +import { + accountStoreContext, + useAssociationBackend, + useAssociationBackendResolver, + type AssociationBackend, +} from '@/libs/auth/useAssociationBackend'; import { Policy } from '@/types/ingredients/Policy'; import type { PolicyCreationPayload } from '@/types/payloads'; import { @@ -19,11 +25,19 @@ import { UserPolicy } from '../types/ingredients/UserPolicy'; const localPolicyStore = new LocalStoragePolicyStore(); +function policyStoreFor(backend: AssociationBackend): UserPolicyStore { + const account = accountStoreContext(backend); + return account ? new ApiPolicyStore(account) : localPolicyStore; +} + type PolicyAssociationStoreSelection = { + /** Waits for the session to be restored, so writes never go to the wrong store. */ store: UserPolicyStore; config: typeof queryConfig.api | typeof queryConfig.localStorage; /** False while the session is being restored; queries wait. */ ready: boolean; + /** The store for the session once it is restored; `store` forwards to it. */ + resolveStore: () => Promise; }; type PolicyAssociationQueryOptions = { @@ -43,17 +57,16 @@ export const useUserPolicyStore = () => { export const usePolicyAssociationStoreForMode = (): PolicyAssociationStoreSelection => { const backend = useAssociationBackend(); - return useMemo( - () => - backend.mode !== 'localStorage' - ? { - store: new ApiPolicyStore(backend), - config: queryConfig.api, - ready: true, - } - : { store: localPolicyStore, config: queryConfig.localStorage, ready: backend.ready }, - [backend] - ); + const resolveBackend = useAssociationBackendResolver(); + return useMemo(() => { + const resolveStore = async () => policyStoreFor(await resolveBackend()); + return { + store: deferredPolicyStore(resolveStore), + config: backend.mode === 'localStorage' ? queryConfig.localStorage : queryConfig.api, + ready: backend.ready, + resolveStore, + }; + }, [backend, resolveBackend]); }; // This fetches only the user-policy associations; see diff --git a/app/src/hooks/useUserReportAssociations.ts b/app/src/hooks/useUserReportAssociations.ts index 3acadab63..7bb91bbbd 100644 --- a/app/src/hooks/useUserReportAssociations.ts +++ b/app/src/hooks/useUserReportAssociations.ts @@ -1,8 +1,14 @@ import { useMemo } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { deferredReportStore } from '@/api/settledStores'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useSessionGatedQuery } from '@/hooks/utils/useSessionGatedQuery'; -import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; +import { + accountStoreContext, + useAssociationBackend, + useAssociationBackendResolver, + type AssociationBackend, +} from '@/libs/auth/useAssociationBackend'; import { ApiReportStore, LocalStorageReportStore, @@ -17,26 +23,33 @@ import { UserReport } from '../types/ingredients/UserReport'; let localReportStore: LocalStorageReportStore | null = null; const getLocalReportStore = () => (localReportStore ??= new LocalStorageReportStore()); +function reportStoreFor(backend: AssociationBackend): UserReportStore { + const account = accountStoreContext(backend); + return account ? new ApiReportStore(account, getLocalReportStore()) : getLocalReportStore(); +} + type ReportAssociationStoreSelection = { + /** Waits for the session to be restored, so writes never go to the wrong store. */ store: UserReportStore; config: typeof queryConfig.api | typeof queryConfig.localStorage; /** False while the session is being restored; queries wait. */ ready: boolean; + /** The store for the session once it is restored; `store` forwards to it. */ + resolveStore: () => Promise; }; export const useReportAssociationStoreForMode = (): ReportAssociationStoreSelection => { const backend = useAssociationBackend(); - return useMemo( - () => - backend.mode !== 'localStorage' - ? { - store: new ApiReportStore(backend, getLocalReportStore()), - config: queryConfig.api, - ready: true, - } - : { store: getLocalReportStore(), config: queryConfig.localStorage, ready: backend.ready }, - [backend] - ); + const resolveBackend = useAssociationBackendResolver(); + return useMemo(() => { + const resolveStore = async () => reportStoreFor(await resolveBackend()); + return { + store: deferredReportStore(resolveStore), + config: backend.mode === 'localStorage' ? queryConfig.localStorage : queryConfig.api, + ready: backend.ready, + resolveStore, + }; + }, [backend, resolveBackend]); }; export const useUserReportStore = () => { diff --git a/app/src/hooks/useUserSimulationAssociations.ts b/app/src/hooks/useUserSimulationAssociations.ts index bb4449f18..8429e1415 100644 --- a/app/src/hooks/useUserSimulationAssociations.ts +++ b/app/src/hooks/useUserSimulationAssociations.ts @@ -1,8 +1,14 @@ import { useMemo } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { deferredSimulationStore } from '@/api/settledStores'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useSessionGatedQuery } from '@/hooks/utils/useSessionGatedQuery'; -import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; +import { + accountStoreContext, + useAssociationBackend, + useAssociationBackendResolver, + type AssociationBackend, +} from '@/libs/auth/useAssociationBackend'; import { ApiSimulationStore, LocalStorageSimulationStore, @@ -17,26 +23,33 @@ import { UserSimulation } from '../types/ingredients/UserSimulation'; let localSimulationStore: LocalStorageSimulationStore | null = null; const getLocalSimulationStore = () => (localSimulationStore ??= new LocalStorageSimulationStore()); +function simulationStoreFor(backend: AssociationBackend): UserSimulationStore { + const account = accountStoreContext(backend); + return account ? new ApiSimulationStore(account) : getLocalSimulationStore(); +} + type SimulationAssociationStoreSelection = { + /** Waits for the session to be restored, so writes never go to the wrong store. */ store: UserSimulationStore; config: typeof queryConfig.api | typeof queryConfig.localStorage; /** False while the session is being restored; queries wait. */ ready: boolean; + /** The store for the session once it is restored; `store` forwards to it. */ + resolveStore: () => Promise; }; export const useSimulationAssociationStoreForMode = (): SimulationAssociationStoreSelection => { const backend = useAssociationBackend(); - return useMemo( - () => - backend.mode !== 'localStorage' - ? { store: new ApiSimulationStore(backend), config: queryConfig.api, ready: true } - : { - store: getLocalSimulationStore(), - config: queryConfig.localStorage, - ready: backend.ready, - }, - [backend] - ); + const resolveBackend = useAssociationBackendResolver(); + return useMemo(() => { + const resolveStore = async () => simulationStoreFor(await resolveBackend()); + return { + store: deferredSimulationStore(resolveStore), + config: backend.mode === 'localStorage' ? queryConfig.localStorage : queryConfig.api, + ready: backend.ready, + resolveStore, + }; + }, [backend, resolveBackend]); }; export const useUserSimulationStore = () => { diff --git a/app/src/libs/auth/AuthContext.ts b/app/src/libs/auth/AuthContext.ts index 097dbd6f1..d949e1c74 100644 --- a/app/src/libs/auth/AuthContext.ts +++ b/app/src/libs/auth/AuthContext.ts @@ -1,4 +1,4 @@ -import { createContext, useContext } from 'react'; +import { createContext, useCallback, useContext, useEffect, useRef } from 'react'; import type { MeItem } from '@/types/v2User'; /** @@ -20,6 +20,12 @@ import type { MeItem } from '@/types/v2User'; */ export type AuthStatus = 'disabled' | 'loading' | 'anonymous' | 'authenticated' | 'account_error'; +/** The session once it has been restored. */ +export interface SettledSession { + status: Exclude; + user: MeItem | null; +} + /** Display details from the identity provider (Auth0 ID token claims). */ export interface AuthProfile { name: string | null; @@ -39,6 +45,12 @@ export interface AuthContextValue { signOut: () => Promise; /** Tries loading the PolicyEngine user again (after 'account_error'). */ retryAccount: () => void; + /** + * The session as soon as it is restored: at once when it already is, + * otherwise when status leaves 'loading'. Saved-work writes wait on it + * so they never go to this browser for a user who is signing in. + */ + settledSession: () => Promise; /** An access token for the PolicyEngine API, or null when signed out. */ getAccessToken: () => Promise; } @@ -51,6 +63,7 @@ export const DISABLED_AUTH: AuthContextValue = { signIn: async () => {}, signOut: async () => {}, retryAccount: () => {}, + settledSession: async () => ({ status: 'disabled', user: null }), getAccessToken: async () => null, }; @@ -60,6 +73,36 @@ export function useAuthStatus(): AuthContextValue { return useContext(AuthContext); } +/** + * `settledSession` for a provider whose state is `status` and `user`: + * callers that ask while the session is loading are answered once it has + * been restored, with the state it settled in. + */ +export function useSettledSession( + status: AuthStatus, + user: MeItem | null +): () => Promise { + const latest = useRef({ status, user }); + latest.current = { status, user }; + const waiting = useRef void>>([]); + + useEffect(() => { + if (status === 'loading') { + return; + } + const answer = waiting.current.splice(0); + answer.forEach((resolve) => resolve({ status, user })); + }, [status, user]); + + return useCallback(() => { + const session = latest.current; + if (session.status === 'loading') { + return new Promise((resolve) => waiting.current.push(resolve)); + } + return Promise.resolve({ status: session.status, user: session.user }); + }, []); +} + export const authKeys = { all: ['auth'] as const, me: (subject: string) => ['auth', 'me', subject] as const, diff --git a/app/src/libs/auth/AuthProvider.tsx b/app/src/libs/auth/AuthProvider.tsx index 3adc5d490..35adbc9ee 100644 --- a/app/src/libs/auth/AuthProvider.tsx +++ b/app/src/libs/auth/AuthProvider.tsx @@ -15,7 +15,13 @@ import { safeReturnTo, type Auth0Config, } from './authConfig'; -import { AuthContext, authKeys, type AuthContextValue, type AuthStatus } from './AuthContext'; +import { + AuthContext, + authKeys, + useSettledSession, + type AuthContextValue, + type AuthStatus, +} from './AuthContext'; import { clearSignedInSessionData } from './sessionData'; /** @@ -196,6 +202,8 @@ function Auth0SessionBridge({ status === 'account_error' ? (meQuery.error ?? new Error(MISSING_SUBJECT_MESSAGE)) : null; const error = auth0Error ?? accountError; + const settledSession = useSettledSession(status, user); + const { refetch: refetchMe } = meQuery; const retryAccount = useCallback(() => { void refetchMe(); @@ -233,6 +241,7 @@ function Auth0SessionBridge({ signIn, signOut, retryAccount, + settledSession, getAccessToken, }), [ @@ -246,6 +255,7 @@ function Auth0SessionBridge({ signIn, signOut, retryAccount, + settledSession, getAccessToken, ] ); diff --git a/app/src/libs/auth/useAssociationBackend.ts b/app/src/libs/auth/useAssociationBackend.ts index d854fa086..9397d24c3 100644 --- a/app/src/libs/auth/useAssociationBackend.ts +++ b/app/src/libs/auth/useAssociationBackend.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import type { ApiStoreContext } from '@/api/v2Associations'; import { createV2UserClient, type V2UserClient } from '@/api/v2UserClient'; import { MOCK_USER_ID } from '@/constants'; @@ -104,43 +104,94 @@ export function accountStoreContext(backend: AssociationBackend): ApiStoreContex return backend.mode === 'localStorage' ? null : backend; } -/** A PolicyEngine API client bound to the current session's access token. */ +/** + * A PolicyEngine API client bound to the session's access token. It asks + * for the token at request time, so a request that waited for the + * session to be restored carries the signed-in user's token. + */ export function useV2UserClient(): V2UserClient { const { getAccessToken } = useAuthStatus(); - return useMemo(() => createV2UserClient({ getAccessToken }), [getAccessToken]); + const latestGetAccessToken = useRef(getAccessToken); + latestGetAccessToken.current = getAccessToken; + return useMemo( + () => createV2UserClient({ getAccessToken: () => latestGetAccessToken.current() }), + [] + ); } -export function useAssociationBackend(countryIdOverride?: string | null): AssociationBackend { +/** The backend for a session state in a country (see selectAssociationBackend). */ +export function associationBackendFor({ + status, + user, + countryId, + client, +}: { + status: AuthStatus; + user: Pick | null; + countryId: string | null; + client: V2UserClient; +}): AssociationBackend { + const mode = selectAssociationBackend({ status, user, countryId }); + if (mode === 'api' && user && isAssociationCountry(countryId)) { + return { mode, ready: true, userId: user.id, countryId, client }; + } + if (mode === 'unavailable' && isAssociationCountry(countryId)) { + const error = new AccountUnavailableError(); + return { + mode, + ready: true, + userId: UNAVAILABLE_ACCOUNT_USER_ID, + countryId, + client: createUnavailableClient(error), + error, + }; + } + return { + mode: 'localStorage', + ready: status !== 'loading', + userId: MOCK_USER_ID, + countryId, + client: null, + }; +} + +function useBackendCountry(countryIdOverride?: string | null): string | null { const contextCountry = useCountryContext(); - const countryId = countryIdOverride ?? contextCountry ?? null; + return countryIdOverride ?? contextCountry ?? null; +} + +/** The backend for the session as it stands (not ready while it is being restored). */ +export function useAssociationBackend(countryIdOverride?: string | null): AssociationBackend { + const countryId = useBackendCountry(countryIdOverride); const { status, user } = useAuthStatus(); const client = useV2UserClient(); - const mode = selectAssociationBackend({ status, user, countryId }); const userId = user?.id ?? null; - return useMemo(() => { - if (mode === 'api' && userId && isAssociationCountry(countryId)) { - return { mode, ready: true, userId, countryId, client }; - } - if (mode === 'unavailable' && isAssociationCountry(countryId)) { - const error = new AccountUnavailableError(); - return { - mode, - ready: true, - userId: UNAVAILABLE_ACCOUNT_USER_ID, - countryId, - client: createUnavailableClient(error), - error, - }; - } - return { - mode: 'localStorage', - ready: status !== 'loading', - userId: MOCK_USER_ID, - countryId, - client: null, - }; - }, [mode, status, userId, countryId, client]); + return useMemo( + () => + associationBackendFor({ status, user: userId ? { id: userId } : null, countryId, client }), + [status, userId, countryId, client] + ); +} + +/** + * The backend saved-work writes should use: it waits until the session + * is restored, then answers for how it settled. A signed-in user's write + * issued while the session is restoring therefore goes to the account, + * never to this browser; a signed-out visitor's waits only for the + * restore and then goes to localStorage. + */ +export function useAssociationBackendResolver( + countryIdOverride?: string | null +): () => Promise { + const countryId = useBackendCountry(countryIdOverride); + const { settledSession } = useAuthStatus(); + const client = useV2UserClient(); + + return useCallback(async () => { + const { status, user } = await settledSession(); + return associationBackendFor({ status, user, countryId, client }); + }, [settledSession, countryId, client]); } /** The user ID that saved work is filed under right now. */ diff --git a/app/src/tests/fixtures/libs/auth/authMocks.tsx b/app/src/tests/fixtures/libs/auth/authMocks.tsx index 548afb2ca..42def439e 100644 --- a/app/src/tests/fixtures/libs/auth/authMocks.tsx +++ b/app/src/tests/fixtures/libs/auth/authMocks.tsx @@ -1,5 +1,11 @@ +import { useMemo } from 'react'; import { vi } from 'vitest'; -import { AuthContext, type AuthContextValue } from '@/libs/auth/AuthContext'; +import { + AuthContext, + useSettledSession, + type AuthContextValue, + type SettledSession, +} from '@/libs/auth/AuthContext'; import { mockMeItem, TEST_ACCESS_TOKEN } from '@/tests/fixtures/api/v2UserMocks'; export const TEST_AUTH0_CONFIG = { @@ -19,8 +25,13 @@ export function stubAuthEnv( vi.stubEnv('VITE_AUTH0_AUDIENCE', config.audience ?? ''); } +/** + * A fixed auth state. Its `settledSession` answers at once unless the + * state is 'loading', which never settles on its own; AuthTestProvider + * replaces it with one that settles when the provided value changes. + */ export function makeAuthValue(overrides: Partial = {}): AuthContextValue { - return { + const value: AuthContextValue = { status: 'anonymous', user: null, profile: null, @@ -28,9 +39,16 @@ export function makeAuthValue(overrides: Partial = {}): AuthCo signIn: vi.fn().mockResolvedValue(undefined), signOut: vi.fn().mockResolvedValue(undefined), retryAccount: vi.fn(), + settledSession: () => { + const { status, user } = value; + return status === 'loading' + ? new Promise(() => {}) + : Promise.resolve({ status, user }); + }, getAccessToken: vi.fn().mockResolvedValue(null), ...overrides, }; + return value; } /** A signed-in session whose PolicyEngine user has loaded. */ @@ -55,6 +73,7 @@ export function accountErrorAuthValue(overrides: Partial = {}) }); } +/** Provides `value`, with a `settledSession` that settles as the real provider's does. */ export function AuthTestProvider({ value, children, @@ -62,5 +81,7 @@ export function AuthTestProvider({ value: AuthContextValue; children: React.ReactNode; }) { - return {children}; + const settledSession = useSettledSession(value.status, value.user); + const provided = useMemo(() => ({ ...value, settledSession }), [value, settledSession]); + return {children}; } diff --git a/app/src/tests/unit/hooks/savedWorkWritesWhileSessionRestores.test.tsx b/app/src/tests/unit/hooks/savedWorkWritesWhileSessionRestores.test.tsx new file mode 100644 index 000000000..b34946257 --- /dev/null +++ b/app/src/tests/unit/hooks/savedWorkWritesWhileSessionRestores.test.tsx @@ -0,0 +1,321 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook } from '@testing-library/react'; +import { Provider as ReduxProvider } from 'react-redux'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { LocalStorageHouseholdStore } from '@/api/householdAssociation'; +import { LocalStoragePolicyStore } from '@/api/policyAssociation'; +import { LocalStorageReformStore } from '@/api/reformStore'; +import { LocalStorageReportStore } from '@/api/reportAssociation'; +import { LocalStorageSimulationStore } from '@/api/simulationAssociation'; +import { MOCK_USER_ID } from '@/constants'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useReformStore } from '@/hooks/useFlagshipStores'; +import { useCreateHouseholdAssociation } from '@/hooks/useUserHousehold'; +import { useCreatePolicyAssociation, useUpdatePolicyAssociation } from '@/hooks/useUserPolicy'; +import { useCreateReportAssociation, useUserReportStore } from '@/hooks/useUserReportAssociations'; +import { + useCreateSimulationAssociation, + useUserSimulationStore, +} from '@/hooks/useUserSimulationAssociations'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import { store } from '@/store'; +import { + ASSOCIATION_IDS, + householdItem, + itemResponse, + LEGACY_IDS, + policyItem, + reportItem, + simulationItem, + TEST_ACCESS_TOKEN, +} from '@/tests/fixtures/api/v2UserMocks'; +import { + AuthTestProvider, + makeAuthValue, + signedInAuthValue, +} from '@/tests/fixtures/libs/auth/authMocks'; +import type { UserPolicy } from '@/types/ingredients/UserPolicy'; + +/** + * Saved-work writes issued while the sign-in session is still being + * restored wait for it, then go where it settles: the account for a + * signed-in user, this browser for a signed-out visitor. + */ + +const fetchMock = vi.fn(); + +function renderWithSession(useHook: () => Result) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + let auth: AuthContextValue = makeAuthValue({ status: 'loading' }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + + ); + const rendered = renderHook(useHook, { wrapper }); + return { + result: rendered.result, + settle: (next: AuthContextValue) => + act(() => { + auth = next; + rendered.rerender(); + }), + }; +} + +/** Lets pending promise callbacks run without settling the session. */ +const flush = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + +function lastRequest() { + const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit]; + return { + method: init.method, + path: new URL(url).pathname, + authorization: (init.headers as Record).Authorization, + body: init.body ? JSON.parse(String(init.body)) : undefined, + }; +} + +const newPolicy: Omit = { + userId: MOCK_USER_ID, + policyId: String(LEGACY_IDS.POLICY), + countryId: 'us', + label: 'CTC expansion', +}; + +beforeEach(() => { + localStorage.clear(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('saved-work writes while the sign-in session is being restored', () => { + test('given a policy saved while signing in then it waits and lands in the account', async () => { + // Given + fetchMock.mockResolvedValue(itemResponse(policyItem({ name: 'CTC expansion' }), 201)); + const { result, settle } = renderWithSession(() => useCreatePolicyAssociation()); + + // When + let saving: Promise | undefined; + act(() => { + saving = result.current.mutateAsync(newPolicy); + }); + await flush(); + + // Then: nothing is written until the session settles + expect(fetchMock).not.toHaveBeenCalled(); + expect(await new LocalStoragePolicyStore().findByUser(MOCK_USER_ID)).toEqual([]); + + // When + await settle(signedInAuthValue()); + const saved = await act(() => saving!); + + // Then + expect(lastRequest()).toMatchObject({ + method: 'POST', + path: '/v2/user-policies', + authorization: `Bearer ${TEST_ACCESS_TOKEN}`, + body: { legacy_policy_id: LEGACY_IDS.POLICY, name: 'CTC expansion' }, + }); + expect(saved.id).toBe(ASSOCIATION_IDS.POLICY); + expect(await new LocalStoragePolicyStore().findByUser(MOCK_USER_ID)).toEqual([]); + }); + + test('given a policy saved while the session restores then a signed-out visitor saves locally', async () => { + // Given + const { result, settle } = renderWithSession(() => useCreatePolicyAssociation()); + let saving: Promise | undefined; + act(() => { + saving = result.current.mutateAsync(newPolicy); + }); + + // When + await settle(makeAuthValue({ status: 'anonymous' })); + const saved = await act(() => saving!); + + // Then + expect(fetchMock).not.toHaveBeenCalled(); + expect(saved.id).toMatch(/^sup-/); + expect(await new LocalStoragePolicyStore().findByUserPolicyId(saved.id!)).toMatchObject({ + label: 'CTC expansion', + }); + }); + + test('given a rename issued while signing in then it patches the account row', async () => { + // Given + fetchMock.mockResolvedValue(itemResponse(policyItem({ name: 'Renamed' }))); + const { result, settle } = renderWithSession(() => useUpdatePolicyAssociation()); + let renaming: Promise | undefined; + act(() => { + renaming = result.current.mutateAsync({ + userPolicyId: ASSOCIATION_IDS.POLICY, + updates: { label: 'Renamed' }, + }); + }); + + // When + await settle(signedInAuthValue()); + await act(() => renaming!); + + // Then + expect(lastRequest()).toMatchObject({ + method: 'PATCH', + path: `/v2/user-policies/${ASSOCIATION_IDS.POLICY}`, + body: { name: 'Renamed' }, + }); + }); + + /** What to save, the account row it returns, its route, and this browser's copies. */ + type SaveCase = [string, () => () => Promise, object, string, () => Promise]; + const saveCases: SaveCase[] = [ + [ + 'report (direct store write, as the report builder does)', + () => { + const reportStore = useUserReportStore(); + return () => + reportStore.create({ + userId: MOCK_USER_ID, + reportId: String(LEGACY_IDS.REPORT), + countryId: 'us', + }); + }, + reportItem(), + '/v2/user-reports', + () => new LocalStorageReportStore().findByUser(MOCK_USER_ID), + ], + [ + 'simulation (direct store write, as the report builder does)', + () => { + const simulationStore = useUserSimulationStore(); + return () => + simulationStore.create({ + userId: MOCK_USER_ID, + simulationId: String(LEGACY_IDS.SIMULATION), + countryId: 'us', + }); + }, + simulationItem(), + '/v2/user-simulations', + () => new LocalStorageSimulationStore().findByUser(MOCK_USER_ID), + ], + [ + 'household', + () => { + const create = useCreateHouseholdAssociation(); + return () => + create.mutateAsync({ + userId: MOCK_USER_ID, + householdId: String(LEGACY_IDS.HOUSEHOLD), + countryId: 'us', + label: 'Family', + }); + }, + householdItem(), + '/v2/user-households', + () => new LocalStorageHouseholdStore().findByUser(MOCK_USER_ID), + ], + [ + 'simulation', + () => { + const create = useCreateSimulationAssociation(); + return () => + create.mutateAsync({ + userId: MOCK_USER_ID, + simulationId: String(LEGACY_IDS.SIMULATION), + countryId: 'us', + label: 'Baseline', + }); + }, + simulationItem(), + '/v2/user-simulations', + () => new LocalStorageSimulationStore().findByUser(MOCK_USER_ID), + ], + [ + 'report', + () => { + const create = useCreateReportAssociation(); + return () => + create.mutateAsync({ + userId: MOCK_USER_ID, + reportId: String(LEGACY_IDS.REPORT), + countryId: 'us', + label: 'My report', + }); + }, + reportItem(), + '/v2/user-reports', + () => new LocalStorageReportStore().findByUser(MOCK_USER_ID), + ], + ]; + + test.each(saveCases)( + 'given a %s saved while signing in then it lands in the account, not this browser', + async (_, useSave, accountItem, path, readLocal) => { + // Given + fetchMock.mockResolvedValue(itemResponse(accountItem, 201)); + const { result, settle } = renderWithSession(useSave); + let saving: Promise | undefined; + act(() => { + saving = result.current(); + }); + await flush(); + expect(fetchMock).not.toHaveBeenCalled(); + + // When + await settle(signedInAuthValue()); + await act(() => saving!); + + // Then + expect(lastRequest()).toMatchObject({ method: 'POST', path }); + expect(await readLocal()).toEqual([]); + } + ); + + test('given a reform saved from a direct store write while signing in then it lands in the account', async () => { + // Given + fetchMock.mockResolvedValue( + itemResponse( + policyItem({ + id: ASSOCIATION_IDS.REFORM, + provenance: { source: 'manual', ref: null, confidence: null }, + baseline: 'current-law', + }), + 201 + ) + ); + const { result, settle } = renderWithSession(() => useReformStore()); + let saving: Promise | undefined; + act(() => { + saving = result.current.create({ + userId: MOCK_USER_ID, + countryId: 'us', + label: 'Adjusted', + policyId: String(LEGACY_IDS.POLICY), + parameters: [], + baseline: 'current-law', + provenance: { source: 'manual' }, + }); + }); + await flush(); + expect(fetchMock).not.toHaveBeenCalled(); + + // When + await settle(signedInAuthValue()); + await act(() => saving!); + + // Then + expect(lastRequest()).toMatchObject({ method: 'POST', path: '/v2/user-policies' }); + expect(await new LocalStorageReformStore().findByUser(MOCK_USER_ID)).toEqual([]); + }); +}); diff --git a/app/src/tests/unit/hooks/useCreateReport.test.tsx b/app/src/tests/unit/hooks/useCreateReport.test.tsx index 42392cf69..61f191f3a 100644 --- a/app/src/tests/unit/hooks/useCreateReport.test.tsx +++ b/app/src/tests/unit/hooks/useCreateReport.test.tsx @@ -124,10 +124,17 @@ describe('useCreateReport', () => { payload: mockReportCreationPayload, userId: 'anonymous', label: TEST_LABEL, - // Signed out, the association is saved in localStorage - reportStore: expect.any(LocalStorageReportStore), + reportStore: expect.objectContaining({ create: expect.any(Function) }), }); }); + // Signed out, the association is saved in localStorage + const { reportStore } = vi.mocked(createReportAndAssociateWithUser).mock.calls[0][0]; + const saved = await reportStore!.create({ + userId: 'anonymous', + reportId: '615', + countryId: TEST_COUNTRY_ID, + }); + expect(await new LocalStorageReportStore().findByUserReportId(saved.id)).not.toBeNull(); }); test('given no label when creating report then creates with undefined label', async () => { @@ -146,7 +153,7 @@ describe('useCreateReport', () => { payload: mockReportCreationPayload, userId: 'anonymous', label: undefined, - reportStore: expect.any(LocalStorageReportStore), + reportStore: expect.objectContaining({ create: expect.any(Function) }), }); }); }); diff --git a/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx b/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx index 0721d30d4..4f5853d04 100644 --- a/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx +++ b/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx @@ -1,20 +1,21 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { Provider as ReduxProvider } from 'react-redux'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { ApiFlagshipReportStore } from '@/api/flagshipReportStore'; import { CountryProvider } from '@/contexts/CountryContext'; import { NavigationProvider } from '@/contexts/NavigationContext'; -import { useFlagshipReportStore } from '@/hooks/useFlagshipStores'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; -import { - ACCOUNT_UNAVAILABLE_MESSAGE, - AccountUnavailableError, -} from '@/libs/auth/useAssociationBackend'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import { ACCOUNT_UNAVAILABLE_MESSAGE } from '@/libs/auth/useAssociationBackend'; import { runFlagshipReport, type RunReportProvision } from '@/libs/flagship/runReport'; import { store } from '@/store'; - -vi.mock('@/hooks/useFlagshipStores', () => ({ - useFlagshipReportStore: vi.fn(), -})); +import { PE_USER_ID } from '@/tests/fixtures/api/v2UserMocks'; +import { + accountErrorAuthValue, + AuthTestProvider, + makeAuthValue, + signedInAuthValue, +} from '@/tests/fixtures/libs/auth/authMocks'; vi.mock('@/libs/flagship/runReport', () => ({ runFlagshipReport: vi.fn(), @@ -35,40 +36,38 @@ const PROVISIONS: RunReportProvision[] = [ }, ]; -function wrapper({ children }: { children: React.ReactNode }) { - return ( +function renderRun(initialAuth: AuthContextValue) { + let auth = initialAuth; + const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - + + + {children} + + ); -} - -function selection(overrides: Partial> = {}) { + const rendered = renderHook(() => useRunFlagshipReport(), { wrapper }); return { - store: {} as ReturnType['store'], - isAccount: true, - userId: 'account-unavailable', - ready: true, - accountError: null, - ...overrides, + ...rendered, + settle: (next: AuthContextValue) => { + auth = next; + rendered.rerender(); + }, }; } beforeEach(() => { vi.clearAllMocks(); + vi.mocked(runFlagshipReport).mockResolvedValue('56'); }); describe('useRunFlagshipReport', () => { test('given the account could not be loaded then the run is refused before anything is created', async () => { // Given - vi.mocked(useFlagshipReportStore).mockReturnValue( - selection({ accountError: new AccountUnavailableError() }) - ); - const { result } = renderHook(() => useRunFlagshipReport(), { wrapper }); + const { result } = renderRun(accountErrorAuthValue()); // When await act(() => result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS)); @@ -76,20 +75,44 @@ describe('useRunFlagshipReport', () => { // Then expect(runFlagshipReport).not.toHaveBeenCalled(); expect(result.current.error).toBe(ACCOUNT_UNAVAILABLE_MESSAGE); + expect(result.current.isRunning).toBe(false); }); - test('given the session is still being restored then the run waits instead of saving locally', async () => { + test('given a run started while signing in then it is recorded in the account once signed in', async () => { // Given - vi.mocked(useFlagshipReportStore).mockReturnValue( - selection({ isAccount: false, ready: false }) - ); - const { result } = renderHook(() => useRunFlagshipReport(), { wrapper }); + const { result, settle } = renderRun(makeAuthValue({ status: 'loading' })); + let running: Promise | undefined; + act(() => { + running = result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS); + }); + expect(runFlagshipReport).not.toHaveBeenCalled(); // When - await act(() => result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS)); + act(() => settle(signedInAuthValue())); + await act(() => running!); // Then - expect(runFlagshipReport).not.toHaveBeenCalled(); - expect(result.current.error).toMatch(/still checking your sign-in/i); + expect(runFlagshipReport).toHaveBeenCalledTimes(1); + const { account } = vi.mocked(runFlagshipReport).mock.calls[0][0]; + expect(account?.userId).toBe(PE_USER_ID); + expect(account?.reportStore).toBeInstanceOf(ApiFlagshipReportStore); + }); + + test('given a run started while the session restores then a signed-out visitor runs locally without an error', async () => { + // Given + const { result, settle } = renderRun(makeAuthValue({ status: 'loading' })); + let running: Promise | undefined; + act(() => { + running = result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS); + }); + + // When + act(() => settle(makeAuthValue({ status: 'anonymous' }))); + await act(() => running!); + + // Then + await waitFor(() => expect(runFlagshipReport).toHaveBeenCalledTimes(1)); + expect(vi.mocked(runFlagshipReport).mock.calls[0][0].account).toBeNull(); + expect(result.current.error).toBeNull(); }); }); diff --git a/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx b/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx index 08656da6a..25b5f2f95 100644 --- a/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx +++ b/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx @@ -107,14 +107,15 @@ describe('useAssociationBackend', () => { // When const backend = renderHook(() => useAssociationBackend(), { wrapper }).result.current; - const policyStore = renderHook(() => usePolicyAssociationStoreForMode(), { wrapper }).result - .current.store; + const selection = renderHook(() => usePolicyAssociationStoreForMode(), { wrapper }).result + .current; + const policyStore = selection.store; // Then expect(backend.mode).toBe('unavailable'); expect(backend.ready).toBe(true); expect(backend.userId).toBe(UNAVAILABLE_ACCOUNT_USER_ID); - expect(policyStore).toBeInstanceOf(ApiPolicyStore); + expect(await selection.resolveStore()).toBeInstanceOf(ApiPolicyStore); await expect(policyStore.findByUser(backend.userId, 'us')).rejects.toBeInstanceOf( AccountUnavailableError ); @@ -135,41 +136,49 @@ describe('useAssociationBackend', () => { }); describe('association store selection', () => { - test('given a signed-in UK session then every association hook uses the account stores', () => { - const wrapper = wrapperFor(signedInAuthValue(), 'uk'); - - expect( - renderHook(() => usePolicyAssociationStoreForMode(), { wrapper }).result.current.store - ).toBeInstanceOf(ApiPolicyStore); - expect( - renderHook(() => useHouseholdAssociationStoreForMode(), { wrapper }).result.current.store - ).toBeInstanceOf(ApiHouseholdStore); - expect( - renderHook(() => useSimulationAssociationStoreForMode(), { wrapper }).result.current.store - ).toBeInstanceOf(ApiSimulationStore); - expect( - renderHook(() => useReportAssociationStoreForMode(), { wrapper }).result.current.store - ).toBeInstanceOf(ApiReportStore); + async function resolvedStores(auth: AuthContextValue, countryId: CountryId) { + const wrapper = wrapperFor(auth, countryId); + return Promise.all([ + renderHook(() => usePolicyAssociationStoreForMode(), { + wrapper, + }).result.current.resolveStore(), + renderHook(() => useHouseholdAssociationStoreForMode(), { + wrapper, + }).result.current.resolveStore(), + renderHook(() => useSimulationAssociationStoreForMode(), { + wrapper, + }).result.current.resolveStore(), + renderHook(() => useReportAssociationStoreForMode(), { + wrapper, + }).result.current.resolveStore(), + ]); + } + + test('given a signed-in UK session then every association hook uses the account stores', async () => { + const [policies, households, simulations, reports] = await resolvedStores( + signedInAuthValue(), + 'uk' + ); + + expect(policies).toBeInstanceOf(ApiPolicyStore); + expect(households).toBeInstanceOf(ApiHouseholdStore); + expect(simulations).toBeInstanceOf(ApiSimulationStore); + expect(reports).toBeInstanceOf(ApiReportStore); }); test.each([ ['signed out in the US', makeAuthValue({ status: 'anonymous' }), 'us'], ['sign-in disabled', makeAuthValue({ status: 'disabled' }), 'uk'], ['signed in in Nigeria', signedInAuthValue(), 'ng'], - ] as const)('given %s then every association hook uses localStorage', (_, auth, countryId) => { - const wrapper = wrapperFor(auth, countryId); - - expect( - renderHook(() => usePolicyAssociationStoreForMode(), { wrapper }).result.current.store - ).toBeInstanceOf(LocalStoragePolicyStore); - expect( - renderHook(() => useHouseholdAssociationStoreForMode(), { wrapper }).result.current.store - ).toBeInstanceOf(LocalStorageHouseholdStore); - expect( - renderHook(() => useSimulationAssociationStoreForMode(), { wrapper }).result.current.store - ).toBeInstanceOf(LocalStorageSimulationStore); - expect( - renderHook(() => useReportAssociationStoreForMode(), { wrapper }).result.current.store - ).toBeInstanceOf(LocalStorageReportStore); - }); + ] as const)( + 'given %s then every association hook uses localStorage', + async (_, auth, countryId) => { + const [policies, households, simulations, reports] = await resolvedStores(auth, countryId); + + expect(policies).toBeInstanceOf(LocalStoragePolicyStore); + expect(households).toBeInstanceOf(LocalStorageHouseholdStore); + expect(simulations).toBeInstanceOf(LocalStorageSimulationStore); + expect(reports).toBeInstanceOf(LocalStorageReportStore); + } + ); }); From d0b9b81fda0c7735f611ff5536b409bcbe6f930a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:25:43 +0400 Subject: [PATCH 16/48] Tell the user when a created item could not be added to saved work useCreatePolicy, useCreateHousehold and useCreateSimulation create the v1 artifact and then save its association, catching an association failure with console.error only. That was harmless while saves went to localStorage; with account storage a 413, a 503 or an unavailable account means the item silently is not saved. The failure is now reported through reportSaveFailure and shown by SaveFailureNotice, a FloatingAlert mounted once in each app shell so it stays up after the builder dialog closes. The builder still carries on with the created artifact. Co-Authored-By: Claude Opus 5.5 --- app/src/CalculatorApp.tsx | 2 + .../components/common/SaveFailureNotice.tsx | 19 +++ app/src/hooks/useCreateHousehold.ts | 4 + app/src/hooks/useCreatePolicy.ts | 4 + app/src/hooks/useCreateSimulation.ts | 4 + app/src/libs/saveFailureNotice.ts | 53 ++++++ .../unit/hooks/createSaveFailures.test.tsx | 161 ++++++++++++++++++ .../src/app/[countryId]/providers.tsx | 2 + 8 files changed, 249 insertions(+) create mode 100644 app/src/components/common/SaveFailureNotice.tsx create mode 100644 app/src/libs/saveFailureNotice.ts create mode 100644 app/src/tests/unit/hooks/createSaveFailures.test.tsx diff --git a/app/src/CalculatorApp.tsx b/app/src/CalculatorApp.tsx index 432ce725e..3ed2b7cf1 100644 --- a/app/src/CalculatorApp.tsx +++ b/app/src/CalculatorApp.tsx @@ -6,6 +6,7 @@ import './app.css'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Provider } from 'react-redux'; +import SaveFailureNotice from '@/components/common/SaveFailureNotice'; import DevTools from '@/components/DevTools'; import { TooltipProvider } from '@/components/ui/tooltip'; import { CalculatorRouter } from './CalculatorRouter'; @@ -32,6 +33,7 @@ export default function CalculatorApp() { + diff --git a/app/src/components/common/SaveFailureNotice.tsx b/app/src/components/common/SaveFailureNotice.tsx new file mode 100644 index 000000000..0986e7655 --- /dev/null +++ b/app/src/components/common/SaveFailureNotice.tsx @@ -0,0 +1,19 @@ +import { dismissSaveFailure, useSaveFailure } from '@/libs/saveFailureNotice'; +import { FloatingAlert } from './FloatingAlert'; + +/** + * Tells the user when something they created could not be added to their + * saved work (see reportSaveFailure). Mounted once per app shell, so it + * stays up after the dialog that started the save has closed. + */ +export default function SaveFailureNotice() { + const failure = useSaveFailure(); + if (!failure) { + return null; + } + return ( + + {failure.message} + + ); +} diff --git a/app/src/hooks/useCreateHousehold.ts b/app/src/hooks/useCreateHousehold.ts index b0f863d0e..26dce3828 100644 --- a/app/src/hooks/useCreateHousehold.ts +++ b/app/src/hooks/useCreateHousehold.ts @@ -2,6 +2,7 @@ import { useMutation } from '@tanstack/react-query'; import { createHousehold } from '@/api/household'; import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { countryIds } from '@/libs/countries'; +import { reportSaveFailure } from '@/libs/saveFailureNotice'; import type { HouseholdCreationPayload } from '@/types/payloads'; import { useCreateHouseholdAssociation } from './useUserHousehold'; @@ -30,6 +31,9 @@ export function useCreateHousehold(householdLabel?: string) { }); } catch (error) { console.error('Household created but association failed:', error); + // The builder carries on with the created household; tell the user it + // is not in their saved work (account unavailable, too large, down). + reportSaveFailure('household', error); } }, }); diff --git a/app/src/hooks/useCreatePolicy.ts b/app/src/hooks/useCreatePolicy.ts index fa53d2646..5ac9b3723 100644 --- a/app/src/hooks/useCreatePolicy.ts +++ b/app/src/hooks/useCreatePolicy.ts @@ -1,6 +1,7 @@ import { useMutation } from '@tanstack/react-query'; import { createPolicy } from '@/api/policy'; import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; +import { reportSaveFailure } from '@/libs/saveFailureNotice'; import { PolicyCreationPayload } from '@/types/payloads'; import { useCurrentCountry } from './useCurrentCountry'; import { useCreatePolicyAssociation } from './useUserPolicy'; @@ -32,6 +33,9 @@ export function useCreatePolicy(policyLabel?: string) { }); } catch (error) { console.error('Policy created but association failed:', error); + // The builder carries on with the created policy; tell the user it + // is not in their saved work (account unavailable, too large, down). + reportSaveFailure('policy', error); } }, }); diff --git a/app/src/hooks/useCreateSimulation.ts b/app/src/hooks/useCreateSimulation.ts index 6b910d455..f23410a08 100644 --- a/app/src/hooks/useCreateSimulation.ts +++ b/app/src/hooks/useCreateSimulation.ts @@ -2,6 +2,7 @@ import { useMutation } from '@tanstack/react-query'; import { createSimulation } from '@/api/simulation'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; +import { reportSaveFailure } from '@/libs/saveFailureNotice'; import { SimulationCreationPayload } from '@/types/payloads'; import { useCreateSimulationAssociation } from './useUserSimulationAssociations'; @@ -25,6 +26,9 @@ export function useCreateSimulation(simulationLabel?: string) { }); } catch (error) { console.error('Simulation created but association failed:', error); + // The builder carries on with the created simulation; tell the user it + // is not in their saved work (account unavailable, too large, down). + reportSaveFailure('simulation', error); } }, }); diff --git a/app/src/libs/saveFailureNotice.ts b/app/src/libs/saveFailureNotice.ts new file mode 100644 index 000000000..7270a29df --- /dev/null +++ b/app/src/libs/saveFailureNotice.ts @@ -0,0 +1,53 @@ +import { useSyncExternalStore } from 'react'; + +/** + * Saves that failed after the thing being saved was created: a policy, + * household or simulation exists on the PolicyEngine API, but adding it to + * the user's saved work (their account, or this browser) did not work — + * the account was unavailable, the request was too large, the service + * was down. The builder carries on with the created item, so the failure + * is reported here and shown by SaveFailureNotice, which outlives the + * dialog or view that started the save. + */ + +export interface SaveFailure { + /** Changes with every report, so a repeat of the same message shows again. */ + id: number; + message: string; +} + +let current: SaveFailure | null = null; +let nextId = 1; +const listeners = new Set<() => void>(); + +function publish(next: SaveFailure | null): void { + current = next; + listeners.forEach((listener) => listener()); +} + +/** Reports that `what` (e.g. "policy") was created but not added to saved work. */ +export function reportSaveFailure(what: string, error: unknown): void { + const reason = error instanceof Error && error.message ? ` ${error.message}` : ''; + publish({ + id: nextId++, + message: `Your ${what} was created, but it could not be added to your saved work.${reason}`, + }); +} + +export function dismissSaveFailure(): void { + publish(null); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** The latest unacknowledged save failure, if any. */ +export function useSaveFailure(): SaveFailure | null { + return useSyncExternalStore( + subscribe, + () => current, + () => null + ); +} diff --git a/app/src/tests/unit/hooks/createSaveFailures.test.tsx b/app/src/tests/unit/hooks/createSaveFailures.test.tsx new file mode 100644 index 000000000..282281522 --- /dev/null +++ b/app/src/tests/unit/hooks/createSaveFailures.test.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, render, renderHook, screen } from '@testing-library/react'; +import { Provider as ReduxProvider } from 'react-redux'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { createHousehold } from '@/api/household'; +import { createPolicy } from '@/api/policy'; +import { createSimulation } from '@/api/simulation'; +import SaveFailureNotice from '@/components/common/SaveFailureNotice'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useCreateHousehold } from '@/hooks/useCreateHousehold'; +import { useCreatePolicy } from '@/hooks/useCreatePolicy'; +import { useCreateSimulation } from '@/hooks/useCreateSimulation'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import { ACCOUNT_UNAVAILABLE_MESSAGE } from '@/libs/auth/useAssociationBackend'; +import { dismissSaveFailure } from '@/libs/saveFailureNotice'; +import { store } from '@/store'; +import { errorResponse } from '@/tests/fixtures/api/v2UserMocks'; +import { mockHouseholdCreationPayload } from '@/tests/fixtures/hooks/hooksMocks'; +import { + accountErrorAuthValue, + AuthTestProvider, + makeAuthValue, + signedInAuthValue, +} from '@/tests/fixtures/libs/auth/authMocks'; +import type { PolicyCreationPayload, SimulationCreationPayload } from '@/types/payloads'; + +/** + * Creating a policy, household or simulation creates the v1 artifact and + * then adds it to the user's saved work. When that second step fails the + * builder still gets the artifact, and the user is told it was not saved. + */ + +vi.mock('@/api/policy', () => ({ createPolicy: vi.fn() })); +vi.mock('@/api/household', () => ({ createHousehold: vi.fn() })); +vi.mock('@/api/simulation', () => ({ createSimulation: vi.fn() })); + +const POLICY_RESPONSE = { result: { policy_id: '95762' } }; +const HOUSEHOLD_RESPONSE = { result: { household_id: '123' } }; +const SIMULATION_RESPONSE = { result: { simulation_id: '613' } }; + +const POLICY_PAYLOAD: PolicyCreationPayload = { data: {} }; +const SIMULATION_PAYLOAD: SimulationCreationPayload = { + population_id: 'us', + population_type: 'geography', + policy_id: 95762, +}; + +const fetchMock = vi.fn(); + +function renderWithSession(useHook: () => Result, auth: AuthContextValue) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + + ); + render(); + return renderHook(useHook, { wrapper }); +} + +beforeEach(() => { + localStorage.clear(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(createPolicy).mockResolvedValue(POLICY_RESPONSE); + vi.mocked(createHousehold).mockResolvedValue(HOUSEHOLD_RESPONSE); + vi.mocked(createSimulation).mockResolvedValue(SIMULATION_RESPONSE); +}); + +afterEach(() => { + act(() => dismissSaveFailure()); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('creating while saved work cannot be saved', () => { + test('given the account is unavailable then a new policy is still returned and the user is told', async () => { + // Given + const { result } = renderWithSession( + () => useCreatePolicy('CTC expansion'), + accountErrorAuthValue() + ); + + // When + const created = await act(() => result.current.createPolicy(POLICY_PAYLOAD)); + + // Then + expect(created).toEqual(POLICY_RESPONSE); + expect(screen.getByRole('alert')).toHaveTextContent( + `Your policy was created, but it could not be added to your saved work. ${ACCOUNT_UNAVAILABLE_MESSAGE}` + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('given the account rejects a too-large save then a new household is still returned and the user is told', async () => { + // Given + fetchMock.mockResolvedValue(errorResponse(413, 'Request body is too large.')); + const { result } = renderWithSession(() => useCreateHousehold('Family'), signedInAuthValue()); + + // When + const created = await act(() => result.current.createHousehold(mockHouseholdCreationPayload)); + + // Then + expect(created).toEqual(HOUSEHOLD_RESPONSE); + expect(screen.getByRole('alert')).toHaveTextContent( + 'Your household was created, but it could not be added to your saved work. Request body is too large.' + ); + }); + + test('given saved work is down then a new simulation is still returned and the user is told', async () => { + // Given + fetchMock.mockResolvedValue(new Response('', { status: 503 })); + const { result } = renderWithSession( + () => useCreateSimulation('Baseline'), + signedInAuthValue() + ); + + // When + const created = await act(() => result.current.createSimulation(SIMULATION_PAYLOAD)); + + // Then + expect(created).toEqual(SIMULATION_RESPONSE); + expect(screen.getByRole('alert')).toHaveTextContent( + 'Your simulation was created, but it could not be added to your saved work. Saved work is temporarily unavailable.' + ); + }); + + test('given a successful save then nothing is shown', async () => { + // Given + const { result } = renderWithSession( + () => useCreatePolicy('CTC expansion'), + makeAuthValue({ status: 'anonymous' }) + ); + + // When + await act(() => result.current.createPolicy(POLICY_PAYLOAD)); + + // Then + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('given the notice is closed then it goes away', async () => { + // Given + const { result } = renderWithSession(() => useCreatePolicy(), accountErrorAuthValue()); + await act(() => result.current.createPolicy(POLICY_PAYLOAD)); + + // When + act(() => screen.getByRole('button', { name: 'Close' }).click()); + + // Then + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/calculator-app/src/app/[countryId]/providers.tsx b/calculator-app/src/app/[countryId]/providers.tsx index 86126c751..cbd13ddbd 100644 --- a/calculator-app/src/app/[countryId]/providers.tsx +++ b/calculator-app/src/app/[countryId]/providers.tsx @@ -5,6 +5,7 @@ import { QueryNormalizerProvider } from "@normy/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Provider, useDispatch } from "react-redux"; import LocalDataImportPrompt from "@/components/auth/LocalDataImportPrompt"; +import SaveFailureNotice from "@/components/common/SaveFailureNotice"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AppProvider } from "@/contexts/AppContext"; import { CalcOrchestratorProvider } from "@/contexts/CalcOrchestratorContext"; @@ -66,6 +67,7 @@ export function CalculatorProviders({ {children} + From e2e867c96389925f0c47c9f20ed113ba78368441 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:04:49 +0400 Subject: [PATCH 17/48] Share one helper for saved-work stores and their wait for the session - deferredStore replaces the six hand-written forwarding stores. It forwards each listed method to the store for the settled session, with that store as `this`; the method map's type requires every method. - useSavedWorkStore replaces the four near-identical use*AssociationStoreForMode bodies and backs the reform and flagship report stores too. The test-only resolveStore is gone; the selection tests now check which store is used by what it calls. - The account report store implements createWithId as create (the account assigns IDs), so createWithId is no longer optional and the create mutation needs no casts. - The local delete methods name their callback parameters. Co-Authored-By: Claude Opus 5.5 --- app/src/api/deferredStore.ts | 38 ++++++++ app/src/api/householdAssociation.ts | 4 +- app/src/api/policyAssociation.ts | 2 +- app/src/api/reportAssociation.ts | 15 ++- app/src/api/settledStores.ts | 97 ------------------- app/src/api/simulationAssociation.ts | 4 +- app/src/hooks/useFlagshipStores.ts | 53 ++++++---- app/src/hooks/useUserHousehold.ts | 44 +++------ app/src/hooks/useUserPolicy.ts | 43 +++----- app/src/hooks/useUserReportAssociations.ts | 52 ++++------ .../hooks/useUserSimulationAssociations.ts | 42 +++----- app/src/hooks/utils/useSavedWorkStore.ts | 38 ++++++++ app/src/tests/unit/api/deferredStore.test.ts | 52 ++++++++++ .../unit/api/v2AssociationStores.test.ts | 29 ++++-- .../libs/auth/useAssociationBackend.test.tsx | 75 +++++++------- 15 files changed, 293 insertions(+), 295 deletions(-) create mode 100644 app/src/api/deferredStore.ts delete mode 100644 app/src/api/settledStores.ts create mode 100644 app/src/hooks/utils/useSavedWorkStore.ts create mode 100644 app/src/tests/unit/api/deferredStore.test.ts diff --git a/app/src/api/deferredStore.ts b/app/src/api/deferredStore.ts new file mode 100644 index 000000000..4b4080fa4 --- /dev/null +++ b/app/src/api/deferredStore.ts @@ -0,0 +1,38 @@ +/** + * Saved-work stores (policies, households, simulations, reports, reforms, + * flagship report records): every member is an async method. + */ +export type AsyncStore = { + [Method in keyof Store]: (...args: never[]) => Promise; +}; + +/** + * Every method of a store, each listed once. The type requires them all, + * so a store gaining a method fails to compile until it is listed here. + */ +export type StoreMethods = { readonly [Method in keyof Store]-?: true }; + +/** + * A store whose every call first waits for `resolve` — the store for the + * sign-in session once it has been restored (see + * useAssociationBackendResolver) — and then forwards to it. Until the + * session settles, which store applies is unknown, so nothing is read or + * written: a signed-in user's save never lands in this browser, and a + * signed-out visitor's waits only for the restore. + */ +export function deferredStore>( + resolve: () => Promise, + methods: StoreMethods +): Store { + const forwarders: Partial> = {}; + // `methods` names every method of Store (see StoreMethods), so the + // object built here has the full Store shape. + for (const method of Object.keys(methods) as Array) { + forwarders[method] = async (...args: unknown[]) => { + const store = await resolve(); + const operation: unknown = store[method]; + return Reflect.apply(operation as (...operationArgs: unknown[]) => unknown, store, args); + }; + } + return forwarders as Store; +} diff --git a/app/src/api/householdAssociation.ts b/app/src/api/householdAssociation.ts index 984b5c4ae..4ab9d440a 100644 --- a/app/src/api/householdAssociation.ts +++ b/app/src/api/householdAssociation.ts @@ -154,7 +154,9 @@ export class LocalStorageHouseholdStore implements UserHouseholdStore { } async delete(userHouseholdId: string): Promise { - this.setStoredHouseholds(this.getStoredHouseholds().filter((h) => h.id !== userHouseholdId)); + this.setStoredHouseholds( + this.getStoredHouseholds().filter((household) => household.id !== userHouseholdId) + ); } private getStoredHouseholds(): UserHouseholdPopulation[] { diff --git a/app/src/api/policyAssociation.ts b/app/src/api/policyAssociation.ts index f65b6a603..b3fd2cb93 100644 --- a/app/src/api/policyAssociation.ts +++ b/app/src/api/policyAssociation.ts @@ -169,6 +169,6 @@ export class LocalStoragePolicyStore implements UserPolicyStore { } async delete(userPolicyId: string): Promise { - this.setStoredPolicies(this.getStoredPolicies().filter((p) => p.id !== userPolicyId)); + this.setStoredPolicies(this.getStoredPolicies().filter((policy) => policy.id !== userPolicyId)); } } diff --git a/app/src/api/reportAssociation.ts b/app/src/api/reportAssociation.ts index 57218625c..8dcf3f32a 100644 --- a/app/src/api/reportAssociation.ts +++ b/app/src/api/reportAssociation.ts @@ -6,10 +6,11 @@ import { UserReport } from '../types/ingredients/UserReport'; export interface UserReportStore { create: (report: Omit) => Promise; /** - * Saves under a caller-chosen ID (shared-report saves in localStorage). - * Account storage assigns its own IDs, so the API store omits it. + * Saves under a caller-chosen ID where the store keeps caller IDs + * (shared-report saves in localStorage). The account assigns its own + * IDs, so there it is the same as create. */ - createWithId?: (report: Omit) => Promise; + createWithId: (report: Omit) => Promise; findByUser: (userId: string, countryId?: string) => Promise; findById: (userId: string, reportId: string) => Promise; findByUserReportId: (userReportId: string) => Promise; @@ -48,6 +49,12 @@ export class ApiReportStore implements UserReportStore { return UserReportAdapter.fromApiItem(item); } + /** The account assigns association IDs, so the caller's ID is not kept. */ + createWithId(report: Omit): Promise { + const { id: _callerId, ...fields } = report; + return this.create(fields); + } + async findByUser(_userId: string, countryId?: string): Promise { if (countryId && countryId !== this.routes.countryId) { return []; @@ -194,6 +201,6 @@ export class LocalStorageReportStore implements UserReportStore { } async delete(userReportId: string): Promise { - this.setStoredReports(this.getStoredReports().filter((r) => r.id !== userReportId)); + this.setStoredReports(this.getStoredReports().filter((report) => report.id !== userReportId)); } } diff --git a/app/src/api/settledStores.ts b/app/src/api/settledStores.ts deleted file mode 100644 index 41dd0018d..000000000 --- a/app/src/api/settledStores.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { FlagshipReportStore } from '@/api/flagshipReportStore'; -import type { UserHouseholdStore } from '@/api/householdAssociation'; -import type { UserPolicyStore } from '@/api/policyAssociation'; -import type { ReformStore } from '@/api/reformStore'; -import type { UserReportStore } from '@/api/reportAssociation'; -import type { UserSimulationStore } from '@/api/simulationAssociation'; - -/** - * Saved-work stores that wait for the sign-in session to be restored. - * - * Each takes a resolver (see useAssociationBackendResolver) and forwards - * every call to the store for how the session settled. Until then which - * store applies is unknown, so nothing is written: a signed-in user's save - * never lands in this browser, and a signed-out visitor's waits only for - * the restore. - */ - -export function deferredPolicyStore(resolve: () => Promise): UserPolicyStore { - return { - create: async (policy) => (await resolve()).create(policy), - findByUser: async (userId, countryId) => (await resolve()).findByUser(userId, countryId), - findById: async (userId, policyId) => (await resolve()).findById(userId, policyId), - update: async (userPolicyId, updates) => (await resolve()).update(userPolicyId, updates), - delete: async (userPolicyId) => (await resolve()).delete(userPolicyId), - }; -} - -export function deferredHouseholdStore( - resolve: () => Promise -): UserHouseholdStore { - return { - create: async (association) => (await resolve()).create(association), - findByUser: async (userId, countryId) => (await resolve()).findByUser(userId, countryId), - findById: async (userId, householdId) => (await resolve()).findById(userId, householdId), - update: async (userHouseholdId, updates) => (await resolve()).update(userHouseholdId, updates), - delete: async (userHouseholdId) => (await resolve()).delete(userHouseholdId), - }; -} - -export function deferredSimulationStore( - resolve: () => Promise -): UserSimulationStore { - return { - create: async (simulation) => (await resolve()).create(simulation), - findByUser: async (userId, countryId) => (await resolve()).findByUser(userId, countryId), - findById: async (userId, simulationId) => (await resolve()).findById(userId, simulationId), - update: async (userSimulationId, updates) => - (await resolve()).update(userSimulationId, updates), - delete: async (userSimulationId) => (await resolve()).delete(userSimulationId), - }; -} - -/** - * `createWithId` saves under the caller's ID where the settled store keeps - * caller IDs (localStorage) and lets the account assign one otherwise. - */ -export function deferredReportStore(resolve: () => Promise): UserReportStore { - return { - create: async (report) => (await resolve()).create(report), - createWithId: async (report) => { - const store = await resolve(); - if (store.createWithId) { - return store.createWithId(report); - } - const { id: _id, ...fields } = report; - return store.create(fields); - }, - findByUser: async (userId, countryId) => (await resolve()).findByUser(userId, countryId), - findById: async (userId, reportId) => (await resolve()).findById(userId, reportId), - findByUserReportId: async (userReportId) => (await resolve()).findByUserReportId(userReportId), - update: async (userReportId, updates) => (await resolve()).update(userReportId, updates), - delete: async (userReportId) => (await resolve()).delete(userReportId), - }; -} - -export function deferredReformStore(resolve: () => Promise): ReformStore { - return { - create: async (reform) => (await resolve()).create(reform), - findByUser: async (userId, countryId) => (await resolve()).findByUser(userId, countryId), - findById: async (reformId) => (await resolve()).findById(reformId), - update: async (reformId, updates) => (await resolve()).update(reformId, updates), - delete: async (reformId) => (await resolve()).delete(reformId), - }; -} - -export function deferredFlagshipReportStore( - resolve: () => Promise -): FlagshipReportStore { - return { - create: async (report) => (await resolve()).create(report), - findByUser: async (userId, countryId) => (await resolve()).findByUser(userId, countryId), - findById: async (id) => (await resolve()).findById(id), - findByApiReportId: async (userId, apiReportId) => - (await resolve()).findByApiReportId(userId, apiReportId), - saveValidation: async (id, snapshot) => (await resolve()).saveValidation(id, snapshot), - }; -} diff --git a/app/src/api/simulationAssociation.ts b/app/src/api/simulationAssociation.ts index f473dd2e3..d2561082f 100644 --- a/app/src/api/simulationAssociation.ts +++ b/app/src/api/simulationAssociation.ts @@ -167,6 +167,8 @@ export class LocalStorageSimulationStore implements UserSimulationStore { } async delete(userSimulationId: string): Promise { - this.setStoredSimulations(this.getStoredSimulations().filter((s) => s.id !== userSimulationId)); + this.setStoredSimulations( + this.getStoredSimulations().filter((simulation) => simulation.id !== userSimulationId) + ); } } diff --git a/app/src/hooks/useFlagshipStores.ts b/app/src/hooks/useFlagshipStores.ts index 7f25b6e92..8a9267697 100644 --- a/app/src/hooks/useFlagshipStores.ts +++ b/app/src/hooks/useFlagshipStores.ts @@ -1,9 +1,10 @@ -import { useMemo, useRef } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; +import type { StoreMethods } from '@/api/deferredStore'; import { getFlagshipReportStore, type FlagshipReportStore } from '@/api/flagshipReportStore'; import { getReformStore, type ReformStore } from '@/api/reformStore'; -import { deferredFlagshipReportStore, deferredReformStore } from '@/api/settledStores'; +import { useSavedWorkStore } from '@/hooks/utils/useSavedWorkStore'; import { accountStoreContext, useAssociationBackend, @@ -11,7 +12,6 @@ import { type AssociationBackend, } from '@/libs/auth/useAssociationBackend'; import type { RootState } from '@/store'; -import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; /** * The flagship (v3) stores for the current session: the account's when @@ -21,27 +21,39 @@ import type { ParameterMetadataCollection } from '@/types/metadata/parameterMeta * restored, so nothing is written to the wrong one while it is. */ -function accountReformStore( - backend: AssociationBackend, - getParameterMetadata: () => ParameterMetadataCollection | null | undefined -): ReformStore { - return getReformStore(accountStoreContext(backend), { getParameterMetadata }); +const REFORM_STORE_METHODS: StoreMethods = { + create: true, + findByUser: true, + findById: true, + update: true, + delete: true, +}; + +const FLAGSHIP_REPORT_STORE_METHODS: StoreMethods = { + create: true, + findByUser: true, + findById: true, + findByApiReportId: true, + saveValidation: true, +}; + +function flagshipReportStoreFor(backend: AssociationBackend): FlagshipReportStore { + return getFlagshipReportStore(accountStoreContext(backend)); } export function useReformStore(): ReformStore { - const resolveBackend = useAssociationBackendResolver(); const parameters = useSelector((state: RootState) => state.metadata?.parameters); // Read through a ref so metadata arriving later does not rebuild the store. const parametersRef = useRef(parameters); parametersRef.current = parameters; - - return useMemo( - () => - deferredReformStore(async () => - accountReformStore(await resolveBackend(), () => parametersRef.current) - ), - [resolveBackend] + const reformStoreFor = useCallback( + (backend: AssociationBackend) => + getReformStore(accountStoreContext(backend), { + getParameterMetadata: () => parametersRef.current, + }), + [] ); + return useSavedWorkStore(reformStoreFor, REFORM_STORE_METHODS).store; } /** Where a flagship run records its report. */ @@ -81,17 +93,16 @@ export interface FlagshipReportStoreSelection { export function useFlagshipReportStore(): FlagshipReportStoreSelection { const backend = useAssociationBackend(); const resolveBackend = useAssociationBackendResolver(); + const { store, ready } = useSavedWorkStore(flagshipReportStoreFor, FLAGSHIP_REPORT_STORE_METHODS); return useMemo( () => ({ - store: deferredFlagshipReportStore(async () => - getFlagshipReportStore(accountStoreContext(await resolveBackend())) - ), + store, isAccount: backend.mode !== 'localStorage', userId: backend.userId, - ready: backend.ready, + ready, resolveRunDestination: async () => flagshipRunDestination(await resolveBackend()), }), - [backend, resolveBackend] + [store, backend, ready, resolveBackend] ); } diff --git a/app/src/hooks/useUserHousehold.ts b/app/src/hooks/useUserHousehold.ts index 0ee509ba7..9d37409b2 100644 --- a/app/src/hooks/useUserHousehold.ts +++ b/app/src/hooks/useUserHousehold.ts @@ -1,21 +1,15 @@ -import { useMemo } from 'react'; import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; +import type { StoreMethods } from '@/api/deferredStore'; import { fetchHouseholdById } from '@/api/household'; import type { UserHouseholdStore } from '@/api/householdAssociation'; -import { deferredHouseholdStore } from '@/api/settledStores'; import { replaceHouseholdBaseForAssociation as replaceHouseholdBaseForAssociationAction } from '@/hooks/household/replaceHouseholdBaseForAssociation'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useSavedWorkStore, type SavedWorkStoreSelection } from '@/hooks/utils/useSavedWorkStore'; import { useSessionGatedQuery } from '@/hooks/utils/useSessionGatedQuery'; -import { - accountStoreContext, - useAssociationBackend, - useAssociationBackendResolver, - type AssociationBackend, -} from '@/libs/auth/useAssociationBackend'; +import { accountStoreContext, type AssociationBackend } from '@/libs/auth/useAssociationBackend'; import { Household } from '@/models/Household'; import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; import { ApiHouseholdStore, LocalStorageHouseholdStore } from '../api/householdAssociation'; -import { queryConfig } from '../libs/queryConfig'; import { householdAssociationKeys, householdKeys } from '../libs/queryKeys'; const localHouseholdStore = new LocalStorageHouseholdStore(); @@ -25,16 +19,6 @@ function householdStoreFor(backend: AssociationBackend): UserHouseholdStore { return account ? new ApiHouseholdStore(account) : localHouseholdStore; } -type HouseholdAssociationStoreSelection = { - /** Waits for the session to be restored, so writes never go to the wrong store. */ - store: UserHouseholdStore; - config: typeof queryConfig.api | typeof queryConfig.localStorage; - /** False while the session is being restored; queries wait. */ - ready: boolean; - /** The store for the session once it is restored; `store` forwards to it. */ - resolveStore: () => Promise; -}; - type HouseholdAssociationQueryOptions = { enabled?: boolean; }; @@ -43,20 +27,18 @@ export const useUserHouseholdStore = () => { return useHouseholdAssociationStoreForMode().store; }; -export const useHouseholdAssociationStoreForMode = (): HouseholdAssociationStoreSelection => { - const backend = useAssociationBackend(); - const resolveBackend = useAssociationBackendResolver(); - return useMemo(() => { - const resolveStore = async () => householdStoreFor(await resolveBackend()); - return { - store: deferredHouseholdStore(resolveStore), - config: backend.mode === 'localStorage' ? queryConfig.localStorage : queryConfig.api, - ready: backend.ready, - resolveStore, - }; - }, [backend, resolveBackend]); +const HOUSEHOLD_STORE_METHODS: StoreMethods = { + create: true, + findByUser: true, + findById: true, + update: true, + delete: true, }; +export const useHouseholdAssociationStoreForMode = + (): SavedWorkStoreSelection => + useSavedWorkStore(householdStoreFor, HOUSEHOLD_STORE_METHODS); + // This fetches only the user-household associations; see // 'useUserHouseholds' below to also fetch full household details export const useHouseholdAssociationsByUser = (userId: string) => { diff --git a/app/src/hooks/useUserPolicy.ts b/app/src/hooks/useUserPolicy.ts index b1b5efa8d..ffc407582 100644 --- a/app/src/hooks/useUserPolicy.ts +++ b/app/src/hooks/useUserPolicy.ts @@ -1,17 +1,12 @@ -import { useMemo } from 'react'; import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; import { PolicyAdapter } from '@/adapters'; +import type { StoreMethods } from '@/api/deferredStore'; import { fetchPolicyById } from '@/api/policy'; -import { deferredPolicyStore } from '@/api/settledStores'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useParameterMetadata } from '@/hooks/useParameterMetadata'; +import { useSavedWorkStore, type SavedWorkStoreSelection } from '@/hooks/utils/useSavedWorkStore'; import { useSessionGatedQuery } from '@/hooks/utils/useSessionGatedQuery'; -import { - accountStoreContext, - useAssociationBackend, - useAssociationBackendResolver, - type AssociationBackend, -} from '@/libs/auth/useAssociationBackend'; +import { accountStoreContext, type AssociationBackend } from '@/libs/auth/useAssociationBackend'; import { Policy } from '@/types/ingredients/Policy'; import type { PolicyCreationPayload } from '@/types/payloads'; import { @@ -19,7 +14,6 @@ import { LocalStoragePolicyStore, type UserPolicyStore, } from '../api/policyAssociation'; -import { queryConfig } from '../libs/queryConfig'; import { policyAssociationKeys, policyKeys } from '../libs/queryKeys'; import { UserPolicy } from '../types/ingredients/UserPolicy'; @@ -30,16 +24,6 @@ function policyStoreFor(backend: AssociationBackend): UserPolicyStore { return account ? new ApiPolicyStore(account) : localPolicyStore; } -type PolicyAssociationStoreSelection = { - /** Waits for the session to be restored, so writes never go to the wrong store. */ - store: UserPolicyStore; - config: typeof queryConfig.api | typeof queryConfig.localStorage; - /** False while the session is being restored; queries wait. */ - ready: boolean; - /** The store for the session once it is restored; `store` forwards to it. */ - resolveStore: () => Promise; -}; - type PolicyAssociationQueryOptions = { enabled?: boolean; }; @@ -55,20 +39,17 @@ export const useUserPolicyStore = () => { return usePolicyAssociationStoreForMode().store; }; -export const usePolicyAssociationStoreForMode = (): PolicyAssociationStoreSelection => { - const backend = useAssociationBackend(); - const resolveBackend = useAssociationBackendResolver(); - return useMemo(() => { - const resolveStore = async () => policyStoreFor(await resolveBackend()); - return { - store: deferredPolicyStore(resolveStore), - config: backend.mode === 'localStorage' ? queryConfig.localStorage : queryConfig.api, - ready: backend.ready, - resolveStore, - }; - }, [backend, resolveBackend]); +const POLICY_STORE_METHODS: StoreMethods = { + create: true, + findByUser: true, + findById: true, + update: true, + delete: true, }; +export const usePolicyAssociationStoreForMode = (): SavedWorkStoreSelection => + useSavedWorkStore(policyStoreFor, POLICY_STORE_METHODS); + // This fetches only the user-policy associations; see // 'useUserPolicies' below to also fetch full policy details export const usePolicyAssociationsByUser = (userId: string) => { diff --git a/app/src/hooks/useUserReportAssociations.ts b/app/src/hooks/useUserReportAssociations.ts index 7bb91bbbd..e050e291c 100644 --- a/app/src/hooks/useUserReportAssociations.ts +++ b/app/src/hooks/useUserReportAssociations.ts @@ -1,20 +1,14 @@ -import { useMemo } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { deferredReportStore } from '@/api/settledStores'; +import type { StoreMethods } from '@/api/deferredStore'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useSavedWorkStore, type SavedWorkStoreSelection } from '@/hooks/utils/useSavedWorkStore'; import { useSessionGatedQuery } from '@/hooks/utils/useSessionGatedQuery'; -import { - accountStoreContext, - useAssociationBackend, - useAssociationBackendResolver, - type AssociationBackend, -} from '@/libs/auth/useAssociationBackend'; +import { accountStoreContext, type AssociationBackend } from '@/libs/auth/useAssociationBackend'; import { ApiReportStore, LocalStorageReportStore, type UserReportStore, } from '../api/reportAssociation'; -import { queryConfig } from '../libs/queryConfig'; import { reportAssociationKeys } from '../libs/queryKeys'; import { UserReport } from '../types/ingredients/UserReport'; @@ -28,29 +22,18 @@ function reportStoreFor(backend: AssociationBackend): UserReportStore { return account ? new ApiReportStore(account, getLocalReportStore()) : getLocalReportStore(); } -type ReportAssociationStoreSelection = { - /** Waits for the session to be restored, so writes never go to the wrong store. */ - store: UserReportStore; - config: typeof queryConfig.api | typeof queryConfig.localStorage; - /** False while the session is being restored; queries wait. */ - ready: boolean; - /** The store for the session once it is restored; `store` forwards to it. */ - resolveStore: () => Promise; +const REPORT_STORE_METHODS: StoreMethods = { + create: true, + createWithId: true, + findByUser: true, + findById: true, + findByUserReportId: true, + update: true, + delete: true, }; -export const useReportAssociationStoreForMode = (): ReportAssociationStoreSelection => { - const backend = useAssociationBackend(); - const resolveBackend = useAssociationBackendResolver(); - return useMemo(() => { - const resolveStore = async () => reportStoreFor(await resolveBackend()); - return { - store: deferredReportStore(resolveStore), - config: backend.mode === 'localStorage' ? queryConfig.localStorage : queryConfig.api, - ready: backend.ready, - resolveStore, - }; - }, [backend, resolveBackend]); -}; +export const useReportAssociationStoreForMode = (): SavedWorkStoreSelection => + useSavedWorkStore(reportStoreFor, REPORT_STORE_METHODS); export const useUserReportStore = () => { return useReportAssociationStoreForMode().store; @@ -124,13 +107,12 @@ export const useCreateReportAssociation = () => { mutationFn: ( userReport: Omit | Omit ) => { - // If id is provided and the store keeps caller IDs, save under it (idempotent) - if ('id' in userReport && userReport.id && store.createWithId) { - return store.createWithId(userReport as Omit); + // With an id, save under it where the store keeps caller IDs (idempotent) + if ('id' in userReport && userReport.id) { + return store.createWithId({ ...userReport, id: userReport.id }); } // Otherwise the store assigns the id - const { id: _id, ...fields } = userReport as Partial; - return store.create(fields as Omit); + return store.create(userReport); }, onSuccess: (newAssociation) => { // Invalidate and refetch related queries diff --git a/app/src/hooks/useUserSimulationAssociations.ts b/app/src/hooks/useUserSimulationAssociations.ts index 8429e1415..c2ccf054d 100644 --- a/app/src/hooks/useUserSimulationAssociations.ts +++ b/app/src/hooks/useUserSimulationAssociations.ts @@ -1,20 +1,14 @@ -import { useMemo } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { deferredSimulationStore } from '@/api/settledStores'; +import type { StoreMethods } from '@/api/deferredStore'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useSavedWorkStore, type SavedWorkStoreSelection } from '@/hooks/utils/useSavedWorkStore'; import { useSessionGatedQuery } from '@/hooks/utils/useSessionGatedQuery'; -import { - accountStoreContext, - useAssociationBackend, - useAssociationBackendResolver, - type AssociationBackend, -} from '@/libs/auth/useAssociationBackend'; +import { accountStoreContext, type AssociationBackend } from '@/libs/auth/useAssociationBackend'; import { ApiSimulationStore, LocalStorageSimulationStore, type UserSimulationStore, } from '../api/simulationAssociation'; -import { queryConfig } from '../libs/queryConfig'; import { simulationAssociationKeys } from '../libs/queryKeys'; import { UserSimulation } from '../types/ingredients/UserSimulation'; @@ -28,29 +22,17 @@ function simulationStoreFor(backend: AssociationBackend): UserSimulationStore { return account ? new ApiSimulationStore(account) : getLocalSimulationStore(); } -type SimulationAssociationStoreSelection = { - /** Waits for the session to be restored, so writes never go to the wrong store. */ - store: UserSimulationStore; - config: typeof queryConfig.api | typeof queryConfig.localStorage; - /** False while the session is being restored; queries wait. */ - ready: boolean; - /** The store for the session once it is restored; `store` forwards to it. */ - resolveStore: () => Promise; +const SIMULATION_STORE_METHODS: StoreMethods = { + create: true, + findByUser: true, + findById: true, + update: true, + delete: true, }; -export const useSimulationAssociationStoreForMode = (): SimulationAssociationStoreSelection => { - const backend = useAssociationBackend(); - const resolveBackend = useAssociationBackendResolver(); - return useMemo(() => { - const resolveStore = async () => simulationStoreFor(await resolveBackend()); - return { - store: deferredSimulationStore(resolveStore), - config: backend.mode === 'localStorage' ? queryConfig.localStorage : queryConfig.api, - ready: backend.ready, - resolveStore, - }; - }, [backend, resolveBackend]); -}; +export const useSimulationAssociationStoreForMode = + (): SavedWorkStoreSelection => + useSavedWorkStore(simulationStoreFor, SIMULATION_STORE_METHODS); export const useUserSimulationStore = () => { return useSimulationAssociationStoreForMode().store; diff --git a/app/src/hooks/utils/useSavedWorkStore.ts b/app/src/hooks/utils/useSavedWorkStore.ts new file mode 100644 index 000000000..1b482aceb --- /dev/null +++ b/app/src/hooks/utils/useSavedWorkStore.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react'; +import { deferredStore, type AsyncStore, type StoreMethods } from '@/api/deferredStore'; +import { + useAssociationBackend, + useAssociationBackendResolver, + type AssociationBackend, +} from '@/libs/auth/useAssociationBackend'; +import { queryConfig } from '@/libs/queryConfig'; + +export interface SavedWorkStoreSelection { + /** Waits for the session to be restored, so nothing goes to the wrong store. */ + store: Store; + config: typeof queryConfig.api | typeof queryConfig.localStorage; + /** False while the session is being restored; queries wait. */ + ready: boolean; +} + +/** + * The saved-work store for the session: `storeFor` picks it for a settled + * backend (the account's, or localStorage), and the returned store waits + * for the session before each call. `storeFor` and `methods` must be + * stable (module-level, or memoised). + */ +export function useSavedWorkStore>( + storeFor: (backend: AssociationBackend) => Store, + methods: StoreMethods +): SavedWorkStoreSelection { + const backend = useAssociationBackend(); + const resolveBackend = useAssociationBackendResolver(); + return useMemo( + () => ({ + store: deferredStore(async () => storeFor(await resolveBackend()), methods), + config: backend.mode === 'localStorage' ? queryConfig.localStorage : queryConfig.api, + ready: backend.ready, + }), + [backend, resolveBackend, storeFor, methods] + ); +} diff --git a/app/src/tests/unit/api/deferredStore.test.ts b/app/src/tests/unit/api/deferredStore.test.ts new file mode 100644 index 000000000..bcff1540b --- /dev/null +++ b/app/src/tests/unit/api/deferredStore.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'vitest'; +import { deferredStore } from '@/api/deferredStore'; + +interface CounterStore { + increment: (by: number) => Promise; + read: () => Promise; +} + +class InMemoryCounterStore implements CounterStore { + private total = 0; + + async increment(by: number): Promise { + this.total += by; + return this.total; + } + + async read(): Promise { + return this.total; + } +} + +describe('deferredStore', () => { + test('given a call before the store is known then it runs once the store resolves', async () => { + // Given + let provideStore: (store: CounterStore) => void = () => {}; + const storeKnown = new Promise((resolve) => { + provideStore = resolve; + }); + const deferred = deferredStore(() => storeKnown, { increment: true, read: true }); + const counter = new InMemoryCounterStore(); + + // When + const incremented = deferred.increment(2); + provideStore(counter); + + // Then + expect(await incremented).toBe(2); + expect(await counter.read()).toBe(2); + }); + + test('given a class-based store then its methods run with the store as `this`', async () => { + // Given + const counter = new InMemoryCounterStore(); + const deferred = deferredStore(async () => counter, { increment: true, read: true }); + + // When + await deferred.increment(3); + + // Then + expect(await deferred.read()).toBe(3); + }); +}); diff --git a/app/src/tests/unit/api/v2AssociationStores.test.ts b/app/src/tests/unit/api/v2AssociationStores.test.ts index 2cdbb863a..4189852d5 100644 --- a/app/src/tests/unit/api/v2AssociationStores.test.ts +++ b/app/src/tests/unit/api/v2AssociationStores.test.ts @@ -1,11 +1,7 @@ import { beforeEach, describe, expect, test } from 'vitest'; import { ApiHouseholdStore, LocalStorageHouseholdStore } from '@/api/householdAssociation'; import { ApiPolicyStore, LocalStoragePolicyStore } from '@/api/policyAssociation'; -import { - ApiReportStore, - LocalStorageReportStore, - type UserReportStore, -} from '@/api/reportAssociation'; +import { ApiReportStore, LocalStorageReportStore } from '@/api/reportAssociation'; import { ApiSimulationStore, LocalStorageSimulationStore } from '@/api/simulationAssociation'; import { MOCK_USER_ID } from '@/constants'; import { recordImportedIds, resolveImportedId } from '@/libs/auth/importedIds'; @@ -405,7 +401,6 @@ describe('ApiReportStore', () => { }); // Then - expect((store as UserReportStore).createWithId).toBeUndefined(); expect(fetchCall(fetchMock).body).toEqual({ country_id: 'us', legacy_report_id: LEGACY_IDS.REPORT, @@ -415,6 +410,28 @@ describe('ApiReportStore', () => { expect(created.reportId).toBe(String(LEGACY_IDS.REPORT)); }); + test('given a caller-chosen id then the account assigns its own instead', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(reportItem(), 201)); + const store = new ApiReportStore(context); + + // When + const created = await store.createWithId({ + id: 'sur-shared1', + userId: MOCK_USER_ID, + reportId: String(LEGACY_IDS.REPORT), + countryId: 'us', + }); + + // Then + expect(fetchCall(fetchMock).body).toEqual({ + country_id: 'us', + legacy_report_id: LEGACY_IDS.REPORT, + }); + expect(created.id).toBe(ASSOCIATION_IDS.REPORT); + }); + test('given an association uuid then findByUserReportId reads that row', async () => { // Given const { fetchMock, context } = createTestApiContext(); diff --git a/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx b/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx index 25b5f2f95..146b66023 100644 --- a/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx +++ b/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx @@ -1,9 +1,5 @@ import { renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; -import { ApiHouseholdStore, LocalStorageHouseholdStore } from '@/api/householdAssociation'; -import { ApiPolicyStore, LocalStoragePolicyStore } from '@/api/policyAssociation'; -import { ApiReportStore, LocalStorageReportStore } from '@/api/reportAssociation'; -import { ApiSimulationStore, LocalStorageSimulationStore } from '@/api/simulationAssociation'; import { MOCK_USER_ID } from '@/constants'; import { CountryProvider } from '@/contexts/CountryContext'; import { useHouseholdAssociationStoreForMode } from '@/hooks/useUserHousehold'; @@ -21,7 +17,7 @@ import { type AssociationBackendMode, } from '@/libs/auth/useAssociationBackend'; import type { CountryId } from '@/libs/countries'; -import { mockMeItem, PE_USER_ID } from '@/tests/fixtures/api/v2UserMocks'; +import { mockMeItem, pageResponse, PE_USER_ID } from '@/tests/fixtures/api/v2UserMocks'; import { accountErrorAuthValue, AuthTestProvider, @@ -107,15 +103,13 @@ describe('useAssociationBackend', () => { // When const backend = renderHook(() => useAssociationBackend(), { wrapper }).result.current; - const selection = renderHook(() => usePolicyAssociationStoreForMode(), { wrapper }).result - .current; - const policyStore = selection.store; + const policyStore = renderHook(() => usePolicyAssociationStoreForMode(), { wrapper }).result + .current.store; // Then expect(backend.mode).toBe('unavailable'); expect(backend.ready).toBe(true); expect(backend.userId).toBe(UNAVAILABLE_ACCOUNT_USER_ID); - expect(await selection.resolveStore()).toBeInstanceOf(ApiPolicyStore); await expect(policyStore.findByUser(backend.userId, 'us')).rejects.toBeInstanceOf( AccountUnavailableError ); @@ -136,34 +130,36 @@ describe('useAssociationBackend', () => { }); describe('association store selection', () => { - async function resolvedStores(auth: AuthContextValue, countryId: CountryId) { + /** Lists saved work through every association hook's store. */ + async function listThroughEveryStore(auth: AuthContextValue, countryId: CountryId) { const wrapper = wrapperFor(auth, countryId); - return Promise.all([ - renderHook(() => usePolicyAssociationStoreForMode(), { - wrapper, - }).result.current.resolveStore(), - renderHook(() => useHouseholdAssociationStoreForMode(), { - wrapper, - }).result.current.resolveStore(), - renderHook(() => useSimulationAssociationStoreForMode(), { - wrapper, - }).result.current.resolveStore(), - renderHook(() => useReportAssociationStoreForMode(), { - wrapper, - }).result.current.resolveStore(), - ]); + const userId = renderHook(() => useCurrentUserId(), { wrapper }).result.current; + const stores = [ + renderHook(() => usePolicyAssociationStoreForMode(), { wrapper }).result.current.store, + renderHook(() => useHouseholdAssociationStoreForMode(), { wrapper }).result.current.store, + renderHook(() => useSimulationAssociationStoreForMode(), { wrapper }).result.current.store, + renderHook(() => useReportAssociationStoreForMode(), { wrapper }).result.current.store, + ]; + return Promise.all(stores.map((store) => store.findByUser(userId, countryId))); } test('given a signed-in UK session then every association hook uses the account stores', async () => { - const [policies, households, simulations, reports] = await resolvedStores( - signedInAuthValue(), - 'uk' - ); + // Given + const fetchMock = vi.fn(async (_url: string) => pageResponse([])); + vi.stubGlobal('fetch', fetchMock); + + // When + await listThroughEveryStore(signedInAuthValue(), 'uk'); - expect(policies).toBeInstanceOf(ApiPolicyStore); - expect(households).toBeInstanceOf(ApiHouseholdStore); - expect(simulations).toBeInstanceOf(ApiSimulationStore); - expect(reports).toBeInstanceOf(ApiReportStore); + // Then + const paths = fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname); + expect(paths).toEqual([ + '/v2/user-policies', + '/v2/user-households', + '/v2/user-simulations', + '/v2/user-reports', + ]); + vi.unstubAllGlobals(); }); test.each([ @@ -173,12 +169,17 @@ describe('association store selection', () => { ] as const)( 'given %s then every association hook uses localStorage', async (_, auth, countryId) => { - const [policies, households, simulations, reports] = await resolvedStores(auth, countryId); + // Given + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + // When + const lists = await listThroughEveryStore(auth, countryId); - expect(policies).toBeInstanceOf(LocalStoragePolicyStore); - expect(households).toBeInstanceOf(LocalStorageHouseholdStore); - expect(simulations).toBeInstanceOf(LocalStorageSimulationStore); - expect(reports).toBeInstanceOf(LocalStorageReportStore); + // Then + expect(lists).toEqual([[], [], [], []]); + expect(fetchMock).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); } ); }); From a601cdf906829568a8d6b9f774545e09b6cbe262 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:08:38 +0400 Subject: [PATCH 18/48] Load each saved reform's changes separately and cache v1 policies Signed in, the reforms list fetched every reform's v1 policy through Promise.all, so one 5xx or 429 failed the whole list: "Yours" showed its error state and ReportAdjustPanel aborted its run. Every list load also refetched every v1 policy. - ApiReformStore.findByUser settles each reform separately. A reform whose v1 policy does not load is listed with parametersUnavailable set and no parameters. - The Reforms page labels that card, explains it in the detail view with a "Try again" that reloads the list, and disables run, save, Build and duplicate for it, which would otherwise act on an empty reform. Delete still works. - ReportAdjustPanel never matches such a reform, so it saves the adjusted set as a new reform and runs. - v1 policies never change, so useReformStore fetches each one through the query cache, keyed by country and policy ID. Co-Authored-By: Claude Opus 5.5 --- app/src/api/reformStore.ts | 21 +++-- .../components/flagship/ReportAdjustPanel.tsx | 8 +- app/src/hooks/useFlagshipStores.ts | 20 ++++- app/src/libs/queryKeys.ts | 3 + app/src/pages/flagship/Reforms.page.tsx | 46 ++++++++++- app/src/tests/unit/api/reformStore.test.ts | 32 ++++++++ .../flagship/ReportAdjustPanel.test.tsx | 19 +++++ .../unit/hooks/useFlagshipStores.test.tsx | 77 +++++++++++++++++++ .../unit/pages/flagship/Reforms.test.tsx | 42 ++++++++++ app/src/types/ingredients/Reform.ts | 6 ++ 10 files changed, 260 insertions(+), 14 deletions(-) create mode 100644 app/src/tests/unit/hooks/useFlagshipStores.test.tsx diff --git a/app/src/api/reformStore.ts b/app/src/api/reformStore.ts index ed75e6a0c..544d9cac7 100644 --- a/app/src/api/reformStore.ts +++ b/app/src/api/reformStore.ts @@ -198,7 +198,8 @@ export function materializeReformPolicy( ); } -async function fetchV1PolicyJson( +/** A v1 policy's parameter map. v1 policies never change once created. */ +export async function fetchV1PolicyJson( countryId: string, policyId: string ): Promise { @@ -255,7 +256,14 @@ export class ApiReformStore implements ReformStore { return []; } const items = (await this.routes.list()).filter(isAccountReform); - return Promise.all(items.map((item) => this.toReform(item))); + // Each reform's changes come from its own v1 policy. One that fails to + // load is listed without them (flagged), rather than failing the list. + const loaded = await Promise.allSettled(items.map((item) => this.toReform(item))); + return loaded.map((outcome, index) => + outcome.status === 'fulfilled' + ? outcome.value + : { ...this.reformFromItem(items[index], []), parametersUnavailable: true } + ); } async findById(reformId: string): Promise { @@ -320,19 +328,22 @@ export class ApiReformStore implements ReformStore { } private async toReform(item: UserPolicyItem, knownParameters?: Parameter[]): Promise { - const policyId = String(item.legacy_policy_id); const parameters = knownParameters ?? parametersFromPolicyJson( - await this.fetchPolicyJson(item.country_id, policyId), + await this.fetchPolicyJson(item.country_id, String(item.legacy_policy_id)), this.getParameterMetadata() ); + return this.reformFromItem(item, parameters); + } + + private reformFromItem(item: UserPolicyItem, parameters: Parameter[]): Reform { return { id: item.id, userId: item.user_id, countryId: item.country_id, label: item.name ?? undefined, - policyId, + policyId: String(item.legacy_policy_id), parameters, baseline: (item.baseline ?? 'current-law') as ReformBaseline, provenance: provenanceFromWire(item.provenance), diff --git a/app/src/components/flagship/ReportAdjustPanel.tsx b/app/src/components/flagship/ReportAdjustPanel.tsx index fc77e71fb..40a293e4c 100644 --- a/app/src/components/flagship/ReportAdjustPanel.tsx +++ b/app/src/components/flagship/ReportAdjustPanel.tsx @@ -22,9 +22,13 @@ interface ReportAdjustPanelProps { provisions: RunReportProvision[]; } -/** True when the reform's parameter set is exactly the given provisions. */ +/** + * True when the reform's parameter set is exactly the given provisions. A + * reform whose changes could not be loaded matches nothing: its empty + * parameter list is not its real changes. + */ function reformMatches(reform: Reform, provisions: { path: string; value: any }[]): boolean { - if (reform.parameters.length !== provisions.length) { + if (reform.parametersUnavailable || reform.parameters.length !== provisions.length) { return false; } return provisions.every((provision) => diff --git a/app/src/hooks/useFlagshipStores.ts b/app/src/hooks/useFlagshipStores.ts index 8a9267697..738a7a61a 100644 --- a/app/src/hooks/useFlagshipStores.ts +++ b/app/src/hooks/useFlagshipStores.ts @@ -1,9 +1,9 @@ import { useCallback, useMemo, useRef } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import type { StoreMethods } from '@/api/deferredStore'; import { getFlagshipReportStore, type FlagshipReportStore } from '@/api/flagshipReportStore'; -import { getReformStore, type ReformStore } from '@/api/reformStore'; +import { fetchV1PolicyJson, getReformStore, type ReformStore } from '@/api/reformStore'; import { useSavedWorkStore } from '@/hooks/utils/useSavedWorkStore'; import { accountStoreContext, @@ -11,6 +11,7 @@ import { useAssociationBackendResolver, type AssociationBackend, } from '@/libs/auth/useAssociationBackend'; +import { policyKeys } from '@/libs/queryKeys'; import type { RootState } from '@/store'; /** @@ -21,6 +22,9 @@ import type { RootState } from '@/store'; * restored, so nothing is written to the wrong one while it is. */ +/** How long a fetched v1 policy stays cached without a reader. */ +const V1_POLICY_JSON_GC_TIME = 30 * 60 * 1000; + const REFORM_STORE_METHODS: StoreMethods = { create: true, findByUser: true, @@ -46,12 +50,22 @@ export function useReformStore(): ReformStore { // Read through a ref so metadata arriving later does not rebuild the store. const parametersRef = useRef(parameters); parametersRef.current = parameters; + const queryClient = useQueryClient(); const reformStoreFor = useCallback( (backend: AssociationBackend) => getReformStore(accountStoreContext(backend), { getParameterMetadata: () => parametersRef.current, + // v1 policies never change, so each is fetched once per session + // rather than on every list of the account's reforms. + fetchPolicyJson: (countryId, policyId) => + queryClient.fetchQuery({ + queryKey: policyKeys.json(countryId, policyId), + queryFn: () => fetchV1PolicyJson(countryId, policyId), + staleTime: Infinity, + gcTime: V1_POLICY_JSON_GC_TIME, + }), }), - [] + [queryClient] ); return useSavedWorkStore(reformStoreFor, REFORM_STORE_METHODS).store; } diff --git a/app/src/libs/queryKeys.ts b/app/src/libs/queryKeys.ts index 5f6260fff..27154117b 100644 --- a/app/src/libs/queryKeys.ts +++ b/app/src/libs/queryKeys.ts @@ -69,6 +69,9 @@ export const policyKeys = { all: ['policies'] as const, byId: (policyId: string) => [...policyKeys.all, 'policy_id', policyId] as const, byUser: (userId: string) => [...policyKeys.all, 'user_id', userId] as const, + /** A v1 policy's raw parameter map (`policy_json`), per country. */ + json: (countryId: string, policyId: string) => + [...policyKeys.all, 'policy_json', countryId, policyId] as const, }; export const householdKeys = { diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index 4b31d25cb..d338b1b8e 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -385,6 +385,24 @@ export default function ReformsPage() { setEditedValues(Object.fromEntries(reform.parameters.map((p) => [p.name, p.values[0]?.value]))); }; + // A reform opened before its changes loaded starts its edits from them + // once "Try again" brings them in. + useEffect(() => { + if (!selectedReform || selectedReform.parametersUnavailable) { + return; + } + setEditedValues((current) => + Object.keys(current).length > 0 + ? current + : Object.fromEntries( + selectedReform.parameters.map((parameter) => [ + parameter.name, + parameter.values[0]?.value, + ]) + ) + ); + }, [selectedReform]); + const openBillAsDraft = (bill: TrackedBill) => { clearDraftReform(); billProvisions(bill).forEach((provision) => @@ -610,6 +628,17 @@ export default function ReformsPage() { }} /> + {selectedReform.parametersUnavailable && ( + + + This reform's changes could not be loaded, so it cannot be run, edited or duplicated + right now. Nothing about it has changed. + + + + )} {selectedReform.parameters.map((parameter) => { const baseline = getCurrentValue(parameters?.[parameter.name]?.values); @@ -674,14 +703,18 @@ export default function ReformsPage() { reformProvisions(selectedReform) ) } - disabled={runReport.isRunning} + disabled={runReport.isRunning || selectedReform.parametersUnavailable} > {runReport.isRunning ? 'Starting report…' : 'View full impact report'} +
+ {auth.sessionEnded && ( + + Your session ended. Sign in again to keep saving to your account. + + )} + +
); } const name = accountDisplayName(auth); - const email = auth.user?.email ?? auth.profile?.email; - const accountUnavailable = auth.status === 'account_error'; + const email = auth.profile?.email; return ( @@ -129,31 +120,6 @@ export default function AuthMenu({ variant = 'default' }: AuthMenuProps) {
)} - {accountUnavailable && ( - <> - -
- - - We could not load your account, so saved work is unavailable. Nothing is saved until - it loads. - -
- auth.retryAccount()}> - - Try again - - - )} nav.push(`/${countryId}/account`)}> diff --git a/app/src/components/auth/LocalDataImportPrompt.tsx b/app/src/components/auth/LocalDataImportPrompt.tsx deleted file mode 100644 index 680257d4c..000000000 --- a/app/src/components/auth/LocalDataImportPrompt.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useEffect, useState } from 'react'; -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Spinner, - Stack, - Text, -} from '@/components/ui'; -import { colors } from '@/designTokens'; -import { useLocalDataImport } from '@/hooks/useLocalDataImport'; -import { importSummaryLines } from './importSummaryText'; - -/** - * Offered once per account after signing in (US/UK), when this browser - * holds saved work the account does not have yet. Saving copies it to - * the account; nothing is deleted from the device. Closing the prompt, - * with "Not now" or after an import, is remembered; the account page can - * still save what is left. - */ -export default function LocalDataImportPrompt() { - const localImport = useLocalDataImport(); - const [open, setOpen] = useState(false); - - const shouldOffer = - localImport.available && localImport.pendingCount > 0 && !localImport.declined; - - useEffect(() => { - if (shouldOffer) { - setOpen(true); - } - }, [shouldOffer]); - - if (!localImport.available) { - return null; - } - - const running = localImport.state === 'running'; - const finished = localImport.state === 'done' && localImport.summary; - - // Closing — "Not now", or after an import ran, whatever its outcome — - // stops the prompt for this account; the account page offers the rest. - const close = () => { - if (running) { - return; - } - localImport.decline(); - setOpen(false); - }; - - return ( - !next && close()}> - - - - {finished - ? 'Your work is saved to your account' - : 'Save the work on this device to your account?'} - - - {finished - ? 'It now appears wherever you sign in.' - : `This browser has ${localImport.pendingCount} item${ - localImport.pendingCount === 1 ? '' : 's' - } saved on it while signed out — policies, households, simulations, reports and reforms — that are not in your account. Anyone using this browser signed out can see them. Saving copies them to your account so they are available on any device. Nothing is removed from this device.`} - - - - {finished && localImport.summary && ( - - {importSummaryLines(localImport.summary).map((line) => ( - - {line} - - ))} - - )} - - {localImport.state === 'error' && ( - - {localImport.error ?? 'The import did not complete.'} Try again, or choose not now. - - )} - - - {finished ? ( - - ) : ( - <> - - - - )} - - - - ); -} diff --git a/app/src/components/flagship/ReportAdjustPanel.tsx b/app/src/components/flagship/ReportAdjustPanel.tsx index 40a293e4c..9893637b1 100644 --- a/app/src/components/flagship/ReportAdjustPanel.tsx +++ b/app/src/components/flagship/ReportAdjustPanel.tsx @@ -2,12 +2,11 @@ import { useState } from 'react'; import { IconChartBar, IconX } from '@tabler/icons-react'; import { useQueryClient } from '@tanstack/react-query'; import { Button, Stack, Text } from '@/components/ui'; -import { CURRENT_YEAR, FOREVER } from '@/constants'; +import { CURRENT_YEAR, FOREVER, MOCK_USER_ID } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useReformStore } from '@/hooks/useFlagshipStores'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; -import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { RunReportProvision } from '@/libs/flagship/runReport'; import { Reform } from '@/types/ingredients/Reform'; import { formatCompactBreadcrumb } from '@/utils/parameterLabels'; @@ -55,7 +54,6 @@ export default function ReportAdjustPanel({ const runReport = useRunFlagshipReport(); const countryId = useCurrentCountry(); const store = useReformStore(); - const userId = useCurrentUserId(); const queryClient = useQueryClient(); const [removed, setRemoved] = useState>(new Set()); const [reconcileError, setReconcileError] = useState(null); @@ -84,7 +82,7 @@ export default function ReportAdjustPanel({ // Reconcile with saved reforms: reuse an exact match, otherwise // save the adjusted set as its own reform. - const existing = await store.findByUser(userId, countryId); + const existing = await store.findByUser(MOCK_USER_ID, countryId); const match = existing.find((reform) => reformMatches(reform, adjusted)); let reformId: string; @@ -94,7 +92,7 @@ export default function ReportAdjustPanel({ runTitle = match.label || `${title} (adjusted)`; } else { const created = await store.create({ - userId, + userId: MOCK_USER_ID, countryId, label: `${title} (adjusted)`, parameters: adjusted.map((provision) => ({ diff --git a/app/src/components/flagship/reform/DraftHeader.tsx b/app/src/components/flagship/reform/DraftHeader.tsx index 8aee233f0..81b916062 100644 --- a/app/src/components/flagship/reform/DraftHeader.tsx +++ b/app/src/components/flagship/reform/DraftHeader.tsx @@ -10,11 +10,11 @@ import { SelectTrigger, SelectValue, } from '@/components/ui'; +import { MOCK_USER_ID } from '@/constants'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; import { useReformStore } from '@/hooks/useFlagshipStores'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; -import { useCurrentUserId } from '@/libs/auth/useAssociationBackend'; import { clearDraftReform, DraftReform, @@ -113,7 +113,6 @@ export default function DraftHeader({ draft }: { draft: DraftReform }) { const queryClient = useQueryClient(); const runReport = useRunFlagshipReport(); const reformStore = useReformStore(); - const userId = useCurrentUserId(); const taxYears = useSelector(getTaxYears); const parameters = useSelector((state: RootState) => state.metadata.parameters); const currentLawAt = (path: string, at: number) => { @@ -125,7 +124,7 @@ export default function DraftHeader({ draft }: { draft: DraftReform }) { const saveMutation = useMutation({ mutationFn: async () => { - const payload = draftToReform(draft, userId); + const payload = draftToReform(draft, MOCK_USER_ID); if (draft.editingReformId) { return reformStore.update(draft.editingReformId, { label: payload.label, diff --git a/app/src/hooks/useFlagshipStores.ts b/app/src/hooks/useFlagshipStores.ts index 7a99496db..eae964d3a 100644 --- a/app/src/hooks/useFlagshipStores.ts +++ b/app/src/hooks/useFlagshipStores.ts @@ -1,25 +1,47 @@ -import { useCallback, useMemo, useRef } from 'react'; +import { useMemo, useRef } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import type { StoreMethods } from '@/api/deferredStore'; import { getFlagshipReportStore, type FlagshipReportStore } from '@/api/flagshipReportStore'; import { fetchV1PolicyJson, getReformStore, type ReformStore } from '@/api/reformStore'; -import { useSavedWorkStore } from '@/hooks/utils/useSavedWorkStore'; -import { - accountStoreContext, - useAssociationBackend, - useAssociationBackendResolver, - type AssociationBackend, -} from '@/libs/auth/useAssociationBackend'; +import type { ApiStoreContext } from '@/api/v2Associations'; +import { MOCK_USER_ID } from '@/constants'; +import { useCountryContext } from '@/contexts/CountryContext'; +import { useAuthStatus } from '@/libs/auth/AuthContext'; +import { useV2UserClient } from '@/libs/auth/useV2UserClient'; import type { RootState } from '@/store'; +import { isAssociationCountry } from '@/types/v2User'; /** - * The flagship (v3) stores for the current session: the account's when - * signed in (US/UK), localStorage otherwise. A signed-in session whose - * account could not be loaded gets the account stores, whose calls fail, - * rather than localStorage. The stores wait for the session to be - * restored, so nothing is written to the wrong one while it is. + * Where the v3 workspace keeps saved work: the account when signed in (US + * and UK), this browser otherwise. Nothing else decides it, and nothing + * falls back: an account request that fails is an ordinary error. */ +export interface V3SavedWork { + /** The account's routes when signed in; null keeps saved work in this browser. */ + account: ApiStoreContext | null; + /** + * Scopes cached saved work to the session: the Auth0 subject when + * signed in, 'browser' otherwise. Signing in or out reloads the page; a + * session that ends without a reload moves every query to new keys. + */ + scope: string; +} + +export function useV3SavedWork(): V3SavedWork { + const { status, subject } = useAuthStatus(); + const countryId = useCountryContext(); + const client = useV2UserClient(); + return useMemo( + () => + status === 'signed_in' && subject && isAssociationCountry(countryId) + ? { + account: { client, countryId, userId: subject }, + scope: `account:${subject}`, + } + : { account: null, scope: 'browser' }, + [status, subject, countryId, client] + ); +} /** A v1 policy's raw parameter map (`policy_json`), per country. */ const v1PolicyJsonKey = (countryId: string, policyId: string) => @@ -28,35 +50,17 @@ const v1PolicyJsonKey = (countryId: string, policyId: string) => /** How long a fetched v1 policy stays cached without a reader. */ const V1_POLICY_JSON_GC_TIME = 30 * 60 * 1000; -const REFORM_STORE_METHODS: StoreMethods = { - create: true, - findByUser: true, - findById: true, - update: true, - delete: true, -}; - -const FLAGSHIP_REPORT_STORE_METHODS: StoreMethods = { - create: true, - findByUser: true, - findById: true, - findByApiReportId: true, - saveValidation: true, -}; - -function flagshipReportStoreFor(backend: AssociationBackend): FlagshipReportStore { - return getFlagshipReportStore(accountStoreContext(backend)); -} - export function useReformStore(): ReformStore { + const { account } = useV3SavedWork(); const parameters = useSelector((state: RootState) => state.metadata?.parameters); // Read through a ref so metadata arriving later does not rebuild the store. const parametersRef = useRef(parameters); parametersRef.current = parameters; const queryClient = useQueryClient(); - const reformStoreFor = useCallback( - (backend: AssociationBackend) => - getReformStore(accountStoreContext(backend), { + + return useMemo( + () => + getReformStore(account, { getParameterMetadata: () => parametersRef.current, // v1 policies never change, so each is fetched once per session // rather than on every list of the account's reforms. @@ -68,58 +72,27 @@ export function useReformStore(): ReformStore { gcTime: V1_POLICY_JSON_GC_TIME, }), }), - [queryClient] + [account, queryClient] ); - return useSavedWorkStore(reformStoreFor, REFORM_STORE_METHODS).store; -} - -/** Where a flagship run records its report. */ -export interface FlagshipRunDestination { - /** The account's report store and user; null keeps the run in this browser. */ - account: { reportStore: FlagshipReportStore; userId: string } | null; - /** Set when signed in but the account could not be loaded: the run must not start. */ - accountError: Error | null; -} - -function flagshipRunDestination(backend: AssociationBackend): FlagshipRunDestination { - if (backend.mode === 'unavailable') { - return { account: null, accountError: backend.error }; - } - if (backend.mode === 'api') { - return { - account: { reportStore: getFlagshipReportStore(backend), userId: backend.userId }, - accountError: null, - }; - } - return { account: null, accountError: null }; } export interface FlagshipReportStoreSelection { - /** Waits for the session to be restored. */ store: FlagshipReportStore; /** True when records go to the account (one `/v2/user-reports` row per run). */ isAccount: boolean; - /** The user ID records are filed under. */ - userId: string; - /** False while the session is being restored. */ - ready: boolean; - /** Where a run records its report, once the session is restored. */ - resolveRunDestination: () => Promise; + /** The session scope for cached records (see V3SavedWork). */ + scope: string; } export function useFlagshipReportStore(): FlagshipReportStoreSelection { - const backend = useAssociationBackend(); - const resolveBackend = useAssociationBackendResolver(); - const { store, ready } = useSavedWorkStore(flagshipReportStoreFor, FLAGSHIP_REPORT_STORE_METHODS); + const { account, scope } = useV3SavedWork(); return useMemo( () => ({ - store, - isAccount: backend.mode !== 'localStorage', - userId: backend.userId, - ready, - resolveRunDestination: async () => flagshipRunDestination(await resolveBackend()), + store: getFlagshipReportStore(account), + isAccount: !!account, + scope, }), - [store, backend, ready, resolveBackend] + [account, scope] ); } @@ -129,11 +102,11 @@ export function useFlagshipReportStore(): FlagshipReportStoreSelection { * header. Signed-in (US/UK) sessions only; null otherwise. */ export function useAccountReportRecord(apiReportId: string | undefined) { - const { store, isAccount, userId, ready } = useFlagshipReportStore(); + const { store, isAccount, scope } = useFlagshipReportStore(); const { data } = useQuery({ - queryKey: ['flagship-report-record', userId, apiReportId ?? ''], - queryFn: () => store.findByApiReportId(userId, apiReportId!), - enabled: isAccount && ready && !!apiReportId, + queryKey: ['flagship-report-record', scope, apiReportId ?? ''], + queryFn: () => store.findByApiReportId(MOCK_USER_ID, apiReportId!), + enabled: isAccount && !!apiReportId, staleTime: 5 * 60 * 1000, retry: 1, }); diff --git a/app/src/hooks/useLocalDataImport.ts b/app/src/hooks/useLocalDataImport.ts deleted file mode 100644 index 36571e7d7..000000000 --- a/app/src/hooks/useLocalDataImport.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import { - declineImport, - isImportDeclined, - planLocalImport, - runLocalImport, - type LocalImportSummary, -} from '@/libs/auth/localImport'; -import { useAssociationBackend } from '@/libs/auth/useAssociationBackend'; -import { - householdAssociationKeys, - policyAssociationKeys, - reportAssociationKeys, - simulationAssociationKeys, -} from '@/libs/queryKeys'; - -export type LocalImportState = 'idle' | 'running' | 'done' | 'error'; - -export interface LocalDataImport { - /** True when signed in somewhere with account storage (US, UK). */ - available: boolean; - /** Items in this browser the account does not have yet. */ - pendingCount: number; - /** Items from countries without account storage; they stay on this device. */ - unsupportedCount: number; - declined: boolean; - state: LocalImportState; - summary: LocalImportSummary | null; - error: string | null; - run: () => Promise; - decline: () => void; -} - -/** This browser's saved work, offered to the signed-in account. */ -export function useLocalDataImport(): LocalDataImport { - const backend = useAssociationBackend(); - const queryClient = useQueryClient(); - const available = backend.mode === 'api'; - const userId = backend.userId; - - const [pendingCount, setPendingCount] = useState(0); - const [unsupportedCount, setUnsupportedCount] = useState(0); - const [declined, setDeclined] = useState(false); - const [state, setState] = useState('idle'); - const [summary, setSummary] = useState(null); - const [error, setError] = useState(null); - - const refresh = useCallback(() => { - if (!available) { - setPendingCount(0); - setUnsupportedCount(0); - setDeclined(false); - return; - } - const plan = planLocalImport(userId); - setPendingCount(plan.items.length); - setUnsupportedCount(plan.unsupported.length); - setDeclined(isImportDeclined(userId)); - }, [available, userId]); - - useEffect(() => { - refresh(); - }, [refresh]); - - const run = useCallback(async () => { - if (backend.mode !== 'api') { - return; - } - setState('running'); - setError(null); - try { - const result = await runLocalImport({ client: backend.client, userId: backend.userId }); - setSummary(result); - setState('done'); - await Promise.all( - [ - policyAssociationKeys.all, - householdAssociationKeys.all, - simulationAssociationKeys.all, - reportAssociationKeys.all, - ['reforms'], - ['flagship-report-record'], - ].map((queryKey) => queryClient.invalidateQueries({ queryKey })) - ); - } catch (caught) { - setError(caught instanceof Error ? caught.message : 'The import did not complete.'); - setState('error'); - } finally { - refresh(); - } - }, [backend, queryClient, refresh]); - - const decline = useCallback(() => { - if (available) { - declineImport(userId); - setDeclined(true); - } - }, [available, userId]); - - return { - available, - pendingCount, - unsupportedCount, - declined, - state, - summary, - error, - run, - decline, - }; -} diff --git a/app/src/hooks/useReportValidationSnapshot.ts b/app/src/hooks/useReportValidationSnapshot.ts index b9b2e56d3..d8026be75 100644 --- a/app/src/hooks/useReportValidationSnapshot.ts +++ b/app/src/hooks/useReportValidationSnapshot.ts @@ -26,7 +26,7 @@ export function useReportValidationSnapshot( apiReportId: string | undefined, live: LiveValidation ): ReportValidationState { - const { store, ready } = useFlagshipReportStore(); + const { store } = useFlagshipReportStore(); const [snapshot, setSnapshot] = useState(null); const [recordId, setRecordId] = useState(null); const pinned = useRef(false); @@ -36,7 +36,7 @@ export function useReportValidationSnapshot( pinned.current = false; setSnapshot(null); setRecordId(null); - if (!apiReportId || !ready) { + if (!apiReportId) { return; } store @@ -57,7 +57,7 @@ export function useReportValidationSnapshot( return () => { cancelled = true; }; - }, [store, ready, userId, apiReportId]); + }, [store, userId, apiReportId]); const settled = isLiveValidationSettled(live); useEffect(() => { diff --git a/app/src/hooks/useRunFlagshipReport.ts b/app/src/hooks/useRunFlagshipReport.ts index 8d9bf6e95..83ee33fa9 100644 --- a/app/src/hooks/useRunFlagshipReport.ts +++ b/app/src/hooks/useRunFlagshipReport.ts @@ -48,15 +48,6 @@ export function useRunFlagshipReport() { } setIsRunning(true); setError(null); - // Where the run's record goes is known once the session is restored: - // the account when signed in (never this browser while signing in), - // this browser when signed out. - const destination = await reportStore.resolveRunDestination(); - if (destination.accountError) { - setError(destination.accountError.message); - setIsRunning(false); - return; - } try { const userReportId = await runFlagshipReport({ countryId, @@ -66,7 +57,8 @@ export function useRunFlagshipReport() { currentLawId: Number(currentLawId), reformId, year, - account: destination.account, + // Signed in, the run's record is the account's; otherwise this browser's. + accountReportStore: reportStore.isAccount ? reportStore.store : null, }); if (fromDraft) { markDraftRun(userReportId); diff --git a/app/src/hooks/utils/useSavedWorkStore.ts b/app/src/hooks/utils/useSavedWorkStore.ts deleted file mode 100644 index 1b482aceb..000000000 --- a/app/src/hooks/utils/useSavedWorkStore.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useMemo } from 'react'; -import { deferredStore, type AsyncStore, type StoreMethods } from '@/api/deferredStore'; -import { - useAssociationBackend, - useAssociationBackendResolver, - type AssociationBackend, -} from '@/libs/auth/useAssociationBackend'; -import { queryConfig } from '@/libs/queryConfig'; - -export interface SavedWorkStoreSelection { - /** Waits for the session to be restored, so nothing goes to the wrong store. */ - store: Store; - config: typeof queryConfig.api | typeof queryConfig.localStorage; - /** False while the session is being restored; queries wait. */ - ready: boolean; -} - -/** - * The saved-work store for the session: `storeFor` picks it for a settled - * backend (the account's, or localStorage), and the returned store waits - * for the session before each call. `storeFor` and `methods` must be - * stable (module-level, or memoised). - */ -export function useSavedWorkStore>( - storeFor: (backend: AssociationBackend) => Store, - methods: StoreMethods -): SavedWorkStoreSelection { - const backend = useAssociationBackend(); - const resolveBackend = useAssociationBackendResolver(); - return useMemo( - () => ({ - store: deferredStore(async () => storeFor(await resolveBackend()), methods), - config: backend.mode === 'localStorage' ? queryConfig.localStorage : queryConfig.api, - ready: backend.ready, - }), - [backend, resolveBackend, storeFor, methods] - ); -} diff --git a/app/src/libs/auth/AuthContext.ts b/app/src/libs/auth/AuthContext.ts index aa9802832..1483c6810 100644 --- a/app/src/libs/auth/AuthContext.ts +++ b/app/src/libs/auth/AuthContext.ts @@ -1,32 +1,19 @@ -import { createContext, useCallback, useContext, useEffect, useRef } from 'react'; -import type { MeItem } from '@/types/v2User'; +import { createContext, useContext } from 'react'; /** - * Sign-in state shared with the whole app. This module has no Auth0 - * dependency, so code that only reads the state (stores, menus, the - * legacy Vite build) never bundles the SDK. Without an AuthProvider in - * the tree the state is 'disabled' and the app behaves exactly as it - * does without sign-in. + * Sign-in state for the v3 workspace. This module has no Auth0 + * dependency, so code that only reads the state never bundles the SDK. + * Without an AuthProvider in the tree (the legacy calculator, or sign-in + * turned off) the state is 'disabled' and saved work stays in this + * browser. + * + * Two states only, decided by the Auth0 session: the provider renders a + * loading state while Auth0 restores the session, so nothing below it + * ever sees an in-between state. */ +export type AuthStatus = 'disabled' | 'signed_out' | 'signed_in'; -/** - * - 'loading': the session (or, in the US and UK, the PolicyEngine user - * behind it) is still being restored. - * - 'authenticated': signed in; in the US and UK the PolicyEngine user - * has loaded. - * - 'account_error': signed in, but the PolicyEngine user could not be - * loaded (US and UK). Saved work is unavailable, never silently kept - * on this device, until `retryAccount` succeeds or the user signs out. - */ -export type AuthStatus = 'disabled' | 'loading' | 'anonymous' | 'authenticated' | 'account_error'; - -/** The session once it has been restored. */ -export interface SettledSession { - status: Exclude; - user: MeItem | null; -} - -/** Display details from the identity provider (Auth0 ID token claims). */ +/** Display details from the Auth0 ID token. */ export interface AuthProfile { name: string | null; email: string | null; @@ -35,35 +22,29 @@ export interface AuthProfile { export interface AuthContextValue { status: AuthStatus; - /** The PolicyEngine user (`GET /v2/me`), once loaded; null otherwise. */ - user: MeItem | null; + /** From the ID token while signed in; null otherwise. */ profile: AuthProfile | null; - /** Why sign-in or loading the PolicyEngine user failed, when it did. */ + /** The Auth0 subject while signed in; scopes cached saved work to the session. */ + subject: string | null; + /** True when a signed-in session ended because its token could not be renewed. */ + sessionEnded: boolean; + /** Why the last sign-in did not complete, when it did not. */ error: Error | null; /** Redirects to the hosted sign-in page; returns to `returnTo` (default: here). */ signIn: (returnTo?: string) => Promise; signOut: () => Promise; - /** Tries loading the PolicyEngine user again (after 'account_error'). */ - retryAccount: () => void; - /** - * The session as soon as it is restored: at once when it already is, - * otherwise when status leaves 'loading'. Saved-work writes wait on it - * so they never go to this browser for a user who is signing in. - */ - settledSession: () => Promise; /** An access token for the PolicyEngine API, or null when signed out. */ getAccessToken: () => Promise; } export const DISABLED_AUTH: AuthContextValue = { status: 'disabled', - user: null, profile: null, + subject: null, + sessionEnded: false, error: null, signIn: async () => {}, signOut: async () => {}, - retryAccount: () => {}, - settledSession: async () => ({ status: 'disabled', user: null }), getAccessToken: async () => null, }; @@ -72,65 +53,3 @@ export const AuthContext = createContext(DISABLED_AUTH); export function useAuthStatus(): AuthContextValue { return useContext(AuthContext); } - -interface SessionWaiter { - resolve: (session: SettledSession) => void; - reject: (error: Error) => void; -} - -/** What a wait for the session fails with when its provider goes away first. */ -export class SessionClosedError extends Error { - constructor() { - super( - 'Sign-in was interrupted before your session was restored, so nothing was saved. Try again.' - ); - this.name = 'SessionClosedError'; - } -} - -/** - * `settledSession` for a provider whose state is `status` and `user`: - * callers that ask while the session is loading are answered once it has - * been restored, with the state it settled in. - */ -export function useSettledSession( - status: AuthStatus, - user: MeItem | null -): () => Promise { - const latest = useRef({ status, user }); - latest.current = { status, user }; - const waiting = useRef([]); - - useEffect(() => { - if (status === 'loading') { - return; - } - const answered = waiting.current.splice(0); - answered.forEach((waiter) => waiter.resolve({ status, user })); - }, [status, user]); - - // A provider that goes away mid-restore (e.g. a country switch remounts - // it) can never answer: its waiters fail instead of hanging forever. - useEffect( - () => () => { - const abandoned = waiting.current.splice(0); - abandoned.forEach((waiter) => waiter.reject(new SessionClosedError())); - }, - [] - ); - - return useCallback(() => { - const session = latest.current; - if (session.status === 'loading') { - return new Promise((resolve, reject) => - waiting.current.push({ resolve, reject }) - ); - } - return Promise.resolve({ status: session.status, user: session.user }); - }, []); -} - -export const authKeys = { - all: ['auth'] as const, - me: (subject: string) => ['auth', 'me', subject] as const, -}; diff --git a/app/src/libs/auth/AuthProvider.tsx b/app/src/libs/auth/AuthProvider.tsx index 35adbc9ee..bb44951d2 100644 --- a/app/src/libs/auth/AuthProvider.tsx +++ b/app/src/libs/auth/AuthProvider.tsx @@ -1,12 +1,9 @@ -import { useCallback, useMemo, type ReactNode } from 'react'; +import { useCallback, useMemo, useState, type ReactNode } from 'react'; import { Auth0Provider, useAuth0, type AppState } from '@auth0/auth0-react'; -import { useQuery, type UseQueryResult } from '@tanstack/react-query'; -import { fetchMe } from '@/api/v2Me'; -import { createV2UserClient } from '@/api/v2UserClient'; +import { Spinner, Stack, Text } from '@/components/ui'; import { useCountryContext } from '@/contexts/CountryContext'; -import { useAppNavigate } from '@/contexts/NavigationContext'; +import { colors, spacing } from '@/designTokens'; import { DEFAULT_COUNTRY } from '@/libs/countries'; -import { isAssociationCountry, type MeItem } from '@/types/v2User'; import { AUTH_SCOPE, callbackPath, @@ -15,25 +12,22 @@ import { safeReturnTo, type Auth0Config, } from './authConfig'; -import { - AuthContext, - authKeys, - useSettledSession, - type AuthContextValue, - type AuthStatus, -} from './AuthContext'; +import { AuthContext, type AuthContextValue } from './AuthContext'; import { clearSignedInSessionData } from './sessionData'; /** - * Social sign-on for the calculator. With sign-in off (or Auth0 not - * configured) this renders its children unchanged and the app sees + * Social sign-on for the v3 workspace. With sign-in off (or Auth0 not + * configured) this renders its children unchanged and the workspace sees * status 'disabled'. Otherwise the browser session is managed by - * @auth0/auth0-react: tokens stay in memory and are renewed with - * rotating refresh tokens (falling back to silent re-authentication - * against the Auth0 custom domain after a reload). + * @auth0/auth0-react: tokens stay in memory and are renewed with rotating + * refresh tokens (falling back to silent re-authentication against the + * Auth0 custom domain after a reload). + * + * While Auth0 restores the session the provider renders a loading state + * instead of its children, so the workspace only ever sees signed in or + * signed out. * - * Must sit inside the QueryClientProvider, CountryProvider and - * NavigationProvider. + * Must sit inside the CountryProvider. */ export function AuthProvider({ children }: { children: ReactNode }) { const config = getAuth0Config(); @@ -43,24 +37,27 @@ export function AuthProvider({ children }: { children: ReactNode }) { return {children}; } -let redirectHandled = false; +let signInReturnTo: string | null = null; -/** Whether this page load has already finished a sign-in redirect. */ -export function wasSignInRedirectHandled(): boolean { - return redirectHandled; +/** + * Where the sign-in that just completed started, once per sign-in. The + * callback page reads it to navigate on after setting up the account. + */ +export function takeSignInReturnTo(): string | null { + const returnTo = signInReturnTo; + signInReturnTo = null; + return returnTo; } function Auth0SessionProvider({ config, children }: { config: Auth0Config; children: ReactNode }) { const countryId = useCountryContext() ?? DEFAULT_COUNTRY; - const nav = useAppNavigate(); const inBrowser = typeof window !== 'undefined'; const onRedirectCallback = useCallback( (appState?: AppState) => { - redirectHandled = true; - nav.replace(safeReturnTo(appState?.returnTo, `/${countryId}`)); + signInReturnTo = safeReturnTo(appState?.returnTo, `/${countryId}`); }, - [nav, countryId] + [countryId] ); return ( @@ -91,48 +88,34 @@ function stringClaim(value: unknown): string | null { return typeof value === 'string' && value ? value : null; } -/** Attempts after the first before the PolicyEngine user counts as unavailable. */ -export const ME_RETRIES = 3; - -/** 1s, 2s, 4s between attempts (capped at 8s). */ -export function meRetryDelay(attempt: number): number { - return Math.min(1000 * 2 ** attempt, 8000); +/** Auth0 errors meaning the session cannot be renewed without signing in again. */ +const SESSION_ENDED_ERRORS = new Set([ + 'login_required', + 'consent_required', + 'interaction_required', + 'missing_refresh_token', + 'invalid_grant', +]); + +export function isSessionEndedError(error: unknown): boolean { + if (!error || typeof error !== 'object' || !('error' in error)) { + return false; + } + return SESSION_ENDED_ERRORS.has(String(error.error)); } -const MISSING_SUBJECT_MESSAGE = 'The sign-in session did not identify a user.'; - -/** - * Where the session stands. In the US and UK a signed-in session is only - * 'authenticated' once its PolicyEngine user has loaded; while that is - * in flight (retries included) it is 'loading', and once it has failed - * it is 'account_error'. - */ -export function sessionStatus({ - isLoading, - isAuthenticated, - hasSubject, - meCountry, - me, -}: { - isLoading: boolean; - isAuthenticated: boolean; - hasSubject: boolean; - meCountry: string | null; - me: Pick, 'data' | 'isError'>; -}): AuthStatus { - if (isLoading) { - return 'loading'; - } - if (!isAuthenticated) { - return 'anonymous'; - } - if (!meCountry || me.data) { - return 'authenticated'; - } - if (!hasSubject || me.isError) { - return 'account_error'; - } - return 'loading'; +/** Shown in place of the workspace while Auth0 restores the session. */ +export function SessionRestoring() { + return ( + + + Loading… + + ); } function Auth0SessionBridge({ @@ -147,67 +130,31 @@ function Auth0SessionBridge({ const { isLoading, isAuthenticated, - error: auth0Error, - user: auth0User, + error, + user, getAccessTokenSilently, loginWithRedirect, logout, } = useAuth0(); + const [sessionEnded, setSessionEnded] = useState(false); + // A token that cannot be renewed ends the session: the user is signed + // out here (without a redirect) and invited to sign in again. const getAccessToken = useCallback(async () => { try { const token = await getAccessTokenSilently({ authorizationParams: { audience: config.audience }, }); return token || null; - } catch { + } catch (caught) { + if (isSessionEndedError(caught)) { + setSessionEnded(true); + clearSignedInSessionData(); + await logout({ openUrl: false }); + } return null; } - }, [getAccessTokenSilently, config.audience]); - - const subject = stringClaim(auth0User?.sub); - const name = stringClaim(auth0User?.name); - const email = stringClaim(auth0User?.email); - const picture = stringClaim(auth0User?.picture); - - // The PolicyEngine user behind the session. Fetched with the current - // country (us/uk) so a first sign-in provisions with it; elsewhere the - // session is still signed in but saved work stays local. Until it - // loads, saved work has nowhere to go, so a failure keeps retrying: - // with backoff at first, then whenever the window regains focus or - // the network comes back (and on retryAccount). - const meCountry = isAssociationCountry(countryId) ? countryId : null; - const meQuery = useQuery({ - queryKey: authKeys.me(subject ?? ''), - queryFn: () => fetchMe(createV2UserClient({ getAccessToken }), meCountry!), - enabled: isAuthenticated && !!subject && !!meCountry, - staleTime: Infinity, - gcTime: Infinity, - retry: ME_RETRIES, - retryDelay: meRetryDelay, - refetchOnWindowFocus: (query) => query.state.status === 'error', - refetchOnReconnect: (query) => query.state.status === 'error', - }); - - const status = sessionStatus({ - isLoading, - isAuthenticated, - hasSubject: !!subject, - meCountry, - me: meQuery, - }); - - const user = isAuthenticated ? (meQuery.data ?? null) : null; - const accountError = - status === 'account_error' ? (meQuery.error ?? new Error(MISSING_SUBJECT_MESSAGE)) : null; - const error = auth0Error ?? accountError; - - const settledSession = useSettledSession(status, user); - - const { refetch: refetchMe } = meQuery; - const retryAccount = useCallback(() => { - void refetchMe(); - }, [refetchMe]); + }, [getAccessTokenSilently, config.audience, logout]); const signIn = useCallback( async (returnTo?: string) => { @@ -232,33 +179,38 @@ function Auth0SessionBridge({ await logout({ logoutParams: { returnTo: `${window.location.origin}/${countryId}` } }); }, [logout, countryId]); + const subject = isAuthenticated ? stringClaim(user?.sub) : null; + const name = stringClaim(user?.name); + const email = stringClaim(user?.email); + const picture = stringClaim(user?.picture); + const value = useMemo( () => ({ - status, - user, + status: isAuthenticated ? 'signed_in' : 'signed_out', profile: isAuthenticated ? { name, email, picture } : null, - error, + subject, + sessionEnded: !isAuthenticated && sessionEnded, + error: error ?? null, signIn, signOut, - retryAccount, - settledSession, getAccessToken, }), [ - status, - user, isAuthenticated, name, email, picture, + subject, + sessionEnded, error, signIn, signOut, - retryAccount, - settledSession, getAccessToken, ] ); + if (isLoading) { + return ; + } return {children}; } diff --git a/app/src/libs/auth/useAssociationBackend.ts b/app/src/libs/auth/useAssociationBackend.ts deleted file mode 100644 index 9397d24c3..000000000 --- a/app/src/libs/auth/useAssociationBackend.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { useCallback, useMemo, useRef } from 'react'; -import type { ApiStoreContext } from '@/api/v2Associations'; -import { createV2UserClient, type V2UserClient } from '@/api/v2UserClient'; -import { MOCK_USER_ID } from '@/constants'; -import { useCountryContext } from '@/contexts/CountryContext'; -import { isAssociationCountry, type AssociationCountryId, type MeItem } from '@/types/v2User'; -import { useAuthStatus, type AuthStatus } from './AuthContext'; - -/** - * The single switch between account storage and localStorage. - * - * Saved work goes to the PolicyEngine API only when the user is signed in - * (and their PolicyEngine user has loaded) and the country has account - * storage (US, UK). Signed out, sign-in disabled, Canada, Nigeria and - * Israel keep the localStorage behaviour unchanged. A signed-in US or UK - * session whose PolicyEngine user could not be loaded is 'unavailable': - * its saved work neither loads nor saves, rather than quietly going to - * this browser while the user appears signed in. - */ - -export type AssociationBackendMode = 'api' | 'unavailable' | 'localStorage'; - -export function selectAssociationBackend({ - status, - user, - countryId, -}: { - status: AuthStatus; - user: Pick | null; - countryId: string | null | undefined; -}): AssociationBackendMode { - if (!isAssociationCountry(countryId)) { - return 'localStorage'; - } - if (status === 'authenticated' || status === 'account_error') { - return user ? 'api' : 'unavailable'; - } - return 'localStorage'; -} - -export const ACCOUNT_UNAVAILABLE_MESSAGE = - 'Your PolicyEngine account could not be loaded. Try again from the account menu, or sign out to keep working on this device.'; - -/** What every account-store call fails with while the account cannot be loaded. */ -export class AccountUnavailableError extends Error { - constructor() { - super(ACCOUNT_UNAVAILABLE_MESSAGE); - this.name = 'AccountUnavailableError'; - } -} - -/** A client that answers every request with `error` and sends nothing. */ -export function createUnavailableClient(error: Error): V2UserClient { - const fail = () => Promise.reject(error); - return { request: fail, listAll: fail }; -} - -/** - * Stands in for the PolicyEngine user ID while none is known; query keys - * carry it, so nothing cached for it is reused once the account loads. - */ -export const UNAVAILABLE_ACCOUNT_USER_ID = 'account-unavailable'; - -export interface ApiAssociationBackend { - mode: 'api'; - ready: true; - /** The PolicyEngine user ID; query keys carry it so caches split per user. */ - userId: string; - countryId: AssociationCountryId; - client: V2UserClient; -} - -export interface UnavailableAssociationBackend { - mode: 'unavailable'; - /** Queries run, and fail with `error` straight away. */ - ready: true; - userId: typeof UNAVAILABLE_ACCOUNT_USER_ID; - countryId: AssociationCountryId; - /** Rejects every request with `error`. */ - client: V2UserClient; - error: AccountUnavailableError; -} - -export interface LocalAssociationBackend { - mode: 'localStorage'; - /** False while the session is still being restored; queries wait for it. */ - ready: boolean; - /** MOCK_USER_ID: localStorage records are filed under it. Never sent to the API. */ - userId: string; - countryId: string | null; - client: null; -} - -export type AssociationBackend = - | ApiAssociationBackend - | UnavailableAssociationBackend - | LocalAssociationBackend; - -/** - * What the account stores are built from: the account when signed in, or - * the unavailable account (whose every call fails), never localStorage. - */ -export function accountStoreContext(backend: AssociationBackend): ApiStoreContext | null { - return backend.mode === 'localStorage' ? null : backend; -} - -/** - * A PolicyEngine API client bound to the session's access token. It asks - * for the token at request time, so a request that waited for the - * session to be restored carries the signed-in user's token. - */ -export function useV2UserClient(): V2UserClient { - const { getAccessToken } = useAuthStatus(); - const latestGetAccessToken = useRef(getAccessToken); - latestGetAccessToken.current = getAccessToken; - return useMemo( - () => createV2UserClient({ getAccessToken: () => latestGetAccessToken.current() }), - [] - ); -} - -/** The backend for a session state in a country (see selectAssociationBackend). */ -export function associationBackendFor({ - status, - user, - countryId, - client, -}: { - status: AuthStatus; - user: Pick | null; - countryId: string | null; - client: V2UserClient; -}): AssociationBackend { - const mode = selectAssociationBackend({ status, user, countryId }); - if (mode === 'api' && user && isAssociationCountry(countryId)) { - return { mode, ready: true, userId: user.id, countryId, client }; - } - if (mode === 'unavailable' && isAssociationCountry(countryId)) { - const error = new AccountUnavailableError(); - return { - mode, - ready: true, - userId: UNAVAILABLE_ACCOUNT_USER_ID, - countryId, - client: createUnavailableClient(error), - error, - }; - } - return { - mode: 'localStorage', - ready: status !== 'loading', - userId: MOCK_USER_ID, - countryId, - client: null, - }; -} - -function useBackendCountry(countryIdOverride?: string | null): string | null { - const contextCountry = useCountryContext(); - return countryIdOverride ?? contextCountry ?? null; -} - -/** The backend for the session as it stands (not ready while it is being restored). */ -export function useAssociationBackend(countryIdOverride?: string | null): AssociationBackend { - const countryId = useBackendCountry(countryIdOverride); - const { status, user } = useAuthStatus(); - const client = useV2UserClient(); - const userId = user?.id ?? null; - - return useMemo( - () => - associationBackendFor({ status, user: userId ? { id: userId } : null, countryId, client }), - [status, userId, countryId, client] - ); -} - -/** - * The backend saved-work writes should use: it waits until the session - * is restored, then answers for how it settled. A signed-in user's write - * issued while the session is restoring therefore goes to the account, - * never to this browser; a signed-out visitor's waits only for the - * restore and then goes to localStorage. - */ -export function useAssociationBackendResolver( - countryIdOverride?: string | null -): () => Promise { - const countryId = useBackendCountry(countryIdOverride); - const { settledSession } = useAuthStatus(); - const client = useV2UserClient(); - - return useCallback(async () => { - const { status, user } = await settledSession(); - return associationBackendFor({ status, user, countryId, client }); - }, [settledSession, countryId, client]); -} - -/** The user ID that saved work is filed under right now. */ -export function useCurrentUserId(): string { - return useAssociationBackend().userId; -} diff --git a/app/src/libs/auth/useV2UserClient.ts b/app/src/libs/auth/useV2UserClient.ts new file mode 100644 index 000000000..24b795cfc --- /dev/null +++ b/app/src/libs/auth/useV2UserClient.ts @@ -0,0 +1,9 @@ +import { useMemo } from 'react'; +import { createV2UserClient, type V2UserClient } from '@/api/v2UserClient'; +import { useAuthStatus } from './AuthContext'; + +/** A PolicyEngine API client that sends the session's access token. */ +export function useV2UserClient(): V2UserClient { + const { getAccessToken } = useAuthStatus(); + return useMemo(() => createV2UserClient({ getAccessToken }), [getAccessToken]); +} diff --git a/app/src/libs/flagship/runReport.ts b/app/src/libs/flagship/runReport.ts index 6572f2843..7e504ca78 100644 --- a/app/src/libs/flagship/runReport.ts +++ b/app/src/libs/flagship/runReport.ts @@ -87,12 +87,11 @@ export interface RunFlagshipReportArgs { /** The year to simulate; the current year when unset. */ year?: number; /** - * Signed in (US/UK): the account's report store and user. The run then - * writes exactly one `/v2/user-reports` row — with the title, source - * note, provisions and reform link — instead of the localStorage - * association and record. + * Signed in (US/UK): the account's report store. The run then writes + * exactly one `/v2/user-reports` row — with the title, source note, + * provisions and reform link — instead of the browser record. */ - account?: { reportStore: FlagshipReportStore; userId: string } | null; + accountReportStore?: FlagshipReportStore | null; } /** @@ -136,7 +135,7 @@ export async function runFlagshipReport({ currentLawId, reformId, year = Number(CURRENT_YEAR), - account, + accountReportStore, }: RunFlagshipReportArgs): Promise { if (provisions.length === 0) { throw new Error('Cannot run a report with no provisions'); @@ -163,7 +162,7 @@ export async function runFlagshipReport({ year: String(year), }; - if (account) { + if (accountReportStore) { // Signed in: the account's row is the report's only association. It is // written right after the v1 report exists, and a failure fails the // run, so a report that opens is a report that was saved. @@ -175,8 +174,8 @@ export async function runFlagshipReport({ provisions, createdAt: new Date().toISOString(), }); - await account.reportStore.create({ - userId: account.userId, + await accountReportStore.create({ + userId: MOCK_USER_ID, countryId, apiReportId: reportId, title: title || null, diff --git a/app/src/pages/Account.page.tsx b/app/src/pages/flagship/Account.page.tsx similarity index 71% rename from app/src/pages/Account.page.tsx rename to app/src/pages/flagship/Account.page.tsx index 3611127fe..2c8ecbb30 100644 --- a/app/src/pages/Account.page.tsx +++ b/app/src/pages/flagship/Account.page.tsx @@ -1,9 +1,9 @@ import { useState } from 'react'; -import { IconDownload, IconTrash, IconUpload } from '@tabler/icons-react'; -import { deleteMe, exportMe } from '@/api/v2Me'; +import { IconDownload, IconTrash } from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { deleteMe, exportMe, fetchMe } from '@/api/v2Me'; import { AppLink } from '@/components/AppLink'; import { accountDisplayName } from '@/components/auth/AuthMenu'; -import { importSummaryLines } from '@/components/auth/importSummaryText'; import { Button, Card, @@ -24,11 +24,9 @@ import { } from '@/components/ui'; import { colors, spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import { useLocalDataImport } from '@/hooks/useLocalDataImport'; import { useAuthStatus } from '@/libs/auth/AuthContext'; -import { forgetImportState } from '@/libs/auth/localImport'; -import { useV2UserClient } from '@/libs/auth/useAssociationBackend'; -import { isAssociationCountry } from '@/types/v2User'; +import { useV2UserClient } from '@/libs/auth/useV2UserClient'; +import { isAssociationCountry, type AssociationCountryId } from '@/types/v2User'; import { getCountryLabel } from '@/utils/geographyUtils'; const EXPORT_FILE_NAME = 'policyengine-account-data.json'; @@ -83,19 +81,13 @@ function DetailRow({ label, value }: { label: string; value: string | null }) { } /** - * The signed-in user's account: who they are, saving this device's work - * to the account, downloading everything saved, and deleting the account. + * The v3 account page: who the signed-in user is, downloading everything + * saved to the account, and deleting the account. Reads the PolicyEngine + * user from `GET /v2/me` (the user menu uses the Auth0 ID token instead). */ export default function AccountPage() { const auth = useAuthStatus(); const countryId = useCurrentCountry(); - const client = useV2UserClient(); - const localImport = useLocalDataImport(); - const [exporting, setExporting] = useState(false); - const [exportError, setExportError] = useState(null); - const [confirmingDelete, setConfirmingDelete] = useState(false); - const [deleting, setDeleting] = useState(false); - const [deleteError, setDeleteError] = useState(null); if (auth.status === 'disabled') { return ( @@ -105,26 +97,15 @@ export default function AccountPage() { ); } - if (auth.status === 'loading') { - return ( - - - - Loading your account… - - - ); - } - - if (auth.status === 'anonymous') { + if (auth.status === 'signed_out') { return ( Sign in to keep your work - Signed in, the policies, reports and reforms you save are kept in your account and - available on any device. + Signed in, the reforms and reports you save are kept in your account and available on + any device. @@ -153,16 +134,41 @@ export default function AccountPage() { ); } - const user = auth.user; - if (!user) { + return ; +} + +function SignedInAccount({ countryId }: { countryId: AssociationCountryId }) { + const auth = useAuthStatus(); + const client = useV2UserClient(); + const me = useQuery({ + queryKey: ['account', auth.subject, countryId], + queryFn: () => fetchMe(client, countryId), + }); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(null); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + if (me.isPending) { + return ( + + + + Loading your account… + + + ); + } + + if (me.isError) { return ( - We could not load your PolicyEngine account - {auth.error ? `: ${auth.error.message}` : '.'} Saved work is unavailable until it loads. + We could not load your PolicyEngine account: {me.error.message}
- + @@ -171,6 +177,8 @@ export default function AccountPage() { ); } + const user = me.data; + const handleExport = async () => { setExporting(true); setExportError(null); @@ -188,7 +196,6 @@ export default function AccountPage() { setDeleteError(null); try { await deleteMe(client, countryId); - forgetImportState(user.id); await auth.signOut(); } catch (error) { setDeleteError(error instanceof Error ? error.message : 'The account was not deleted.'); @@ -213,51 +220,6 @@ export default function AccountPage() { - {localImport.available && (localImport.pendingCount > 0 || localImport.summary) && ( - - - Work saved on this device - - {localImport.pendingCount > 0 - ? `This browser has ${localImport.pendingCount} item${ - localImport.pendingCount === 1 ? '' : 's' - } saved on it while signed out that are not in your account. Saving copies them; nothing is removed from this device.` - : 'Everything this browser can save is in your account.'} - - - - - {localImport.summary && - importSummaryLines(localImport.summary).map((line) => ( - - {line} - - ))} - {localImport.state === 'error' && ( - - {localImport.error} - - )} - {localImport.pendingCount > 0 && ( -
- -
- )} -
-
-
- )} - Download my data @@ -286,8 +248,8 @@ export default function AccountPage() { Delete my account - Permanently deletes your account and everything saved to it. Work saved only on this - device stays on this device. + Permanently deletes your account and everything saved to it. Work saved on this browser + while signed out stays on this browser. @@ -306,8 +268,8 @@ export default function AccountPage() { Delete your account? - This permanently deletes your PolicyEngine account and every policy, household, - simulation, report and reform saved to it. It cannot be undone. + This permanently deletes your PolicyEngine account and every reform and report saved + to it. It cannot be undone. {deleteError && ( diff --git a/app/src/pages/AuthCallback.page.tsx b/app/src/pages/flagship/AuthCallback.page.tsx similarity index 63% rename from app/src/pages/AuthCallback.page.tsx rename to app/src/pages/flagship/AuthCallback.page.tsx index c34ca353e..e7ec38704 100644 --- a/app/src/pages/AuthCallback.page.tsx +++ b/app/src/pages/flagship/AuthCallback.page.tsx @@ -1,43 +1,33 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { Button, Spinner, Stack, Text, Title } from '@/components/ui'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useAuthStatus } from '@/libs/auth/AuthContext'; -import { wasSignInRedirectHandled } from '@/libs/auth/AuthProvider'; - -function hasSignInParams(search: string): boolean { - const params = new URLSearchParams(search); - return params.has('state') && (params.has('code') || params.has('error')); -} +import { takeSignInReturnTo } from '@/libs/auth/AuthProvider'; /** * Where Auth0 sends the browser after sign-in. The AuthProvider finishes - * the code exchange and navigates to where sign-in started; this page - * shows progress meanwhile, and the error when sign-in did not complete. + * the code exchange (showing its loading state meanwhile); this page then + * returns to where sign-in started, and shows the error when sign-in did + * not complete. */ export default function AuthCallbackPage() { const countryId = useCurrentCountry(); const nav = useAppNavigate(); const { status, error } = useAuthStatus(); + const finished = useRef(false); useEffect(() => { - if (status === 'disabled') { - nav.replace(`/${countryId}`); + if (finished.current || (status === 'signed_out' && error)) { return; } - // A visit without sign-in parameters (a reload, a bookmark) has nothing to finish. - if ( - status !== 'loading' && - !error && - !wasSignInRedirectHandled() && - !hasSignInParams(window.location.search) - ) { - nav.replace(`/${countryId}`); - } + finished.current = true; + const home = `/${countryId}`; + nav.replace(status === 'signed_in' ? (takeSignInReturnTo() ?? home) : home); }, [status, error, countryId, nav]); - if (error && status !== 'authenticated') { + if (status === 'signed_out' && error) { return ( reformStore.findByUser(userId, countryId), - enabled: storeReady, + queryKey: ['reforms', scope, countryId], + queryFn: () => reformStore.findByUser(MOCK_USER_ID, countryId), }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ['reforms'] }); diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 1d8028f08..83a206788 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -9,10 +9,11 @@ import ReportAdjustPanel from '@/components/flagship/ReportAdjustPanel'; import { ReportUnresolvable } from '@/components/flagship/ReportComputing'; import { trackRecordPrograms, useModelTrackRecord } from '@/components/flagship/ValidationPanel'; import { Spinner, Stack, Text, Title } from '@/components/ui'; +import { MOCK_USER_ID } from '@/constants'; import { colors, spacing } from '@/designTokens'; import { useCalculationStatus } from '@/hooks/useCalculationStatus'; import { isApiReportId, useFlagshipReport } from '@/hooks/useFlagshipReport'; -import { useAccountReportRecord, useFlagshipReportStore } from '@/hooks/useFlagshipStores'; +import { useAccountReportRecord } from '@/hooks/useFlagshipStores'; import { useReportValidationSnapshot } from '@/hooks/useReportValidationSnapshot'; import { useStartCalculationOnLoad } from '@/hooks/useStartCalculationOnLoad'; import { clearDraftAfterRun, loadProvisionsIntoDraft } from '@/libs/draftReform'; @@ -41,7 +42,6 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep // A local association id from another browser resolves to nothing. const unresolvable = !isLoading && !report && !!userReportId && !isApiReportId(userReportId); const parameters = useSelector((state: RootState) => state.metadata.parameters); - const { userId: storeUserId } = useFlagshipReportStore(); const apiReportId = report?.id !== undefined && report?.id !== null ? String(report.id) : undefined; // Signed in, the account's record carries the provenance to every device. @@ -120,7 +120,7 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const trackRecord = useModelTrackRecord(provisionPaths); const calibration = useCalibrationMatches(provisionPaths, region); // The store keys records by the API report id as a string. - const pin = useReportValidationSnapshot(storeUserId, apiReportId, { + const pin = useReportValidationSnapshot(MOCK_USER_ID, apiReportId, { calibration, programs: trackRecordPrograms(trackRecord), }); diff --git a/app/src/tests/fixtures/libs/auth/authMocks.tsx b/app/src/tests/fixtures/libs/auth/authMocks.tsx index 42def439e..6f4130546 100644 --- a/app/src/tests/fixtures/libs/auth/authMocks.tsx +++ b/app/src/tests/fixtures/libs/auth/authMocks.tsx @@ -1,12 +1,6 @@ -import { useMemo } from 'react'; import { vi } from 'vitest'; -import { - AuthContext, - useSettledSession, - type AuthContextValue, - type SettledSession, -} from '@/libs/auth/AuthContext'; -import { mockMeItem, TEST_ACCESS_TOKEN } from '@/tests/fixtures/api/v2UserMocks'; +import { AuthContext, type AuthContextValue } from '@/libs/auth/AuthContext'; +import { TEST_ACCESS_TOKEN } from '@/tests/fixtures/api/v2UserMocks'; export const TEST_AUTH0_CONFIG = { domain: 'auth.test.policyengine.org', @@ -14,6 +8,14 @@ export const TEST_AUTH0_CONFIG = { audience: 'https://api.test.policyengine.org/', } as const; +export const TEST_AUTH0_SUBJECT = 'google-oauth2|1234'; + +export const TEST_PROFILE = { + name: 'Ada Lovelace', + email: 'ada@example.org', + picture: null, +} as const; + /** Stubs the build-time sign-in environment (undo with vi.unstubAllEnvs()). */ export function stubAuthEnv( enabled: boolean, @@ -25,55 +27,32 @@ export function stubAuthEnv( vi.stubEnv('VITE_AUTH0_AUDIENCE', config.audience ?? ''); } -/** - * A fixed auth state. Its `settledSession` answers at once unless the - * state is 'loading', which never settles on its own; AuthTestProvider - * replaces it with one that settles when the provided value changes. - */ +/** A signed-out session. */ export function makeAuthValue(overrides: Partial = {}): AuthContextValue { - const value: AuthContextValue = { - status: 'anonymous', - user: null, + return { + status: 'signed_out', profile: null, + subject: null, + sessionEnded: false, error: null, signIn: vi.fn().mockResolvedValue(undefined), signOut: vi.fn().mockResolvedValue(undefined), - retryAccount: vi.fn(), - settledSession: () => { - const { status, user } = value; - return status === 'loading' - ? new Promise(() => {}) - : Promise.resolve({ status, user }); - }, getAccessToken: vi.fn().mockResolvedValue(null), ...overrides, }; - return value; } -/** A signed-in session whose PolicyEngine user has loaded. */ +/** A signed-in session with a working access token. */ export function signedInAuthValue(overrides: Partial = {}): AuthContextValue { return makeAuthValue({ - status: 'authenticated', - user: mockMeItem, - profile: { name: 'Ada Lovelace', email: mockMeItem.email, picture: null }, - getAccessToken: vi.fn().mockResolvedValue(TEST_ACCESS_TOKEN), - ...overrides, - }); -} - -/** Signed in, but the PolicyEngine user could not be loaded. */ -export function accountErrorAuthValue(overrides: Partial = {}): AuthContextValue { - return makeAuthValue({ - status: 'account_error', - profile: { name: 'Ada Lovelace', email: mockMeItem.email, picture: null }, - error: new Error('Saved work is temporarily unavailable.'), + status: 'signed_in', + profile: { ...TEST_PROFILE }, + subject: TEST_AUTH0_SUBJECT, getAccessToken: vi.fn().mockResolvedValue(TEST_ACCESS_TOKEN), ...overrides, }); } -/** Provides `value`, with a `settledSession` that settles as the real provider's does. */ export function AuthTestProvider({ value, children, @@ -81,7 +60,5 @@ export function AuthTestProvider({ value: AuthContextValue; children: React.ReactNode; }) { - const settledSession = useSettledSession(value.status, value.user); - const provided = useMemo(() => ({ ...value, settledSession }), [value, settledSession]); - return {children}; + return {children}; } diff --git a/app/src/tests/unit/api/deferredStore.test.ts b/app/src/tests/unit/api/deferredStore.test.ts deleted file mode 100644 index bcff1540b..000000000 --- a/app/src/tests/unit/api/deferredStore.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { deferredStore } from '@/api/deferredStore'; - -interface CounterStore { - increment: (by: number) => Promise; - read: () => Promise; -} - -class InMemoryCounterStore implements CounterStore { - private total = 0; - - async increment(by: number): Promise { - this.total += by; - return this.total; - } - - async read(): Promise { - return this.total; - } -} - -describe('deferredStore', () => { - test('given a call before the store is known then it runs once the store resolves', async () => { - // Given - let provideStore: (store: CounterStore) => void = () => {}; - const storeKnown = new Promise((resolve) => { - provideStore = resolve; - }); - const deferred = deferredStore(() => storeKnown, { increment: true, read: true }); - const counter = new InMemoryCounterStore(); - - // When - const incremented = deferred.increment(2); - provideStore(counter); - - // Then - expect(await incremented).toBe(2); - expect(await counter.read()).toBe(2); - }); - - test('given a class-based store then its methods run with the store as `this`', async () => { - // Given - const counter = new InMemoryCounterStore(); - const deferred = deferredStore(async () => counter, { increment: true, read: true }); - - // When - await deferred.increment(3); - - // Then - expect(await deferred.read()).toBe(3); - }); -}); diff --git a/app/src/tests/unit/components/auth/AuthMenu.test.tsx b/app/src/tests/unit/components/auth/AuthMenu.test.tsx index 025c9bf69..f518171a4 100644 --- a/app/src/tests/unit/components/auth/AuthMenu.test.tsx +++ b/app/src/tests/unit/components/auth/AuthMenu.test.tsx @@ -1,14 +1,14 @@ import { render, screen, userEvent } from '@test-utils'; import { describe, expect, test } from 'vitest'; import AuthMenu from '@/components/auth/AuthMenu'; +import FlagshipTopBar from '@/components/flagship/FlagshipTopBar'; import HeaderActionButtons from '@/components/homeHeader/HeaderActionButtons'; import type { AuthContextValue } from '@/libs/auth/AuthContext'; -import { mockMeItem } from '@/tests/fixtures/api/v2UserMocks'; import { - accountErrorAuthValue, AuthTestProvider, makeAuthValue, signedInAuthValue, + TEST_PROFILE, } from '@/tests/fixtures/libs/auth/authMocks'; function renderMenu(auth: AuthContextValue) { @@ -19,81 +19,72 @@ function renderMenu(auth: AuthContextValue) { ); } -describe('AuthMenu', () => { - test('given sign-in is disabled then the header shows no sign-in control', () => { +describe('legacy calculator pages', () => { + test('given the legacy header then it has no sign-in control', () => { // When render(); // Then expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /account menu/i })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: /country selector/i })).toBeInTheDocument(); }); - test('given the session is loading then nothing is shown', () => { + test('given the v3 top bar outside the workspace (no auth provider) then it has no sign-in control', () => { // When - renderMenu(makeAuthValue({ status: 'loading' })); + render(); // Then - expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /account menu/i })).not.toBeInTheDocument(); }); +}); - test('given a signed-out visitor then "Sign in" starts sign-in', async () => { - // Given - const user = userEvent.setup(); - const auth = makeAuthValue({ status: 'anonymous' }); - renderMenu(auth); - +describe('AuthMenu', () => { + test('given sign-in is disabled then nothing is shown', () => { // When - await user.click(screen.getByRole('button', { name: 'Sign in' })); + renderMenu(makeAuthValue({ status: 'disabled' })); // Then - expect(auth.signIn).toHaveBeenCalled(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); - test('given a signed-in user then the menu offers My account and Sign out', async () => { + test('given a signed-out visitor then "Sign in" starts sign-in', async () => { // Given const user = userEvent.setup(); - const auth = signedInAuthValue(); + const auth = makeAuthValue(); renderMenu(auth); // When - await user.click(screen.getByRole('button', { name: /account menu for ada lovelace/i })); + await user.click(screen.getByRole('button', { name: 'Sign in' })); // Then - expect(screen.getByText(mockMeItem.email!)).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: /my account/i })).toBeInTheDocument(); - await user.click(screen.getByRole('menuitem', { name: /sign out/i })); - expect(auth.signOut).toHaveBeenCalled(); + expect(auth.signIn).toHaveBeenCalled(); + expect(screen.queryByText(/your session ended/i)).not.toBeInTheDocument(); }); - test('given the account could not be loaded then the menu says so and offers Try again', async () => { - // Given - const user = userEvent.setup(); - const auth = accountErrorAuthValue(); - renderMenu(auth); - + test('given the session ended on its own then the menu invites signing in again', () => { // When - await user.click( - screen.getByRole('button', { name: /account menu for ada lovelace \(account not loaded\)/i }) - ); + renderMenu(makeAuthValue({ sessionEnded: true })); // Then - expect(screen.getByRole('alert')).toHaveTextContent(/could not load your account/i); - await user.click(screen.getByRole('menuitem', { name: /try again/i })); - expect(auth.retryAccount).toHaveBeenCalled(); + expect(screen.getByText(/your session ended/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument(); }); - test('given the account could not be loaded then Sign out is still offered', async () => { + test('given a signed-in user then the menu shows the ID-token identity, My account and Sign out', async () => { // Given const user = userEvent.setup(); - const auth = accountErrorAuthValue(); + const auth = signedInAuthValue(); renderMenu(auth); // When - await user.click(screen.getByRole('button', { name: /account menu for/i })); - await user.click(screen.getByRole('menuitem', { name: /sign out/i })); + await user.click(screen.getByRole('button', { name: /account menu for ada lovelace/i })); // Then + expect(screen.getByText(TEST_PROFILE.email)).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /my account/i })).toBeInTheDocument(); + await user.click(screen.getByRole('menuitem', { name: /sign out/i })); expect(auth.signOut).toHaveBeenCalled(); }); }); diff --git a/app/src/tests/unit/components/auth/LocalDataImportPrompt.test.tsx b/app/src/tests/unit/components/auth/LocalDataImportPrompt.test.tsx deleted file mode 100644 index de1570179..000000000 --- a/app/src/tests/unit/components/auth/LocalDataImportPrompt.test.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, userEvent, waitFor } from '@test-utils'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import LocalDataImportPrompt from '@/components/auth/LocalDataImportPrompt'; -import type { AuthContextValue } from '@/libs/auth/AuthContext'; -import { isImportDeclined } from '@/libs/auth/localImport'; -import { - errorResponse, - mockMeItem, - okResponse, - PE_USER_ID, -} from '@/tests/fixtures/api/v2UserMocks'; -import { - AuthTestProvider, - makeAuthValue, - signedInAuthValue, -} from '@/tests/fixtures/libs/auth/authMocks'; -import { seedManySimulations } from '@/tests/fixtures/libs/auth/localImportMocks'; - -const fetchMock = vi.fn(); - -function renderPrompt(auth: AuthContextValue, countryId = 'us') { - const queryClient = new QueryClient(); - return render( - - - - - , - countryId - ); -} - -beforeEach(() => { - localStorage.clear(); - fetchMock.mockReset(); - vi.stubGlobal('fetch', fetchMock); -}); - -describe('LocalDataImportPrompt', () => { - test('given a signed-in user with local work then the prompt offers to save it', async () => { - // Given - seedManySimulations(2); - - // When - renderPrompt(signedInAuthValue()); - - // Then - expect( - await screen.findByText('Save the work on this device to your account?') - ).toBeInTheDocument(); - expect( - screen.getByText(/this browser has 2 items saved on it while signed out/i) - ).toBeInTheDocument(); - }); - - test('given a signed-out visitor then nothing is offered', () => { - // Given - seedManySimulations(2); - - // When - renderPrompt(makeAuthValue({ status: 'anonymous' })); - - // Then - expect(screen.queryByText(/save the work on this device/i)).not.toBeInTheDocument(); - }); - - test('given a signed-in user in Canada then nothing is offered', () => { - // Given - seedManySimulations(2); - - // When - renderPrompt(signedInAuthValue(), 'ca'); - - // Then - expect(screen.queryByText(/save the work on this device/i)).not.toBeInTheDocument(); - }); - - test('given "Not now" then the choice is remembered', async () => { - // Given - const user = userEvent.setup(); - seedManySimulations(1); - renderPrompt(signedInAuthValue()); - - // When - await user.click(await screen.findByRole('button', { name: 'Not now' })); - - // Then - expect(isImportDeclined(PE_USER_ID)).toBe(true); - }); - - test('given "Save to my account" then the work is imported and summarised', async () => { - // Given - const user = userEvent.setup(); - seedManySimulations(2); - fetchMock.mockImplementation(async () => - okResponse({ id_map: { 'sus-0': 'uuid-0', 'sus-1': 'uuid-1' }, skipped: [] }) - ); - renderPrompt(signedInAuthValue()); - - // When - await user.click(await screen.findByRole('button', { name: 'Save to my account' })); - - // Then - await waitFor(() => - expect(screen.getByText('Your work is saved to your account')).toBeInTheDocument() - ); - expect(screen.getByText('Saved 2 items to your account.')).toBeInTheDocument(); - expect(screen.getByText('Nothing was removed from this device.')).toBeInTheDocument(); - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe('https://api.policyengine.org/v2/me/imports?country_id=us'); - expect(init.method).toBe('POST'); - expect(mockMeItem.id).toBe(PE_USER_ID); - }); - - test('given an import with a failed request then closing does not bring the prompt back', async () => { - // Given: the request fails, so every item is still pending afterwards - const user = userEvent.setup(); - seedManySimulations(2); - fetchMock.mockImplementation(async () => errorResponse(503, 'Saved work is unavailable')); - const { unmount } = renderPrompt(signedInAuthValue()); - await user.click(await screen.findByRole('button', { name: 'Save to my account' })); - await screen.findByText('2 items did not go through. You can try again later.'); - - // When - await user.click(screen.getByRole('button', { name: 'Done' })); - unmount(); - renderPrompt(signedInAuthValue()); - - // Then - expect(isImportDeclined(PE_USER_ID)).toBe(true); - expect(screen.queryByText(/save the work on this device/i)).not.toBeInTheDocument(); - }); - - test('given a finished import then "Done" is remembered too', async () => { - // Given - const user = userEvent.setup(); - seedManySimulations(2); - fetchMock.mockImplementation(async () => - okResponse({ - id_map: { 'sus-0': 'uuid-0' }, - skipped: [{ client_ref: null, reason: 'invalid' }], - }) - ); - renderPrompt(signedInAuthValue()); - await user.click(await screen.findByRole('button', { name: 'Save to my account' })); - - // When - await user.click(await screen.findByRole('button', { name: 'Done' })); - - // Then - expect(isImportDeclined(PE_USER_ID)).toBe(true); - }); -}); diff --git a/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx b/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx index 4f5853d04..1541c920f 100644 --- a/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx +++ b/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx @@ -6,12 +6,9 @@ import { CountryProvider } from '@/contexts/CountryContext'; import { NavigationProvider } from '@/contexts/NavigationContext'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; import type { AuthContextValue } from '@/libs/auth/AuthContext'; -import { ACCOUNT_UNAVAILABLE_MESSAGE } from '@/libs/auth/useAssociationBackend'; import { runFlagshipReport, type RunReportProvision } from '@/libs/flagship/runReport'; import { store } from '@/store'; -import { PE_USER_ID } from '@/tests/fixtures/api/v2UserMocks'; import { - accountErrorAuthValue, AuthTestProvider, makeAuthValue, signedInAuthValue, @@ -36,8 +33,7 @@ const PROVISIONS: RunReportProvision[] = [ }, ]; -function renderRun(initialAuth: AuthContextValue) { - let auth = initialAuth; +function renderRun(auth: AuthContextValue) { const wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -49,14 +45,7 @@ function renderRun(initialAuth: AuthContextValue) { ); - const rendered = renderHook(() => useRunFlagshipReport(), { wrapper }); - return { - ...rendered, - settle: (next: AuthContextValue) => { - auth = next; - rendered.rerender(); - }, - }; + return renderHook(() => useRunFlagshipReport(), { wrapper }); } beforeEach(() => { @@ -65,54 +54,29 @@ beforeEach(() => { }); describe('useRunFlagshipReport', () => { - test('given the account could not be loaded then the run is refused before anything is created', async () => { + test('given a signed-in session then the run records in the account', async () => { // Given - const { result } = renderRun(accountErrorAuthValue()); + const { result } = renderRun(signedInAuthValue()); // When await act(() => result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS)); - // Then - expect(runFlagshipReport).not.toHaveBeenCalled(); - expect(result.current.error).toBe(ACCOUNT_UNAVAILABLE_MESSAGE); - expect(result.current.isRunning).toBe(false); - }); - - test('given a run started while signing in then it is recorded in the account once signed in', async () => { - // Given - const { result, settle } = renderRun(makeAuthValue({ status: 'loading' })); - let running: Promise | undefined; - act(() => { - running = result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS); - }); - expect(runFlagshipReport).not.toHaveBeenCalled(); - - // When - act(() => settle(signedInAuthValue())); - await act(() => running!); - // Then expect(runFlagshipReport).toHaveBeenCalledTimes(1); - const { account } = vi.mocked(runFlagshipReport).mock.calls[0][0]; - expect(account?.userId).toBe(PE_USER_ID); - expect(account?.reportStore).toBeInstanceOf(ApiFlagshipReportStore); + const { accountReportStore } = vi.mocked(runFlagshipReport).mock.calls[0][0]; + expect(accountReportStore).toBeInstanceOf(ApiFlagshipReportStore); }); - test('given a run started while the session restores then a signed-out visitor runs locally without an error', async () => { + test('given a signed-out session then the run records in this browser', async () => { // Given - const { result, settle } = renderRun(makeAuthValue({ status: 'loading' })); - let running: Promise | undefined; - act(() => { - running = result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS); - }); + const { result } = renderRun(makeAuthValue()); // When - act(() => settle(makeAuthValue({ status: 'anonymous' }))); - await act(() => running!); + await act(() => result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS)); // Then await waitFor(() => expect(runFlagshipReport).toHaveBeenCalledTimes(1)); - expect(vi.mocked(runFlagshipReport).mock.calls[0][0].account).toBeNull(); + expect(vi.mocked(runFlagshipReport).mock.calls[0][0].accountReportStore).toBeNull(); expect(result.current.error).toBeNull(); }); }); diff --git a/app/src/tests/unit/libs/auth/AuthContext.test.tsx b/app/src/tests/unit/libs/auth/AuthContext.test.tsx deleted file mode 100644 index bcae281e8..000000000 --- a/app/src/tests/unit/libs/auth/AuthContext.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { act, render } from '@testing-library/react'; -import { describe, expect, test } from 'vitest'; -import { - SessionClosedError, - useAuthStatus, - type AuthContextValue, - type SettledSession, -} from '@/libs/auth/AuthContext'; -import { mockMeItem } from '@/tests/fixtures/api/v2UserMocks'; -import { - AuthTestProvider, - makeAuthValue, - signedInAuthValue, -} from '@/tests/fixtures/libs/auth/authMocks'; - -/** Asks for the settled session on mount and hands the promise to the test. */ -function AskForSession({ onAsk }: { onAsk: (waiting: Promise) => void }) { - const { settledSession } = useAuthStatus(); - onAsk(settledSession()); - return null; -} - -function renderWaiting(auth: AuthContextValue) { - let waiting: Promise | undefined; - const rendered = render( - - { - waiting ??= asked; - }} - /> - - ); - return { ...rendered, waiting: () => waiting! }; -} - -describe('settledSession', () => { - test('given the session is restoring then a waiter is answered once it settles', async () => { - // Given - const { waiting, rerender } = renderWaiting(makeAuthValue({ status: 'loading' })); - - // When - rerender( - - {}} /> - - ); - - // Then - await expect(waiting()).resolves.toEqual({ status: 'authenticated', user: mockMeItem }); - }); - - test('given the provider unmounts while restoring then its waiters fail instead of hanging', async () => { - // Given - const { waiting, unmount } = renderWaiting(makeAuthValue({ status: 'loading' })); - - // When - act(() => unmount()); - - // Then - await expect(waiting()).rejects.toBeInstanceOf(SessionClosedError); - await expect(waiting()).rejects.toThrow(/interrupted before your session was restored/); - }); -}); diff --git a/app/src/tests/unit/libs/auth/AuthProvider.test.tsx b/app/src/tests/unit/libs/auth/AuthProvider.test.tsx index 2e41bc0fe..3c9be013a 100644 --- a/app/src/tests/unit/libs/auth/AuthProvider.test.tsx +++ b/app/src/tests/unit/libs/auth/AuthProvider.test.tsx @@ -1,22 +1,21 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { FlagshipReportStore } from '@/api/flagshipReportStore'; import { CountryProvider } from '@/contexts/CountryContext'; -import { NavigationProvider } from '@/contexts/NavigationContext'; import { useAuthStatus } from '@/libs/auth/AuthContext'; -import { AuthProvider, ME_RETRIES } from '@/libs/auth/AuthProvider'; +import { AuthProvider, takeSignInReturnTo } from '@/libs/auth/AuthProvider'; import type { CountryId } from '@/libs/countries'; import { getDraftReform, startDraftReform } from '@/libs/draftReform'; import { readReportMeta, runFlagshipReport } from '@/libs/flagship/runReport'; +import { TEST_ACCESS_TOKEN } from '@/tests/fixtures/api/v2UserMocks'; import { - errorResponse, - itemResponse, - mockMeItem, - TEST_ACCESS_TOKEN, -} from '@/tests/fixtures/api/v2UserMocks'; -import { stubAuthEnv, TEST_AUTH0_CONFIG } from '@/tests/fixtures/libs/auth/authMocks'; + stubAuthEnv, + TEST_AUTH0_CONFIG, + TEST_AUTH0_SUBJECT, + TEST_PROFILE, +} from '@/tests/fixtures/libs/auth/authMocks'; const auth0 = vi.hoisted(() => ({ state: { @@ -54,44 +53,43 @@ vi.mock('@/api/simulation', () => ({ })); vi.mock('@/api/report', () => ({ createReport: mockCreateReport, - createReportAndAssociateWithUser: vi.fn(), })); -const navigation = { push: vi.fn(), replace: vi.fn(), back: vi.fn() }; const fetchMock = vi.fn(); function Probe() { const auth = useAuthStatus(); + const [token, setToken] = useState(''); return (
{auth.status} - {auth.user?.id ?? 'none'} - {auth.error?.message ?? 'none'} + {auth.subject ?? 'none'} + {auth.profile?.name ?? 'none'} + {String(auth.sessionEnded)} + {token} - +
); } function renderProvider(countryId: CountryId = 'us') { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( - - - - - - - - - + + + + + ); } @@ -103,6 +101,10 @@ beforeEach(() => { auth0.state.error = undefined; auth0.state.user = undefined; auth0.getAccessTokenSilently.mockResolvedValue(TEST_ACCESS_TOKEN); + auth0.logout.mockImplementation(async () => { + auth0.state.isAuthenticated = false; + auth0.state.user = undefined; + }); }); afterEach(() => { @@ -159,7 +161,7 @@ describe('AuthProvider', () => { ); }); - test('given the session is being restored then status is loading', () => { + test('given Auth0 is restoring the session then a loading state renders instead of the workspace', () => { // Given stubAuthEnv(true); auth0.state.isLoading = true; @@ -168,10 +170,11 @@ describe('AuthProvider', () => { renderProvider(); // Then - expect(screen.getByTestId('status')).toHaveTextContent('loading'); + expect(screen.getByText('Loading…')).toBeInTheDocument(); + expect(screen.queryByTestId('status')).not.toBeInTheDocument(); }); - test('given no session then status is anonymous', () => { + test('given no session then the workspace is signed out', () => { // Given stubAuthEnv(true); @@ -179,88 +182,59 @@ describe('AuthProvider', () => { renderProvider(); // Then - expect(screen.getByTestId('status')).toHaveTextContent('anonymous'); - expect(fetchMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('status')).toHaveTextContent('signed_out'); + expect(screen.getByTestId('subject')).toHaveTextContent('none'); }); - test('given a session in the US then /v2/me loads the PolicyEngine user with the bearer token', async () => { + test('given a session then it is signed in, with its identity from the ID token and no /v2/me call', () => { // Given stubAuthEnv(true); auth0.state.isAuthenticated = true; - auth0.state.user = { sub: 'google-oauth2|1234', email: mockMeItem.email }; - fetchMock.mockResolvedValue(itemResponse(mockMeItem)); + auth0.state.user = { sub: TEST_AUTH0_SUBJECT, ...TEST_PROFILE }; // When - renderProvider('us'); + renderProvider(); // Then - expect(screen.getByTestId('status')).toHaveTextContent('loading'); - await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated')); - expect(screen.getByTestId('user')).toHaveTextContent(mockMeItem.id); - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe('https://api.policyengine.org/v2/me?country_id=us'); - expect(init.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); - expect(auth0.getAccessTokenSilently).toHaveBeenCalledWith({ - authorizationParams: { audience: TEST_AUTH0_CONFIG.audience }, - }); - }); - - test('given /v2/me keeps failing then the session reports an account error, not a signed-in user', async () => { - // Given - vi.useFakeTimers({ shouldAdvanceTime: true }); - stubAuthEnv(true); - auth0.state.isAuthenticated = true; - auth0.state.user = { sub: 'google-oauth2|1234' }; - fetchMock.mockImplementation(async () => errorResponse(503, 'Database unavailable')); - - // When - renderProvider('uk'); - expect(screen.getByTestId('status')).toHaveTextContent('loading'); - await vi.advanceTimersByTimeAsync(10_000); - - // Then: every retry was spent before giving up - await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('account_error')); - expect(fetchMock).toHaveBeenCalledTimes(1 + ME_RETRIES); - expect(screen.getByTestId('user')).toHaveTextContent('none'); - expect(screen.getByTestId('error')).toHaveTextContent('Database unavailable'); - vi.useRealTimers(); + expect(screen.getByTestId('status')).toHaveTextContent('signed_in'); + expect(screen.getByTestId('subject')).toHaveTextContent(TEST_AUTH0_SUBJECT); + expect(screen.getByTestId('name')).toHaveTextContent(TEST_PROFILE.name); + expect(fetchMock).not.toHaveBeenCalled(); }); - test('given an account error then retrying loads the PolicyEngine user', async () => { + test('given a token that can no longer be renewed then the session ends and invites sign-in', async () => { // Given - vi.useFakeTimers({ shouldAdvanceTime: true }); stubAuthEnv(true); auth0.state.isAuthenticated = true; - auth0.state.user = { sub: 'google-oauth2|1234' }; - fetchMock.mockImplementation(async () => errorResponse(503, 'Database unavailable')); - renderProvider('us'); - await vi.advanceTimersByTimeAsync(10_000); - await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('account_error')); - vi.useRealTimers(); + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + auth0.getAccessTokenSilently.mockRejectedValue({ error: 'login_required' }); + renderProvider(); // When - fetchMock.mockImplementation(async () => itemResponse(mockMeItem)); - await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' })); + await userEvent.setup().click(screen.getByRole('button', { name: 'Get token' })); // Then - await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated')); - expect(screen.getByTestId('user')).toHaveTextContent(mockMeItem.id); - expect(screen.getByTestId('error')).toHaveTextContent('none'); + expect(auth0.logout).toHaveBeenCalledWith({ openUrl: false }); + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('signed_out')); + expect(screen.getByTestId('session-ended')).toHaveTextContent('true'); + expect(screen.getByTestId('token')).toHaveTextContent('no token'); }); - test('given a session in a country without account storage then /v2/me is not called', () => { + test('given a token request that fails for another reason then the session stays signed in', async () => { // Given stubAuthEnv(true); auth0.state.isAuthenticated = true; - auth0.state.user = { sub: 'google-oauth2|1234' }; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + auth0.getAccessTokenSilently.mockRejectedValue({ error: 'timeout' }); + renderProvider(); // When - renderProvider('ca'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Get token' })); // Then - expect(screen.getByTestId('status')).toHaveTextContent('authenticated'); - expect(screen.getByTestId('user')).toHaveTextContent('none'); - expect(fetchMock).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.getByTestId('token')).toHaveTextContent('no token')); + expect(auth0.logout).not.toHaveBeenCalled(); + expect(screen.getByTestId('status')).toHaveTextContent('signed_in'); }); test('given sign in then the redirect returns to the requested page via the country callback', async () => { @@ -269,42 +243,40 @@ describe('AuthProvider', () => { renderProvider('us'); // When - screen.getByRole('button', { name: 'Sign in' }).click(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Sign in' })); // Then - await waitFor(() => - expect(auth0.loginWithRedirect).toHaveBeenCalledWith({ - appState: { returnTo: '/us/reforms?filter=yours' }, - authorizationParams: { - audience: TEST_AUTH0_CONFIG.audience, - redirect_uri: `${window.location.origin}/us/callback`, - scope: 'openid profile email offline_access', - }, - }) - ); + expect(auth0.loginWithRedirect).toHaveBeenCalledWith({ + appState: { returnTo: '/us/reforms?filter=yours' }, + authorizationParams: { + audience: TEST_AUTH0_CONFIG.audience, + redirect_uri: `${window.location.origin}/us/callback`, + scope: 'openid profile email offline_access', + }, + }); }); - test('given the redirect completes then it navigates to the saved return path', () => { + test('given the redirect completes then the callback can read where sign-in started, once', () => { // Given stubAuthEnv(true); renderProvider('us'); const { onRedirectCallback } = auth0.providerProps.mock.calls[0][0]; // When - onRedirectCallback({ returnTo: '/us/build' }); - onRedirectCallback({ returnTo: 'https://evil.example/' }); + act(() => onRedirectCallback({ returnTo: '/us/build' })); // Then - expect(navigation.replace).toHaveBeenNthCalledWith(1, '/us/build'); - expect(navigation.replace).toHaveBeenNthCalledWith(2, '/us'); + expect(takeSignInReturnTo()).toBe('/us/build'); + expect(takeSignInReturnTo()).toBeNull(); + act(() => onRedirectCallback({ returnTo: 'https://evil.example/' })); + expect(takeSignInReturnTo()).toBe('/us'); }); test('given sign out then what the session left in this browser is cleared first', async () => { // Given: a signed-in run's provenance and an account reform loaded for editing stubAuthEnv(true); auth0.state.isAuthenticated = true; - auth0.state.user = { sub: 'google-oauth2|1234' }; - fetchMock.mockResolvedValue(itemResponse(mockMeItem)); + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; mockCreateReport.mockResolvedValue({ id: 56 }); await runFlagshipReport({ countryId: 'us', @@ -312,10 +284,9 @@ describe('AuthProvider', () => { sourceNote: '', provisions: [{ path: 'gov.x', breadcrumb: 'X', unit: null, baselineValue: 1, value: 2 }], currentLawId: 2, - account: { - reportStore: { create: vi.fn().mockResolvedValue({}) } as unknown as FlagshipReportStore, - userId: mockMeItem.id, - }, + accountReportStore: { + create: vi.fn().mockResolvedValue({}), + } as unknown as FlagshipReportStore, }); startDraftReform('us', 'manual'); renderProvider('us'); diff --git a/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx b/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx deleted file mode 100644 index 7560dfed6..000000000 --- a/app/src/tests/unit/libs/auth/useAssociationBackend.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { describe, expect, test } from 'vitest'; -import { MOCK_USER_ID } from '@/constants'; -import { CountryProvider } from '@/contexts/CountryContext'; -import type { AuthContextValue, AuthStatus } from '@/libs/auth/AuthContext'; -import { - selectAssociationBackend, - useAssociationBackend, - useCurrentUserId, - type AssociationBackendMode, -} from '@/libs/auth/useAssociationBackend'; -import type { CountryId } from '@/libs/countries'; -import { mockMeItem, PE_USER_ID } from '@/tests/fixtures/api/v2UserMocks'; -import { - AuthTestProvider, - makeAuthValue, - signedInAuthValue, -} from '@/tests/fixtures/libs/auth/authMocks'; - -function wrapperFor(auth: AuthContextValue, countryId: CountryId) { - return ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); -} - -describe('selectAssociationBackend', () => { - const statuses: AuthStatus[] = [ - 'disabled', - 'loading', - 'anonymous', - 'authenticated', - 'account_error', - ]; - const countries: CountryId[] = ['us', 'uk', 'ca', 'ng', 'il']; - const expectedMode: Record = { - disabled: 'localStorage', - loading: 'localStorage', - anonymous: 'localStorage', - authenticated: 'api', - account_error: 'unavailable', - }; - - test.each(statuses.flatMap((status) => countries.map((country) => [status, country] as const)))( - 'given status %s in %s then account storage is used only when signed in to US or UK', - (status, countryId) => { - const user = status === 'authenticated' ? mockMeItem : null; - const expected = - countryId === 'us' || countryId === 'uk' ? expectedMode[status] : 'localStorage'; - expect(selectAssociationBackend({ status, user, countryId })).toBe(expected); - } - ); - - test('given a US session whose PolicyEngine user is missing then saved work is unavailable, not local', () => { - expect(selectAssociationBackend({ status: 'authenticated', user: null, countryId: 'us' })).toBe( - 'unavailable' - ); - }); -}); - -describe('useAssociationBackend', () => { - test('given a signed-in US session then saved work is filed under the PolicyEngine user', () => { - const { result } = renderHook(() => useAssociationBackend(), { - wrapper: wrapperFor(signedInAuthValue(), 'us'), - }); - - expect(result.current.mode).toBe('api'); - expect(result.current.userId).toBe(PE_USER_ID); - expect(result.current.ready).toBe(true); - }); - - test('given a signed-in Canada session then saved work stays local under the local user', () => { - const { result } = renderHook(() => useCurrentUserId(), { - wrapper: wrapperFor(signedInAuthValue(), 'ca'), - }); - - expect(result.current).toBe(MOCK_USER_ID); - }); - - test('given the session is still loading then queries are told to wait', () => { - const { result } = renderHook(() => useAssociationBackend(), { - wrapper: wrapperFor(makeAuthValue({ status: 'loading' }), 'us'), - }); - - expect(result.current.mode).toBe('localStorage'); - expect(result.current.ready).toBe(false); - }); - - test('given no auth provider then sign-in is disabled and storage is local', () => { - const { result } = renderHook(() => useAssociationBackend('us')); - - expect(result.current.mode).toBe('localStorage'); - expect(result.current.userId).toBe(MOCK_USER_ID); - expect(result.current.ready).toBe(true); - }); -}); diff --git a/app/src/tests/unit/libs/flagship/runReport.test.ts b/app/src/tests/unit/libs/flagship/runReport.test.ts index 96301e551..3e8514b74 100644 --- a/app/src/tests/unit/libs/flagship/runReport.test.ts +++ b/app/src/tests/unit/libs/flagship/runReport.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { FlagshipReportStore } from '@/api/flagshipReportStore'; +import { MOCK_USER_ID } from '@/constants'; import { forgetAccountReportMeta, readReportMeta, @@ -23,7 +24,6 @@ vi.mock('@/api/report', () => ({ createReport: (...args: any[]) => mockCreateReport(...args), })); -const ACCOUNT_USER_ID = '7c1e5a8e-2b1f-4c55-9a3e-1f2d3c4b5a69'; const REFORM_ROW_ID = '55555555-5555-4555-8555-555555555555'; const PROVISION = { @@ -183,7 +183,7 @@ describe('runFlagshipReport', () => { currentLawId: 2, reformId: REFORM_ROW_ID, year: 2027, - account: { reportStore: accountStore, userId: ACCOUNT_USER_ID }, + accountReportStore: accountStore, }); // Then @@ -196,7 +196,7 @@ describe('runFlagshipReport', () => { expect(mockCreateReportAndAssociate).not.toHaveBeenCalled(); expect(accountStore.create).toHaveBeenCalledTimes(1); expect(accountStore.create).toHaveBeenCalledWith({ - userId: ACCOUNT_USER_ID, + userId: MOCK_USER_ID, countryId: 'us', apiReportId: '56', title: 'CTC expansion', @@ -223,7 +223,7 @@ describe('runFlagshipReport', () => { sourceNote: 'Federal · Introduced', provisions: [PROVISION], currentLawId: 2, - account: { reportStore: accountStore, userId: ACCOUNT_USER_ID }, + accountReportStore: accountStore, }); // Then: the next person on this browser cannot find it @@ -247,7 +247,7 @@ describe('runFlagshipReport', () => { sourceNote: '', provisions: [PROVISION], currentLawId: 2, - account: { reportStore: accountStore, userId: ACCOUNT_USER_ID }, + accountReportStore: accountStore, }) ).rejects.toThrow('Saved work is temporarily unavailable.'); }); diff --git a/app/src/tests/unit/pages/Account.page.test.tsx b/app/src/tests/unit/pages/flagship/Account.page.test.tsx similarity index 68% rename from app/src/tests/unit/pages/Account.page.test.tsx rename to app/src/tests/unit/pages/flagship/Account.page.test.tsx index f1bb2cf1c..5a1af515d 100644 --- a/app/src/tests/unit/pages/Account.page.test.tsx +++ b/app/src/tests/unit/pages/flagship/Account.page.test.tsx @@ -2,15 +2,16 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, userEvent, waitFor } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { AuthContextValue } from '@/libs/auth/AuthContext'; -import AccountPage from '@/pages/Account.page'; +import AccountPage from '@/pages/flagship/Account.page'; import { + errorResponse, + itemResponse, mockMeItem, noContentResponse, okResponse, TEST_ACCESS_TOKEN, } from '@/tests/fixtures/api/v2UserMocks'; import { - accountErrorAuthValue, AuthTestProvider, makeAuthValue, signedInAuthValue, @@ -19,8 +20,9 @@ import { const fetchMock = vi.fn(); function renderPage(auth: AuthContextValue, countryId = 'us') { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( - + @@ -29,6 +31,13 @@ function renderPage(auth: AuthContextValue, countryId = 'us') { ); } +/** Answers GET /v2/me with the user, and everything else with `other`. */ +function answerMe(other: () => Response = () => noContentResponse()) { + fetchMock.mockImplementation(async (url: string, init: RequestInit) => + new URL(url).pathname === '/v2/me' && init.method === 'GET' ? itemResponse(mockMeItem) : other() + ); +} + beforeEach(() => { localStorage.clear(); fetchMock.mockReset(); @@ -45,7 +54,7 @@ describe('AccountPage', () => { test('given a signed-out visitor then the page offers sign-in', async () => { // Given const user = userEvent.setup(); - const auth = makeAuthValue({ status: 'anonymous' }); + const auth = makeAuthValue(); renderPage(auth); // When @@ -53,14 +62,39 @@ describe('AccountPage', () => { // Then expect(auth.signIn).toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); }); - test('given a signed-in user then their account details are shown', () => { + test('given a signed-in user then the page loads their PolicyEngine user from /v2/me', async () => { + // Given + answerMe(); + + // When renderPage(signedInAuthValue()); + // Then + expect(await screen.findByText(mockMeItem.id)).toBeInTheDocument(); expect(screen.getByText('Ada Lovelace')).toBeInTheDocument(); - expect(screen.getByText(mockMeItem.email!)).toBeInTheDocument(); - expect(screen.getByText(mockMeItem.id)).toBeInTheDocument(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.policyengine.org/v2/me?country_id=us'); + expect(init.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + }); + + test('given /v2/me fails then the error shows with Try again, and nothing falls back', async () => { + // Given + fetchMock.mockResolvedValueOnce(errorResponse(503, 'Saved work is temporarily unavailable.')); + const user = userEvent.setup(); + renderPage(signedInAuthValue()); + expect(await screen.findByRole('alert')).toHaveTextContent( + /could not load your PolicyEngine account: Saved work is temporarily unavailable/ + ); + + // When + answerMe(); + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(await screen.findByText(mockMeItem.id)).toBeInTheDocument(); }); test('given "Download my data" then the export is fetched with the session token', async () => { @@ -74,17 +108,17 @@ describe('AccountPage', () => { writable: true, value: revokeObjectURL, }); - fetchMock.mockResolvedValue( + answerMe(() => okResponse({ user: mockMeItem, policies: [], households: [], simulations: [], reports: [] }) ); renderPage(signedInAuthValue()); // When - await user.click(screen.getByRole('button', { name: /download my data/i })); + await user.click(await screen.findByRole('button', { name: /download my data/i })); // Then await waitFor(() => expect(createObjectURL).toHaveBeenCalled()); - const [url, init] = fetchMock.mock.calls[0]; + const [url, init] = fetchMock.mock.calls[1]; expect(url).toBe('https://api.policyengine.org/v2/me/export?country_id=us'); expect(init.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); expect(revokeObjectURL).toHaveBeenCalledWith('blob:export'); @@ -94,11 +128,11 @@ describe('AccountPage', () => { // Given const user = userEvent.setup(); const auth = signedInAuthValue(); - fetchMock.mockResolvedValue(noContentResponse()); + answerMe(); renderPage(auth); // When - await user.click(screen.getByRole('button', { name: /delete my account/i })); + await user.click(await screen.findByRole('button', { name: /delete my account/i })); const confirm = await screen.findByRole('dialog'); await user.click( Array.from(confirm.querySelectorAll('button')).find( @@ -108,7 +142,7 @@ describe('AccountPage', () => { // Then await waitFor(() => expect(auth.signOut).toHaveBeenCalled()); - const [url, init] = fetchMock.mock.calls[0]; + const [url, init] = fetchMock.mock.calls[1]; expect(url).toBe('https://api.policyengine.org/v2/me?country_id=us'); expect(init.method).toBe('DELETE'); }); @@ -120,22 +154,6 @@ describe('AccountPage', () => { screen.getByText(/accounts store work for the us and uk calculators/i) ).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /delete my account/i })).not.toBeInTheDocument(); - }); - - test('given the account could not be loaded then the page offers Try again and Sign out', async () => { - // Given - const user = userEvent.setup(); - const auth = accountErrorAuthValue(); - renderPage(auth); - - // When - await user.click(screen.getByRole('button', { name: 'Try again' })); - - // Then - expect(screen.getByRole('alert')).toHaveTextContent( - /could not load your PolicyEngine account: Saved work is temporarily unavailable/ - ); - expect(auth.retryAccount).toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/calculator-app/src/app/[countryId]/(workspace)/account/page.tsx b/calculator-app/src/app/[countryId]/(workspace)/account/page.tsx new file mode 100644 index 000000000..4f37cea2d --- /dev/null +++ b/calculator-app/src/app/[countryId]/(workspace)/account/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import AccountPage from "@/pages/flagship/Account.page"; +import FlagshipGate from "../FlagshipGate"; + +/** The v3 account page (`/{countryId}/account`), in the workspace shell. */ +export default function AccountRoute() { + return ( + + + + ); +} diff --git a/calculator-app/src/app/[countryId]/(workspace)/callback/page.tsx b/calculator-app/src/app/[countryId]/(workspace)/callback/page.tsx new file mode 100644 index 000000000..8747f43c9 --- /dev/null +++ b/calculator-app/src/app/[countryId]/(workspace)/callback/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import AuthCallbackPage from "@/pages/flagship/AuthCallback.page"; +import FlagshipGate from "../FlagshipGate"; + +/** + * Auth0 redirect target (`/{countryId}/callback`). It sits in the + * workspace group so the workspace's AuthProvider completes the sign-in + * and stays mounted on the way back to where it started. + */ +export default function AuthCallbackRoute() { + return ( + + + + ); +} diff --git a/calculator-app/src/app/[countryId]/(workspace)/layout.tsx b/calculator-app/src/app/[countryId]/(workspace)/layout.tsx index 4f8e3dc61..739bcd85e 100644 --- a/calculator-app/src/app/[countryId]/(workspace)/layout.tsx +++ b/calculator-app/src/app/[countryId]/(workspace)/layout.tsx @@ -1,10 +1,15 @@ "use client"; +import { AuthProvider } from "@/libs/auth/AuthProvider"; import WorkspaceNavigation from "./WorkspaceNavigation"; /** * Flagship workspace shell. Kept in its own route group so the local * screen switching never wraps the legacy calculator routes. + * + * Sign-in belongs to the workspace alone: the legacy calculator routes + * have no AuthProvider and keep saved work in the browser. The provider + * is inert unless NEXT_PUBLIC_AUTH_ENABLED=true and Auth0 is configured. */ export default function WorkspaceLayout({ children, @@ -12,9 +17,9 @@ export default function WorkspaceLayout({ children: React.ReactNode; }) { return ( - <> + {children}
- + ); } diff --git a/calculator-app/src/app/[countryId]/callback/page.tsx b/calculator-app/src/app/[countryId]/callback/page.tsx deleted file mode 100644 index 1c308ab2f..000000000 --- a/calculator-app/src/app/[countryId]/callback/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; - -import AuthCallbackPage from "@/pages/AuthCallback.page"; - -/** - * Auth0 redirect target (`/{countryId}/callback`). The AuthProvider in - * providers.tsx completes the sign-in and returns to where it started. - */ -export default function AuthCallbackRoute() { - return ; -} From d72964935f656d4af9375db82eb42f602f9fb5cd Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:57:41 +0400 Subject: [PATCH 26/48] Keep v3 off legacy storage and open only account IDs when signed in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A signed-out v3 run creates the v1 report and records it only in pe-flagship-reports and pe-flagship-report-meta. It no longer calls createReportAndAssociateWithUser, so it stops writing a sur- entry in the legacy report key (and stops appearing on the legacy Reports page). - v3 no longer resolves old sur- links through useUserReportById: report URLs are v1 report IDs, and anything else shows "Report not found", with how to get a working link. - Signed in, the v3 account stores accept account IDs only: no browser-ID rule, no local fallback, no promote. A browser ID (rf-…, lfr-…) answers BrowserOnlyIdError, "This was saved on this browser while signed out. Sign out to open it.", which the draft's save shows. A run linked to a browser-only reform is saved without the link. - ApiStoreContext no longer carries a user ID. Co-Authored-By: Claude Opus 5.5 --- app/src/api/flagshipReportStore.ts | 37 ++--- app/src/api/reformStore.ts | 38 +---- app/src/api/v2Associations.ts | 152 +++++------------- .../components/flagship/ReportComputing.tsx | 7 +- .../flagship/reform/DraftHeader.tsx | 6 +- app/src/hooks/useFlagshipReport.ts | 23 +-- app/src/hooks/useFlagshipStores.ts | 2 +- app/src/libs/flagship/runReport.ts | 82 ++++------ app/src/pages/flagship/Report.page.tsx | 2 +- app/src/tests/fixtures/api/v2UserMocks.ts | 10 +- .../unit/api/flagshipReportStore.test.ts | 53 ++---- app/src/tests/unit/api/reformStore.test.ts | 104 +++--------- .../flagship/ReportComputing.test.tsx | 7 +- .../flagship/reform/DraftHeader.test.tsx | 39 ++++- .../unit/hooks/useFlagshipStores.test.tsx | 61 ++++++- .../unit/libs/flagship/runReport.test.ts | 55 ++++--- .../unit/libs/flagship/v3CleanSlate.test.tsx | 107 ++++++++++++ 17 files changed, 377 insertions(+), 408 deletions(-) create mode 100644 app/src/tests/unit/libs/flagship/v3CleanSlate.test.tsx diff --git a/app/src/api/flagshipReportStore.ts b/app/src/api/flagshipReportStore.ts index eb4806e7f..4c4bba907 100644 --- a/app/src/api/flagshipReportStore.ts +++ b/app/src/api/flagshipReportStore.ts @@ -1,5 +1,9 @@ -import { AccountAssociationRoutes, isLegacyId, type ApiStoreContext } from '@/api/v2Associations'; -import { resolveImportedId } from '@/libs/auth/importedIds'; +import { + AccountAssociationRoutes, + isAccountId, + isLegacyId, + type ApiStoreContext, +} from '@/api/v2Associations'; import { CountryId } from '@/libs/countries'; import { ReportValidationSnapshot, @@ -102,25 +106,21 @@ export function recordFromItem(item: UserReportItem): FlagshipReportRecord { } /** - * Flagship report records as the account's `/v2/user-reports` rows. Local - * `lfr-` IDs follow the shared policy in v2Associations. + * Flagship report records as the account's `/v2/user-reports` rows. Only + * account IDs open: a record saved in this browser while signed out + * (`lfr-…`) answers BrowserOnlyIdError. */ export class ApiFlagshipReportStore implements FlagshipReportStore { private readonly routes: AccountAssociationRoutes; - constructor( - private readonly context: ApiStoreContext, - private readonly local: FlagshipReportStore = new LocalStorageFlagshipReportStore() - ) { + constructor(private readonly context: ApiStoreContext) { this.routes = new AccountAssociationRoutes(context, 'user-reports', 'Report'); } private creationBody(report: NewFlagshipReport): UserReportCreateBody { - // The reform link must be one of this account's rows: a UUID, or a - // local reform ID this browser imported. - const userPolicyId = report.reformId - ? resolveImportedId(this.context.userId, report.reformId) - : null; + // The reform link must be one of this account's rows; a reform saved + // in this browser while signed out is not linked. + const userPolicyId = isAccountId(report.reformId) ? report.reformId : null; return { country_id: this.routes.writeCountry(report.countryId), legacy_report_id: Number(report.apiReportId), @@ -145,9 +145,6 @@ export class ApiFlagshipReportStore implements FlagshipReportStore { } async findById(id: string): Promise { - if (this.routes.isLocalOnly(id)) { - return this.local.findById(id); - } const item = await this.routes.get(id); return item && isLegacyId(item.legacy_report_id) ? recordFromItem(item) : null; } @@ -167,13 +164,7 @@ export class ApiFlagshipReportStore implements FlagshipReportStore { id: string, snapshot: ReportValidationSnapshot ): Promise { - const changes = { validation: validationToWire(snapshot) }; - if (this.routes.isLocalOnly(id)) { - const record = await this.routes.requireLocal(id, this.local.findById(id)); - const body = { ...this.creationBody(record), ...changes }; - return recordFromItem(await this.routes.promote(id, body, changes)); - } - return recordFromItem(await this.routes.patch(id, changes)); + return recordFromItem(await this.routes.patch(id, { validation: validationToWire(snapshot) })); } } diff --git a/app/src/api/reformStore.ts b/app/src/api/reformStore.ts index 2d8512c8f..1af211257 100644 --- a/app/src/api/reformStore.ts +++ b/app/src/api/reformStore.ts @@ -209,9 +209,8 @@ export async function fetchV1PolicyJson( /** * Reforms in the account (`/v2/user-policies`). Parameter edits create a * new v1 policy and repoint the row (`legacy_policy_id`); a rename only - * patches the name. Local `rf-` IDs follow the shared policy in - * v2Associations: a reform saved while signed out is read from this - * browser until it is changed, which saves it to the account. + * patches the name. Only account IDs open: a reform saved in this browser + * while signed out (`rf-…`) answers BrowserOnlyIdError. */ export class ApiReformStore implements ReformStore { private readonly routes: AccountAssociationRoutes; @@ -221,8 +220,7 @@ export class ApiReformStore implements ReformStore { constructor( private readonly context: ApiStoreContext, - options: ApiReformStoreOptions = {}, - private readonly local: ReformStore = new LocalStorageReformStore() + options: ApiReformStoreOptions = {} ) { this.routes = new AccountAssociationRoutes(context, 'user-policies', 'Reform'); this.getParameterMetadata = options.getParameterMetadata ?? (() => null); @@ -268,17 +266,11 @@ export class ApiReformStore implements ReformStore { } async findById(reformId: string): Promise { - if (this.routes.isLocalOnly(reformId)) { - return this.local.findById(reformId); - } const item = await this.routes.get(reformId); return item && isAccountReform(item) ? this.toReform(item) : null; } async update(reformId: string, updates: ReformUpdates): Promise { - if (this.routes.isLocalOnly(reformId)) { - return this.saveLocalReform(reformId, updates); - } const body: UserPolicyPatchBody = {}; if (updates.label !== undefined) { body.name = updates.label || null; @@ -303,29 +295,7 @@ export class ApiReformStore implements ReformStore { } delete(reformId: string): Promise { - return this.routes.isLocalOnly(reformId) - ? this.local.delete(reformId) - : this.routes.delete(reformId); - } - - /** - * A reform that exists only in this browser (saved while signed out, - * never imported): changing it while signed in saves it, changed, to - * the account under its `rf-` ID, so a later import finds the row. - */ - private async saveLocalReform(reformId: string, updates: ReformUpdates): Promise { - const local = await this.routes.requireLocal(reformId, this.local.findById(reformId)); - const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...fields } = local; - const merged = { - ...fields, - ...updates, - userId: this.context.userId, - policyId: updates.parameters ? null : fields.policyId, - }; - const body = await this.creationBody(merged); - const { country_id: _country, ...changes } = body; - const item = await this.routes.promote(reformId, body, changes); - return this.toReform(item, merged.parameters); + return this.routes.delete(reformId); } private async toReform(item: UserPolicyItem, knownParameters?: Parameter[]): Promise { diff --git a/app/src/api/v2Associations.ts b/app/src/api/v2Associations.ts index aadbd3d53..5b2ff478c 100644 --- a/app/src/api/v2Associations.ts +++ b/app/src/api/v2Associations.ts @@ -1,5 +1,4 @@ import { isV2ApiError, V2ApiError, type V2Query, type V2UserClient } from '@/api/v2UserClient'; -import { recordImportedIds, resolveImportedId } from '@/libs/auth/importedIds'; import { isAssociationCountry, type AssociationCountryId, @@ -8,34 +7,31 @@ import { } from '@/types/v2User'; /** - * Local IDs: how every account store treats the IDs it is handed. - * - * - A UUID, or a local ID this browser imported, names an account row. - * - Any other local ID (`sup-`, `suh-`, `sus-`, `sur-`, `lfr-`, `rf-`) - * names an item only this browser has: saved while signed out and never - * imported. Reading it reads this browser's copy. Changing it first - * copies it into the account under its local ID as `client_ref` and - * records the mapping, so links keep resolving and a later import finds - * that row instead of adding a second one. Deleting it deletes this - * browser's copy. + * What every v3 account store needs: an authenticated client and the + * country its routes are called with (`country_id`). The server derives + * the owner from the token. */ +export interface ApiStoreContext { + client: V2UserClient; + countryId: AssociationCountryId; +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -/** What every account row carries. */ -export interface AccountRow { - id: string; - client_ref: string | null; +/** Account rows have UUIDs; items saved in this browser (`rf-…`, `lfr-…`) never do. */ +export function isAccountId(id: unknown): id is string { + return typeof id === 'string' && UUID_PATTERN.test(id); } /** - * What every account-backed store needs: an authenticated client, the - * country its routes are called with (`country_id`), and the signed-in - * PolicyEngine user (only used to resolve this browser's imported local - * IDs — the server derives the owner from the token). + * What an account store answers when handed the ID of something saved in + * this browser while signed out: signed in, only account items open. */ -export interface ApiStoreContext { - client: V2UserClient; - countryId: AssociationCountryId; - userId: string; +export class BrowserOnlyIdError extends Error { + constructor() { + super('This was saved on this browser while signed out. Sign out to open it.'); + this.name = 'BrowserOnlyIdError'; + } } /** @@ -50,31 +46,12 @@ export function isLegacyId(value: unknown): value is string | number { return /^\d+$/.test(text) && Number.isSafeInteger(Number(text)) && Number(text) >= 1; } -export function toLegacyId(value: string | number | null | undefined, what: string): number { - if (!isLegacyId(value)) { - throw new Error(`Cannot save ${what} ${String(value)}: it has no PolicyEngine API ID`); - } - return Number(String(value).trim()); -} - -/** The account association ID for an ID the UI holds (a UUID or an imported local ID). */ -export function accountAssociationId(context: ApiStoreContext, id: string): string | null { - return resolveImportedId(context.userId, id); -} - -/** Drops undefined fields so a patch carries only what the caller set. */ -export function definedFields(body: T): Partial { - return Object.fromEntries( - Object.entries(body).filter(([, value]) => value !== undefined) - ) as Partial; -} - /** - * The five operations every `/v2/user-*` resource supports, scoped to the + * The operations every `/v2/user-*` resource supports, scoped to the * store's country. Stores map items to app types; this class owns the - * wire calls and the local-ID → UUID resolution. + * wire calls. It accepts account IDs only. */ -export class AccountAssociationRoutes { +export class AccountAssociationRoutes { constructor( private readonly context: ApiStoreContext, private readonly path: string, @@ -101,12 +78,11 @@ export class AccountAssociationRoutes { return result.item; } - /** Rows in `countryId` (default: the store's country). */ - list( - filters: V2Query = {}, - countryId: AssociationCountryId = this.context.countryId - ): Promise { - return this.context.client.listAll(this.path, { ...filters, country_id: countryId }); + list(filters: V2Query = {}): Promise { + return this.context.client.listAll(this.path, { + ...filters, + country_id: this.context.countryId, + }); } /** The first (oldest) row matching a list filter, or null. */ @@ -117,59 +93,9 @@ export class AccountAssociationRoutes { return page.items[0] ?? null; } - /** Whether the ID names an item only this browser has (see "Local IDs" above). */ - isLocalOnly(id: string): boolean { - return accountAssociationId(this.context, id) === null; - } - - /** This browser's copy of a local-only item; not found (404) when it has none. */ - async requireLocal(localId: string, lookup: Promise): Promise { - const copy = await lookup; - if (!copy) { - throw new V2ApiError(404, `${this.what} with id ${localId} not found`); - } - return copy; - } - - /** - * Copies a local-only item into the account under its local ID and - * records the mapping. If the account already has that `client_ref` (an - * import or another tab got there first), `changes` go to that row. - */ - async promote( - localId: string, - body: TBody, - changes: object - ): Promise { - let item: TItem; - try { - item = await this.create({ ...body, client_ref: localId }); - } catch (error) { - if (!isV2ApiError(error, 409)) { - throw error; - } - // Look for it in the country it was created in. - const existing = (await this.list({}, body.country_id)).find( - (row) => row.client_ref === localId - ); - if (!existing) { - throw error; - } - item = - Object.keys(changes).length > 0 - ? await this.patch(existing.id, changes, body.country_id) - : existing; - } - recordImportedIds(this.context.userId, { [localId]: item.id }); - return item; - } - /** The row with this ID; null when it does not exist or belongs to someone else. */ async get(id: string): Promise { - const accountId = accountAssociationId(this.context, id); - if (!accountId) { - return null; - } + const accountId = this.requireAccountId(id); try { const result = await this.context.client.request>( 'GET', @@ -186,17 +112,13 @@ export class AccountAssociationRoutes { } /** Applies only the supplied fields; an empty patch just reads the row. */ - async patch( - id: string, - body: object, - countryId: AssociationCountryId = this.context.countryId - ): Promise { - const accountId = this.requireId(id); + async patch(id: string, body: object): Promise { + const accountId = this.requireAccountId(id); const result = await this.context.client.request>( Object.keys(body).length === 0 ? 'GET' : 'PATCH', `${this.path}/${encodeURIComponent(accountId)}`, { - query: { country_id: countryId }, + query: { country_id: this.context.countryId }, ...(Object.keys(body).length === 0 ? {} : { body }), } ); @@ -204,7 +126,7 @@ export class AccountAssociationRoutes { } async delete(id: string): Promise { - const accountId = this.requireId(id); + const accountId = this.requireAccountId(id); await this.context.client.request( 'DELETE', `${this.path}/${encodeURIComponent(accountId)}`, @@ -212,11 +134,13 @@ export class AccountAssociationRoutes { ); } - private requireId(id: string): string { - const accountId = accountAssociationId(this.context, id); - if (!accountId) { + private requireAccountId(id: string): string { + if (!id.trim()) { throw new V2ApiError(404, `${this.what} with id ${id} not found`); } - return accountId; + if (!isAccountId(id)) { + throw new BrowserOnlyIdError(); + } + return id; } } diff --git a/app/src/components/flagship/ReportComputing.tsx b/app/src/components/flagship/ReportComputing.tsx index d3b52f4de..e0bd11056 100644 --- a/app/src/components/flagship/ReportComputing.tsx +++ b/app/src/components/flagship/ReportComputing.tsx @@ -245,12 +245,11 @@ export function ReportUnresolvable() { color: colors.text.primary, }} > - This link only opens in the browser that ran the report. + Report not found. - It carries a local report id. Reports run from now on carry a shareable id in their link; - ask for the link again from the browser that created this one, or run the reform afresh from - Build. + This is an old link that only worked in the browser that created it. Report links now carry + the report's number and open anywhere; run the reform again from Build to get one. ); diff --git a/app/src/components/flagship/reform/DraftHeader.tsx b/app/src/components/flagship/reform/DraftHeader.tsx index 81b916062..9ec5a7174 100644 --- a/app/src/components/flagship/reform/DraftHeader.tsx +++ b/app/src/components/flagship/reform/DraftHeader.tsx @@ -1,6 +1,7 @@ import { IconCalendarEvent, IconChartBar, IconUsers } from '@tabler/icons-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; +import { BrowserOnlyIdError } from '@/api/v2Associations'; import { Badge, Button, @@ -252,7 +253,10 @@ export default function DraftHeader({ draft }: { draft: DraftReform }) { role="alert" style={{ margin: 0, fontSize: typography.fontSize.sm, color: colors.error }} > - {runReport.error ?? 'Could not save the reform. Try again.'} + {runReport.error ?? + (saveMutation.error instanceof BrowserOnlyIdError + ? saveMutation.error.message + : 'Could not save the reform. Try again.')}

)} diff --git a/app/src/hooks/useFlagshipReport.ts b/app/src/hooks/useFlagshipReport.ts index ffb5dedfd..c6ad6c3a0 100644 --- a/app/src/hooks/useFlagshipReport.ts +++ b/app/src/hooks/useFlagshipReport.ts @@ -5,7 +5,6 @@ import { fetchReportById } from '@/api/report'; import { fetchSimulationById } from '@/api/simulation'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useParameterMetadata } from '@/hooks/useParameterMetadata'; -import { useUserReportById } from '@/hooks/useUserReports'; import { GC_TIME_5_MIN } from '@/libs/queryConfig'; import { Policy } from '@/types/ingredients/Policy'; import { Report } from '@/types/ingredients/Report'; @@ -15,8 +14,9 @@ import { extractUniqueIds, useParallelQueries } from './utils/queryUtils'; /** * A flagship report URL carries the API report id, which anyone can - * resolve. Links minted before that carry the local association id - * ("sur-…"), which only the browser that ran the report can resolve. + * resolve. Links minted before that carried a legacy browser association + * id ("sur-…"); v3 does not read the legacy calculator's storage, so they + * no longer open. */ export function isApiReportId(id: string): boolean { return /^\d+$/.test(id); @@ -30,10 +30,10 @@ export interface FlagshipReportData { } /** - * Resolves a flagship report by either id form. The API form fetches the - * report, its simulations, and their policies straight from the - * PolicyEngine API, so a shared link opens with full provenance on any - * device; the association form keeps old links working. + * Resolves a flagship report by its API report id: the report, its + * simulations, and their policies come straight from the PolicyEngine + * API, so a shared link opens with full provenance on any device. Any + * other id resolves to nothing. */ export function useFlagshipReport(id: string): FlagshipReportData { const country = useCurrentCountry(); @@ -41,8 +41,6 @@ export function useFlagshipReport(id: string): FlagshipReportData { useParameterMetadata(country); const direct = isApiReportId(id); - const legacy = useUserReportById(id, { enabled: !direct && !!id }); - const { data: report, isLoading: reportLoading } = useQuery({ queryKey: reportKeys.byId(id), queryFn: async () => ReportAdapter.fromMetadata(await fetchReportById(country, id)), @@ -75,12 +73,7 @@ export function useFlagshipReport(id: string): FlagshipReportData { const policies = policyResults.queries.map((q) => q.data).filter((p): p is Policy => !!p); if (!direct) { - return { - report: legacy.report, - simulations: legacy.simulations, - policies: legacy.policies ?? [], - isLoading: legacy.isLoading, - }; + return { report: undefined, simulations: [], policies: [], isLoading: false }; } return { report, diff --git a/app/src/hooks/useFlagshipStores.ts b/app/src/hooks/useFlagshipStores.ts index eae964d3a..f6cec1593 100644 --- a/app/src/hooks/useFlagshipStores.ts +++ b/app/src/hooks/useFlagshipStores.ts @@ -35,7 +35,7 @@ export function useV3SavedWork(): V3SavedWork { () => status === 'signed_in' && subject && isAssociationCountry(countryId) ? { - account: { client, countryId, userId: subject }, + account: { client, countryId }, scope: `account:${subject}`, } : { account: null, scope: 'browser' }, diff --git a/app/src/libs/flagship/runReport.ts b/app/src/libs/flagship/runReport.ts index 7e504ca78..cc3eeca3e 100644 --- a/app/src/libs/flagship/runReport.ts +++ b/app/src/libs/flagship/runReport.ts @@ -1,6 +1,9 @@ -import type { FlagshipReportStore } from '@/api/flagshipReportStore'; +import { + LocalStorageFlagshipReportStore, + type FlagshipReportStore, +} from '@/api/flagshipReportStore'; import { createPolicy } from '@/api/policy'; -import { createReport, createReportAndAssociateWithUser } from '@/api/report'; +import { createReport } from '@/api/report'; import { createSimulation } from '@/api/simulation'; import { CURRENT_YEAR, FOREVER, MOCK_USER_ID } from '@/constants'; import { CountryId } from '@/libs/countries'; @@ -162,63 +165,36 @@ export async function runFlagshipReport({ year: String(year), }; + // The v1 report comes first; its ID is the report's link. Then exactly + // one record of it: the account's row when signed in, otherwise this + // browser's flagship record (v3 never writes the legacy report keys). + const reportMetadata = await createReport(countryId, payload); + const reportId = String(reportMetadata.id); + const meta = { title, sourceNote, provisions, createdAt: new Date().toISOString() }; + const record = { + userId: MOCK_USER_ID, + countryId, + apiReportId: reportId, + title: title || null, + sourceNote: sourceNote || null, + provisions, + year: String(year), + reformId: reformId ?? null, + }; + if (accountReportStore) { - // Signed in: the account's row is the report's only association. It is - // written right after the v1 report exists, and a failure fails the - // run, so a report that opens is a report that was saved. - const reportMetadata = await createReport(countryId, payload); - const reportId = String(reportMetadata.id); - rememberAccountReportMeta(reportId, { - title, - sourceNote, - provisions, - createdAt: new Date().toISOString(), - }); - await accountReportStore.create({ - userId: MOCK_USER_ID, - countryId, - apiReportId: reportId, - title: title || null, - sourceNote: sourceNote || null, - provisions, - year: String(year), - reformId: reformId ?? null, - }); + // A failure fails the run, so a report that opens is one that was saved. + rememberAccountReportMeta(reportId, meta); + await accountReportStore.create(record); return reportId; } - const { metadata } = await createReportAndAssociateWithUser({ - countryId, - userId: MOCK_USER_ID, - label: title || undefined, - payload, - }); - - const reportId = String(metadata.baseReportId); - saveReportMeta(reportId, { - title, - sourceNote, - provisions, - createdAt: new Date().toISOString(), - }); - - // Record the report locally (pointer + provenance; never outputs). - // Best-effort: the run already succeeded. + saveReportMeta(reportId, meta); + // Best-effort: the run already succeeded, and the link opens without it. try { - const { LocalStorageFlagshipReportStore } = await import('@/api/flagshipReportStore'); - await new LocalStorageFlagshipReportStore().create({ - userId: MOCK_USER_ID, - countryId, - apiReportId: String(metadata.baseReportId), - title: title || null, - sourceNote: sourceNote || null, - provisions, - year: String(year), - reformId: reformId ?? null, - }); + await new LocalStorageFlagshipReportStore().create(record); } catch { - // The report still opens via the local association. + // The report still opens by its v1 report ID. } - return reportId; } diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 83a206788..369dd8773 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -39,7 +39,7 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const params = useParams<{ userReportId: string }>(); const userReportId = propId ?? params.userReportId ?? ''; const { report, simulations, policies, isLoading } = useFlagshipReport(userReportId); - // A local association id from another browser resolves to nothing. + // Only API report ids open; an old browser-only "sur-" link is not found. const unresolvable = !isLoading && !report && !!userReportId && !isApiReportId(userReportId); const parameters = useSelector((state: RootState) => state.metadata.parameters); const apiReportId = diff --git a/app/src/tests/fixtures/api/v2UserMocks.ts b/app/src/tests/fixtures/api/v2UserMocks.ts index b0bdddfd3..0aea2de83 100644 --- a/app/src/tests/fixtures/api/v2UserMocks.ts +++ b/app/src/tests/fixtures/api/v2UserMocks.ts @@ -128,17 +128,17 @@ export function noContentResponse(): Response { } /** A fetch mock plus an API store context that uses it. */ -export function createTestApiContext( - countryId: 'us' | 'uk' = 'us', - userId: string = PE_USER_ID -): { fetchMock: ReturnType; context: ApiStoreContext } { +export function createTestApiContext(countryId: 'us' | 'uk' = 'us'): { + fetchMock: ReturnType; + context: ApiStoreContext; +} { const fetchMock = vi.fn(); const client = createV2UserClient({ getAccessToken: async () => TEST_ACCESS_TOKEN, baseUrl: TEST_API_BASE, fetchImpl: fetchMock as unknown as typeof fetch, }); - return { fetchMock, context: { client, countryId, userId } }; + return { fetchMock, context: { client, countryId } }; } /** The (url, init) of the nth fetch call, with the parsed URL and JSON body. */ diff --git a/app/src/tests/unit/api/flagshipReportStore.test.ts b/app/src/tests/unit/api/flagshipReportStore.test.ts index 96b2197b5..95b86540f 100644 --- a/app/src/tests/unit/api/flagshipReportStore.test.ts +++ b/app/src/tests/unit/api/flagshipReportStore.test.ts @@ -7,7 +7,7 @@ import { provisionsFromWire, provisionsToWire, } from '@/api/flagshipReportStore'; -import { recordImportedIds, resolveImportedId } from '@/libs/auth/importedIds'; +import { BrowserOnlyIdError } from '@/api/v2Associations'; import { validationToWire } from '@/libs/flagship/reportValidation'; import { ASSOCIATION_IDS, @@ -166,16 +166,15 @@ describe('ApiFlagshipReportStore', () => { }); }); - test('given a local reform id then the link uses its imported account id, or is left out', async () => { + test('given a reform saved in this browser then the account row is not linked to it', async () => { // Given const { fetchMock, context } = createTestApiContext(); fetchMock.mockImplementation(async () => itemResponse(reportItem(), 201)); - recordImportedIds(PE_USER_ID, { 'rf-imported': ASSOCIATION_IDS.REFORM }); const store = new ApiFlagshipReportStore(context); // When - await store.create({ ...newReport, reformId: 'rf-imported' }); - await store.create({ ...newReport, reformId: 'rf-never-imported' }); + await store.create({ ...newReport, reformId: ASSOCIATION_IDS.REFORM }); + await store.create({ ...newReport, reformId: 'rf-browser1' }); // Then expect(fetchCall(fetchMock, 0).body.user_policy_id).toBe(ASSOCIATION_IDS.REFORM); @@ -236,50 +235,24 @@ describe('ApiFlagshipReportStore', () => { }); }); -describe('ApiFlagshipReportStore with records kept only in this browser', () => { +describe('ApiFlagshipReportStore with records saved in this browser', () => { beforeEach(() => { localStorage.clear(); }); - test('given an lfr- record never imported then it is read from this browser', async () => { + test('given an lfr- record saved while signed out then signed in it is refused, not read locally', async () => { // Given const { fetchMock, context } = createTestApiContext(); - const local = new LocalStorageFlagshipReportStore(); - const localRecord = await local.create(newReport); - const store = new ApiFlagshipReportStore(context, local); - - // When - const found = await store.findById(localRecord.id); + const localRecord = await new LocalStorageFlagshipReportStore().create(newReport); + const store = new ApiFlagshipReportStore(context); - // Then - expect(found?.title).toBe(newReport.title); + // When / Then + await expect(store.findById(localRecord.id)).rejects.toBeInstanceOf(BrowserOnlyIdError); + await expect( + store.saveValidation(localRecord.id, mockReportValidationSnapshot) + ).rejects.toBeInstanceOf(BrowserOnlyIdError); expect(fetchMock).not.toHaveBeenCalled(); }); - - test('given an lfr- record never imported then pinning validation saves it to the account', async () => { - // Given - const { fetchMock, context } = createTestApiContext(); - const local = new LocalStorageFlagshipReportStore(); - const localRecord = await local.create(newReport); - const wire = validationToWire(mockReportValidationSnapshot); - fetchMock.mockResolvedValue( - itemResponse(reportItem({ legacy_report_id: Number(API_REPORT_ID), validation: wire }), 201) - ); - const store = new ApiFlagshipReportStore(context, local); - - // When - await store.saveValidation(localRecord.id, mockReportValidationSnapshot); - - // Then - const call = fetchCall(fetchMock); - expect(call.method).toBe('POST'); - expect(call.body).toMatchObject({ - legacy_report_id: Number(API_REPORT_ID), - validation: wire, - client_ref: localRecord.id, - }); - expect(resolveImportedId(PE_USER_ID, localRecord.id)).toBe(ASSOCIATION_IDS.REPORT); - }); }); describe('getFlagshipReportStore', () => { diff --git a/app/src/tests/unit/api/reformStore.test.ts b/app/src/tests/unit/api/reformStore.test.ts index 8742aaf43..718fd3320 100644 --- a/app/src/tests/unit/api/reformStore.test.ts +++ b/app/src/tests/unit/api/reformStore.test.ts @@ -5,12 +5,9 @@ import { LocalStorageReformStore, parametersFromPolicyJson, } from '@/api/reformStore'; -import { resolveImportedId } from '@/libs/auth/importedIds'; -import { planLocalImport } from '@/libs/auth/localImport'; import { ASSOCIATION_IDS, createTestApiContext, - errorResponse, fetchCall, itemResponse, noContentResponse, @@ -205,88 +202,25 @@ describe('ApiReformStore', () => { expect(fetchCall(fetchMock).body).toEqual({ name: 'Renamed' }); }); - it('given a reform kept only in this browser, when saved signed in, then it is added to the account', async () => { - // Given - const local = await new LocalStorageReformStore().create(mockReformInput); - const { store, fetchMock, materializePolicy } = apiReformStore(); - fetchMock.mockResolvedValue(itemResponse({ ...reformRow, name: 'Renamed' }, 201)); - - // When - const saved = await store.update(local.id!, { label: 'Renamed' }); - - // Then - expect(materializePolicy).toHaveBeenCalledWith('us', 'Renamed', mockReformInput.parameters); - const call = fetchCall(fetchMock); - expect(call.method).toBe('POST'); - expect(call.body).toMatchObject({ - name: 'Renamed', - legacy_policy_id: MATERIALIZED_POLICY_ID, - client_ref: local.id, - }); - expect(saved.id).toBe(ASSOCIATION_IDS.REFORM); - }); - - it('given a local reform saved signed in, when the browser is imported later, then it is not added twice', async () => { - // Given - const local = await new LocalStorageReformStore().create(mockReformInput); - const { store, fetchMock } = apiReformStore(); - fetchMock.mockResolvedValue(itemResponse({ ...reformRow, client_ref: local.id! }, 201)); - - // When - await store.update(local.id!, { label: 'Renamed' }); - - // Then: the rf- link resolves to the row, and the import no longer plans it - expect(resolveImportedId(PE_USER_ID, local.id!)).toBe(ASSOCIATION_IDS.REFORM); - expect(planLocalImport(PE_USER_ID).items.map((item) => item.clientRef)).not.toContain(local.id); - }); - - it('given the account already has the local reform, when saved, then that row is updated instead', async () => { - // Given: an import (or another tab) already added it under its rf- client_ref - const local = await new LocalStorageReformStore().create(mockReformInput); - const { store, fetchMock } = apiReformStore(); - fetchMock - .mockResolvedValueOnce(errorResponse(409, 'client_ref already exists')) - .mockResolvedValueOnce(pageResponse([{ ...reformRow, client_ref: local.id! }])) - .mockResolvedValueOnce(itemResponse({ ...reformRow, name: 'Renamed' })); - - // When - const saved = await store.update(local.id!, { label: 'Renamed' }); - - // Then - const patch = fetchCall(fetchMock, 2); - expect(patch.method).toBe('PATCH'); - expect(patch.path).toBe(`/v2/user-policies/${ASSOCIATION_IDS.REFORM}`); - expect(patch.body).toMatchObject({ name: 'Renamed' }); - expect(saved.label).toBe('Renamed'); - expect(resolveImportedId(PE_USER_ID, local.id!)).toBe(ASSOCIATION_IDS.REFORM); - }); - - it("given a reform kept only in this browser, when read signed in, then this browser's copy is returned", async () => { - // Given - const local = await new LocalStorageReformStore().create(mockReformInput); - const { store, fetchMock } = apiReformStore(); - - // When - const found = await store.findById(local.id!); - - // Then - expect(found?.label).toBe(mockReformInput.label); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("given a reform kept only in this browser, when deleted signed in, then this browser's copy goes", async () => { - // Given - const localStore = new LocalStorageReformStore(); - const local = await localStore.create(mockReformInput); - const { store, fetchMock } = apiReformStore(); - - // When - await store.delete(local.id!); - - // Then - expect(await localStore.findById(local.id!)).toBeNull(); - expect(fetchMock).not.toHaveBeenCalled(); - }); + it.each([ + ['read', (store: ApiReformStore, id: string) => store.findById(id)], + ['renamed', (store: ApiReformStore, id: string) => store.update(id, { label: 'Renamed' })], + ['deleted', (store: ApiReformStore, id: string) => store.delete(id)], + ])( + 'given a reform saved on this browser while signed out, when %s signed in, then it is refused', + async (_, operate) => { + // Given + const local = await new LocalStorageReformStore().create(mockReformInput); + const { store, fetchMock } = apiReformStore(); + + // When / Then + await expect(operate(store, local.id!)).rejects.toThrow( + 'This was saved on this browser while signed out. Sign out to open it.' + ); + expect(fetchMock).not.toHaveBeenCalled(); + expect(await new LocalStorageReformStore().findById(local.id!)).not.toBeNull(); + } + ); it('given one reform whose v1 policy fails to load, when listed, then the others still list', async () => { // Given diff --git a/app/src/tests/unit/components/flagship/ReportComputing.test.tsx b/app/src/tests/unit/components/flagship/ReportComputing.test.tsx index 8623a5d61..8a43f2f49 100644 --- a/app/src/tests/unit/components/flagship/ReportComputing.test.tsx +++ b/app/src/tests/unit/components/flagship/ReportComputing.test.tsx @@ -76,10 +76,11 @@ describe('ReportComputingScreen', () => { }); describe('ReportUnresolvable', () => { - test('given a local-only link then the page says so and how to get a shareable one', () => { + test('given an old sur- link then the page says the report is not found and how to get a link', () => { render(); - expect(screen.getByText(/only opens in the browser that ran the report/i)).toBeInTheDocument(); - expect(screen.getByText(/shareable id/i)).toBeInTheDocument(); + expect(screen.getByText('Report not found.')).toBeInTheDocument(); + expect(screen.getByText(/old link that only worked in the browser/i)).toBeInTheDocument(); + expect(screen.getByText(/run the reform again from build/i)).toBeInTheDocument(); }); }); diff --git a/app/src/tests/unit/components/flagship/reform/DraftHeader.test.tsx b/app/src/tests/unit/components/flagship/reform/DraftHeader.test.tsx index 4fff07b08..6899724c8 100644 --- a/app/src/tests/unit/components/flagship/reform/DraftHeader.test.tsx +++ b/app/src/tests/unit/components/flagship/reform/DraftHeader.test.tsx @@ -1,11 +1,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, userEvent, waitFor } from '@test-utils'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { BrowserOnlyIdError } from '@/api/v2Associations'; import DraftHeader from '@/components/flagship/reform/DraftHeader'; import { addDraftProvision, clearDraftReform, getDraftReform, + loadReformIntoDraft, startDraftReform, useDraftReform, } from '@/libs/draftReform'; @@ -14,6 +16,7 @@ import { store } from '@/store'; import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; const mockCreate = vi.fn(); +const mockUpdate = vi.fn(); const mockRun = vi.fn(); vi.mock('@/hooks/useRunFlagshipReport', () => ({ @@ -22,7 +25,7 @@ vi.mock('@/hooks/useRunFlagshipReport', () => ({ vi.mock('@/api/reformStore', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, getReformStore: () => ({ create: mockCreate, update: vi.fn() }) }; + return { ...actual, getReformStore: () => ({ create: mockCreate, update: mockUpdate }) }; }); function HeaderHarness() { @@ -149,6 +152,40 @@ describe('DraftHeader', () => { await waitFor(() => expect(getDraftReform()).toBeNull()); }); + test('given a reform saved on this browser is edited while signed in then saving explains why it cannot', async () => { + // Given: the account store refuses the browser ID + const user = userEvent.setup(); + mockUpdate.mockRejectedValue(new BrowserOnlyIdError()); + loadReformIntoDraft( + { + id: 'rf-browser1', + userId: 'anonymous', + countryId: 'us', + label: 'Saved signed out', + parameters: [ + { + name: 'gov.irs.credits.ctc.amount.base', + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 2500 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'manual' }, + }, + () => ({ breadcrumb: 'CTC', unit: 'currency-USD', baselineValue: 2000 }) + ); + renderHeader(); + + // When + await user.click(screen.getByRole('button', { name: 'Save changes' })); + + // Then + expect(await screen.findByRole('alert')).toHaveTextContent( + 'This was saved on this browser while signed out. Sign out to open it.' + ); + expect(mockUpdate).toHaveBeenCalledWith('rf-browser1', expect.anything()); + expect(getDraftReform()?.editingReformId).toBe('rf-browser1'); + }); + test('given discard then the draft clears without saving', async () => { const user = userEvent.setup(); renderHeader(); diff --git a/app/src/tests/unit/hooks/useFlagshipStores.test.tsx b/app/src/tests/unit/hooks/useFlagshipStores.test.tsx index b3edc3c2b..5e4f89707 100644 --- a/app/src/tests/unit/hooks/useFlagshipStores.test.tsx +++ b/app/src/tests/unit/hooks/useFlagshipStores.test.tsx @@ -3,8 +3,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook } from '@testing-library/react'; import { Provider as ReduxProvider } from 'react-redux'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { ApiFlagshipReportStore, LocalStorageFlagshipReportStore } from '@/api/flagshipReportStore'; +import { ApiReformStore, LocalStorageReformStore } from '@/api/reformStore'; import { CountryProvider } from '@/contexts/CountryContext'; -import { useReformStore } from '@/hooks/useFlagshipStores'; +import { useFlagshipReportStore, useReformStore, useV3SavedWork } from '@/hooks/useFlagshipStores'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; import { store } from '@/store'; import { ASSOCIATION_IDS, @@ -13,7 +16,12 @@ import { PE_USER_ID, policyItem, } from '@/tests/fixtures/api/v2UserMocks'; -import { AuthTestProvider, signedInAuthValue } from '@/tests/fixtures/libs/auth/authMocks'; +import { + AuthTestProvider, + makeAuthValue, + signedInAuthValue, + TEST_AUTH0_SUBJECT, +} from '@/tests/fixtures/libs/auth/authMocks'; const REFORM_POLICY_ID = 95762; const REFORM_ROW = policyItem({ @@ -50,6 +58,55 @@ afterEach(() => { vi.unstubAllGlobals(); }); +function wrapperFor(auth: AuthContextValue, countryId: 'us' | 'uk' | 'ca' = 'us') { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + + ); +} + +describe('v3 store choice', () => { + test('given a signed-in session then reforms and reports go to the account, scoped by subject', () => { + // Given + const wrapper = wrapperFor(signedInAuthValue()); + + // When + const reforms = renderHook(() => useReformStore(), { wrapper }).result.current; + const reports = renderHook(() => useFlagshipReportStore(), { wrapper }).result.current; + const savedWork = renderHook(() => useV3SavedWork(), { wrapper }).result.current; + + // Then + expect(reforms).toBeInstanceOf(ApiReformStore); + expect(reports.store).toBeInstanceOf(ApiFlagshipReportStore); + expect(reports.isAccount).toBe(true); + expect(savedWork.scope).toBe(`account:${TEST_AUTH0_SUBJECT}`); + }); + + test.each([ + ['signed out', makeAuthValue(), 'us'], + ['sign-in disabled', makeAuthValue({ status: 'disabled' }), 'uk'], + ['signed in outside the US and UK', signedInAuthValue(), 'ca'], + ] as const)('given %s then reforms and reports stay in this browser', (_, auth, countryId) => { + // Given + const wrapper = wrapperFor(auth, countryId); + + // When + const reforms = renderHook(() => useReformStore(), { wrapper }).result.current; + const reports = renderHook(() => useFlagshipReportStore(), { wrapper }).result.current; + + // Then + expect(reforms).toBeInstanceOf(LocalStorageReformStore); + expect(reports.store).toBeInstanceOf(LocalStorageFlagshipReportStore); + expect(reports.scope).toBe('browser'); + }); +}); + describe('useReformStore', () => { test('given the reform list is loaded again then each v1 policy is fetched only once', async () => { // Given diff --git a/app/src/tests/unit/libs/flagship/runReport.test.ts b/app/src/tests/unit/libs/flagship/runReport.test.ts index 3e8514b74..c54fa7647 100644 --- a/app/src/tests/unit/libs/flagship/runReport.test.ts +++ b/app/src/tests/unit/libs/flagship/runReport.test.ts @@ -10,7 +10,6 @@ import { const mockCreatePolicy = vi.fn(); const mockCreateSimulation = vi.fn(); -const mockCreateReportAndAssociate = vi.fn(); const mockCreateReport = vi.fn(); vi.mock('@/api/policy', () => ({ @@ -20,10 +19,17 @@ vi.mock('@/api/simulation', () => ({ createSimulation: (...args: any[]) => mockCreateSimulation(...args), })); vi.mock('@/api/report', () => ({ - createReportAndAssociateWithUser: (...args: any[]) => mockCreateReportAndAssociate(...args), createReport: (...args: any[]) => mockCreateReport(...args), })); +/** The localStorage keys the legacy calculator keeps its saved work under. */ +const LEGACY_KEYS = [ + 'user-policy-associations', + 'user-population-households', + 'user-simulation-associations', + 'user-report-associations', +]; + const REFORM_ROW_ID = '55555555-5555-4555-8555-555555555555'; const PROVISION = { @@ -42,10 +48,7 @@ describe('runFlagshipReport', () => { mockCreateSimulation .mockResolvedValueOnce({ result: { simulation_id: '101' } }) .mockResolvedValueOnce({ result: { simulation_id: '102' } }); - mockCreateReportAndAssociate.mockResolvedValue({ - metadata: { baseReportId: '55', userReportId: 'sur-abc', countryId: 'us' }, - }); - mockCreateReport.mockResolvedValue({ id: 56, country_id: 'us', year: '2026' }); + mockCreateReport.mockResolvedValue({ id: 55, country_id: 'us', year: '2026' }); }); test('starts both simulations before either finishes and waits for both before creating the report', async () => { @@ -71,10 +74,10 @@ describe('runFlagshipReport', () => { currentLawId: 2, }); await vi.waitFor(() => expect(mockCreateSimulation).toHaveBeenCalledTimes(2)); - expect(mockCreateReportAndAssociate).not.toHaveBeenCalled(); + expect(mockCreateReport).not.toHaveBeenCalled(); resolveReform({ result: { simulation_id: '102' } }); await Promise.resolve(); - expect(mockCreateReportAndAssociate).not.toHaveBeenCalled(); + expect(mockCreateReport).not.toHaveBeenCalled(); resolveBaseline({ result: { simulation_id: '101' } }); await expect(pending).resolves.toBe('55'); }); @@ -105,12 +108,11 @@ describe('runFlagshipReport', () => { population_type: 'geography', policy_id: 77, }); - expect(mockCreateReportAndAssociate).toHaveBeenCalledWith( - expect.objectContaining({ - countryId: 'us', - payload: { simulation_1_id: 101, simulation_2_id: 102, year: '2026' }, - }) - ); + expect(mockCreateReport).toHaveBeenCalledWith('us', { + simulation_1_id: 101, + simulation_2_id: 102, + year: '2026', + }); }); test('given a run then provenance is stashed under the API report id', async () => { @@ -141,7 +143,7 @@ describe('runFlagshipReport', () => { expect(mockCreatePolicy).not.toHaveBeenCalled(); }); - test('given a signed-out run then the local association and local record are written', async () => { + test('given a signed-out run then only the v3 keys are written, never the legacy ones', async () => { // When await runFlagshipReport({ countryId: 'us', @@ -153,10 +155,6 @@ describe('runFlagshipReport', () => { }); // Then - expect(mockCreateReportAndAssociate).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'anonymous', label: 'CTC expansion' }) - ); - expect(mockCreateReport).not.toHaveBeenCalled(); const records = JSON.parse(localStorage.getItem('pe-flagship-reports') ?? '[]'); expect(records).toEqual([ expect.objectContaining({ @@ -166,6 +164,10 @@ describe('runFlagshipReport', () => { reformId: 'rf-local1', }), ]); + expect(localStorage.getItem(REPORT_META_KEY)).not.toBeNull(); + for (const legacyKey of LEGACY_KEYS) { + expect(localStorage.getItem(legacyKey)).toBeNull(); + } }); test('given a signed-in run then exactly one account association is written', async () => { @@ -187,18 +189,17 @@ describe('runFlagshipReport', () => { }); // Then - expect(reportId).toBe('56'); + expect(reportId).toBe('55'); expect(mockCreateReport).toHaveBeenCalledWith('us', { simulation_1_id: 101, simulation_2_id: 102, year: '2027', }); - expect(mockCreateReportAndAssociate).not.toHaveBeenCalled(); expect(accountStore.create).toHaveBeenCalledTimes(1); expect(accountStore.create).toHaveBeenCalledWith({ userId: MOCK_USER_ID, countryId: 'us', - apiReportId: '56', + apiReportId: '55', title: 'CTC expansion', sourceNote: 'Federal · Introduced', provisions: [PROVISION], @@ -206,8 +207,10 @@ describe('runFlagshipReport', () => { reformId: REFORM_ROW_ID, }); expect(localStorage.getItem('pe-flagship-reports')).toBeNull(); - expect(localStorage.getItem('user-report-associations')).toBeNull(); - expect(readReportMeta('56')?.title).toBe('CTC expansion'); + for (const legacyKey of LEGACY_KEYS) { + expect(localStorage.getItem(legacyKey)).toBeNull(); + } + expect(readReportMeta('55')?.title).toBe('CTC expansion'); }); test('given a signed-in run then its provenance stays out of localStorage and goes on sign-out', async () => { @@ -228,9 +231,9 @@ describe('runFlagshipReport', () => { // Then: the next person on this browser cannot find it expect(localStorage.getItem(REPORT_META_KEY)).toBeNull(); - expect(readReportMeta('56')?.title).toBe('CTC expansion'); + expect(readReportMeta('55')?.title).toBe('CTC expansion'); forgetAccountReportMeta(); - expect(readReportMeta('56')).toBeNull(); + expect(readReportMeta('55')).toBeNull(); }); test('given the account association fails then the signed-in run fails', async () => { diff --git a/app/src/tests/unit/libs/flagship/v3CleanSlate.test.tsx b/app/src/tests/unit/libs/flagship/v3CleanSlate.test.tsx new file mode 100644 index 000000000..25ae7e20c --- /dev/null +++ b/app/src/tests/unit/libs/flagship/v3CleanSlate.test.tsx @@ -0,0 +1,107 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import { Provider as ReduxProvider } from 'react-redux'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { LocalStorageFlagshipReportStore } from '@/api/flagshipReportStore'; +import { LocalStorageReformStore } from '@/api/reformStore'; +import { MOCK_USER_ID } from '@/constants'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useFlagshipReport } from '@/hooks/useFlagshipReport'; +import { runFlagshipReport } from '@/libs/flagship/runReport'; +import { store } from '@/store'; + +/** + * v3 is a clean slate: it never reads or writes the legacy calculator's + * saved-work keys, signed in or out. + */ + +vi.mock('@/api/policy', () => ({ + createPolicy: vi.fn().mockResolvedValue({ result: { policy_id: '77' } }), + fetchPolicyById: vi.fn(), +})); +vi.mock('@/api/simulation', () => ({ + createSimulation: vi.fn().mockResolvedValue({ result: { simulation_id: '101' } }), + fetchSimulationById: vi.fn(), +})); +vi.mock('@/api/report', () => ({ + createReport: vi.fn().mockResolvedValue({ id: 615 }), + fetchReportById: vi.fn(), +})); + +const LEGACY_KEYS = [ + 'user-policy-associations', + 'user-population-households', + 'user-simulation-associations', + 'user-report-associations', +]; + +const touchedKeys = new Set(); + +beforeEach(() => { + localStorage.clear(); + touchedKeys.clear(); + for (const method of ['getItem', 'setItem', 'removeItem'] as const) { + const original = localStorage[method].bind(localStorage); + vi.spyOn(localStorage, method).mockImplementation((key: string, ...rest: string[]) => { + touchedKeys.add(key); + return (original as (...args: string[]) => string | null)(key, ...rest); + }); + } +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function legacyKeysTouched(): string[] { + return LEGACY_KEYS.filter((key) => touchedKeys.has(key)); +} + +describe('v3 and the legacy calculator storage', () => { + test('given a signed-out run, reform save and record listing then no legacy key is touched', async () => { + // When + await runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: 'Hand-built draft', + provisions: [{ path: 'gov.x', breadcrumb: 'X', unit: null, baselineValue: 1, value: 2 }], + currentLawId: 2, + }); + await new LocalStorageReformStore().create({ + userId: MOCK_USER_ID, + countryId: 'us', + label: 'CTC expansion', + parameters: [], + baseline: 'current-law', + provenance: { source: 'manual' }, + }); + await new LocalStorageFlagshipReportStore().findByUser(MOCK_USER_ID, 'us'); + + // Then + expect(touchedKeys.has('pe-flagship-reports')).toBe(true); + expect(legacyKeysTouched()).toEqual([]); + }); + + test('given an old sur- report link then it resolves to nothing without reading legacy storage', () => { + // Given + localStorage.setItem( + 'user-report-associations', + JSON.stringify([{ id: 'sur-old1', reportId: '615', userId: MOCK_USER_ID, countryId: 'us' }]) + ); + touchedKeys.clear(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + + // When + const { result } = renderHook(() => useFlagshipReport('sur-old1'), { wrapper }); + + // Then + expect(result.current).toMatchObject({ report: undefined, isLoading: false }); + expect(legacyKeysTouched()).toEqual([]); + }); +}); From 51286ba3485afc570737fe9dee7e7b7f394cd675 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:02:19 +0400 Subject: [PATCH 27/48] Copy this browser's v3 work into an account when sign-in creates it GET /v2/me now returns account_created, true only on the request that created the user. - The callback calls GET /v2/me once before navigating on. When it created the account, the v3 reforms and reports saved on this browser while signed out (US and UK) are copied in with POST /v2/me/imports: reforms without a v1 policy get one first, and each report is linked to its reform by client_ref. Then a notice says "Added N reforms and M reports saved on this browser to your account." - If the import fails, the notice says the work is still on this browser and offers one "Try again"; the server deduplicates by client_ref, so a retry is safe. Existing accounts that sign in are not offered an import, and browser copies stay for signed-out use. - The import reads only v3's keys (reforms, pe-flagship-reports, pe-flagship-report-meta). - Removed: the first-sign-in prompt, the legacy-key gathering and the sur-/lfr- alias folding, the import ID maps, skip lists and "declined" flags, the summary text, and the wire types for households and simulations, which v3 never saves. Co-Authored-By: Claude Opus 5.5 --- .../components/auth/AccountImportNotice.tsx | 30 + app/src/components/auth/importSummaryText.ts | 47 -- app/src/libs/auth/accountImport.ts | 301 +++++++++ app/src/libs/auth/importedIds.ts | 69 --- app/src/libs/auth/localImport.ts | 574 ------------------ app/src/libs/auth/sessionData.ts | 6 +- app/src/pages/flagship/AuthCallback.page.tsx | 60 +- app/src/tests/fixtures/api/v2UserMocks.ts | 34 +- .../fixtures/libs/auth/localImportMocks.ts | 163 ----- .../components/auth/importSummaryText.test.ts | 67 -- .../unit/libs/auth/accountImport.test.ts | 252 ++++++++ .../tests/unit/libs/auth/localImport.test.ts | 471 -------------- .../pages/flagship/AuthCallback.page.test.tsx | 165 +++++ app/src/types/v2User.ts | 67 +- .../app/[countryId]/(workspace)/layout.tsx | 2 + 15 files changed, 809 insertions(+), 1499 deletions(-) create mode 100644 app/src/components/auth/AccountImportNotice.tsx delete mode 100644 app/src/components/auth/importSummaryText.ts create mode 100644 app/src/libs/auth/accountImport.ts delete mode 100644 app/src/libs/auth/importedIds.ts delete mode 100644 app/src/libs/auth/localImport.ts delete mode 100644 app/src/tests/fixtures/libs/auth/localImportMocks.ts delete mode 100644 app/src/tests/unit/components/auth/importSummaryText.test.ts create mode 100644 app/src/tests/unit/libs/auth/accountImport.test.ts delete mode 100644 app/src/tests/unit/libs/auth/localImport.test.ts create mode 100644 app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx diff --git a/app/src/components/auth/AccountImportNotice.tsx b/app/src/components/auth/AccountImportNotice.tsx new file mode 100644 index 000000000..2b63a206e --- /dev/null +++ b/app/src/components/auth/AccountImportNotice.tsx @@ -0,0 +1,30 @@ +import { FloatingAlert } from '@/components/common/FloatingAlert'; +import { Button } from '@/components/ui'; +import { dismissAccountImportNotice, useAccountImportNotice } from '@/libs/auth/accountImport'; + +/** + * Reports the import at account creation (see runAccountCreationImport). + * Mounted once in the v3 workspace, so it stays up after the callback + * page has moved on. + */ +export default function AccountImportNotice() { + const notice = useAccountImportNotice(); + if (!notice) { + return null; + } + return ( + + {notice.message} + {notice.retry && ( + + + + )} + + ); +} diff --git a/app/src/components/auth/importSummaryText.ts b/app/src/components/auth/importSummaryText.ts deleted file mode 100644 index a15740704..000000000 --- a/app/src/components/auth/importSummaryText.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { LocalImportSummary } from '@/libs/auth/localImport'; -import { IMPORT_SKIP_REASONS } from '@/types/v2User'; -import { getCountryLabel } from '@/utils/geographyUtils'; - -function items(count: number): string { - return `${count} item${count === 1 ? '' : 's'}`; -} - -/** Plain-language lines describing an import's outcome. */ -export function importSummaryLines(summary: LocalImportSummary): string[] { - const lines = [`Saved ${items(summary.imported)} to your account.`]; - const skippedFor = (reason: string) => - summary.skipped.filter((note) => note.reason === reason).length; - const missing = skippedFor(IMPORT_SKIP_REASONS.UNKNOWN_LEGACY_REFERENCE); - const elsewhere = skippedFor(IMPORT_SKIP_REASONS.CLIENT_REF_CONFLICT); - const notAccepted = summary.skipped.length - missing - elsewhere + summary.invalid.length; - if (missing > 0) { - lines.push( - `${items(missing)} could not be saved because what ${ - missing === 1 ? 'it refers' : 'they refer' - } to no longer exists.` - ); - } - if (elsewhere > 0) { - lines.push( - `${items(elsewhere)} could not be saved because your account already has ${ - elsewhere === 1 ? 'it' : 'them' - } under another country.` - ); - } - if (notAccepted > 0) { - lines.push(`${items(notAccepted)} could not be saved.`); - } - if (summary.unsupported.length > 0) { - const countries = [ - ...new Set(summary.unsupported.map((note) => getCountryLabel(note.countryId))), - ].join(', '); - lines.push( - `${items(summary.unsupported.length)} from ${countries} stay on this device: accounts store US and UK work only.` - ); - } - if (summary.failed.length > 0) { - lines.push(`${items(summary.failed.length)} did not go through. You can try again later.`); - } - lines.push('Nothing was removed from this device.'); - return lines; -} diff --git a/app/src/libs/auth/accountImport.ts b/app/src/libs/auth/accountImport.ts new file mode 100644 index 000000000..82474bfa4 --- /dev/null +++ b/app/src/libs/auth/accountImport.ts @@ -0,0 +1,301 @@ +import { useSyncExternalStore } from 'react'; +import { + FLAGSHIP_REPORTS_KEY, + provisionsToWire, + type FlagshipReportRecord, +} from '@/api/flagshipReportStore'; +import { materializeReformPolicy } from '@/api/reformStore'; +import { isLegacyId } from '@/api/v2Associations'; +import { importToAccount } from '@/api/v2Me'; +import type { V2UserClient } from '@/api/v2UserClient'; +import { validationToWire } from '@/libs/flagship/reportValidation'; +import { REPORT_META_KEY, type FlagshipReportMeta } from '@/libs/flagship/runReport'; +import type { Reform } from '@/types/ingredients/Reform'; +import { + ASSOCIATION_COUNTRY_IDS, + isAssociationCountry, + type AssociationCountryId, + type ImportPolicyItem, + type ImportReportItem, + type ImportRequestBody, +} from '@/types/v2User'; + +/** + * The import at account creation: when a sign-in creates the PolicyEngine + * account (`account_created` from `GET /v2/me`), the v3 reforms and + * reports saved on this browser while signed out (US and UK) are copied + * into it with `POST /v2/me/imports`. Only v3's own keys are read; the + * browser copies stay for signed-out use. The server deduplicates by + * `client_ref` (the browser ID), so running it again is safe. + */ + +/** The v3 keys the import reads; v3 never reads the legacy calculator's keys. */ +const REFORMS_KEY = 'reforms'; + +/** Server limits on one import request. */ +export const IMPORT_MAX_ITEMS = 500; +/** Kept under the 1 MiB body cap to leave room for the envelope. */ +export const IMPORT_MAX_BYTES = 950_000; + +function readArray(key: string): T[] { + try { + const parsed = JSON.parse(localStorage.getItem(key) ?? '[]'); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function readObject(key: string): Record { + try { + const parsed = JSON.parse(localStorage.getItem(key) ?? '{}'); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null; +} + +type PlannedItem = + | { kind: 'policies'; clientRef: string; body: ImportPolicyItem; reform?: Reform } + | { kind: 'reports'; clientRef: string; body: ImportReportItem }; + +/** This browser's v3 reforms and reports, per account country. */ +export function planBrowserWorkImport(): Record { + const reforms = readArray(REFORMS_KEY); + const records = readArray(FLAGSHIP_REPORTS_KEY); + const meta = readObject(REPORT_META_KEY); + const plan: Record = { us: [], uk: [] }; + + for (const reform of reforms) { + if (!reform.id || !isAssociationCountry(reform.countryId)) { + continue; + } + plan[reform.countryId].push({ + kind: 'policies', + clientRef: reform.id, + body: { + client_ref: reform.id, + legacy_policy_id: isLegacyId(reform.policyId) ? Number(reform.policyId) : 0, + name: text(reform.label), + description: null, + baseline: reform.baseline ?? null, + provenance: reform.provenance + ? { + source: reform.provenance.source, + ref: reform.provenance.ref ?? null, + confidence: reform.provenance.confidence ?? null, + } + : null, + }, + // Reforms without a v1 policy get one before the import. + ...(isLegacyId(reform.policyId) ? {} : { reform }), + }); + } + + for (const record of records) { + if (!record.id || !isAssociationCountry(record.countryId) || !isLegacyId(record.apiReportId)) { + continue; + } + const runMeta = meta[record.apiReportId]; + const provisions = record.provisions?.length ? record.provisions : runMeta?.provisions; + const reformImported = plan[record.countryId].some( + (item) => item.kind === 'policies' && item.clientRef === record.reformId + ); + plan[record.countryId].push({ + kind: 'reports', + clientRef: record.id, + body: { + client_ref: record.id, + legacy_report_id: Number(record.apiReportId), + name: text(record.title) ?? text(runMeta?.title), + description: null, + // Linked to its reform by the reform's browser ID (its client_ref). + user_policy_client_ref: reformImported ? (record.reformId ?? null) : null, + source_note: text(record.sourceNote) ?? text(runMeta?.sourceNote), + provisions: provisions?.length ? provisionsToWire(provisions) : null, + validation: record.validation ? validationToWire(record.validation) : null, + last_run_at: null, + }, + }); + } + return plan; +} + +function byteLength(value: unknown): number { + return new TextEncoder().encode(JSON.stringify(value)).length; +} + +/** Request bodies of at most `maxItems` items and about `maxBytes`, reforms first. */ +export function chunkImportItems( + items: PlannedItem[], + { maxItems = IMPORT_MAX_ITEMS, maxBytes = IMPORT_MAX_BYTES } = {} +): ImportRequestBody[] { + const chunks: PlannedItem[][] = []; + let current: PlannedItem[] = []; + let currentBytes = 0; + for (const item of items) { + const size = byteLength(item.body) + 1; + if (current.length > 0 && (current.length >= maxItems || currentBytes + size > maxBytes)) { + chunks.push(current); + current = []; + currentBytes = 0; + } + current.push(item); + currentBytes += size; + } + if (current.length > 0) { + chunks.push(current); + } + return chunks.map((chunk) => ({ + policies: chunk.flatMap((item) => (item.kind === 'policies' ? [item.body] : [])), + reports: chunk.flatMap((item) => (item.kind === 'reports' ? [item.body] : [])), + })); +} + +export interface BrowserWorkImportOutcome { + reformsAdded: number; + reportsAdded: number; + /** Items that were not added (the server skipped them, or a reform had no v1 policy). */ + notAdded: number; +} + +export interface ImportBrowserWorkOptions { + client: V2UserClient; + /** Materializes a reform's parameters as a v1 policy (default: POST /{c}/policy). */ + materialize?: (reform: Reform) => Promise; +} + +/** + * Copies this browser's signed-out v3 work into the account. Rejects when + * a request fails; nothing is removed from this browser either way. + */ +export async function importBrowserWork({ + client, + materialize = (reform) => + materializeReformPolicy(reform.countryId, reform.label, reform.parameters), +}: ImportBrowserWorkOptions): Promise { + const outcome: BrowserWorkImportOutcome = { reformsAdded: 0, reportsAdded: 0, notAdded: 0 }; + const plan = planBrowserWorkImport(); + + for (const countryId of ASSOCIATION_COUNTRY_IDS) { + const ready: PlannedItem[] = []; + const unmaterialized = new Set(); + for (const item of plan[countryId]) { + if (item.kind === 'policies' && item.reform) { + try { + const policyId = await materialize(item.reform); + ready.push({ ...item, body: { ...item.body, legacy_policy_id: policyId } }); + } catch { + unmaterialized.add(item.clientRef); + outcome.notAdded += 1; + } + continue; + } + ready.push(item); + } + // A report keeps its reform link only when the reform is going too. + const items = ready.map((item) => + item.kind === 'reports' && + item.body.user_policy_client_ref && + unmaterialized.has(item.body.user_policy_client_ref) + ? { ...item, body: { ...item.body, user_policy_client_ref: null } } + : item + ); + + for (const body of chunkImportItems(items)) { + const result = await importToAccount(client, countryId, body); + const added = (refs: Array<{ client_ref: string }> | undefined) => + (refs ?? []).filter((item) => result.id_map[item.client_ref]).length; + const reformsAdded = added(body.policies); + const reportsAdded = added(body.reports); + outcome.reformsAdded += reformsAdded; + outcome.reportsAdded += reportsAdded; + outcome.notAdded += + (body.policies?.length ?? 0) + (body.reports?.length ?? 0) - reformsAdded - reportsAdded; + } + } + return outcome; +} + +/** Whether this browser holds any v3 work the import would copy. */ +export function hasBrowserWorkToImport(): boolean { + const plan = planBrowserWorkImport(); + return ASSOCIATION_COUNTRY_IDS.some((countryId) => plan[countryId].length > 0); +} + +/* The notice shown after the import, which outlives the callback page. */ + +export interface AccountImportNotice { + kind: 'success' | 'failure'; + message: string; + /** Runs the import again; present after a failure. */ + retry: (() => void) | null; +} + +let currentNotice: AccountImportNotice | null = null; +const listeners = new Set<() => void>(); + +function publishNotice(notice: AccountImportNotice | null): void { + currentNotice = notice; + listeners.forEach((listener) => listener()); +} + +export function dismissAccountImportNotice(): void { + publishNotice(null); +} + +export function useAccountImportNotice(): AccountImportNotice | null { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => currentNotice, + () => null + ); +} + +function count(amount: number, noun: string): string { + return `${amount} ${noun}${amount === 1 ? '' : 's'}`; +} + +/** "Added N reforms and M reports saved on this browser to your account." */ +export function importSuccessMessage(outcome: BrowserWorkImportOutcome): string { + const added = `Added ${count(outcome.reformsAdded, 'reform')} and ${count( + outcome.reportsAdded, + 'report' + )} saved on this browser to your account.`; + return outcome.notAdded > 0 + ? `${added} ${count(outcome.notAdded, 'item')} could not be added and ${ + outcome.notAdded === 1 ? 'is' : 'are' + } still on this browser.` + : added; +} + +export const IMPORT_FAILURE_MESSAGE = + 'Your reforms and reports saved on this browser could not be added to your account. They are still on this browser.'; + +/** + * Imports this browser's v3 work into a new account and publishes the + * outcome as a notice. A failure offers "Try again", which runs the whole + * import again. + */ +export async function runAccountCreationImport(options: ImportBrowserWorkOptions): Promise { + if (!hasBrowserWorkToImport()) { + return; + } + const retry = () => { + dismissAccountImportNotice(); + void runAccountCreationImport(options); + }; + try { + const outcome = await importBrowserWork(options); + publishNotice({ kind: 'success', message: importSuccessMessage(outcome), retry: null }); + } catch { + publishNotice({ kind: 'failure', message: IMPORT_FAILURE_MESSAGE, retry }); + } +} diff --git a/app/src/libs/auth/importedIds.ts b/app/src/libs/auth/importedIds.ts deleted file mode 100644 index 245589b26..000000000 --- a/app/src/libs/auth/importedIds.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Where this browser's localStorage items went when they were saved to an - * account: `client_ref` (the local ID, e.g. `sur-…`, `lfr-…`, `rf-…`) → - * the account's association UUID, per PolicyEngine user. Old links that - * carry a local ID keep resolving after sign-in through this map. Items - * the import skipped are remembered too, so the prompt does not offer - * them again. Nothing here deletes local data. - */ - -export const IMPORTED_ID_MAP_KEY = 'pe-imported-id-map'; -export const IMPORT_SKIPPED_KEY = 'pe-import-skipped'; - -type PerUser = Record>; - -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -/** Association IDs on the server are UUIDs; local IDs never are. */ -export function isUuid(id: unknown): id is string { - return typeof id === 'string' && UUID_RE.test(id); -} - -function readPerUser(key: string): PerUser { - try { - const parsed = JSON.parse(localStorage.getItem(key) ?? '{}'); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; - } catch { - return {}; - } -} - -function writePerUser(key: string, value: PerUser): void { - try { - localStorage.setItem(key, JSON.stringify(value)); - } catch { - // Storage full or unavailable: the import itself still succeeded. - } -} - -export function readImportedIdMap(userId: string): Record { - return { ...(readPerUser(IMPORTED_ID_MAP_KEY)[userId] ?? {}) }; -} - -export function recordImportedIds(userId: string, idMap: Record): void { - const all = readPerUser(IMPORTED_ID_MAP_KEY); - all[userId] = { ...(all[userId] ?? {}), ...idMap }; - writePerUser(IMPORTED_ID_MAP_KEY, all); -} - -export function readImportSkipped(userId: string): Record { - return { ...(readPerUser(IMPORT_SKIPPED_KEY)[userId] ?? {}) }; -} - -export function recordImportSkipped(userId: string, skipped: Record): void { - const all = readPerUser(IMPORT_SKIPPED_KEY); - all[userId] = { ...(all[userId] ?? {}), ...skipped }; - writePerUser(IMPORT_SKIPPED_KEY, all); -} - -/** - * The account association ID for an ID from a link or a local record: - * UUIDs pass through, imported local IDs map to their UUID, anything else - * is null. - */ -export function resolveImportedId(userId: string, id: string): string | null { - if (isUuid(id)) { - return id; - } - return readImportedIdMap(userId)[id] ?? null; -} diff --git a/app/src/libs/auth/localImport.ts b/app/src/libs/auth/localImport.ts deleted file mode 100644 index 92268bae5..000000000 --- a/app/src/libs/auth/localImport.ts +++ /dev/null @@ -1,574 +0,0 @@ -import { - FLAGSHIP_REPORTS_KEY, - provisionsToWire, - type FlagshipReportRecord, -} from '@/api/flagshipReportStore'; -import { materializeReformPolicy } from '@/api/reformStore'; -import { isLegacyId } from '@/api/v2Associations'; -import { importToAccount } from '@/api/v2Me'; -import type { V2UserClient } from '@/api/v2UserClient'; -import { validationToWire } from '@/libs/flagship/reportValidation'; -import { REPORT_META_KEY, type FlagshipReportMeta } from '@/libs/flagship/runReport'; -import type { Reform } from '@/types/ingredients/Reform'; -import type { UserPolicy } from '@/types/ingredients/UserPolicy'; -import type { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; -import type { UserReport } from '@/types/ingredients/UserReport'; -import type { UserSimulation } from '@/types/ingredients/UserSimulation'; -import { - IMPORT_SKIP_REASONS, - isAssociationCountry, - type AssociationCountryId, - type ImportHouseholdItem, - type ImportPolicyItem, - type ImportReportItem, - type ImportRequestBody, - type ImportSimulationItem, -} from '@/types/v2User'; -import { - IMPORT_SKIPPED_KEY, - IMPORTED_ID_MAP_KEY, - readImportedIdMap, - readImportSkipped, - recordImportedIds, - recordImportSkipped, -} from './importedIds'; - -/** - * First-sign-in import: offers the work saved on this browser while - * signed out (localStorage) to the signed-in account via - * `POST /v2/me/imports`, per account: each is offered what it has not - * imported itself. - * - * - Only US and UK items can be saved to an account; others are reported - * and stay on this device. - * - Reforms are materialized as v1 policies first (`POST /{c}/policy`). - * - A flagship run left two local records for one v1 report (the `sur-` - * association and the `lfr-` record, plus its provenance stash); they - * import as one account row, and both local IDs map to it. - * - Requests are per country and at most 500 items (and ~1 MB) each; - * policies go first so reports can reference reforms by `client_ref`. - * - The returned id_map is kept per user, so old local links resolve; - * imported and skipped items are not offered again. An item the server - * neither saved nor named (it skips unparseable items without a - * client_ref) counts as skipped too. Local data is never deleted. - */ - -export const LOCAL_STORAGE_KEYS = { - policies: 'user-policy-associations', - households: 'user-population-households', - simulations: 'user-simulation-associations', - reports: 'user-report-associations', - reforms: 'reforms', - flagshipReports: FLAGSHIP_REPORTS_KEY, - reportMeta: REPORT_META_KEY, -} as const; - -export const IMPORT_DECLINED_KEY = 'pe-import-declined'; - -/** Server limits on one import request. */ -export const IMPORT_MAX_ITEMS = 500; -/** Kept under the 1 MiB body cap to leave room for the envelope. */ -export const IMPORT_MAX_BYTES = 950_000; - -export type ImportKind = 'policies' | 'households' | 'simulations' | 'reports'; - -type ImportBody = ImportPolicyItem | ImportHouseholdItem | ImportSimulationItem | ImportReportItem; - -export interface PlannedImportItem { - kind: ImportKind; - countryId: AssociationCountryId; - clientRef: string; - /** Other local IDs that become the same account row. */ - aliases: string[]; - body: ImportBody; - /** Set for reforms that still need a v1 policy. */ - reform?: Reform; -} - -export interface LocalItemNote { - kind: ImportKind; - clientRef: string; - countryId: string; - reason: string; -} - -export interface LocalImportPlan { - items: PlannedImportItem[]; - /** Countries without account storage (Canada, Nigeria, Israel). */ - unsupported: LocalItemNote[]; - /** Records that cannot be saved (no PolicyEngine API ID). */ - invalid: LocalItemNote[]; -} - -export interface LocalImportSummary { - imported: number; - /** Items the server did not save; `reason` is one of IMPORT_SKIP_REASONS. */ - skipped: LocalItemNote[]; - unsupported: LocalItemNote[]; - invalid: LocalItemNote[]; - failed: LocalItemNote[]; -} - -export interface LocalSnapshot { - policies: UserPolicy[]; - households: UserHouseholdPopulation[]; - simulations: UserSimulation[]; - reports: UserReport[]; - reforms: Reform[]; - flagshipReports: FlagshipReportRecord[]; - reportMeta: Record; -} - -function readArray(key: string): T[] { - try { - const parsed = JSON.parse(localStorage.getItem(key) ?? '[]'); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -function readObject(key: string): Record { - try { - const parsed = JSON.parse(localStorage.getItem(key) ?? '{}'); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; - } catch { - return {}; - } -} - -export function readLocalSnapshot(): LocalSnapshot { - return { - policies: readArray(LOCAL_STORAGE_KEYS.policies), - households: readArray(LOCAL_STORAGE_KEYS.households), - simulations: readArray(LOCAL_STORAGE_KEYS.simulations), - reports: readArray(LOCAL_STORAGE_KEYS.reports), - reforms: readArray(LOCAL_STORAGE_KEYS.reforms), - flagshipReports: readArray(LOCAL_STORAGE_KEYS.flagshipReports), - reportMeta: readObject(LOCAL_STORAGE_KEYS.reportMeta), - }; -} - -function text(value: unknown): string | null { - return typeof value === 'string' && value.trim() ? value : null; -} - -/** Builds the import plan for this browser's records, minus what was already handled. */ -export function planLocalImport( - userId: string, - snapshot: LocalSnapshot = readLocalSnapshot() -): LocalImportPlan { - // Each account is offered this browser's signed-out items that it has - // not imported or had skipped itself. Another account having imported - // them does not matter: anyone signed out on this browser can see them. - const handled = new Set([ - ...Object.keys(readImportedIdMap(userId)), - ...Object.keys(readImportSkipped(userId)), - ]); - const items: PlannedImportItem[] = []; - const unsupported: LocalItemNote[] = []; - const invalid: LocalItemNote[] = []; - - const consider = ( - kind: ImportKind, - clientRef: unknown, - countryId: unknown, - legacyId: unknown, - build: (country: AssociationCountryId, legacyId: number) => Omit, - options: { needsLegacyId?: boolean } = {} - ) => { - if (typeof clientRef !== 'string' || !clientRef || handled.has(clientRef)) { - return; - } - handled.add(clientRef); - if (!isAssociationCountry(countryId)) { - unsupported.push({ - kind, - clientRef, - countryId: String(countryId ?? ''), - reason: 'country_not_supported', - }); - return; - } - if ((options.needsLegacyId ?? true) && !isLegacyId(legacyId)) { - invalid.push({ kind, clientRef, countryId, reason: 'no_api_id' }); - return; - } - items.push({ kind, ...build(countryId, Number(legacyId)) }); - }; - - for (const policy of snapshot.policies) { - consider('policies', policy.id, policy.countryId, policy.policyId, (countryId, id) => ({ - countryId, - clientRef: policy.id!, - aliases: [], - body: { - client_ref: policy.id!, - legacy_policy_id: id, - name: text(policy.label), - description: null, - baseline: null, - provenance: null, - }, - })); - } - - for (const reform of snapshot.reforms) { - consider( - 'policies', - reform.id, - reform.countryId, - reform.policyId, - (countryId) => ({ - countryId, - clientRef: reform.id!, - aliases: [], - body: { - client_ref: reform.id!, - legacy_policy_id: isLegacyId(reform.policyId) ? Number(reform.policyId) : 0, - name: text(reform.label), - description: null, - baseline: reform.baseline ?? null, - provenance: reform.provenance - ? { - source: reform.provenance.source, - ref: reform.provenance.ref ?? null, - confidence: reform.provenance.confidence ?? null, - } - : null, - }, - ...(isLegacyId(reform.policyId) ? {} : { reform }), - }), - { needsLegacyId: false } - ); - } - - for (const household of snapshot.households) { - consider( - 'households', - household.id, - household.countryId, - household.householdId, - (countryId, id) => ({ - countryId, - clientRef: household.id!, - aliases: [], - body: { - client_ref: household.id!, - legacy_household_id: id, - name: text(household.label), - description: null, - }, - }) - ); - } - - for (const simulation of snapshot.simulations) { - consider( - 'simulations', - simulation.id, - simulation.countryId, - simulation.simulationId, - (countryId, id) => ({ - countryId, - clientRef: simulation.id!, - aliases: [], - body: { - client_ref: simulation.id!, - legacy_simulation_id: id, - name: text(simulation.label), - description: null, - }, - }) - ); - } - - const importedRefs = new Set(Object.keys(readImportedIdMap(userId))); - const plannedPolicyRefs = new Set( - items.filter((item) => item.kind === 'policies').map((item) => item.clientRef) - ); - const reformRef = (reformId: unknown): string | null => - typeof reformId === 'string' && (plannedPolicyRefs.has(reformId) || importedRefs.has(reformId)) - ? reformId - : null; - - // Flagship records by (country, v1 report), to fold into the run's association. - const recordsByReport = new Map(); - for (const record of snapshot.flagshipReports) { - const key = `${record.countryId}:${record.apiReportId}`; - recordsByReport.set(key, [...(recordsByReport.get(key) ?? []), record]); - } - - const reportBody = ( - clientRef: string, - legacyId: number, - label: string | null, - meta: FlagshipReportMeta | undefined, - record: FlagshipReportRecord | undefined - ): ImportReportItem => { - const provisions = record?.provisions?.length ? record.provisions : meta?.provisions; - return { - client_ref: clientRef, - legacy_report_id: legacyId, - name: text(record?.title) ?? text(meta?.title) ?? label, - description: null, - user_policy_client_ref: reformRef(record?.reformId), - source_note: text(record?.sourceNote) ?? text(meta?.sourceNote), - provisions: provisions?.length ? provisionsToWire(provisions) : null, - validation: record?.validation ? validationToWire(record.validation) : null, - last_run_at: null, - }; - }; - - for (const report of snapshot.reports) { - const key = `${report.countryId}:${report.reportId}`; - const record = recordsByReport.get(key)?.shift(); - consider('reports', report.id, report.countryId, report.reportId, (countryId, id) => { - const aliases = record && !handled.has(record.id) ? [record.id] : []; - aliases.forEach((alias) => handled.add(alias)); - return { - countryId, - clientRef: report.id, - aliases, - body: reportBody( - report.id, - id, - text(report.label), - snapshot.reportMeta[report.reportId], - record - ), - }; - }); - } - - // Records with no local association of their own. - for (const records of recordsByReport.values()) { - for (const record of records) { - consider('reports', record.id, record.countryId, record.apiReportId, (countryId, id) => ({ - countryId, - clientRef: record.id, - aliases: [], - body: reportBody(record.id, id, null, snapshot.reportMeta[record.apiReportId], record), - })); - } - } - - return { items, unsupported, invalid }; -} - -function byteLength(value: unknown): number { - return new TextEncoder().encode(JSON.stringify(value)).length; -} - -/** - * Splits items into request bodies of at most `maxItems` items and about - * `maxBytes`, keeping their order (policies before the reports that - * reference them). - */ -export function chunkImportItems( - items: PlannedImportItem[], - { maxItems = IMPORT_MAX_ITEMS, maxBytes = IMPORT_MAX_BYTES } = {} -): PlannedImportItem[][] { - const chunks: PlannedImportItem[][] = []; - let current: PlannedImportItem[] = []; - let currentBytes = 0; - for (const item of items) { - const size = byteLength(item.body) + 1; - if (current.length > 0 && (current.length >= maxItems || currentBytes + size > maxBytes)) { - chunks.push(current); - current = []; - currentBytes = 0; - } - current.push(item); - currentBytes += size; - } - if (current.length > 0) { - chunks.push(current); - } - return chunks; -} - -export function toImportRequestBody(items: PlannedImportItem[]): ImportRequestBody { - const body: ImportRequestBody = {}; - for (const item of items) { - const list = (body[item.kind] ??= []) as ImportBody[]; - list.push(item.body); - } - return body; -} - -/** How many items this browser could still save to the account (US and UK). */ -export function countImportableItems(userId: string): number { - return planLocalImport(userId).items.length; -} - -/** Whether this account has dismissed the import prompt in this browser. */ -export function isImportDeclined(userId: string): boolean { - try { - const declined = JSON.parse(localStorage.getItem(IMPORT_DECLINED_KEY) ?? '{}'); - return Boolean(declined?.[userId]); - } catch { - return false; - } -} - -/** - * Stops offering the import prompt to this account in this browser, after - * "Not now" or after an import ran. The account page still offers what - * is left. - */ -export function declineImport(userId: string): void { - try { - const declined = JSON.parse(localStorage.getItem(IMPORT_DECLINED_KEY) ?? '{}'); - localStorage.setItem( - IMPORT_DECLINED_KEY, - JSON.stringify({ ...declined, [userId]: new Date().toISOString() }) - ); - } catch { - // Without storage the prompt simply returns next visit. - } -} - -export interface RunLocalImportOptions { - client: V2UserClient; - userId: string; - /** Materializes a reform's parameters as a v1 policy (default: POST /{c}/policy). */ - materialize?: (reform: Reform) => Promise; - snapshot?: LocalSnapshot; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : 'request_failed'; -} - -export async function runLocalImport({ - client, - userId, - materialize = (reform) => - materializeReformPolicy(reform.countryId, reform.label, reform.parameters), - snapshot, -}: RunLocalImportOptions): Promise { - const plan = planLocalImport(userId, snapshot); - const failed: LocalItemNote[] = []; - const skipped: LocalItemNote[] = []; - let imported = 0; - - // Reforms first need a v1 policy to point at. - const failedRefs = new Set(); - const ready: PlannedImportItem[] = []; - for (const item of plan.items) { - if (!item.reform) { - ready.push(item); - continue; - } - try { - const policyId = await materialize(item.reform); - ready.push({ - ...item, - body: { ...(item.body as ImportPolicyItem), legacy_policy_id: policyId }, - }); - } catch (error) { - failedRefs.add(item.clientRef); - failed.push({ - kind: item.kind, - clientRef: item.clientRef, - countryId: item.countryId, - reason: errorMessage(error), - }); - } - } - // Reports keep their reform link only when the reform is going too. - const items = ready.map((item) => { - const body = item.body as ImportReportItem; - return item.kind === 'reports' && - body.user_policy_client_ref && - failedRefs.has(body.user_policy_client_ref) - ? { ...item, body: { ...body, user_policy_client_ref: null } } - : item; - }); - - for (const countryId of ['us', 'uk'] as const) { - const countryItems = items.filter((item) => item.countryId === countryId); - const chunks = chunkImportItems(countryItems); - for (let index = 0; index < chunks.length; index += 1) { - const chunk = chunks[index]; - try { - const result = await importToAccount(client, countryId, toImportRequestBody(chunk)); - const idMap: Record = { ...result.id_map }; - for (const item of chunk) { - const uuid = idMap[item.clientRef]; - if (uuid) { - item.aliases.forEach((alias) => { - idMap[alias] = uuid; - }); - } - } - recordImportedIds(userId, idMap); - imported += chunk.filter((item) => result.id_map[item.clientRef]).length; - - // Every item the server did not save is skipped: with the reason it - // gave, or as invalid when it could not name the item (unparseable - // items come back without a client_ref). Otherwise it would stay - // pending and the import would be offered again on every visit. - const reasons = new Map(); - for (const entry of result.skipped ?? []) { - if (entry.client_ref !== null) { - reasons.set(entry.client_ref, entry.reason); - } - } - const skippedMap: Record = {}; - for (const item of chunk) { - if (result.id_map[item.clientRef]) { - continue; - } - const reason = reasons.get(item.clientRef) ?? IMPORT_SKIP_REASONS.INVALID; - for (const ref of [item.clientRef, ...item.aliases]) { - skippedMap[ref] = reason; - } - skipped.push({ - kind: item.kind, - clientRef: item.clientRef, - countryId: item.countryId, - reason, - }); - } - recordImportSkipped(userId, skippedMap); - } catch (error) { - // Later chunks may reference this one; leave the rest for a retry - // (imports are idempotent per client_ref). - for (const item of chunks.slice(index).flat()) { - failed.push({ - kind: item.kind, - clientRef: item.clientRef, - countryId: item.countryId, - reason: errorMessage(error), - }); - } - break; - } - } - } - - return { - imported, - skipped, - unsupported: plan.unsupported, - invalid: plan.invalid, - failed, - }; -} - -/** Forgets this browser's import bookkeeping for a user (after account deletion). */ -export function forgetImportState(userId: string): void { - for (const key of [IMPORTED_ID_MAP_KEY, IMPORT_SKIPPED_KEY, IMPORT_DECLINED_KEY]) { - try { - const all = JSON.parse(localStorage.getItem(key) ?? '{}'); - if (all && typeof all === 'object' && userId in all) { - delete all[userId]; - localStorage.setItem(key, JSON.stringify(all)); - } - } catch { - // Nothing to forget. - } - } -} diff --git a/app/src/libs/auth/sessionData.ts b/app/src/libs/auth/sessionData.ts index 25ffc921a..537642cc7 100644 --- a/app/src/libs/auth/sessionData.ts +++ b/app/src/libs/auth/sessionData.ts @@ -9,10 +9,8 @@ import { forgetAccountReportMeta } from '@/libs/flagship/runReport'; * - The report provenance stash from signed-in runs (kept in memory only). * - The draft reform, which may hold an account reform loaded for editing. * - * Import bookkeeping stays: it is filed per PolicyEngine user, maps only - * this browser's own (signed-out) local IDs, and lets old links resolve - * when the same person signs in again. It never decides what another - * account is offered. + * Signed in, v3 writes nothing else to this browser: reforms and reports + * go to the account, and the browser's signed-out copies are untouched. */ export function clearSignedInSessionData(): void { forgetAccountReportMeta(); diff --git a/app/src/pages/flagship/AuthCallback.page.tsx b/app/src/pages/flagship/AuthCallback.page.tsx index e7ec38704..d031ee990 100644 --- a/app/src/pages/flagship/AuthCallback.page.tsx +++ b/app/src/pages/flagship/AuthCallback.page.tsx @@ -1,31 +1,69 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { fetchMe } from '@/api/v2Me'; +import type { V2UserClient } from '@/api/v2UserClient'; import { Button, Spinner, Stack, Text, Title } from '@/components/ui'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { runAccountCreationImport } from '@/libs/auth/accountImport'; import { useAuthStatus } from '@/libs/auth/AuthContext'; import { takeSignInReturnTo } from '@/libs/auth/AuthProvider'; +import { useV2UserClient } from '@/libs/auth/useV2UserClient'; +import { isAssociationCountry } from '@/types/v2User'; + +/** + * Signed in: calls `GET /v2/me` once (which creates the PolicyEngine user + * on a first sign-in) and, when that request created the account, copies + * this browser's signed-out v3 work into it. Existing accounts are not + * offered an import. A failed `/v2/me` leaves the account to be created + * by the next account request, without an import. + */ +async function setUpAccount(client: V2UserClient, countryId: string): Promise { + if (!isAssociationCountry(countryId)) { + return false; + } + try { + const me = await fetchMe(client, countryId); + return me.account_created; + } catch (error) { + console.error('[AuthCallback] Could not load the PolicyEngine account:', error); + return false; + } +} /** * Where Auth0 sends the browser after sign-in. The AuthProvider finishes - * the code exchange (showing its loading state meanwhile); this page then - * returns to where sign-in started, and shows the error when sign-in did - * not complete. + * the code exchange (showing its loading state meanwhile); this page sets + * up the account, then returns to where sign-in started. It shows the + * error when sign-in did not complete. */ export default function AuthCallbackPage() { const countryId = useCurrentCountry(); const nav = useAppNavigate(); const { status, error } = useAuthStatus(); - const finished = useRef(false); + const client = useV2UserClient(); + const started = useRef(false); + const [importing, setImporting] = useState(false); useEffect(() => { - if (finished.current || (status === 'signed_out' && error)) { + if (started.current || (status === 'signed_out' && error)) { return; } - finished.current = true; + started.current = true; const home = `/${countryId}`; - nav.replace(status === 'signed_in' ? (takeSignInReturnTo() ?? home) : home); - }, [status, error, countryId, nav]); + if (status !== 'signed_in') { + nav.replace(home); + return; + } + const returnTo = takeSignInReturnTo() ?? home; + void (async () => { + if (await setUpAccount(client, countryId)) { + setImporting(true); + await runAccountCreationImport({ client }); + } + nav.replace(returnTo); + })(); + }, [status, error, countryId, nav, client]); if (status === 'signed_out' && error) { return ( @@ -54,7 +92,9 @@ export default function AuthCallbackPage() { style={{ padding: spacing['3xl'], gap: spacing.md, alignItems: 'center' }} > - Signing you in… + + {importing ? 'Adding the work saved on this browser to your account…' : 'Signing you in…'} + ); } diff --git a/app/src/tests/fixtures/api/v2UserMocks.ts b/app/src/tests/fixtures/api/v2UserMocks.ts index 0aea2de83..53688cfef 100644 --- a/app/src/tests/fixtures/api/v2UserMocks.ts +++ b/app/src/tests/fixtures/api/v2UserMocks.ts @@ -1,31 +1,20 @@ import { vi } from 'vitest'; import type { ApiStoreContext } from '@/api/v2Associations'; import { createV2UserClient } from '@/api/v2UserClient'; -import type { - MeItem, - UserHouseholdItem, - UserPolicyItem, - UserReportItem, - UserSimulationItem, -} from '@/types/v2User'; +import type { MeItem, UserPolicyItem, UserReportItem } from '@/types/v2User'; export const TEST_API_BASE = 'https://api.test.policyengine.org'; export const TEST_ACCESS_TOKEN = 'test-access-token'; export const PE_USER_ID = '7c1e5a8e-2b1f-4c55-9a3e-1f2d3c4b5a69'; -export const OTHER_PE_USER_ID = '0b9a8c7d-6e5f-4a3b-8c2d-1e0f9a8b7c6d'; export const ASSOCIATION_IDS = { POLICY: '11111111-1111-4111-8111-111111111111', - HOUSEHOLD: '22222222-2222-4222-8222-222222222222', - SIMULATION: '33333333-3333-4333-8333-333333333333', REPORT: '44444444-4444-4444-8444-444444444444', REFORM: '55555555-5555-4555-8555-555555555555', } as const; export const LEGACY_IDS = { POLICY: 95762, - HOUSEHOLD: 123, - SIMULATION: 613, REPORT: 615, } as const; @@ -36,6 +25,7 @@ export const TIMESTAMPS = { export const mockMeItem: MeItem = { id: PE_USER_ID, + account_created: false, primary_country: 'us', first_name: 'Ada', last_name: 'Lovelace', @@ -65,26 +55,6 @@ export function policyItem(overrides: Partial = {}): UserPolicyI }; } -export function householdItem(overrides: Partial = {}): UserHouseholdItem { - return { - ...base, - id: ASSOCIATION_IDS.HOUSEHOLD, - household_id: null, - legacy_household_id: LEGACY_IDS.HOUSEHOLD, - ...overrides, - }; -} - -export function simulationItem(overrides: Partial = {}): UserSimulationItem { - return { - ...base, - id: ASSOCIATION_IDS.SIMULATION, - simulation_id: null, - legacy_simulation_id: LEGACY_IDS.SIMULATION, - ...overrides, - }; -} - export function reportItem(overrides: Partial = {}): UserReportItem { return { ...base, diff --git a/app/src/tests/fixtures/libs/auth/localImportMocks.ts b/app/src/tests/fixtures/libs/auth/localImportMocks.ts deleted file mode 100644 index b5f6b4cb7..000000000 --- a/app/src/tests/fixtures/libs/auth/localImportMocks.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { LOCAL_STORAGE_KEYS } from '@/libs/auth/localImport'; -import type { Reform } from '@/types/ingredients/Reform'; - -export const LOCAL_USER = 'anonymous'; - -export const LOCAL_IDS = { - POLICY_US: 'sup-us1', - POLICY_CA: 'sup-ca1', - POLICY_INVALID: 'sup-bad1', - HOUSEHOLD_UK: 'suh-uk1', - SIMULATION_US: 'sus-us1', - REPORT_US: 'sur-us1', - REPORT_UK: 'sur-uk1', - FLAGSHIP_MERGED: 'lfr-merged1', - FLAGSHIP_ALONE: 'lfr-alone1', - REFORM_US: 'rf-us1', - REFORM_NG: 'rf-ng1', -} as const; - -export const V1_IDS = { - POLICY_US: '95762', - POLICY_CA: '300', - HOUSEHOLD_UK: '123', - SIMULATION_US: '613', - REPORT_US: '615', - REPORT_UK: '700', - REPORT_FLAGSHIP_ALONE: '801', - MATERIALIZED_REFORM: 95800, -} as const; - -export const PROVISION = { - path: 'gov.irs.credits.ctc.amount.base[0].amount', - breadcrumb: 'IRS → Credits → CTC → Amount', - unit: 'currency-USD', - baselineValue: 2200, - value: 2500, -}; - -export const localReformUS: Reform = { - id: LOCAL_IDS.REFORM_US, - userId: LOCAL_USER, - countryId: 'us', - label: 'CTC to $2,500', - parameters: [ - { - name: PROVISION.path, - values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 2500 }], - }, - ], - baseline: 'current-law', - provenance: { source: 'manual' }, - createdAt: '2026-09-01T00:00:00Z', - updatedAt: '2026-09-01T00:00:00Z', -}; - -/** Writes a representative browser: US/UK/Canada items, a flagship run, reforms. */ -export function seedLocalData(): void { - const set = (key: string, value: unknown) => localStorage.setItem(key, JSON.stringify(value)); - set(LOCAL_STORAGE_KEYS.policies, [ - { - id: LOCAL_IDS.POLICY_US, - userId: LOCAL_USER, - policyId: V1_IDS.POLICY_US, - countryId: 'us', - label: 'My policy', - }, - { - id: LOCAL_IDS.POLICY_CA, - userId: LOCAL_USER, - policyId: V1_IDS.POLICY_CA, - countryId: 'ca', - label: 'Canada policy', - }, - { id: LOCAL_IDS.POLICY_INVALID, userId: LOCAL_USER, policyId: 'current-law', countryId: 'us' }, - ]); - set(LOCAL_STORAGE_KEYS.households, [ - { - type: 'household', - id: LOCAL_IDS.HOUSEHOLD_UK, - userId: LOCAL_USER, - householdId: V1_IDS.HOUSEHOLD_UK, - countryId: 'uk', - label: 'Family', - }, - ]); - set(LOCAL_STORAGE_KEYS.simulations, [ - { - id: LOCAL_IDS.SIMULATION_US, - userId: LOCAL_USER, - simulationId: V1_IDS.SIMULATION_US, - countryId: 'us', - }, - ]); - set(LOCAL_STORAGE_KEYS.reports, [ - { - id: LOCAL_IDS.REPORT_US, - userId: LOCAL_USER, - reportId: V1_IDS.REPORT_US, - countryId: 'us', - label: 'CTC to $2,500', - }, - { - id: LOCAL_IDS.REPORT_UK, - userId: LOCAL_USER, - reportId: V1_IDS.REPORT_UK, - countryId: 'uk', - label: 'UK report', - }, - ]); - set(LOCAL_STORAGE_KEYS.reforms, [ - localReformUS, - { ...localReformUS, id: LOCAL_IDS.REFORM_NG, countryId: 'ng' }, - ]); - set(LOCAL_STORAGE_KEYS.flagshipReports, [ - { - id: LOCAL_IDS.FLAGSHIP_MERGED, - userId: LOCAL_USER, - countryId: 'us', - apiReportId: V1_IDS.REPORT_US, - title: 'CTC to $2,500', - sourceNote: 'Hand-built draft', - provisions: [PROVISION], - year: '2026', - createdAt: '2026-09-02T00:00:00Z', - reformId: LOCAL_IDS.REFORM_US, - }, - { - id: LOCAL_IDS.FLAGSHIP_ALONE, - userId: LOCAL_USER, - countryId: 'us', - apiReportId: V1_IDS.REPORT_FLAGSHIP_ALONE, - title: 'Bill report', - sourceNote: 'Utah · Introduced', - provisions: [], - year: '2026', - createdAt: '2026-09-03T00:00:00Z', - reformId: null, - }, - ]); - set(LOCAL_STORAGE_KEYS.reportMeta, { - [V1_IDS.REPORT_FLAGSHIP_ALONE]: { - title: 'Bill report', - sourceNote: 'Utah · Introduced', - provisions: [PROVISION], - createdAt: '2026-09-03T00:00:00Z', - }, - }); -} - -/** A browser with many saved simulations, for chunking. */ -export function seedManySimulations(count: number): void { - localStorage.setItem( - LOCAL_STORAGE_KEYS.simulations, - JSON.stringify( - Array.from({ length: count }, (_, index) => ({ - id: `sus-${index}`, - userId: LOCAL_USER, - simulationId: String(index + 1), - countryId: 'us', - })) - ) - ); -} diff --git a/app/src/tests/unit/components/auth/importSummaryText.test.ts b/app/src/tests/unit/components/auth/importSummaryText.test.ts deleted file mode 100644 index bbe74c231..000000000 --- a/app/src/tests/unit/components/auth/importSummaryText.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { importSummaryLines } from '@/components/auth/importSummaryText'; -import type { LocalImportSummary, LocalItemNote } from '@/libs/auth/localImport'; -import { IMPORT_SKIP_REASONS } from '@/types/v2User'; - -function note(clientRef: string, reason: string): LocalItemNote { - return { kind: 'reports', clientRef, countryId: 'us', reason }; -} - -function summary(overrides: Partial = {}): LocalImportSummary { - return { imported: 0, skipped: [], unsupported: [], invalid: [], failed: [], ...overrides }; -} - -describe('importSummaryLines', () => { - test('given items whose v1 artifact is gone then only those are said to no longer exist', () => { - // When - const lines = importSummaryLines( - summary({ - imported: 3, - skipped: [ - note('sur-a', IMPORT_SKIP_REASONS.UNKNOWN_LEGACY_REFERENCE), - note('sur-b', IMPORT_SKIP_REASONS.INVALID), - ], - invalid: [note('sup-c', 'no_api_id')], - }) - ); - - // Then - expect(lines).toEqual([ - 'Saved 3 items to your account.', - '1 item could not be saved because what it refers to no longer exists.', - '2 items could not be saved.', - 'Nothing was removed from this device.', - ]); - }); - - test('given only items the server did not accept then none is said to no longer exist', () => { - // When - const lines = importSummaryLines( - summary({ imported: 1, skipped: [note('sur-a', IMPORT_SKIP_REASONS.INVALID)] }) - ); - - // Then - expect(lines.join(' ')).not.toMatch(/no longer exists/); - expect(lines).toContain('1 item could not be saved.'); - }); - - test('given items the account already holds under another country then the summary says so', () => { - // When - const lines = importSummaryLines( - summary({ - imported: 2, - skipped: [ - note('sur-a', IMPORT_SKIP_REASONS.CLIENT_REF_CONFLICT), - note('sur-b', IMPORT_SKIP_REASONS.CLIENT_REF_CONFLICT), - ], - }) - ); - - // Then - expect(lines).toEqual([ - 'Saved 2 items to your account.', - '2 items could not be saved because your account already has them under another country.', - 'Nothing was removed from this device.', - ]); - }); -}); diff --git a/app/src/tests/unit/libs/auth/accountImport.test.ts b/app/src/tests/unit/libs/auth/accountImport.test.ts new file mode 100644 index 000000000..07a915b42 --- /dev/null +++ b/app/src/tests/unit/libs/auth/accountImport.test.ts @@ -0,0 +1,252 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { LocalStorageFlagshipReportStore } from '@/api/flagshipReportStore'; +import { LocalStorageReformStore } from '@/api/reformStore'; +import { MOCK_USER_ID } from '@/constants'; +import { + dismissAccountImportNotice, + IMPORT_FAILURE_MESSAGE, + importBrowserWork, + importSuccessMessage, + runAccountCreationImport, + useAccountImportNotice, +} from '@/libs/auth/accountImport'; +import { saveReportMeta } from '@/libs/flagship/runReport'; +import { + createTestApiContext, + errorResponse, + fetchCall, + okResponse, +} from '@/tests/fixtures/api/v2UserMocks'; +import type { Reform } from '@/types/ingredients/Reform'; + +const LEGACY_KEYS = [ + 'user-policy-associations', + 'user-population-households', + 'user-simulation-associations', + 'user-report-associations', +]; + +const MATERIALIZED_POLICY_ID = 95801; + +const REFORM_FIELDS: Omit = { + userId: MOCK_USER_ID, + label: 'CTC expansion', + parameters: [ + { + name: 'gov.irs.credits.ctc.amount.base[0]', + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3000 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'manual' }, +}; + +/** A US reform run as a report, a saved UK reform, and a Canada reform (not imported). */ +async function seedBrowserWork() { + const reforms = new LocalStorageReformStore(); + const usReform = await reforms.create({ ...REFORM_FIELDS, countryId: 'us' }); + const ukReform = await reforms.create({ + ...REFORM_FIELDS, + countryId: 'uk', + label: 'Personal allowance', + policyId: '501', + }); + await reforms.create({ ...REFORM_FIELDS, countryId: 'ca' }); + const record = await new LocalStorageFlagshipReportStore().create({ + userId: MOCK_USER_ID, + countryId: 'us', + apiReportId: '615', + title: null, + sourceNote: null, + provisions: [], + year: '2026', + reformId: usReform.id, + }); + saveReportMeta('615', { + title: 'CTC expansion', + sourceNote: 'Hand-built draft', + provisions: [ + { + path: 'gov.irs.credits.ctc.amount.base[0]', + breadcrumb: 'CTC', + unit: null, + baselineValue: 2000, + value: 3000, + }, + ], + createdAt: '2026-10-13T00:00:00Z', + }); + return { usReform, ukReform, record }; +} + +/** Answers each import with an id_map entry for every client_ref it was sent. */ +async function acceptEverything(_url: string, init: RequestInit) { + const body = JSON.parse(String(init.body)); + const refs = [...(body.policies ?? []), ...(body.reports ?? [])].map( + (item: { client_ref: string }) => item.client_ref + ); + return okResponse({ + id_map: Object.fromEntries(refs.map((ref, index) => [ref, `uuid-${index}-${ref}`])), + skipped: [], + }); +} + +beforeEach(() => { + localStorage.clear(); + act(() => dismissAccountImportNotice()); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('importBrowserWork', () => { + test('given v3 work on this browser then reforms and reports go in per country, linked by client_ref', async () => { + // Given + const { usReform, ukReform, record } = await seedBrowserWork(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(acceptEverything); + const materialize = vi.fn().mockResolvedValue(MATERIALIZED_POLICY_ID); + + // When + const outcome = await importBrowserWork({ client: context.client, materialize }); + + // Then + expect(outcome).toEqual({ reformsAdded: 2, reportsAdded: 1, notAdded: 0 }); + expect(materialize).toHaveBeenCalledTimes(1); + const usImport = fetchCall(fetchMock, 0); + expect(usImport.query).toEqual({ country_id: 'us' }); + expect(usImport.body.policies).toEqual([ + expect.objectContaining({ + client_ref: usReform.id, + legacy_policy_id: MATERIALIZED_POLICY_ID, + }), + ]); + expect(usImport.body.reports).toEqual([ + expect.objectContaining({ + client_ref: record.id, + legacy_report_id: 615, + name: 'CTC expansion', + source_note: 'Hand-built draft', + user_policy_client_ref: usReform.id, + }), + ]); + const ukImport = fetchCall(fetchMock, 1); + expect(ukImport.query).toEqual({ country_id: 'uk' }); + expect(ukImport.body.policies).toEqual([ + expect.objectContaining({ client_ref: ukReform.id, legacy_policy_id: 501 }), + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('given the import runs then it reads only the v3 keys, never the legacy ones', async () => { + // Given + await seedBrowserWork(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(acceptEverything); + const readKeys = new Set(); + const originalGetItem = localStorage.getItem.bind(localStorage); + vi.spyOn(localStorage, 'getItem').mockImplementation((key: string) => { + readKeys.add(key); + return originalGetItem(key); + }); + + // When + await importBrowserWork({ client: context.client, materialize: async () => 1 }); + + // Then + expect([...readKeys].sort()).toEqual( + ['pe-flagship-report-meta', 'pe-flagship-reports', 'reforms'].sort() + ); + expect(LEGACY_KEYS.filter((key) => readKeys.has(key))).toEqual([]); + }); + + test('given a reform that cannot get a v1 policy then its report goes without the link', async () => { + // Given + const { record } = await seedBrowserWork(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(acceptEverything); + + // When + const outcome = await importBrowserWork({ + client: context.client, + materialize: async () => { + throw new Error('Failed to create policy'); + }, + }); + + // Then + expect(outcome.notAdded).toBe(1); + const report = fetchCall(fetchMock, 0).body.reports[0]; + expect(report).toMatchObject({ client_ref: record.id, user_policy_client_ref: null }); + }); +}); + +describe('runAccountCreationImport', () => { + test('given it succeeds then the notice says what was added', async () => { + // Given + await seedBrowserWork(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(acceptEverything); + const notice = renderHook(() => useAccountImportNotice()); + + // When + await act(() => + runAccountCreationImport({ client: context.client, materialize: async () => 1 }) + ); + + // Then + expect(notice.result.current).toEqual({ + kind: 'success', + message: 'Added 2 reforms and 1 report saved on this browser to your account.', + retry: null, + }); + }); + + test('given it fails then the notice says the work is still here, and Try again runs it again', async () => { + // Given + await seedBrowserWork(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(errorResponse(503, 'Saved work is temporarily unavailable.')); + const notice = renderHook(() => useAccountImportNotice()); + await act(() => + runAccountCreationImport({ client: context.client, materialize: async () => 1 }) + ); + expect(notice.result.current).toMatchObject({ + kind: 'failure', + message: IMPORT_FAILURE_MESSAGE, + }); + + // When + fetchMock.mockImplementation(acceptEverything); + await act(async () => { + notice.result.current!.retry!(); + await vi.waitFor(() => expect(notice.result.current?.kind).toBe('success')); + }); + + // Then + expect(notice.result.current?.message).toMatch(/^Added 2 reforms and 1 report/); + }); + + test('given nothing saved on this browser then nothing is sent and no notice shows', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + const notice = renderHook(() => useAccountImportNotice()); + + // When + await act(() => runAccountCreationImport({ client: context.client })); + + // Then + expect(fetchMock).not.toHaveBeenCalled(); + expect(notice.result.current).toBeNull(); + }); +}); + +describe('importSuccessMessage', () => { + test('given one of each and items left behind then the wording agrees with the counts', () => { + expect(importSuccessMessage({ reformsAdded: 1, reportsAdded: 1, notAdded: 1 })).toBe( + 'Added 1 reform and 1 report saved on this browser to your account. 1 item could not be added and is still on this browser.' + ); + }); +}); diff --git a/app/src/tests/unit/libs/auth/localImport.test.ts b/app/src/tests/unit/libs/auth/localImport.test.ts deleted file mode 100644 index 4e82de53d..000000000 --- a/app/src/tests/unit/libs/auth/localImport.test.ts +++ /dev/null @@ -1,471 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { readImportedIdMap, readImportSkipped, resolveImportedId } from '@/libs/auth/importedIds'; -import { - chunkImportItems, - countImportableItems, - declineImport, - forgetImportState, - isImportDeclined, - LOCAL_STORAGE_KEYS, - planLocalImport, - runLocalImport, - type PlannedImportItem, -} from '@/libs/auth/localImport'; -import { - createTestApiContext, - errorResponse, - fetchCall, - okResponse, - OTHER_PE_USER_ID, - PE_USER_ID, -} from '@/tests/fixtures/api/v2UserMocks'; -import { - LOCAL_IDS, - localReformUS, - PROVISION, - seedLocalData, - seedManySimulations, - V1_IDS, -} from '@/tests/fixtures/libs/auth/localImportMocks'; - -const ACCOUNT_IDS: Record = { - [LOCAL_IDS.POLICY_US]: 'a0000000-0000-4000-8000-000000000001', - [LOCAL_IDS.REFORM_US]: 'a0000000-0000-4000-8000-000000000002', - [LOCAL_IDS.SIMULATION_US]: 'a0000000-0000-4000-8000-000000000003', - [LOCAL_IDS.REPORT_US]: 'a0000000-0000-4000-8000-000000000004', - [LOCAL_IDS.FLAGSHIP_ALONE]: 'a0000000-0000-4000-8000-000000000005', - [LOCAL_IDS.HOUSEHOLD_UK]: 'a0000000-0000-4000-8000-000000000006', - [LOCAL_IDS.REPORT_UK]: 'a0000000-0000-4000-8000-000000000007', -}; - -/** Answers each import with an id_map for every client_ref it was sent. */ -function importResponder(skip: Record = {}) { - return async (_url: string, init: RequestInit) => { - const body = JSON.parse(String(init.body)); - const refs = Object.values(body) - .flat() - .map((item: any) => item.client_ref as string); - return okResponse({ - id_map: Object.fromEntries( - refs.filter((ref) => !skip[ref]).map((ref) => [ref, ACCOUNT_IDS[ref] ?? `uuid-${ref}`]) - ), - skipped: refs - .filter((ref) => skip[ref]) - .map((ref) => ({ client_ref: ref, reason: skip[ref] })), - }); - }; -} - -beforeEach(() => { - localStorage.clear(); -}); - -describe('planLocalImport', () => { - test('given items from several countries then only US and UK are planned and the rest reported', () => { - // Given - seedLocalData(); - - // When - const plan = planLocalImport(PE_USER_ID); - - // Then - expect(plan.items.map((item) => [item.countryId, item.clientRef])).toEqual([ - ['us', LOCAL_IDS.POLICY_US], - ['us', LOCAL_IDS.REFORM_US], - ['uk', LOCAL_IDS.HOUSEHOLD_UK], - ['us', LOCAL_IDS.SIMULATION_US], - ['us', LOCAL_IDS.REPORT_US], - ['uk', LOCAL_IDS.REPORT_UK], - ['us', LOCAL_IDS.FLAGSHIP_ALONE], - ]); - expect(plan.unsupported.map((note) => [note.countryId, note.clientRef])).toEqual([ - ['ca', LOCAL_IDS.POLICY_CA], - ['ng', LOCAL_IDS.REFORM_NG], - ]); - expect(plan.invalid.map((note) => note.clientRef)).toEqual([LOCAL_IDS.POLICY_INVALID]); - }); - - test('given a flagship run then its association, record and provenance become one report', () => { - // Given - seedLocalData(); - - // When - const report = planLocalImport(PE_USER_ID).items.find( - (item) => item.clientRef === LOCAL_IDS.REPORT_US - ); - - // Then - expect(report?.aliases).toEqual([LOCAL_IDS.FLAGSHIP_MERGED]); - expect(report?.body).toEqual({ - client_ref: LOCAL_IDS.REPORT_US, - legacy_report_id: Number(V1_IDS.REPORT_US), - name: 'CTC to $2,500', - description: null, - user_policy_client_ref: LOCAL_IDS.REFORM_US, - source_note: 'Hand-built draft', - provisions: [ - { - path: PROVISION.path, - breadcrumb: PROVISION.breadcrumb, - unit: PROVISION.unit, - baseline_value: PROVISION.baselineValue, - value: PROVISION.value, - intervals: null, - }, - ], - validation: null, - last_run_at: null, - }); - }); - - test('given a flagship record alone then its provenance stash fills the provisions', () => { - // Given - seedLocalData(); - - // When - const report = planLocalImport(PE_USER_ID).items.find( - (item) => item.clientRef === LOCAL_IDS.FLAGSHIP_ALONE - ); - - // Then - expect(report?.body).toMatchObject({ - legacy_report_id: Number(V1_IDS.REPORT_FLAGSHIP_ALONE), - name: 'Bill report', - source_note: 'Utah · Introduced', - provisions: [expect.objectContaining({ path: PROVISION.path })], - }); - }); - - test('given a reform without a v1 policy then it is planned for materialization', () => { - // Given - seedLocalData(); - - // When - const reform = planLocalImport(PE_USER_ID).items.find( - (item) => item.clientRef === LOCAL_IDS.REFORM_US - ); - - // Then - expect(reform?.reform).toEqual(localReformUS); - expect(reform?.body).toMatchObject({ - baseline: 'current-law', - provenance: { source: 'manual', ref: null, confidence: null }, - }); - }); -}); - -describe('chunkImportItems', () => { - const item = (index: number): PlannedImportItem => ({ - kind: 'simulations', - countryId: 'us', - clientRef: `sus-${index}`, - aliases: [], - body: { - client_ref: `sus-${index}`, - legacy_simulation_id: index + 1, - name: null, - description: null, - }, - }); - - test('given 1201 items then requests hold at most 500 each, in order', () => { - const items = Array.from({ length: 1201 }, (_, index) => item(index)); - - const chunks = chunkImportItems(items); - - expect(chunks.map((chunk) => chunk.length)).toEqual([500, 500, 201]); - expect(chunks.flat()).toEqual(items); - }); - - test('given large items then requests also stay under the byte limit', () => { - const items = Array.from({ length: 10 }, (_, index) => item(index)); - const size = new TextEncoder().encode(JSON.stringify(items[0].body)).length + 1; - - const chunks = chunkImportItems(items, { maxBytes: size * 3 }); - - expect(chunks.map((chunk) => chunk.length)).toEqual([3, 3, 3, 1]); - }); -}); - -describe('runLocalImport', () => { - test('given saved work then it is imported per country, with reforms materialized first', async () => { - // Given - seedLocalData(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(importResponder()); - const materialize = vi.fn().mockResolvedValue(V1_IDS.MATERIALIZED_REFORM); - - // When - const summary = await runLocalImport({ - client: context.client, - userId: PE_USER_ID, - materialize, - }); - - // Then - expect(materialize).toHaveBeenCalledWith(localReformUS); - expect(fetchMock).toHaveBeenCalledTimes(2); - const us = fetchCall(fetchMock, 0); - expect(us.path).toBe('/v2/me/imports'); - expect(us.query).toEqual({ country_id: 'us' }); - expect(Object.keys(us.body)).toEqual(['policies', 'simulations', 'reports']); - expect(us.body.policies[1]).toMatchObject({ - client_ref: LOCAL_IDS.REFORM_US, - legacy_policy_id: V1_IDS.MATERIALIZED_REFORM, - }); - expect(JSON.stringify(us.body)).not.toContain('user_id'); - const uk = fetchCall(fetchMock, 1); - expect(uk.query).toEqual({ country_id: 'uk' }); - expect(uk.body).toEqual({ - households: [expect.objectContaining({ client_ref: LOCAL_IDS.HOUSEHOLD_UK })], - reports: [expect.objectContaining({ client_ref: LOCAL_IDS.REPORT_UK })], - }); - expect(summary).toMatchObject({ imported: 7, failed: [], skipped: [] }); - expect(summary.unsupported).toHaveLength(2); - expect(summary.invalid).toHaveLength(1); - }); - - test('given an import then the id_map is kept so old local links resolve, aliases included', async () => { - // Given - seedLocalData(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(importResponder()); - - // When - await runLocalImport({ - client: context.client, - userId: PE_USER_ID, - materialize: async () => V1_IDS.MATERIALIZED_REFORM, - }); - - // Then - expect(resolveImportedId(PE_USER_ID, LOCAL_IDS.REPORT_US)).toBe( - ACCOUNT_IDS[LOCAL_IDS.REPORT_US] - ); - expect(resolveImportedId(PE_USER_ID, LOCAL_IDS.FLAGSHIP_MERGED)).toBe( - ACCOUNT_IDS[LOCAL_IDS.REPORT_US] - ); - expect(resolveImportedId('someone-else', LOCAL_IDS.REPORT_US)).toBeNull(); - expect(countImportableItems(PE_USER_ID)).toBe(0); - }); - - test('given an import then nothing is removed from this device', async () => { - // Given - seedLocalData(); - const before = Object.values(LOCAL_STORAGE_KEYS).map((key) => localStorage.getItem(key)); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(importResponder()); - - // When - await runLocalImport({ - client: context.client, - userId: PE_USER_ID, - materialize: async () => V1_IDS.MATERIALIZED_REFORM, - }); - - // Then - expect(Object.values(LOCAL_STORAGE_KEYS).map((key) => localStorage.getItem(key))).toEqual( - before - ); - }); - - test('given skipped items then they are recorded and not offered again', async () => { - // Given - seedLocalData(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation( - importResponder({ [LOCAL_IDS.SIMULATION_US]: 'unknown_legacy_reference' }) - ); - - // When - const summary = await runLocalImport({ - client: context.client, - userId: PE_USER_ID, - materialize: async () => V1_IDS.MATERIALIZED_REFORM, - }); - - // Then - expect(summary.skipped).toEqual([ - { - kind: 'simulations', - clientRef: LOCAL_IDS.SIMULATION_US, - countryId: 'us', - reason: 'unknown_legacy_reference', - }, - ]); - expect(readImportSkipped(PE_USER_ID)).toEqual({ - [LOCAL_IDS.SIMULATION_US]: 'unknown_legacy_reference', - }); - expect(countImportableItems(PE_USER_ID)).toBe(0); - }); - - test('given an item the server skipped without naming it then it is recorded as invalid', async () => { - // Given: the server could not parse the simulation, so its skip has no client_ref - seedLocalData(); - const { fetchMock, context } = createTestApiContext(); - const respond = importResponder(); - fetchMock.mockImplementation(async (url: string, init: RequestInit) => { - const response = await respond(url, init); - const payload = await response.json(); - delete payload.result.id_map[LOCAL_IDS.SIMULATION_US]; - payload.result.skipped.push({ client_ref: null, reason: 'invalid' }); - return okResponse(payload.result); - }); - - // When - const summary = await runLocalImport({ - client: context.client, - userId: PE_USER_ID, - materialize: async () => V1_IDS.MATERIALIZED_REFORM, - }); - - // Then: it counts once, is not pending any more, and no "null" ref is kept - expect(summary.skipped).toEqual([ - { - kind: 'simulations', - clientRef: LOCAL_IDS.SIMULATION_US, - countryId: 'us', - reason: 'invalid', - }, - ]); - expect(readImportSkipped(PE_USER_ID)).toEqual({ [LOCAL_IDS.SIMULATION_US]: 'invalid' }); - expect(countImportableItems(PE_USER_ID)).toBe(0); - }); - - test('given an item missing from both id_map and skipped then it is not offered again', async () => { - // Given - seedLocalData(); - const { fetchMock, context } = createTestApiContext(); - const respond = importResponder(); - fetchMock.mockImplementation(async (url: string, init: RequestInit) => { - const response = await respond(url, init); - const payload = await response.json(); - delete payload.result.id_map[LOCAL_IDS.POLICY_US]; - return okResponse(payload.result); - }); - - // When - const summary = await runLocalImport({ - client: context.client, - userId: PE_USER_ID, - materialize: async () => V1_IDS.MATERIALIZED_REFORM, - }); - - // Then - expect(summary.skipped.map((note) => note.clientRef)).toEqual([LOCAL_IDS.POLICY_US]); - expect(countImportableItems(PE_USER_ID)).toBe(0); - }); - - test('given more than 500 items then they go in several requests', async () => { - // Given - seedManySimulations(600); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(importResponder()); - - // When - const summary = await runLocalImport({ client: context.client, userId: PE_USER_ID }); - - // Then - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchCall(fetchMock, 0).body.simulations).toHaveLength(500); - expect(fetchCall(fetchMock, 1).body.simulations).toHaveLength(100); - expect(summary.imported).toBe(600); - }); - - test('given a failed request then its items are reported and left for a retry', async () => { - // Given - seedManySimulations(600); - const { fetchMock, context } = createTestApiContext(); - fetchMock - .mockImplementationOnce(importResponder()) - .mockImplementationOnce(async () => errorResponse(503, 'Database unavailable')); - - // When - const summary = await runLocalImport({ client: context.client, userId: PE_USER_ID }); - - // Then - expect(summary.imported).toBe(500); - expect(summary.failed).toHaveLength(100); - expect(Object.keys(readImportedIdMap(PE_USER_ID))).toHaveLength(500); - expect(countImportableItems(PE_USER_ID)).toBe(100); - }); - - test('given a reform that cannot be materialized then reports drop their link to it', async () => { - // Given - seedLocalData(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(importResponder()); - - // When - const summary = await runLocalImport({ - client: context.client, - userId: PE_USER_ID, - materialize: async () => { - throw new Error('Failed to create policy'); - }, - }); - - // Then - expect(summary.failed).toEqual([ - expect.objectContaining({ - clientRef: LOCAL_IDS.REFORM_US, - reason: 'Failed to create policy', - }), - ]); - const report = fetchCall(fetchMock, 0).body.reports.find( - (item: any) => item.client_ref === LOCAL_IDS.REPORT_US - ); - expect(report.user_policy_client_ref).toBeNull(); - expect(countImportableItems(PE_USER_ID)).toBe(1); - }); -}); - -describe('import bookkeeping', () => { - test('given "not now" then the prompt is declined for that user only', () => { - declineImport(PE_USER_ID); - - expect(isImportDeclined(PE_USER_ID)).toBe(true); - expect(isImportDeclined('someone-else')).toBe(false); - }); - - test('given a deleted account then its bookkeeping is forgotten', async () => { - // Given - seedLocalData(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(importResponder()); - await runLocalImport({ - client: context.client, - userId: PE_USER_ID, - materialize: async () => V1_IDS.MATERIALIZED_REFORM, - }); - declineImport(PE_USER_ID); - - // When - forgetImportState(PE_USER_ID); - - // Then - expect(readImportedIdMap(PE_USER_ID)).toEqual({}); - expect(isImportDeclined(PE_USER_ID)).toBe(false); - }); - - test('given one account imported this browser then a second account is still offered its signed-out items', async () => { - // Given - seedLocalData(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(importResponder()); - await runLocalImport({ - client: context.client, - userId: PE_USER_ID, - materialize: async () => V1_IDS.MATERIALIZED_REFORM, - }); - - // When - const plan = planLocalImport(OTHER_PE_USER_ID); - - // Then: the same seven items, and the first account's links stay its own - expect(plan.items).toHaveLength(7); - expect(countImportableItems(PE_USER_ID)).toBe(0); - expect(readImportedIdMap(OTHER_PE_USER_ID)).toEqual({}); - expect(resolveImportedId(OTHER_PE_USER_ID, LOCAL_IDS.REPORT_US)).toBeNull(); - }); -}); diff --git a/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx b/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx new file mode 100644 index 000000000..3dcc13254 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx @@ -0,0 +1,165 @@ +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { LocalStorageReformStore } from '@/api/reformStore'; +import AccountImportNotice from '@/components/auth/AccountImportNotice'; +import { MOCK_USER_ID } from '@/constants'; +import { NavigationProvider } from '@/contexts/NavigationContext'; +import { dismissAccountImportNotice } from '@/libs/auth/accountImport'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import { takeSignInReturnTo } from '@/libs/auth/AuthProvider'; +import AuthCallbackPage from '@/pages/flagship/AuthCallback.page'; +import { + errorResponse, + itemResponse, + mockMeItem, + okResponse, +} from '@/tests/fixtures/api/v2UserMocks'; +import { + AuthTestProvider, + makeAuthValue, + signedInAuthValue, +} from '@/tests/fixtures/libs/auth/authMocks'; + +vi.mock('@/libs/auth/AuthProvider', () => ({ + takeSignInReturnTo: vi.fn(), +})); + +const fetchMock = vi.fn(); +const navigation = { push: vi.fn(), replace: vi.fn(), back: vi.fn() }; + +function renderCallback(auth: AuthContextValue) { + return render( + + + + + + + ); +} + +function requestPaths(): string[] { + return fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname); +} + +/** Answers /v2/me with `accountCreated`, and imports with everything added. */ +function answer({ + accountCreated, + imports = 'accept', +}: { + accountCreated: boolean; + imports?: 'accept' | 'fail'; +}) { + fetchMock.mockImplementation(async (url: string, init: RequestInit) => { + const path = new URL(url).pathname; + if (path === '/v2/me') { + return itemResponse({ ...mockMeItem, account_created: accountCreated }); + } + if (path === '/v2/me/imports') { + if (imports === 'fail') { + return errorResponse(503, 'Saved work is temporarily unavailable.'); + } + const body = JSON.parse(String(init.body)); + return okResponse({ + id_map: Object.fromEntries( + (body.policies ?? []).map((item: { client_ref: string }) => [item.client_ref, 'uuid-1']) + ), + skipped: [], + }); + } + return new Response('', { status: 404 }); + }); +} + +beforeEach(async () => { + localStorage.clear(); + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); + vi.mocked(takeSignInReturnTo).mockReturnValue('/us/reforms'); + dismissAccountImportNotice(); + // A reform saved on this browser while signed out, with its v1 policy. + await new LocalStorageReformStore().create({ + userId: MOCK_USER_ID, + countryId: 'us', + label: 'CTC expansion', + policyId: '95762', + parameters: [], + baseline: 'current-law', + provenance: { source: 'manual' }, + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('AuthCallbackPage', () => { + test("given the sign-in created the account then this browser's v3 work is imported, then it returns", async () => { + // Given + answer({ accountCreated: true }); + + // When + renderCallback(signedInAuthValue()); + + // Then + await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); + expect(requestPaths()).toEqual(['/v2/me', '/v2/me/imports']); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Added 1 reform and 0 reports saved on this browser to your account.' + ); + }); + + test('given an existing account signs in then nothing is imported', async () => { + // Given + answer({ accountCreated: false }); + + // When + renderCallback(signedInAuthValue()); + + // Then + await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); + expect(requestPaths()).toEqual(['/v2/me']); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + test('given the import fails then the notice says the work is still here, with one Try again', async () => { + // Given + answer({ accountCreated: true, imports: 'fail' }); + const user = userEvent.setup(); + renderCallback(signedInAuthValue()); + expect(await screen.findByRole('alert')).toHaveTextContent(/still on this browser/); + await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); + + // When + answer({ accountCreated: false }); + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then: the retry imports again; /v2/me is not asked again + expect(await screen.findByText(/^Added 1 reform/)).toBeInTheDocument(); + expect(requestPaths().filter((path) => path === '/v2/me')).toHaveLength(1); + expect(requestPaths().filter((path) => path === '/v2/me/imports')).toHaveLength(2); + }); + + test('given /v2/me fails then it still returns, without an import', async () => { + // Given + fetchMock.mockResolvedValue(errorResponse(503, 'Saved work is temporarily unavailable.')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + // When + renderCallback(signedInAuthValue()); + + // Then + await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); + expect(requestPaths()).toEqual(['/v2/me']); + }); + + test('given sign-in did not complete then the error shows and nothing is called', () => { + // When + renderCallback(makeAuthValue({ error: new Error('access_denied') })); + + // Then + expect(screen.getByText('Sign-in did not complete')).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(navigation.replace).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/types/v2User.ts b/app/src/types/v2User.ts index d10833b6b..3e4c42cbe 100644 --- a/app/src/types/v2User.ts +++ b/app/src/types/v2User.ts @@ -37,6 +37,8 @@ export interface V2PageResult { export interface MeItem { /** The PolicyEngine user ID that owns every association. */ id: string; + /** True only on the request that created this PolicyEngine user. */ + account_created: boolean; primary_country: AssociationCountryId; first_name: string | null; last_name: string | null; @@ -70,16 +72,6 @@ export interface UserPolicyItem extends AssociationItemBase { provenance: ProvenanceWire | null; } -export interface UserHouseholdItem extends AssociationItemBase { - household_id: string | null; - legacy_household_id: number | null; -} - -export interface UserSimulationItem extends AssociationItemBase { - simulation_id: string | null; - legacy_simulation_id: number | null; -} - export interface ReportIntervalWire { parameter?: string; start_date: string; @@ -126,30 +118,6 @@ export type UserPolicyPatchBody = Partial< > >; -export interface UserHouseholdCreateBody { - country_id: AssociationCountryId; - legacy_household_id: number; - name?: string | null; - description?: string | null; - client_ref?: string | null; -} - -export type UserHouseholdPatchBody = Partial< - Pick ->; - -export interface UserSimulationCreateBody { - country_id: AssociationCountryId; - legacy_simulation_id: number; - name?: string | null; - description?: string | null; - client_ref?: string | null; -} - -export type UserSimulationPatchBody = Partial< - Pick ->; - export interface UserReportCreateBody { country_id: AssociationCountryId; legacy_report_id: number; @@ -179,12 +147,12 @@ export type UserReportPatchBody = Partial< /* /v2/me/export */ +/** Everything saved to the account, in every country; downloaded as is. */ export interface MeExportResult { user: MeItem; policies: UserPolicyItem[]; - households: UserHouseholdItem[]; - simulations: UserSimulationItem[]; reports: UserReportItem[]; + [otherRecords: string]: unknown; } /* /v2/me/imports */ @@ -198,20 +166,6 @@ export interface ImportPolicyItem { provenance: ProvenanceWire | null; } -export interface ImportHouseholdItem { - client_ref: string; - legacy_household_id: number; - name: string | null; - description: string | null; -} - -export interface ImportSimulationItem { - client_ref: string; - legacy_simulation_id: number; - name: string | null; - description: string | null; -} - export interface ImportReportItem { client_ref: string; legacy_report_id: number; @@ -224,23 +178,12 @@ export interface ImportReportItem { last_run_at: string | null; } +/** v3 imports its reforms (as policies) and its reports only. */ export interface ImportRequestBody { policies?: ImportPolicyItem[]; - households?: ImportHouseholdItem[]; - simulations?: ImportSimulationItem[]; reports?: ImportReportItem[]; } -/** Why the server skipped an import item (`skipped[].reason`). */ -export const IMPORT_SKIP_REASONS = { - /** The item did not validate; the server may not know its client_ref. */ - INVALID: 'invalid', - /** The v1 artifact it points at does not exist in that country. */ - UNKNOWN_LEGACY_REFERENCE: 'unknown_legacy_reference', - /** The account already holds an item under this client_ref in another country. */ - CLIENT_REF_CONFLICT: 'client_ref_conflict', -} as const; - export interface ImportSkippedItem { /** Null when the server could not parse the item at all. */ client_ref: string | null; diff --git a/calculator-app/src/app/[countryId]/(workspace)/layout.tsx b/calculator-app/src/app/[countryId]/(workspace)/layout.tsx index 739bcd85e..b1fed4019 100644 --- a/calculator-app/src/app/[countryId]/(workspace)/layout.tsx +++ b/calculator-app/src/app/[countryId]/(workspace)/layout.tsx @@ -1,5 +1,6 @@ "use client"; +import AccountImportNotice from "@/components/auth/AccountImportNotice"; import { AuthProvider } from "@/libs/auth/AuthProvider"; import WorkspaceNavigation from "./WorkspaceNavigation"; @@ -19,6 +20,7 @@ export default function WorkspaceLayout({ return ( {children} +
); From 98e407754381b5f1359359c0ebd4c04a2a82bdad Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:03:54 +0400 Subject: [PATCH 28/48] Describe v3-only sign-in, and hide it outside the US and UK - The README and calculator-app/.env.example describe sign-in as the v3 workspace's alone: reforms and reports go to the account when signed in (US and UK), the browser's signed-out v3 work is copied in when the account is created, and the legacy calculator keeps its localStorage behaviour. Auth0 callbacks are needed for /us and /uk only. - The v3 user menu renders nothing outside the US and UK, where v3 stays in the browser, so sign-in never redirects to a /ca, /ng or /il callback. - Internal helpers of the provider and the import are no longer exported, and an unused wire type is gone. Co-Authored-By: Claude Opus 5.5 --- README.md | 29 ++++++++++--------- app/src/components/auth/AuthMenu.tsx | 7 +++-- app/src/libs/auth/AuthProvider.tsx | 4 +-- app/src/libs/auth/accountImport.ts | 10 +++---- .../unit/components/auth/AuthMenu.test.tsx | 13 +++++++-- app/src/types/v2User.ts | 14 --------- calculator-app/.env.example | 8 ++--- 7 files changed, 42 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 4f7ff08f8..659c8dd1e 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,14 @@ Format code: make format ``` -## Sign-in and saved work (calculator) +## Sign-in and saved work (v3 workspace) -The calculator can sign users in with social sign-on (Auth0) and save their -policies, households, simulations, reports and reforms to their PolicyEngine -account through the PolicyEngine API (`/v2/me` and `/v2/user-*`). It is off -unless all of these are set at build time (see `calculator-app/.env.example`): +The v3 workspace (the flagship shell: Ask, Build, Reforms, reports and the +account page) can sign users in with social sign-on (Auth0) and save their +reforms and reports to their PolicyEngine account through the PolicyEngine +API (`/v2/me` and `/v2/user-*`). It is off unless all of these are set at +build time (see `calculator-app/.env.example`), alongside +`NEXT_PUBLIC_FLAGSHIP_SHELL=true`: | Variable | Purpose | | --- | --- | @@ -67,15 +69,14 @@ unless all of these are set at build time (see `calculator-app/.env.example`): | `NEXT_PUBLIC_AUTH0_AUDIENCE` | PolicyEngine API resource server identifier | | `NEXT_PUBLIC_API_URL` | PolicyEngine API origin (default `https://api.policyengine.org`); set to the staging API on staging | -The legacy Vite build reads the same settings as `VITE_AUTH_ENABLED`, -`VITE_AUTH0_*` and `VITE_API_URL`, but does not mount the sign-in provider. - -- Signed out, and in every country without account storage (Canada, - Nigeria, Israel), saved work stays in the browser's localStorage, as before. -- Signed in on the US or UK calculator, saved work goes to the account. The - first sign-in offers to copy this browser's saved work to the account; - nothing is deleted from the device. -- Saved geographies always stay in localStorage. +- Signed in on the US or UK workspace, reforms and reports go to the + account. Signed out, they stay in this browser, in v3's own keys. +- The first sign-in, which creates the account, copies the v3 reforms and + reports saved on this browser while signed out into it; nothing is + removed from the browser. Existing accounts are not offered an import. +- The legacy calculator (policies, households, simulations and reports + built outside the workspace) has no sign-in and keeps its saved work in + localStorage, as before. v3 never reads or writes that storage. ## Project structure diff --git a/app/src/components/auth/AuthMenu.tsx b/app/src/components/auth/AuthMenu.tsx index c73b392d0..cd19b05cb 100644 --- a/app/src/components/auth/AuthMenu.tsx +++ b/app/src/components/auth/AuthMenu.tsx @@ -12,6 +12,7 @@ import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useAuthStatus, type AuthContextValue } from '@/libs/auth/AuthContext'; +import { isAssociationCountry } from '@/types/v2User'; interface AuthMenuProps { /** 'inverse' for the teal calculator header; 'default' for light bars. */ @@ -30,7 +31,8 @@ function initialOf(name: string): string { /** * The v3 header's sign-in control: "Sign in" when signed out (with a note * when the last session ended on its own), the user menu (My account, - * Sign out) when signed in, and nothing when sign-in is disabled. + * Sign out) when signed in, and nothing when sign-in is disabled or + * outside the US and UK. */ export default function AuthMenu({ variant = 'default' }: AuthMenuProps) { const auth = useAuthStatus(); @@ -38,7 +40,8 @@ export default function AuthMenu({ variant = 'default' }: AuthMenuProps) { const nav = useAppNavigate(); const inverse = variant === 'inverse'; - if (auth.status === 'disabled') { + // Accounts hold US and UK work only; elsewhere v3 stays in this browser. + if (auth.status === 'disabled' || !isAssociationCountry(countryId)) { return null; } diff --git a/app/src/libs/auth/AuthProvider.tsx b/app/src/libs/auth/AuthProvider.tsx index bb44951d2..224695ef7 100644 --- a/app/src/libs/auth/AuthProvider.tsx +++ b/app/src/libs/auth/AuthProvider.tsx @@ -97,7 +97,7 @@ const SESSION_ENDED_ERRORS = new Set([ 'invalid_grant', ]); -export function isSessionEndedError(error: unknown): boolean { +function isSessionEndedError(error: unknown): boolean { if (!error || typeof error !== 'object' || !('error' in error)) { return false; } @@ -105,7 +105,7 @@ export function isSessionEndedError(error: unknown): boolean { } /** Shown in place of the workspace while Auth0 restores the session. */ -export function SessionRestoring() { +function SessionRestoring() { return ( (key: string): T[] { try { @@ -64,7 +64,7 @@ type PlannedItem = | { kind: 'reports'; clientRef: string; body: ImportReportItem }; /** This browser's v3 reforms and reports, per account country. */ -export function planBrowserWorkImport(): Record { +function planBrowserWorkImport(): Record { const reforms = readArray(REFORMS_KEY); const records = readArray(FLAGSHIP_REPORTS_KEY); const meta = readObject(REPORT_META_KEY); @@ -130,7 +130,7 @@ function byteLength(value: unknown): number { } /** Request bodies of at most `maxItems` items and about `maxBytes`, reforms first. */ -export function chunkImportItems( +function chunkImportItems( items: PlannedItem[], { maxItems = IMPORT_MAX_ITEMS, maxBytes = IMPORT_MAX_BYTES } = {} ): ImportRequestBody[] { @@ -222,7 +222,7 @@ export async function importBrowserWork({ } /** Whether this browser holds any v3 work the import would copy. */ -export function hasBrowserWorkToImport(): boolean { +function hasBrowserWorkToImport(): boolean { const plan = planBrowserWorkImport(); return ASSOCIATION_COUNTRY_IDS.some((countryId) => plan[countryId].length > 0); } diff --git a/app/src/tests/unit/components/auth/AuthMenu.test.tsx b/app/src/tests/unit/components/auth/AuthMenu.test.tsx index f518171a4..f8a013da0 100644 --- a/app/src/tests/unit/components/auth/AuthMenu.test.tsx +++ b/app/src/tests/unit/components/auth/AuthMenu.test.tsx @@ -11,11 +11,12 @@ import { TEST_PROFILE, } from '@/tests/fixtures/libs/auth/authMocks'; -function renderMenu(auth: AuthContextValue) { +function renderMenu(auth: AuthContextValue, countryId = 'us') { return render( - + , + countryId ); } @@ -49,6 +50,14 @@ describe('AuthMenu', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); + test('given a country without accounts then nothing is shown, signed in or out', () => { + // When + renderMenu(makeAuthValue(), 'ca'); + + // Then + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + test('given a signed-out visitor then "Sign in" starts sign-in', async () => { // Given const user = userEvent.setup(); diff --git a/app/src/types/v2User.ts b/app/src/types/v2User.ts index 3e4c42cbe..8fbfc2dd7 100644 --- a/app/src/types/v2User.ts +++ b/app/src/types/v2User.ts @@ -131,20 +131,6 @@ export interface UserReportCreateBody { client_ref?: string | null; } -export type UserReportPatchBody = Partial< - Pick< - UserReportCreateBody, - | 'legacy_report_id' - | 'name' - | 'description' - | 'user_policy_id' - | 'source_note' - | 'provisions' - | 'validation' - | 'last_run_at' - > ->; - /* /v2/me/export */ /** Everything saved to the account, in every country; downloaded as is. */ diff --git a/calculator-app/.env.example b/calculator-app/.env.example index cdee42f43..fc05fc010 100644 --- a/calculator-app/.env.example +++ b/calculator-app/.env.example @@ -9,10 +9,11 @@ # staging API so staging artifacts never land in production. # NEXT_PUBLIC_API_URL=https:// -# --- Sign-in (Auth0 social sign-on) ----------------------------------------- +# --- Sign-in (Auth0 social sign-on, v3 workspace only) ----------------------- # Sign-in is inert unless NEXT_PUBLIC_AUTH_ENABLED=true AND all three Auth0 # values are set; otherwise the app behaves exactly as it does without -# sign-in (everything saved in localStorage). +# sign-in (everything saved in localStorage). The legacy calculator never +# signs in. # NEXT_PUBLIC_AUTH_ENABLED=true # Auth0 (custom) domain, e.g. auth.policyengine.org # NEXT_PUBLIC_AUTH0_DOMAIN= @@ -22,8 +23,7 @@ # NEXT_PUBLIC_AUTH0_AUDIENCE= # # Auth0 application settings this app relies on: -# Allowed callback URLs: /us/callback, /uk/callback, -# /ca/callback, /ng/callback, /il/callback +# Allowed callback URLs: /us/callback, /uk/callback # Allowed logout URLs and web origins: /, # Refresh-token rotation on; the API resource server allows offline access. From f5dd27602ae27111043fb8c68f429a58fab3e20a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:17:22 +0400 Subject: [PATCH 29/48] Move browser-saved v3 work into the account at every sign-in Every sign-in now moves the v3 reforms and reports saved on this browser while signed out (US and UK) into the account with POST /v2/me/imports, instead of copying them once when the sign-in created the account. - Each item whose client_ref comes back in id_map is removed from the browser, along with the run details of reports no remaining record uses. Anything the account skips, and anything not sent because a request failed, stays and is sent again at the next sign-in. The server deduplicates by client_ref, so an item sent again returns its existing ID. - A reform that cannot get a v1 policy stays, and so do its reports, so they keep their link when they move later. A report whose reform moved at an earlier sign-in links to it through user_policy_client_ref. - The sign-in country's items go first, so the request that creates the account records that country. - The callback no longer calls GET /v2/me; it only runs the import when the browser holds v3 work. MeItem drops account_created. - The notice says what moved and what is still on the browser. A failed request keeps "Try again"; skipped items show a warning without it. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +- .../components/auth/AccountImportNotice.tsx | 12 +- app/src/libs/auth/accountImport.ts | 215 ++++++++++++------ app/src/pages/flagship/AuthCallback.page.tsx | 37 +-- app/src/tests/fixtures/api/v2UserMocks.ts | 1 - .../unit/libs/auth/accountImport.test.ts | 207 +++++++++++++---- .../pages/flagship/AuthCallback.page.test.tsx | 88 +++---- app/src/types/v2User.ts | 2 - 8 files changed, 369 insertions(+), 200 deletions(-) diff --git a/README.md b/README.md index 659c8dd1e..5c6e80d54 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,10 @@ build time (see `calculator-app/.env.example`), alongside - Signed in on the US or UK workspace, reforms and reports go to the account. Signed out, they stay in this browser, in v3's own keys. -- The first sign-in, which creates the account, copies the v3 reforms and - reports saved on this browser while signed out into it; nothing is - removed from the browser. Existing accounts are not offered an import. +- Every sign-in moves the v3 reforms and reports saved on this browser + while signed out into the account. Each item the account confirms is + removed from the browser; anything else stays and is sent again at the + next sign-in. Work outside the US and UK always stays in the browser. - The legacy calculator (policies, households, simulations and reports built outside the workspace) has no sign-in and keeps its saved work in localStorage, as before. v3 never reads or writes that storage. diff --git a/app/src/components/auth/AccountImportNotice.tsx b/app/src/components/auth/AccountImportNotice.tsx index 2b63a206e..f0b0fd459 100644 --- a/app/src/components/auth/AccountImportNotice.tsx +++ b/app/src/components/auth/AccountImportNotice.tsx @@ -2,10 +2,11 @@ import { FloatingAlert } from '@/components/common/FloatingAlert'; import { Button } from '@/components/ui'; import { dismissAccountImportNotice, useAccountImportNotice } from '@/libs/auth/accountImport'; +const ALERT_TYPES = { success: 'success', warning: 'warning', failure: 'error' } as const; + /** - * Reports the import at account creation (see runAccountCreationImport). - * Mounted once in the v3 workspace, so it stays up after the callback - * page has moved on. + * Reports the import at sign-in (see runSignInImport). Mounted once in the + * v3 workspace, so it stays up after the callback page has moved on. */ export default function AccountImportNotice() { const notice = useAccountImportNotice(); @@ -13,10 +14,7 @@ export default function AccountImportNotice() { return null; } return ( - + {notice.message} {notice.retry && ( diff --git a/app/src/libs/auth/accountImport.ts b/app/src/libs/auth/accountImport.ts index 1e211072a..924adf326 100644 --- a/app/src/libs/auth/accountImport.ts +++ b/app/src/libs/auth/accountImport.ts @@ -21,12 +21,13 @@ import { } from '@/types/v2User'; /** - * The import at account creation: when a sign-in creates the PolicyEngine - * account (`account_created` from `GET /v2/me`), the v3 reforms and - * reports saved on this browser while signed out (US and UK) are copied - * into it with `POST /v2/me/imports`. Only v3's own keys are read; the - * browser copies stay for signed-out use. The server deduplicates by - * `client_ref` (the browser ID), so running it again is safe. + * The import at sign-in: every sign-in moves the v3 reforms and reports + * saved on this browser while signed out (US and UK) into the account with + * `POST /v2/me/imports`. Each item the account confirms (its `client_ref` + * comes back in `id_map`) is removed from this browser; everything else + * stays here and is sent again at the next sign-in. Only v3's own keys are + * read. The server deduplicates by `client_ref` (the browser ID), so an + * item sent again returns its existing ID. */ /** The v3 keys the import reads; v3 never reads the legacy calculator's keys. */ @@ -102,9 +103,6 @@ function planBrowserWorkImport(): Record { } const runMeta = meta[record.apiReportId]; const provisions = record.provisions?.length ? record.provisions : runMeta?.provisions; - const reformImported = plan[record.countryId].some( - (item) => item.kind === 'policies' && item.clientRef === record.reformId - ); plan[record.countryId].push({ kind: 'reports', clientRef: record.id, @@ -114,7 +112,10 @@ function planBrowserWorkImport(): Record { name: text(record.title) ?? text(runMeta?.title), description: null, // Linked to its reform by the reform's browser ID (its client_ref). - user_policy_client_ref: reformImported ? (record.reformId ?? null) : null, + // The account resolves it against reforms in the same request or + // moved at an earlier sign-in, and leaves the report unlinked + // when there is none. + user_policy_client_ref: text(record.reformId), source_note: text(record.sourceNote) ?? text(runMeta?.sourceNote), provisions: provisions?.length ? provisionsToWire(provisions) : null, validation: record.validation ? validationToWire(record.validation) : null, @@ -125,6 +126,42 @@ function planBrowserWorkImport(): Record { return plan; } +/** + * Removes the items the account confirmed from this browser: the reforms + * and report records with those IDs, and the run details of reports no + * remaining record points at. An item left behind by a failed write is + * sent again next time and answers its existing ID. + */ +function removeMovedItems(movedClientRefs: ReadonlySet): void { + try { + const reforms = readArray(REFORMS_KEY); + const keptReforms = reforms.filter((reform) => !(reform.id && movedClientRefs.has(reform.id))); + if (keptReforms.length !== reforms.length) { + localStorage.setItem(REFORMS_KEY, JSON.stringify(keptReforms)); + } + + const records = readArray(FLAGSHIP_REPORTS_KEY); + const keptRecords = records.filter((record) => !movedClientRefs.has(record.id)); + if (keptRecords.length === records.length) { + return; + } + localStorage.setItem(FLAGSHIP_REPORTS_KEY, JSON.stringify(keptRecords)); + + const stillReferenced = new Set(keptRecords.map((record) => record.apiReportId)); + const movedReportIds = records + .filter((record) => movedClientRefs.has(record.id)) + .map((record) => record.apiReportId) + .filter((apiReportId) => !stillReferenced.has(apiReportId)); + const meta = readObject(REPORT_META_KEY); + if (movedReportIds.some((apiReportId) => apiReportId in meta)) { + movedReportIds.forEach((apiReportId) => delete meta[apiReportId]); + localStorage.setItem(REPORT_META_KEY, JSON.stringify(meta)); + } + } catch { + // Storage unavailable: the items stay and are sent again next time. + } +} + function byteLength(value: unknown): number { return new TextEncoder().encode(JSON.stringify(value)).length; } @@ -156,73 +193,110 @@ function chunkImportItems( })); } +/** The account countries, `firstCountryId` first. */ +function countryOrder(firstCountryId?: string): AssociationCountryId[] { + return isAssociationCountry(firstCountryId) + ? [firstCountryId, ...ASSOCIATION_COUNTRY_IDS.filter((id) => id !== firstCountryId)] + : [...ASSOCIATION_COUNTRY_IDS]; +} + export interface BrowserWorkImportOutcome { - reformsAdded: number; - reportsAdded: number; - /** Items that were not added (the server skipped them, or a reform had no v1 policy). */ - notAdded: number; + reformsMoved: number; + reportsMoved: number; + /** Items still on this browser: skipped, held back, or not sent. */ + leftOnBrowser: number; + /** True when an import request failed, which stops the run. */ + requestFailed: boolean; } export interface ImportBrowserWorkOptions { client: V2UserClient; + /** + * The country signed in from. Its items go first, so the request that + * creates the account on a first sign-in records it as the account's + * country. + */ + firstCountryId?: string; /** Materializes a reform's parameters as a v1 policy (default: POST /{c}/policy). */ materialize?: (reform: Reform) => Promise; } /** - * Copies this browser's signed-out v3 work into the account. Rejects when - * a request fails; nothing is removed from this browser either way. + * Moves this browser's signed-out v3 work into the account, removing each + * item the account confirms. A reform that cannot get a v1 policy stays, + * and so do its reports, so they keep their link when they move later. + * A failed request stops the run; everything not yet moved stays. */ export async function importBrowserWork({ client, + firstCountryId, materialize = (reform) => materializeReformPolicy(reform.countryId, reform.label, reform.parameters), }: ImportBrowserWorkOptions): Promise { - const outcome: BrowserWorkImportOutcome = { reformsAdded: 0, reportsAdded: 0, notAdded: 0 }; const plan = planBrowserWorkImport(); + const planned = ASSOCIATION_COUNTRY_IDS.reduce( + (total, countryId) => total + plan[countryId].length, + 0 + ); + const outcome: BrowserWorkImportOutcome = { + reformsMoved: 0, + reportsMoved: 0, + leftOnBrowser: planned, + requestFailed: false, + }; - for (const countryId of ASSOCIATION_COUNTRY_IDS) { + for (const countryId of countryOrder(firstCountryId)) { const ready: PlannedItem[] = []; - const unmaterialized = new Set(); + const reformsLeft = new Set(); for (const item of plan[countryId]) { if (item.kind === 'policies' && item.reform) { try { const policyId = await materialize(item.reform); ready.push({ ...item, body: { ...item.body, legacy_policy_id: policyId } }); } catch { - unmaterialized.add(item.clientRef); - outcome.notAdded += 1; + reformsLeft.add(item.clientRef); } continue; } ready.push(item); } - // A report keeps its reform link only when the reform is going too. - const items = ready.map((item) => - item.kind === 'reports' && - item.body.user_policy_client_ref && - unmaterialized.has(item.body.user_policy_client_ref) - ? { ...item, body: { ...item.body, user_policy_client_ref: null } } - : item + // A report whose reform stays on this browser stays with it. + const items = ready.filter( + (item) => + !( + item.kind === 'reports' && + item.body.user_policy_client_ref && + reformsLeft.has(item.body.user_policy_client_ref) + ) ); for (const body of chunkImportItems(items)) { - const result = await importToAccount(client, countryId, body); - const added = (refs: Array<{ client_ref: string }> | undefined) => - (refs ?? []).filter((item) => result.id_map[item.client_ref]).length; - const reformsAdded = added(body.policies); - const reportsAdded = added(body.reports); - outcome.reformsAdded += reformsAdded; - outcome.reportsAdded += reportsAdded; - outcome.notAdded += - (body.policies?.length ?? 0) + (body.reports?.length ?? 0) - reformsAdded - reportsAdded; + const result = await importToAccount(client, countryId, body).catch((error: unknown) => { + console.error('[accountImport] Import request failed:', error); + return null; + }); + if (!result) { + outcome.requestFailed = true; + return outcome; + } + const moved = new Set( + [...(body.policies ?? []), ...(body.reports ?? [])] + .map((item) => item.client_ref) + .filter((clientRef) => result.id_map[clientRef]) + ); + removeMovedItems(moved); + const countMoved = (refs: Array<{ client_ref: string }> | undefined) => + (refs ?? []).filter((item) => moved.has(item.client_ref)).length; + outcome.reformsMoved += countMoved(body.policies); + outcome.reportsMoved += countMoved(body.reports); + outcome.leftOnBrowser -= moved.size; } } return outcome; } -/** Whether this browser holds any v3 work the import would copy. */ -function hasBrowserWorkToImport(): boolean { +/** Whether this browser holds any v3 work the import would move. */ +export function hasBrowserWorkToImport(): boolean { const plan = planBrowserWorkImport(); return ASSOCIATION_COUNTRY_IDS.some((countryId) => plan[countryId].length > 0); } @@ -230,9 +304,9 @@ function hasBrowserWorkToImport(): boolean { /* The notice shown after the import, which outlives the callback page. */ export interface AccountImportNotice { - kind: 'success' | 'failure'; + kind: 'success' | 'warning' | 'failure'; message: string; - /** Runs the import again; present after a failure. */ + /** Runs the import again; present after a failed request. */ retry: (() => void) | null; } @@ -263,39 +337,50 @@ function count(amount: number, noun: string): string { return `${amount} ${noun}${amount === 1 ? '' : 's'}`; } -/** "Added N reforms and M reports saved on this browser to your account." */ -export function importSuccessMessage(outcome: BrowserWorkImportOutcome): string { - const added = `Added ${count(outcome.reformsAdded, 'reform')} and ${count( - outcome.reportsAdded, +const TRIED_AGAIN = 'will be tried again the next time you sign in'; + +/** + * "Moved N reforms and M reports from this browser to your account.", + * plus how many items stay on this browser for the next sign-in. + */ +export function importOutcomeMessage(outcome: BrowserWorkImportOutcome): string { + const movedAny = outcome.reformsMoved + outcome.reportsMoved > 0; + const moved = `Moved ${count(outcome.reformsMoved, 'reform')} and ${count( + outcome.reportsMoved, 'report' - )} saved on this browser to your account.`; - return outcome.notAdded > 0 - ? `${added} ${count(outcome.notAdded, 'item')} could not be added and ${ - outcome.notAdded === 1 ? 'is' : 'are' - } still on this browser.` - : added; + )} from this browser to your account.`; + if (outcome.leftOnBrowser === 0) { + return moved; + } + if (!movedAny) { + return outcome.requestFailed + ? `Your reforms and reports saved on this browser could not be moved to your account. They are still on this browser and ${TRIED_AGAIN}.` + : `${count(outcome.leftOnBrowser, 'item')} saved on this browser could not be moved to your account. ${ + outcome.leftOnBrowser === 1 ? 'It is' : 'They are' + } still on this browser and ${TRIED_AGAIN}.`; + } + return `${moved} ${count(outcome.leftOnBrowser, 'item')} ${ + outcome.leftOnBrowser === 1 ? 'is' : 'are' + } still on this browser and ${TRIED_AGAIN}.`; } -export const IMPORT_FAILURE_MESSAGE = - 'Your reforms and reports saved on this browser could not be added to your account. They are still on this browser.'; - /** - * Imports this browser's v3 work into a new account and publishes the - * outcome as a notice. A failure offers "Try again", which runs the whole - * import again. + * Moves this browser's v3 work into the account at sign-in and publishes + * the outcome as a notice. A failed request offers "Try again", which runs + * the import again for whatever is still on this browser. */ -export async function runAccountCreationImport(options: ImportBrowserWorkOptions): Promise { +export async function runSignInImport(options: ImportBrowserWorkOptions): Promise { if (!hasBrowserWorkToImport()) { return; } + const outcome = await importBrowserWork(options); const retry = () => { dismissAccountImportNotice(); - void runAccountCreationImport(options); + void runSignInImport(options); }; - try { - const outcome = await importBrowserWork(options); - publishNotice({ kind: 'success', message: importSuccessMessage(outcome), retry: null }); - } catch { - publishNotice({ kind: 'failure', message: IMPORT_FAILURE_MESSAGE, retry }); - } + publishNotice({ + kind: outcome.leftOnBrowser === 0 ? 'success' : outcome.requestFailed ? 'failure' : 'warning', + message: importOutcomeMessage(outcome), + retry: outcome.requestFailed ? retry : null, + }); } diff --git a/app/src/pages/flagship/AuthCallback.page.tsx b/app/src/pages/flagship/AuthCallback.page.tsx index d031ee990..22a7d622d 100644 --- a/app/src/pages/flagship/AuthCallback.page.tsx +++ b/app/src/pages/flagship/AuthCallback.page.tsx @@ -1,40 +1,19 @@ import { useEffect, useRef, useState } from 'react'; -import { fetchMe } from '@/api/v2Me'; -import type { V2UserClient } from '@/api/v2UserClient'; import { Button, Spinner, Stack, Text, Title } from '@/components/ui'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import { runAccountCreationImport } from '@/libs/auth/accountImport'; +import { hasBrowserWorkToImport, runSignInImport } from '@/libs/auth/accountImport'; import { useAuthStatus } from '@/libs/auth/AuthContext'; import { takeSignInReturnTo } from '@/libs/auth/AuthProvider'; import { useV2UserClient } from '@/libs/auth/useV2UserClient'; -import { isAssociationCountry } from '@/types/v2User'; - -/** - * Signed in: calls `GET /v2/me` once (which creates the PolicyEngine user - * on a first sign-in) and, when that request created the account, copies - * this browser's signed-out v3 work into it. Existing accounts are not - * offered an import. A failed `/v2/me` leaves the account to be created - * by the next account request, without an import. - */ -async function setUpAccount(client: V2UserClient, countryId: string): Promise { - if (!isAssociationCountry(countryId)) { - return false; - } - try { - const me = await fetchMe(client, countryId); - return me.account_created; - } catch (error) { - console.error('[AuthCallback] Could not load the PolicyEngine account:', error); - return false; - } -} /** * Where Auth0 sends the browser after sign-in. The AuthProvider finishes - * the code exchange (showing its loading state meanwhile); this page sets - * up the account, then returns to where sign-in started. It shows the + * the code exchange (showing its loading state meanwhile); this page moves + * the v3 work saved on this browser while signed out into the account + * (see runSignInImport), then returns to where sign-in started. The + * account's first request creates the PolicyEngine user. It shows the * error when sign-in did not complete. */ export default function AuthCallbackPage() { @@ -57,9 +36,9 @@ export default function AuthCallbackPage() { } const returnTo = takeSignInReturnTo() ?? home; void (async () => { - if (await setUpAccount(client, countryId)) { + if (hasBrowserWorkToImport()) { setImporting(true); - await runAccountCreationImport({ client }); + await runSignInImport({ client, firstCountryId: countryId }); } nav.replace(returnTo); })(); @@ -93,7 +72,7 @@ export default function AuthCallbackPage() { > - {importing ? 'Adding the work saved on this browser to your account…' : 'Signing you in…'} + {importing ? 'Moving the work saved on this browser to your account…' : 'Signing you in…'} ); diff --git a/app/src/tests/fixtures/api/v2UserMocks.ts b/app/src/tests/fixtures/api/v2UserMocks.ts index 53688cfef..e99a09e4b 100644 --- a/app/src/tests/fixtures/api/v2UserMocks.ts +++ b/app/src/tests/fixtures/api/v2UserMocks.ts @@ -25,7 +25,6 @@ export const TIMESTAMPS = { export const mockMeItem: MeItem = { id: PE_USER_ID, - account_created: false, primary_country: 'us', first_name: 'Ada', last_name: 'Lovelace', diff --git a/app/src/tests/unit/libs/auth/accountImport.test.ts b/app/src/tests/unit/libs/auth/accountImport.test.ts index 07a915b42..00eb2873f 100644 --- a/app/src/tests/unit/libs/auth/accountImport.test.ts +++ b/app/src/tests/unit/libs/auth/accountImport.test.ts @@ -5,13 +5,12 @@ import { LocalStorageReformStore } from '@/api/reformStore'; import { MOCK_USER_ID } from '@/constants'; import { dismissAccountImportNotice, - IMPORT_FAILURE_MESSAGE, importBrowserWork, - importSuccessMessage, - runAccountCreationImport, + importOutcomeMessage, + runSignInImport, useAccountImportNotice, } from '@/libs/auth/accountImport'; -import { saveReportMeta } from '@/libs/flagship/runReport'; +import { readReportMeta, saveReportMeta } from '@/libs/flagship/runReport'; import { createTestApiContext, errorResponse, @@ -80,18 +79,30 @@ async function seedBrowserWork() { return { usReform, ukReform, record }; } -/** Answers each import with an id_map entry for every client_ref it was sent. */ -async function acceptEverything(_url: string, init: RequestInit) { - const body = JSON.parse(String(init.body)); - const refs = [...(body.policies ?? []), ...(body.reports ?? [])].map( - (item: { client_ref: string }) => item.client_ref - ); - return okResponse({ - id_map: Object.fromEntries(refs.map((ref, index) => [ref, `uuid-${index}-${ref}`])), - skipped: [], - }); +/** Answers each import with an id_map entry for every client_ref it was sent, except `refusedRefs`. */ +function acceptAllBut(...refusedRefs: Array) { + const refused = new Set(refusedRefs); + return async (_url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)); + const refs: string[] = [...(body.policies ?? []), ...(body.reports ?? [])].map( + (item: { client_ref: string }) => item.client_ref + ); + return okResponse({ + id_map: Object.fromEntries( + refs.filter((ref) => !refused.has(ref)).map((ref, index) => [ref, `uuid-${index}-${ref}`]) + ), + skipped: refs + .filter((ref) => refused.has(ref)) + .map((ref) => ({ client_ref: ref, reason: 'unknown_legacy_reference' })), + }); + }; } +const acceptEverything = acceptAllBut(); + +const storedIds = (key: string) => + JSON.parse(localStorage.getItem(key) ?? '[]').map((item: { id: string }) => item.id); + beforeEach(() => { localStorage.clear(); act(() => dismissAccountImportNotice()); @@ -113,7 +124,12 @@ describe('importBrowserWork', () => { const outcome = await importBrowserWork({ client: context.client, materialize }); // Then - expect(outcome).toEqual({ reformsAdded: 2, reportsAdded: 1, notAdded: 0 }); + expect(outcome).toEqual({ + reformsMoved: 2, + reportsMoved: 1, + leftOnBrowser: 0, + requestFailed: false, + }); expect(materialize).toHaveBeenCalledTimes(1); const usImport = fetchCall(fetchMock, 0); expect(usImport.query).toEqual({ country_id: 'us' }); @@ -140,6 +156,91 @@ describe('importBrowserWork', () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + test('given everything moves then this browser keeps only work outside the US and UK', async () => { + // Given + await seedBrowserWork(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(acceptEverything); + + // When + await importBrowserWork({ client: context.client, materialize: async () => 1 }); + + // Then + const remaining = JSON.parse(localStorage.getItem('reforms') ?? '[]'); + expect(remaining.map((reform: Reform) => reform.countryId)).toEqual(['ca']); + expect(storedIds('pe-flagship-reports')).toEqual([]); + expect(readReportMeta('615')).toBeNull(); + }); + + test('given the account skips an item then only that item stays, and goes again next time', async () => { + // Given + const { usReform, ukReform, record } = await seedBrowserWork(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(acceptAllBut(ukReform.id)); + + // When + const outcome = await importBrowserWork({ client: context.client, materialize: async () => 1 }); + + // Then + expect(outcome).toMatchObject({ reformsMoved: 1, reportsMoved: 1, leftOnBrowser: 1 }); + expect(storedIds('reforms')).not.toContain(usReform.id); + expect(storedIds('reforms')).toContain(ukReform.id); + expect(storedIds('pe-flagship-reports')).not.toContain(record.id); + + // When: the next sign-in + fetchMock.mockClear(); + fetchMock.mockImplementation(acceptEverything); + const next = await importBrowserWork({ client: context.client, materialize: async () => 1 }); + + // Then + expect(next).toMatchObject({ reformsMoved: 1, reportsMoved: 0, leftOnBrowser: 0 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchCall(fetchMock, 0).body.policies).toEqual([ + expect.objectContaining({ client_ref: ukReform.id }), + ]); + }); + + test('given a request fails then the run stops and only the work already moved is removed', async () => { + // Given + const { usReform, ukReform } = await seedBrowserWork(); + const { fetchMock, context } = createTestApiContext(); + fetchMock + .mockImplementationOnce(acceptEverything) + .mockResolvedValueOnce(errorResponse(503, 'Saved work is temporarily unavailable.')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + // When + const outcome = await importBrowserWork({ client: context.client, materialize: async () => 1 }); + + // Then + expect(outcome).toEqual({ + reformsMoved: 1, + reportsMoved: 1, + leftOnBrowser: 1, + requestFailed: true, + }); + expect(storedIds('reforms')).not.toContain(usReform.id); + expect(storedIds('reforms')).toContain(ukReform.id); + }); + + test('given the sign-in country then its work goes first', async () => { + // Given + await seedBrowserWork(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(acceptEverything); + + // When + await importBrowserWork({ + client: context.client, + firstCountryId: 'uk', + materialize: async () => 1, + }); + + // Then + expect(fetchCall(fetchMock, 0).query).toEqual({ country_id: 'uk' }); + expect(fetchCall(fetchMock, 1).query).toEqual({ country_id: 'us' }); + }); + test('given the import runs then it reads only the v3 keys, never the legacy ones', async () => { // Given await seedBrowserWork(); @@ -162,9 +263,9 @@ describe('importBrowserWork', () => { expect(LEGACY_KEYS.filter((key) => readKeys.has(key))).toEqual([]); }); - test('given a reform that cannot get a v1 policy then its report goes without the link', async () => { + test('given a reform that cannot get a v1 policy then it and its report stay for next time', async () => { // Given - const { record } = await seedBrowserWork(); + const { usReform, ukReform, record } = await seedBrowserWork(); const { fetchMock, context } = createTestApiContext(); fetchMock.mockImplementation(acceptEverything); @@ -176,15 +277,21 @@ describe('importBrowserWork', () => { }, }); - // Then - expect(outcome.notAdded).toBe(1); - const report = fetchCall(fetchMock, 0).body.reports[0]; - expect(report).toMatchObject({ client_ref: record.id, user_policy_client_ref: null }); + // Then: only the UK reform, which already had its v1 policy, is sent + expect(outcome).toMatchObject({ reformsMoved: 1, reportsMoved: 0, leftOnBrowser: 2 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchCall(fetchMock, 0).body).toEqual({ + policies: [expect.objectContaining({ client_ref: ukReform.id })], + reports: [], + }); + expect(storedIds('reforms')).toContain(usReform.id); + expect(storedIds('pe-flagship-reports')).toEqual([record.id]); + expect(readReportMeta('615')).not.toBeNull(); }); }); -describe('runAccountCreationImport', () => { - test('given it succeeds then the notice says what was added', async () => { +describe('runSignInImport', () => { + test('given it succeeds then the notice says what moved', async () => { // Given await seedBrowserWork(); const { fetchMock, context } = createTestApiContext(); @@ -192,30 +299,28 @@ describe('runAccountCreationImport', () => { const notice = renderHook(() => useAccountImportNotice()); // When - await act(() => - runAccountCreationImport({ client: context.client, materialize: async () => 1 }) - ); + await act(() => runSignInImport({ client: context.client, materialize: async () => 1 })); // Then expect(notice.result.current).toEqual({ kind: 'success', - message: 'Added 2 reforms and 1 report saved on this browser to your account.', + message: 'Moved 2 reforms and 1 report from this browser to your account.', retry: null, }); }); - test('given it fails then the notice says the work is still here, and Try again runs it again', async () => { + test('given a request fails then the notice says the work is still here, and Try again moves it', async () => { // Given await seedBrowserWork(); const { fetchMock, context } = createTestApiContext(); fetchMock.mockResolvedValue(errorResponse(503, 'Saved work is temporarily unavailable.')); + vi.spyOn(console, 'error').mockImplementation(() => {}); const notice = renderHook(() => useAccountImportNotice()); - await act(() => - runAccountCreationImport({ client: context.client, materialize: async () => 1 }) - ); + await act(() => runSignInImport({ client: context.client, materialize: async () => 1 })); expect(notice.result.current).toMatchObject({ kind: 'failure', - message: IMPORT_FAILURE_MESSAGE, + message: + 'Your reforms and reports saved on this browser could not be moved to your account. They are still on this browser and will be tried again the next time you sign in.', }); // When @@ -226,7 +331,26 @@ describe('runAccountCreationImport', () => { }); // Then - expect(notice.result.current?.message).toMatch(/^Added 2 reforms and 1 report/); + expect(notice.result.current?.message).toMatch(/^Moved 2 reforms and 1 report/); + }); + + test('given the account skips items then the notice warns, without Try again', async () => { + // Given + const { ukReform } = await seedBrowserWork(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(acceptAllBut(ukReform.id)); + const notice = renderHook(() => useAccountImportNotice()); + + // When + await act(() => runSignInImport({ client: context.client, materialize: async () => 1 })); + + // Then + expect(notice.result.current).toEqual({ + kind: 'warning', + message: + 'Moved 1 reform and 1 report from this browser to your account. 1 item is still on this browser and will be tried again the next time you sign in.', + retry: null, + }); }); test('given nothing saved on this browser then nothing is sent and no notice shows', async () => { @@ -235,7 +359,7 @@ describe('runAccountCreationImport', () => { const notice = renderHook(() => useAccountImportNotice()); // When - await act(() => runAccountCreationImport({ client: context.client })); + await act(() => runSignInImport({ client: context.client })); // Then expect(fetchMock).not.toHaveBeenCalled(); @@ -243,10 +367,17 @@ describe('runAccountCreationImport', () => { }); }); -describe('importSuccessMessage', () => { - test('given one of each and items left behind then the wording agrees with the counts', () => { - expect(importSuccessMessage({ reformsAdded: 1, reportsAdded: 1, notAdded: 1 })).toBe( - 'Added 1 reform and 1 report saved on this browser to your account. 1 item could not be added and is still on this browser.' +describe('importOutcomeMessage', () => { + test('given nothing moved and no request failed then the wording agrees with the count', () => { + expect( + importOutcomeMessage({ + reformsMoved: 0, + reportsMoved: 0, + leftOnBrowser: 2, + requestFailed: false, + }) + ).toBe( + '2 items saved on this browser could not be moved to your account. They are still on this browser and will be tried again the next time you sign in.' ); }); }); diff --git a/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx b/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx index 3dcc13254..1a49e7683 100644 --- a/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx +++ b/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx @@ -8,12 +8,7 @@ import { dismissAccountImportNotice } from '@/libs/auth/accountImport'; import type { AuthContextValue } from '@/libs/auth/AuthContext'; import { takeSignInReturnTo } from '@/libs/auth/AuthProvider'; import AuthCallbackPage from '@/pages/flagship/AuthCallback.page'; -import { - errorResponse, - itemResponse, - mockMeItem, - okResponse, -} from '@/tests/fixtures/api/v2UserMocks'; +import { errorResponse, okResponse } from '@/tests/fixtures/api/v2UserMocks'; import { AuthTestProvider, makeAuthValue, @@ -42,35 +37,27 @@ function requestPaths(): string[] { return fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname); } -/** Answers /v2/me with `accountCreated`, and imports with everything added. */ -function answer({ - accountCreated, - imports = 'accept', -}: { - accountCreated: boolean; - imports?: 'accept' | 'fail'; -}) { +/** Answers imports with every reform added, or with a failure. */ +function answerImports(imports: 'accept' | 'fail') { fetchMock.mockImplementation(async (url: string, init: RequestInit) => { - const path = new URL(url).pathname; - if (path === '/v2/me') { - return itemResponse({ ...mockMeItem, account_created: accountCreated }); + if (new URL(url).pathname !== '/v2/me/imports') { + return new Response('', { status: 404 }); } - if (path === '/v2/me/imports') { - if (imports === 'fail') { - return errorResponse(503, 'Saved work is temporarily unavailable.'); - } - const body = JSON.parse(String(init.body)); - return okResponse({ - id_map: Object.fromEntries( - (body.policies ?? []).map((item: { client_ref: string }) => [item.client_ref, 'uuid-1']) - ), - skipped: [], - }); + if (imports === 'fail') { + return errorResponse(503, 'Saved work is temporarily unavailable.'); } - return new Response('', { status: 404 }); + const body = JSON.parse(String(init.body)); + return okResponse({ + id_map: Object.fromEntries( + (body.policies ?? []).map((item: { client_ref: string }) => [item.client_ref, 'uuid-1']) + ), + skipped: [], + }); }); } +const browserReformCount = () => JSON.parse(localStorage.getItem('reforms') ?? '[]').length; + beforeEach(async () => { localStorage.clear(); vi.clearAllMocks(); @@ -94,63 +81,54 @@ afterEach(() => { }); describe('AuthCallbackPage', () => { - test("given the sign-in created the account then this browser's v3 work is imported, then it returns", async () => { + test('given work saved on this browser then it moves to the account, then it returns', async () => { // Given - answer({ accountCreated: true }); + answerImports('accept'); // When renderCallback(signedInAuthValue()); // Then await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); - expect(requestPaths()).toEqual(['/v2/me', '/v2/me/imports']); + expect(requestPaths()).toEqual(['/v2/me/imports']); expect(await screen.findByRole('alert')).toHaveTextContent( - 'Added 1 reform and 0 reports saved on this browser to your account.' + 'Moved 1 reform and 0 reports from this browser to your account.' ); + expect(browserReformCount()).toBe(0); }); - test('given an existing account signs in then nothing is imported', async () => { + test('given nothing saved on this browser then nothing is called', async () => { // Given - answer({ accountCreated: false }); + localStorage.clear(); + answerImports('accept'); // When renderCallback(signedInAuthValue()); // Then await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); - expect(requestPaths()).toEqual(['/v2/me']); + expect(fetchMock).not.toHaveBeenCalled(); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); - test('given the import fails then the notice says the work is still here, with one Try again', async () => { + test('given the import fails then the work stays, and Try again moves it', async () => { // Given - answer({ accountCreated: true, imports: 'fail' }); + answerImports('fail'); + vi.spyOn(console, 'error').mockImplementation(() => {}); const user = userEvent.setup(); renderCallback(signedInAuthValue()); expect(await screen.findByRole('alert')).toHaveTextContent(/still on this browser/); await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); + expect(browserReformCount()).toBe(1); // When - answer({ accountCreated: false }); + answerImports('accept'); await user.click(screen.getByRole('button', { name: 'Try again' })); - // Then: the retry imports again; /v2/me is not asked again - expect(await screen.findByText(/^Added 1 reform/)).toBeInTheDocument(); - expect(requestPaths().filter((path) => path === '/v2/me')).toHaveLength(1); - expect(requestPaths().filter((path) => path === '/v2/me/imports')).toHaveLength(2); - }); - - test('given /v2/me fails then it still returns, without an import', async () => { - // Given - fetchMock.mockResolvedValue(errorResponse(503, 'Saved work is temporarily unavailable.')); - vi.spyOn(console, 'error').mockImplementation(() => {}); - - // When - renderCallback(signedInAuthValue()); - // Then - await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); - expect(requestPaths()).toEqual(['/v2/me']); + expect(await screen.findByText(/^Moved 1 reform/)).toBeInTheDocument(); + expect(requestPaths()).toEqual(['/v2/me/imports', '/v2/me/imports']); + expect(browserReformCount()).toBe(0); }); test('given sign-in did not complete then the error shows and nothing is called', () => { diff --git a/app/src/types/v2User.ts b/app/src/types/v2User.ts index 8fbfc2dd7..60146739b 100644 --- a/app/src/types/v2User.ts +++ b/app/src/types/v2User.ts @@ -37,8 +37,6 @@ export interface V2PageResult { export interface MeItem { /** The PolicyEngine user ID that owns every association. */ id: string; - /** True only on the request that created this PolicyEngine user. */ - account_created: boolean; primary_country: AssociationCountryId; first_name: string | null; last_name: string | null; From 333b6809f7bfd41e12edf693ddd072448ce9529f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:32:12 +0400 Subject: [PATCH 30/48] Create a reform's v1 policy when it is saved while signed out Saving a reform signed out kept the whole reform, parameters included, only in localStorage; its v1 policy appeared only when a report ran, and that policy's ID was never written back. Signed out now matches signed in: the reform's work lives in the Public API, and the browser keeps the record that points at it. - LocalStorageReformStore materializes the parameters as a v1 policy on create (POST /{country}/policy, which dedupes identical ones) and keeps the policy ID on the browser copy. Changing the parameters creates the new policy and repoints the reform; a rename keeps the policy. A save that cannot create the policy fails and stores nothing, as signed in. - The sign-in import no longer creates v1 policies: every browser reform already has one. A reform saved by an earlier build without one is not sent and stays in the browser until it is saved again. - Duplicating a reform reuses the original's v1 policy. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + app/src/api/reformStore.ts | 59 ++++++++++++---- app/src/libs/auth/accountImport.ts | 50 +++---------- app/src/pages/flagship/Reforms.page.tsx | 2 + app/src/tests/unit/api/reformStore.test.ts | 66 ++++++++++++++++- .../unit/libs/auth/accountImport.test.ts | 70 +++++++++---------- 6 files changed, 159 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 5c6e80d54..20a64884a 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,9 @@ build time (see `calculator-app/.env.example`), alongside - Signed in on the US or UK workspace, reforms and reports go to the account. Signed out, they stay in this browser, in v3's own keys. +- Either way, saving a reform first creates its policy in the PolicyEngine + API, and running a report creates its simulations and report there. The + browser or the account keeps only the records that point at them. - Every sign-in moves the v3 reforms and reports saved on this browser while signed out into the account. Each item the account confirms is removed from the browser; anything else stays and is sent again at the diff --git a/app/src/api/reformStore.ts b/app/src/api/reformStore.ts index 1af211257..4b6ba4236 100644 --- a/app/src/api/reformStore.ts +++ b/app/src/api/reformStore.ts @@ -17,11 +17,12 @@ import type { /** * Store for the flagship app's reform objects. * - * Signed out (and outside the US and UK) reforms live in localStorage. - * Signed in, a reform is a row in the account's `/v2/user-policies`: its - * parameters are materialized as a v1 policy (`POST /{country}/policy`, - * which dedupes identical ones), and the row carries the policy ID, the - * name, the baseline, and the provenance. + * Either way, saving a reform materializes its parameters as a v1 policy + * (`POST /{country}/policy`, which dedupes identical ones), and a save + * that cannot create the policy fails. Signed out (and outside the US and + * UK) the reform, with its policy ID, lives in localStorage. Signed in, it + * is a row in the account's `/v2/user-policies` carrying the policy ID, + * the name, the baseline, and the provenance. */ export interface ReformStore { create: (reform: Omit) => Promise; @@ -38,10 +39,20 @@ export type ReformUpdates = Partial< Omit >; +/** Reforms saved in this browser, each with the ID of its v1 policy. */ export class LocalStorageReformStore implements ReformStore { private readonly STORAGE_KEY = 'reforms'; + private readonly materializePolicy: MaterializeReformPolicy; + + constructor(options: Pick = {}) { + this.materializePolicy = options.materializePolicy ?? materializeReformPolicy; + } async create(reform: Omit): Promise { + const policyId = isLegacyId(reform.policyId) + ? String(reform.policyId) + : String(await this.materializePolicy(reform.countryId, reform.label, reform.parameters)); + // Generate a unique ID for local storage // Format: "rf-[short-timestamp][random]" // Use base36 encoding for compactness @@ -52,6 +63,7 @@ export class LocalStorageReformStore implements ReformStore { const now = new Date().toISOString(); const newReform: Reform = { ...reform, + policyId, id: uniqueId, createdAt: now, updatedAt: now, @@ -72,9 +84,25 @@ export class LocalStorageReformStore implements ReformStore { } async update(reformId: string, updates: ReformUpdates): Promise { + const existing = await this.findById(reformId); + if (!existing) { + throw new Error('Failed to update reform'); + } + // Changed parameters are a new v1 policy; a rename keeps the policy. + const policyId = + updates.parameters !== undefined + ? String( + await this.materializePolicy( + existing.countryId, + updates.label !== undefined ? updates.label : existing.label, + updates.parameters + ) + ) + : (updates.policyId ?? existing.policyId); + + // Read again after the request, so a change made meanwhile is kept. const reforms = this.getStoredReforms(); const index = reforms.findIndex((r) => r.id === reformId); - if (index === -1) { throw new Error('Failed to update reform'); } @@ -82,6 +110,7 @@ export class LocalStorageReformStore implements ReformStore { const updated: Reform = { ...reforms[index], ...updates, + policyId, updatedAt: new Date().toISOString(), }; reforms[index] = updated; @@ -168,15 +197,17 @@ function provenanceFromWire(wire: ProvenanceWire | null): ReformProvenance { }; } +/** Materializes parameters as a v1 policy; returns its ID. */ +export type MaterializeReformPolicy = ( + countryId: string, + label: string | null | undefined, + parameters: Parameter[] +) => Promise; + export interface ApiReformStoreOptions { /** Current parameter metadata, for coercing values read back from v1 policies. */ getParameterMetadata?: () => ParameterMetadataCollection | null | undefined; - /** Materializes parameters as a v1 policy; returns its ID. */ - materializePolicy?: ( - countryId: string, - label: string | null | undefined, - parameters: Parameter[] - ) => Promise; + materializePolicy?: MaterializeReformPolicy; /** Reads a v1 policy's parameter map. */ fetchPolicyJson?: (countryId: string, policyId: string) => Promise; } @@ -215,7 +246,7 @@ export async function fetchV1PolicyJson( export class ApiReformStore implements ReformStore { private readonly routes: AccountAssociationRoutes; private readonly getParameterMetadata: NonNullable; - private readonly materializePolicy: NonNullable; + private readonly materializePolicy: MaterializeReformPolicy; private readonly fetchPolicyJson: NonNullable; constructor( @@ -329,5 +360,5 @@ export function getReformStore( account?: ApiStoreContext | null, options?: ApiReformStoreOptions ): ReformStore { - return account ? new ApiReformStore(account, options) : new LocalStorageReformStore(); + return account ? new ApiReformStore(account, options) : new LocalStorageReformStore(options); } diff --git a/app/src/libs/auth/accountImport.ts b/app/src/libs/auth/accountImport.ts index 924adf326..5e010c86e 100644 --- a/app/src/libs/auth/accountImport.ts +++ b/app/src/libs/auth/accountImport.ts @@ -4,7 +4,6 @@ import { provisionsToWire, type FlagshipReportRecord, } from '@/api/flagshipReportStore'; -import { materializeReformPolicy } from '@/api/reformStore'; import { isLegacyId } from '@/api/v2Associations'; import { importToAccount } from '@/api/v2Me'; import type { V2UserClient } from '@/api/v2UserClient'; @@ -28,6 +27,10 @@ import { * stays here and is sent again at the next sign-in. Only v3's own keys are * read. The server deduplicates by `client_ref` (the browser ID), so an * item sent again returns its existing ID. + * + * Browser reforms already carry their v1 policy (saving one creates it), + * so the import creates nothing in v1. A reform saved by an earlier build + * without a policy is not sent; it stays here until it is saved again. */ /** The v3 keys the import reads; v3 never reads the legacy calculator's keys. */ @@ -61,7 +64,7 @@ function text(value: unknown): string | null { } type PlannedItem = - | { kind: 'policies'; clientRef: string; body: ImportPolicyItem; reform?: Reform } + | { kind: 'policies'; clientRef: string; body: ImportPolicyItem } | { kind: 'reports'; clientRef: string; body: ImportReportItem }; /** This browser's v3 reforms and reports, per account country. */ @@ -72,7 +75,7 @@ function planBrowserWorkImport(): Record { const plan: Record = { us: [], uk: [] }; for (const reform of reforms) { - if (!reform.id || !isAssociationCountry(reform.countryId)) { + if (!reform.id || !isAssociationCountry(reform.countryId) || !isLegacyId(reform.policyId)) { continue; } plan[reform.countryId].push({ @@ -80,7 +83,7 @@ function planBrowserWorkImport(): Record { clientRef: reform.id, body: { client_ref: reform.id, - legacy_policy_id: isLegacyId(reform.policyId) ? Number(reform.policyId) : 0, + legacy_policy_id: Number(reform.policyId), name: text(reform.label), description: null, baseline: reform.baseline ?? null, @@ -92,8 +95,6 @@ function planBrowserWorkImport(): Record { } : null, }, - // Reforms without a v1 policy get one before the import. - ...(isLegacyId(reform.policyId) ? {} : { reform }), }); } @@ -203,7 +204,7 @@ function countryOrder(firstCountryId?: string): AssociationCountryId[] { export interface BrowserWorkImportOutcome { reformsMoved: number; reportsMoved: number; - /** Items still on this browser: skipped, held back, or not sent. */ + /** Items still on this browser: skipped by the account, or not sent. */ leftOnBrowser: number; /** True when an import request failed, which stops the run. */ requestFailed: boolean; @@ -217,21 +218,16 @@ export interface ImportBrowserWorkOptions { * country. */ firstCountryId?: string; - /** Materializes a reform's parameters as a v1 policy (default: POST /{c}/policy). */ - materialize?: (reform: Reform) => Promise; } /** * Moves this browser's signed-out v3 work into the account, removing each - * item the account confirms. A reform that cannot get a v1 policy stays, - * and so do its reports, so they keep their link when they move later. - * A failed request stops the run; everything not yet moved stays. + * item the account confirms. A failed request stops the run; everything + * not yet moved stays. */ export async function importBrowserWork({ client, firstCountryId, - materialize = (reform) => - materializeReformPolicy(reform.countryId, reform.label, reform.parameters), }: ImportBrowserWorkOptions): Promise { const plan = planBrowserWorkImport(); const planned = ASSOCIATION_COUNTRY_IDS.reduce( @@ -246,31 +242,7 @@ export async function importBrowserWork({ }; for (const countryId of countryOrder(firstCountryId)) { - const ready: PlannedItem[] = []; - const reformsLeft = new Set(); - for (const item of plan[countryId]) { - if (item.kind === 'policies' && item.reform) { - try { - const policyId = await materialize(item.reform); - ready.push({ ...item, body: { ...item.body, legacy_policy_id: policyId } }); - } catch { - reformsLeft.add(item.clientRef); - } - continue; - } - ready.push(item); - } - // A report whose reform stays on this browser stays with it. - const items = ready.filter( - (item) => - !( - item.kind === 'reports' && - item.body.user_policy_client_ref && - reformsLeft.has(item.body.user_policy_client_ref) - ) - ); - - for (const body of chunkImportItems(items)) { + for (const body of chunkImportItems(plan[countryId])) { const result = await importToAccount(client, countryId, body).catch((error: unknown) => { console.error('[accountImport] Import request failed:', error); return null; diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index 72198ce92..e6b508416 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -305,6 +305,8 @@ export default function ReformsPage() { userId: reform.userId, countryId: reform.countryId, label: `${reform.label || 'Untitled reform'} (copy)`, + // The copy shares the original's v1 policy; nothing new to create. + policyId: reform.policyId, parameters: reform.parameters, baseline: reform.baseline, provenance: reform.provenance, diff --git a/app/src/tests/unit/api/reformStore.test.ts b/app/src/tests/unit/api/reformStore.test.ts index 718fd3320..34eebaaae 100644 --- a/app/src/tests/unit/api/reformStore.test.ts +++ b/app/src/tests/unit/api/reformStore.test.ts @@ -210,7 +210,9 @@ describe('ApiReformStore', () => { 'given a reform saved on this browser while signed out, when %s signed in, then it is refused', async (_, operate) => { // Given - const local = await new LocalStorageReformStore().create(mockReformInput); + const local = await new LocalStorageReformStore({ + materializePolicy: async () => MATERIALIZED_POLICY_ID, + }).create(mockReformInput); const { store, fetchMock } = apiReformStore(); // When / Then @@ -321,9 +323,11 @@ describe('getReformStore', () => { describe('LocalStorageReformStore', () => { let store: LocalStorageReformStore; + let materializePolicy: ReturnType; beforeEach(() => { - store = new LocalStorageReformStore(); + materializePolicy = vi.fn().mockResolvedValue(MATERIALIZED_POLICY_ID); + store = new LocalStorageReformStore({ materializePolicy }); localStorage.clear(); }); @@ -340,6 +344,32 @@ describe('LocalStorageReformStore', () => { expect(byId?.label).toBe('CTC to $3,600 for children under 6'); }); + it('given a reform, when created, then its v1 policy is created and its ID kept in this browser', async () => { + const created = await store.create(mockReformInput); + + expect(materializePolicy).toHaveBeenCalledWith( + 'us', + mockReformInput.label, + mockReformInput.parameters + ); + expect(created.policyId).toBe(String(MATERIALIZED_POLICY_ID)); + expect((await store.findById(created.id!))?.policyId).toBe(String(MATERIALIZED_POLICY_ID)); + }); + + it('given a reform that already has a v1 policy, when created, then no new policy is made', async () => { + const created = await store.create({ ...mockReformInput, policyId: '501' }); + + expect(materializePolicy).not.toHaveBeenCalled(); + expect(created.policyId).toBe('501'); + }); + + it('given the v1 policy cannot be created, when created, then the save fails and nothing is stored', async () => { + materializePolicy.mockRejectedValue(new Error('Failed to create policy')); + + await expect(store.create(mockReformInput)).rejects.toThrow('Failed to create policy'); + expect(await store.findByUser('user-123')).toEqual([]); + }); + it('given reforms from two countries, when filtering by country, then only matches are returned', async () => { await store.create(mockReformInput); await store.create({ ...mockReformInput, countryId: 'uk' }); @@ -349,13 +379,43 @@ describe('LocalStorageReformStore', () => { expect(usOnly[0].countryId).toBe('us'); }); - it('given an existing reform, when updated, then fields change and updatedAt advances', async () => { + it('given an existing reform, when renamed, then the label changes and the v1 policy stays', async () => { const created = await store.create(mockReformInput); + materializePolicy.mockClear(); const updated = await store.update(created.id!, { label: 'Renamed' }); expect(updated.label).toBe('Renamed'); expect(updated.userId).toBe('user-123'); + expect(updated.policyId).toBe(String(MATERIALIZED_POLICY_ID)); + expect(materializePolicy).not.toHaveBeenCalled(); + }); + + it('given an existing reform, when its parameters change, then it points at a new v1 policy', async () => { + const created = await store.create(mockReformInput); + materializePolicy.mockResolvedValue(REPLACEMENT_POLICY_ID); + const parameters = [ + { + name: 'gov.irs.credits.ctc.amount.base[0]', + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 4000 }], + }, + ]; + + const updated = await store.update(created.id!, { parameters }); + + expect(materializePolicy).toHaveBeenLastCalledWith('us', mockReformInput.label, parameters); + expect(updated.policyId).toBe(String(REPLACEMENT_POLICY_ID)); + expect((await store.findById(created.id!))?.parameters).toEqual(parameters); + }); + + it('given the new v1 policy cannot be created, when updated, then the stored reform is unchanged', async () => { + const created = await store.create(mockReformInput); + materializePolicy.mockRejectedValue(new Error('Failed to create policy')); + + await expect(store.update(created.id!, { parameters: [] })).rejects.toThrow( + 'Failed to create policy' + ); + expect(await store.findById(created.id!)).toEqual(created); }); it('given a missing reform, when updated, then throws', async () => { diff --git a/app/src/tests/unit/libs/auth/accountImport.test.ts b/app/src/tests/unit/libs/auth/accountImport.test.ts index 00eb2873f..949c678a5 100644 --- a/app/src/tests/unit/libs/auth/accountImport.test.ts +++ b/app/src/tests/unit/libs/auth/accountImport.test.ts @@ -41,9 +41,12 @@ const REFORM_FIELDS: Omit MATERIALIZED_POLICY_ID, + }); const usReform = await reforms.create({ ...REFORM_FIELDS, countryId: 'us' }); const ukReform = await reforms.create({ ...REFORM_FIELDS, @@ -118,10 +121,9 @@ describe('importBrowserWork', () => { const { usReform, ukReform, record } = await seedBrowserWork(); const { fetchMock, context } = createTestApiContext(); fetchMock.mockImplementation(acceptEverything); - const materialize = vi.fn().mockResolvedValue(MATERIALIZED_POLICY_ID); // When - const outcome = await importBrowserWork({ client: context.client, materialize }); + const outcome = await importBrowserWork({ client: context.client }); // Then expect(outcome).toEqual({ @@ -130,7 +132,6 @@ describe('importBrowserWork', () => { leftOnBrowser: 0, requestFailed: false, }); - expect(materialize).toHaveBeenCalledTimes(1); const usImport = fetchCall(fetchMock, 0); expect(usImport.query).toEqual({ country_id: 'us' }); expect(usImport.body.policies).toEqual([ @@ -163,7 +164,7 @@ describe('importBrowserWork', () => { fetchMock.mockImplementation(acceptEverything); // When - await importBrowserWork({ client: context.client, materialize: async () => 1 }); + await importBrowserWork({ client: context.client }); // Then const remaining = JSON.parse(localStorage.getItem('reforms') ?? '[]'); @@ -179,7 +180,7 @@ describe('importBrowserWork', () => { fetchMock.mockImplementation(acceptAllBut(ukReform.id)); // When - const outcome = await importBrowserWork({ client: context.client, materialize: async () => 1 }); + const outcome = await importBrowserWork({ client: context.client }); // Then expect(outcome).toMatchObject({ reformsMoved: 1, reportsMoved: 1, leftOnBrowser: 1 }); @@ -190,7 +191,7 @@ describe('importBrowserWork', () => { // When: the next sign-in fetchMock.mockClear(); fetchMock.mockImplementation(acceptEverything); - const next = await importBrowserWork({ client: context.client, materialize: async () => 1 }); + const next = await importBrowserWork({ client: context.client }); // Then expect(next).toMatchObject({ reformsMoved: 1, reportsMoved: 0, leftOnBrowser: 0 }); @@ -210,7 +211,7 @@ describe('importBrowserWork', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); // When - const outcome = await importBrowserWork({ client: context.client, materialize: async () => 1 }); + const outcome = await importBrowserWork({ client: context.client }); // Then expect(outcome).toEqual({ @@ -230,11 +231,7 @@ describe('importBrowserWork', () => { fetchMock.mockImplementation(acceptEverything); // When - await importBrowserWork({ - client: context.client, - firstCountryId: 'uk', - materialize: async () => 1, - }); + await importBrowserWork({ client: context.client, firstCountryId: 'uk' }); // Then expect(fetchCall(fetchMock, 0).query).toEqual({ country_id: 'uk' }); @@ -254,7 +251,7 @@ describe('importBrowserWork', () => { }); // When - await importBrowserWork({ client: context.client, materialize: async () => 1 }); + await importBrowserWork({ client: context.client }); // Then expect([...readKeys].sort()).toEqual( @@ -263,30 +260,33 @@ describe('importBrowserWork', () => { expect(LEGACY_KEYS.filter((key) => readKeys.has(key))).toEqual([]); }); - test('given a reform that cannot get a v1 policy then it and its report stay for next time', async () => { + test('given a reform saved without a v1 policy by an earlier build then it is not sent and stays', async () => { // Given - const { usReform, ukReform, record } = await seedBrowserWork(); + const { usReform } = await seedBrowserWork(); + const stored = JSON.parse(localStorage.getItem('reforms') ?? '[]'); + localStorage.setItem( + 'reforms', + JSON.stringify( + stored.map((reform: Reform) => + reform.id === usReform.id ? { ...reform, policyId: null } : reform + ) + ) + ); const { fetchMock, context } = createTestApiContext(); fetchMock.mockImplementation(acceptEverything); // When - const outcome = await importBrowserWork({ - client: context.client, - materialize: async () => { - throw new Error('Failed to create policy'); - }, - }); + const outcome = await importBrowserWork({ client: context.client }); - // Then: only the UK reform, which already had its v1 policy, is sent - expect(outcome).toMatchObject({ reformsMoved: 1, reportsMoved: 0, leftOnBrowser: 2 }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchCall(fetchMock, 0).body).toEqual({ - policies: [expect.objectContaining({ client_ref: ukReform.id })], - reports: [], - }); + // Then + expect(outcome).toMatchObject({ reformsMoved: 1, reportsMoved: 1, leftOnBrowser: 0 }); + const sentRefs = [0, 1].flatMap((call) => + fetchCall(fetchMock, call).body.policies.map( + (item: { client_ref: string }) => item.client_ref + ) + ); + expect(sentRefs).not.toContain(usReform.id); expect(storedIds('reforms')).toContain(usReform.id); - expect(storedIds('pe-flagship-reports')).toEqual([record.id]); - expect(readReportMeta('615')).not.toBeNull(); }); }); @@ -299,7 +299,7 @@ describe('runSignInImport', () => { const notice = renderHook(() => useAccountImportNotice()); // When - await act(() => runSignInImport({ client: context.client, materialize: async () => 1 })); + await act(() => runSignInImport({ client: context.client })); // Then expect(notice.result.current).toEqual({ @@ -316,7 +316,7 @@ describe('runSignInImport', () => { fetchMock.mockResolvedValue(errorResponse(503, 'Saved work is temporarily unavailable.')); vi.spyOn(console, 'error').mockImplementation(() => {}); const notice = renderHook(() => useAccountImportNotice()); - await act(() => runSignInImport({ client: context.client, materialize: async () => 1 })); + await act(() => runSignInImport({ client: context.client })); expect(notice.result.current).toMatchObject({ kind: 'failure', message: @@ -342,7 +342,7 @@ describe('runSignInImport', () => { const notice = renderHook(() => useAccountImportNotice()); // When - await act(() => runSignInImport({ client: context.client, materialize: async () => 1 })); + await act(() => runSignInImport({ client: context.client })); // Then expect(notice.result.current).toEqual({ From 9b78de29b21306caf505b7a0b8c846c2b13ccf64 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:32:25 +0400 Subject: [PATCH 31/48] Say what a browser reform keeps beside its v1 policy ID Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 20a64884a..c05ce7f5c 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,10 @@ build time (see `calculator-app/.env.example`), alongside - Signed in on the US or UK workspace, reforms and reports go to the account. Signed out, they stay in this browser, in v3's own keys. - Either way, saving a reform first creates its policy in the PolicyEngine - API, and running a report creates its simulations and report there. The - browser or the account keeps only the records that point at them. + API, and running a report creates its simulations and report there. A + browser reform keeps its policy ID next to a copy of its changes; an + account reform keeps only the policy ID and reads the changes from the + policy. - Every sign-in moves the v3 reforms and reports saved on this browser while signed out into the account. Each item the account confirms is removed from the browser; anything else stays and is sent again at the From cb66d9be2917b74e02026e3a6d18c00ee9e69f83 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:40:23 +0400 Subject: [PATCH 32/48] Save a reform before running its report, and link every reform run A report run from Build used the draft's values without saving them, so the report pointed at no reform, and a draft opened from a saved reform could run values that reform did not have. A report run from a saved reform on the Reforms page was not linked to it either. - Build's run button is now "Save and run {year} report". It saves the draft first, exactly as Save does (changing the reform the draft came from, or saving a new one), then runs the report linked to that reform. The draft is marked as that reform, so a retry after a failed run changes it rather than saving another. A failed save shows the save error and runs nothing. - "View full impact report" on a saved reform links the report to it, and now runs the reform as Build does: dated changes go with it (it used to send only each parameter's first value), and parameters left at current law stay out. provisionsToRun and reformToProvisions are shared by both paths. - Bill reports from the "In Congress" tab are unchanged and unlinked. Co-Authored-By: Claude Opus 5.5 --- .../flagship/reform/DraftHeader.tsx | 79 +++++++++------- app/src/libs/draftReform.ts | 82 +++++++++++++---- app/src/pages/flagship/Reforms.page.tsx | 44 ++++----- .../flagship/reform/DraftHeader.test.tsx | 92 ++++++++++++++++++- .../unit/pages/flagship/Reforms.test.tsx | 50 ++++++++++ 5 files changed, 266 insertions(+), 81 deletions(-) diff --git a/app/src/components/flagship/reform/DraftHeader.tsx b/app/src/components/flagship/reform/DraftHeader.tsx index e635a41a8..cfd526bc7 100644 --- a/app/src/components/flagship/reform/DraftHeader.tsx +++ b/app/src/components/flagship/reform/DraftHeader.tsx @@ -22,13 +22,13 @@ import { draftToReform, draftYear, provisionAdjusts, - provisionsForYear, + provisionsToRun, + setDraftEditingReform, setDraftLabel, setDraftYear, } from '@/libs/draftReform'; import { getTaxYears } from '@/libs/metadataUtils'; import { RootState } from '@/store'; -import { ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; /** * How the draft came about, for the report's own record. This is @@ -116,25 +116,24 @@ export default function DraftHeader({ draft }: { draft: DraftReform }) { const reformStore = useReformStore(); const taxYears = useSelector(getTaxYears); const parameters = useSelector((state: RootState) => state.metadata.parameters); - const currentLawAt = (path: string, at: number) => { - const values = parameters?.[path]?.values; - return values ? new ValueIntervalCollection(values).getValueAtDate(`${at}-01-01`) : undefined; - }; const year = draftYear(draft); const yearOptions = taxYears.length > 0 ? taxYears.map((t) => Number(t.value)) : [year]; + // Saving changes the reform the draft came from, or saves a new one. + const saveReform = async () => { + const payload = draftToReform(draft, MOCK_USER_ID); + if (draft.editingReformId) { + return reformStore.update(draft.editingReformId, { + label: payload.label, + parameters: payload.parameters, + provenance: payload.provenance, + }); + } + return reformStore.create(payload); + }; + const saveMutation = useMutation({ - mutationFn: async () => { - const payload = draftToReform(draft, MOCK_USER_ID); - if (draft.editingReformId) { - return reformStore.update(draft.editingReformId, { - label: payload.label, - parameters: payload.parameters, - provenance: payload.provenance, - }); - } - return reformStore.create(payload); - }, + mutationFn: saveReform, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['reforms'] }); clearDraftReform(); @@ -142,6 +141,28 @@ export default function DraftHeader({ draft }: { draft: DraftReform }) { }, }); + // A report always comes from a saved reform: save first, then run the + // report linked to it. The draft becomes that reform, so trying again + // after a failed run changes it rather than saving another. + const saveAndRunMutation = useMutation({ + mutationFn: saveReform, + onSuccess: (saved) => { + queryClient.invalidateQueries({ queryKey: ['reforms'] }); + setDraftEditingReform(saved.id!); + void runReport.run( + draft.label || 'Draft reform', + SOURCE_NOTES[draft.source] ?? 'Draft reform', + // Described as they stand in the year the report simulates. + provisionsToRun(draft.provisions, year, parameters), + saved.id, + year, + true + ); + }, + }); + const saveError = saveMutation.error ?? saveAndRunMutation.error; + const starting = saveAndRunMutation.isPending || runReport.isRunning; + const empty = draft.provisions.length === 0; // The report models what the reform changes: a parameter added but // left at current law stays in the draft, out of the run. @@ -207,27 +228,17 @@ export default function DraftHeader({ draft }: { draft: DraftReform }) {
@@ -246,14 +257,14 @@ export default function DraftHeader({ draft }: { draft: DraftReform }) {

)} - {(runReport.error || saveMutation.isError) && ( + {(runReport.error || saveError) && (

{runReport.error ?? - (saveMutation.error instanceof BrowserOnlyIdError - ? saveMutation.error.message + (saveError instanceof BrowserOnlyIdError + ? saveError.message : 'Could not save the reform. Try again.')}

)} diff --git a/app/src/libs/draftReform.ts b/app/src/libs/draftReform.ts index 9310be8a7..c836f3ef5 100644 --- a/app/src/libs/draftReform.ts +++ b/app/src/libs/draftReform.ts @@ -2,6 +2,7 @@ import { useSyncExternalStore } from 'react'; import { CURRENT_YEAR, FOREVER } from '@/constants'; import { CountryId } from '@/libs/countries'; import { Reform, ReformSource } from '@/types/ingredients/Reform'; +import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; import { ValueInterval, ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; import { getCurrentValue } from '@/utils/parameterValues'; @@ -416,6 +417,29 @@ export function setDraftYear(year: number): void { }); } +/** + * What a report runs for a set of provisions in `year`: only those that + * change the law, each as it stands in that year. Build and a saved + * reform's run both use it, so a reform reports the same either way. + */ +export function provisionsToRun( + provisions: DraftProvision[], + year: number, + parameters: ParameterMetadataCollection | null | undefined +): DraftProvision[] { + const currentLawAt = (path: string, at: number) => { + const values = parameters?.[path]?.values; + return values ? new ValueIntervalCollection(values).getValueAtDate(`${at}-01-01`) : undefined; + }; + return provisionsForYear( + provisions.filter((provision) => + provisionAdjusts(provision, parameters?.[provision.path]?.values) + ), + year, + currentLawAt + ); +} + /** * The provisions as they stand in one year — current law then, and the * reform's value then — so a report's "current law → new value" lines @@ -466,31 +490,49 @@ export function setDraftPopulation(population: DraftPopulation): void { writeDraft({ ...draft, population }); } +/** Display details and current law for a parameter path. */ +export type ProvisionDetails = (path: string) => { + breadcrumb: string; + unit: string | null; + baselineValue: any; +}; + +/** A saved reform's parameters as provisions: one value from this year on, or its dated changes. */ +export function reformToProvisions(reform: Reform, resolve: ProvisionDetails): DraftProvision[] { + return reform.parameters.map((parameter) => { + const { breadcrumb, unit, baselineValue } = resolve(parameter.name); + const single = + parameter.values.length === 1 && + parameter.values[0].startDate === `${CURRENT_YEAR}-01-01` && + parameter.values[0].endDate === FOREVER; + return { + path: parameter.name, + breadcrumb, + unit, + baselineValue, + value: parameter.values[0]?.value ?? baselineValue, + // Anything but one value from this year on keeps its dates. + ...(!single && parameter.values.length > 0 && { intervals: parameter.values }), + }; + }); +} + +/** Marks the draft as the saved reform `reformId`, so the next save changes it. */ +export function setDraftEditingReform(reformId: string): void { + const draft = getSnapshot(); + if (!draft) { + return; + } + writeDraft({ ...draft, editingReformId: reformId }); +} + /** Loads a saved reform into the composer for editing. */ -export function loadReformIntoDraft( - reform: Reform, - resolve: (path: string) => { breadcrumb: string; unit: string | null; baselineValue: any } -): void { +export function loadReformIntoDraft(reform: Reform, resolve: ProvisionDetails): void { writeDraft({ countryId: reform.countryId, label: reform.label ?? '', population: { scope: 'national' }, - provisions: reform.parameters.map((parameter) => { - const { breadcrumb, unit, baselineValue } = resolve(parameter.name); - const single = - parameter.values.length === 1 && - parameter.values[0].startDate === `${CURRENT_YEAR}-01-01` && - parameter.values[0].endDate === FOREVER; - return { - path: parameter.name, - breadcrumb, - unit, - baselineValue, - value: parameter.values[0]?.value ?? baselineValue, - // Anything but one value from this year on keeps its dates. - ...(!single && parameter.values.length > 0 && { intervals: parameter.values }), - }; - }), + provisions: reformToProvisions(reform, resolve), source: reform.provenance.source, sourceRef: reform.provenance.ref, editingReformId: reform.id, diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index e6b508416..7b0ec8ff6 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -20,7 +20,7 @@ import StatTile from '@/components/flagship/StatTile'; import ValueInput from '@/components/flagship/ValueInput'; import WorkspaceLayout from '@/components/flagship/WorkspaceLayout'; import { Button, Spinner, Stack, Text, Title } from '@/components/ui'; -import { FOREVER, MOCK_USER_ID } from '@/constants'; +import { CURRENT_YEAR, FOREVER, MOCK_USER_ID } from '@/constants'; import { useAppLocation } from '@/contexts/LocationContext'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; @@ -32,7 +32,10 @@ import { addDraftProvision, clearDraftReform, loadReformIntoDraft, + provisionsToRun, + reformToProvisions, setDraftLabel, + type ProvisionDetails, } from '@/libs/draftReform'; import { billAlreadyCurrentLaw, billReportProvisions } from '@/libs/flagship/billProvisions'; import { RootState } from '@/store'; @@ -72,14 +75,6 @@ const filterControl: React.CSSProperties = { color: colors.text.secondary, }; -interface ProvisionView { - path: string; - breadcrumb: string; - unit: string | null; - baselineValue: any; - value: any; -} - /** Small-caps muted eyebrow — the one structural label cards carry. */ function Eyebrow({ children }: { children: React.ReactNode }) { return ( @@ -328,17 +323,11 @@ export default function ReformsPage() { }); }; - const reformProvisions = (reform: Reform): ProvisionView[] => - reform.parameters.map((parameter) => { - const metadata = parameters?.[parameter.name]; - return { - path: parameter.name, - breadcrumb: resolveBreadcrumb(parameter.name), - unit: metadata?.unit ?? null, - baselineValue: getCurrentValue(metadata?.values), - value: parameter.values[0]?.value, - }; - }); + const provisionDetails: ProvisionDetails = (path) => ({ + breadcrumb: resolveBreadcrumb(path), + unit: parameters?.[path]?.unit ?? null, + baselineValue: getCurrentValue(parameters?.[path]?.values), + }); // Places with bills, for the place picker: the states by name, and how // many bills each scope holds. Federal bills carry no state. @@ -724,10 +713,17 @@ export default function ReformsPage() { > @@ -95,6 +102,8 @@ function renderProvider(countryId: CountryId = 'us') { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); + clearDraftReform(); vi.stubGlobal('fetch', fetchMock); auth0.state.isLoading = false; auth0.state.isAuthenticated = false; @@ -209,6 +218,7 @@ describe('AuthProvider', () => { auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; auth0.getAccessTokenSilently.mockRejectedValue({ error: 'login_required' }); renderProvider(); + startDraftReform('us', 'manual'); // When await userEvent.setup().click(screen.getByRole('button', { name: 'Get token' })); @@ -218,6 +228,8 @@ describe('AuthProvider', () => { await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('signed_out')); expect(screen.getByTestId('session-ended')).toHaveTextContent('true'); expect(screen.getByTestId('token')).toHaveTextContent('no token'); + // The person still here keeps their unsaved draft. + expect(getDraftReform()).not.toBeNull(); }); test('given a token request that fails for another reason then the session stays signed in', async () => { @@ -231,12 +243,67 @@ describe('AuthProvider', () => { // When await userEvent.setup().click(screen.getByRole('button', { name: 'Get token' })); - // Then - await waitFor(() => expect(screen.getByTestId('token')).toHaveTextContent('no token')); + // Then: the request is told sign-in could not be reached; the session stays + await waitFor(() => expect(screen.getByTestId('token')).toHaveTextContent('unreachable')); expect(auth0.logout).not.toHaveBeenCalled(); expect(screen.getByTestId('status')).toHaveTextContent('signed_in'); }); + test('given a draft left by a session that expired between visits then a signed-out load drops it', () => { + // Given: a signed-in visit that never signed out + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + const signedInVisit = renderProvider(); + startDraftReform('us', 'manual'); + signedInVisit.unmount(); + + // When: the next visit finds the session gone + auth0.state.isAuthenticated = false; + auth0.state.user = undefined; + renderProvider(); + + // Then + expect(screen.getByTestId('status')).toHaveTextContent('signed_out'); + expect(getDraftReform()).toBeNull(); + }); + + test("given another account signs in then the previous account's draft is dropped", () => { + // Given + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + const firstAccount = renderProvider(); + startDraftReform('us', 'manual'); + firstAccount.unmount(); + + // When + auth0.state.user = { sub: 'google-oauth2|someone-else' }; + renderProvider(); + + // Then + expect(getDraftReform()).toBeNull(); + }); + + test('given a signed-out draft then signing in keeps it, now as the account’s', () => { + // Given + stubAuthEnv(true); + startDraftReform('us', 'manual'); + renderProvider().unmount(); + + // When + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + renderProvider().unmount(); + + // Then: kept, and dropped if the next load finds no session + expect(getDraftReform()).not.toBeNull(); + auth0.state.isAuthenticated = false; + auth0.state.user = undefined; + renderProvider(); + expect(getDraftReform()).toBeNull(); + }); + test('given sign in then the redirect returns to the requested page via the country callback', async () => { // Given stubAuthEnv(true); @@ -272,6 +339,22 @@ describe('AuthProvider', () => { expect(takeSignInReturnTo()).toBe('/us'); }); + test('given the redirect completes then the used code leaves the address bar', () => { + // Given + stubAuthEnv(true); + window.history.replaceState(null, '', '/us/callback?code=used-code&state=used-state'); + renderProvider('us'); + const { onRedirectCallback } = auth0.providerProps.mock.calls[0][0]; + + // When + act(() => onRedirectCallback({ returnTo: '/us/build' })); + + // Then: a reload cannot replay it + expect(window.location.pathname).toBe('/us/callback'); + expect(window.location.search).toBe(''); + takeSignInReturnTo(); + }); + test('given sign out then what the session left in this browser is cleared first', async () => { // Given: a signed-in run's provenance and an account reform loaded for editing stubAuthEnv(true); @@ -297,6 +380,7 @@ describe('AuthProvider', () => { // Then expect(readReportMeta('56')).toBeNull(); expect(getDraftReform()).toBeNull(); + expect(localStorage.getItem('pe-draft-reform-owner')).toBeNull(); expect(auth0.logout).toHaveBeenCalledWith({ logoutParams: { returnTo: `${window.location.origin}/us` }, }); diff --git a/app/src/tests/unit/libs/auth/accountImport.test.ts b/app/src/tests/unit/libs/auth/accountImport.test.ts index 949c678a5..7acd633b2 100644 --- a/app/src/tests/unit/libs/auth/accountImport.test.ts +++ b/app/src/tests/unit/libs/auth/accountImport.test.ts @@ -10,11 +10,14 @@ import { runSignInImport, useAccountImportNotice, } from '@/libs/auth/accountImport'; +import { getDraftReform, setDraftEditingReform, startDraftReform } from '@/libs/draftReform'; import { readReportMeta, saveReportMeta } from '@/libs/flagship/runReport'; import { createTestApiContext, errorResponse, fetchCall, + itemResponse, + mockMeItem, okResponse, } from '@/tests/fixtures/api/v2UserMocks'; import type { Reform } from '@/types/ingredients/Reform'; @@ -224,18 +227,55 @@ describe('importBrowserWork', () => { expect(storedIds('reforms')).toContain(ukReform.id); }); - test('given the sign-in country then its work goes first', async () => { + test('given the sign-in country then the account is set up there before anything moves', async () => { + // Given: work in both countries, signing in on the UK calculator + await seedBrowserWork(); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(async (url: string, init: RequestInit) => + new URL(url).pathname === '/v2/me' ? itemResponse(mockMeItem) : acceptEverything(url, init) + ); + + // When + await importBrowserWork({ client: context.client, signInCountryId: 'uk' }); + + // Then + expect(fetchCall(fetchMock, 0)).toMatchObject({ path: '/v2/me', query: { country_id: 'uk' } }); + expect(fetchCall(fetchMock, 1)).toMatchObject({ + path: '/v2/me/imports', + query: { country_id: 'us' }, + }); + }); + + test('given setting up the account fails then the work still moves', async () => { // Given await seedBrowserWork(); const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(acceptEverything); + fetchMock.mockImplementation(async (url: string, init: RequestInit) => + new URL(url).pathname === '/v2/me' + ? errorResponse(503, 'Saved work is temporarily unavailable.') + : acceptEverything(url, init) + ); // When - await importBrowserWork({ client: context.client, firstCountryId: 'uk' }); + const outcome = await importBrowserWork({ client: context.client, signInCountryId: 'uk' }); // Then - expect(fetchCall(fetchMock, 0).query).toEqual({ country_id: 'uk' }); - expect(fetchCall(fetchMock, 1).query).toEqual({ country_id: 'us' }); + expect(outcome).toMatchObject({ reformsMoved: 2, reportsMoved: 1, leftOnBrowser: 0 }); + }); + + test('given the draft edits a browser reform that moves then the draft follows it to the account', async () => { + // Given + const { ukReform } = await seedBrowserWork(); + startDraftReform('uk', 'manual'); + setDraftEditingReform(ukReform.id!); + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(acceptEverything); + + // When + await importBrowserWork({ client: context.client }); + + // Then: acceptEverything names it uuid-- + expect(getDraftReform()?.editingReformId).toBe(`uuid-0-${ukReform.id}`); }); test('given the import runs then it reads only the v3 keys, never the legacy ones', async () => { @@ -254,8 +294,9 @@ describe('importBrowserWork', () => { await importBrowserWork({ client: context.client }); // Then + // v3's saved work, and its draft (which follows a moved reform). expect([...readKeys].sort()).toEqual( - ['pe-flagship-report-meta', 'pe-flagship-reports', 'reforms'].sort() + ['pe-draft-reform', 'pe-flagship-report-meta', 'pe-flagship-reports', 'reforms'].sort() ); expect(LEGACY_KEYS.filter((key) => readKeys.has(key))).toEqual([]); }); diff --git a/app/src/tests/unit/libs/draftReform.test.ts b/app/src/tests/unit/libs/draftReform.test.ts index 7112e4c05..2c9355019 100644 --- a/app/src/tests/unit/libs/draftReform.test.ts +++ b/app/src/tests/unit/libs/draftReform.test.ts @@ -4,6 +4,7 @@ import { clearDraftReform, draftToReform, getDraftReform, + loadProvisionsIntoDraft, loadReformIntoDraft, provisionFromSearchEntry, removeDraftProvision, @@ -115,6 +116,24 @@ describe('draftReform', () => { expect(draft?.provisions[0].baselineValue).toBe(2000); }); + it('given a report reopened for editing with its reform then the draft edits that reform', () => { + loadProvisionsIntoDraft('us', [{ ...CTC_PROVISION, value: 3600 }], { + label: 'CTC expansion', + year: 2027, + reformId: 'rf-ran', + }); + + const draft = getDraftReform(); + expect(draft?.editingReformId).toBe('rf-ran'); + expect(draft?.year).toBe(2027); + }); + + it('given a report reopened for editing without a reform then saving makes a new one', () => { + loadProvisionsIntoDraft('us', [{ ...CTC_PROVISION, value: 3600 }], { label: 'Shared' }); + + expect(getDraftReform()?.editingReformId).toBeUndefined(); + }); + it('given a draft then draftToReform produces the store shape with a year-to-forever interval', () => { startDraftReform('us', 'bill', 'ut-hb-106'); addDraftProvision('us', { ...CTC_PROVISION, value: 3600 }); diff --git a/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx b/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx index 1a49e7683..1eda6c996 100644 --- a/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx +++ b/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx @@ -8,7 +8,12 @@ import { dismissAccountImportNotice } from '@/libs/auth/accountImport'; import type { AuthContextValue } from '@/libs/auth/AuthContext'; import { takeSignInReturnTo } from '@/libs/auth/AuthProvider'; import AuthCallbackPage from '@/pages/flagship/AuthCallback.page'; -import { errorResponse, okResponse } from '@/tests/fixtures/api/v2UserMocks'; +import { + errorResponse, + itemResponse, + mockMeItem, + okResponse, +} from '@/tests/fixtures/api/v2UserMocks'; import { AuthTestProvider, makeAuthValue, @@ -37,10 +42,14 @@ function requestPaths(): string[] { return fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname); } -/** Answers imports with every reform added, or with a failure. */ +/** Answers /v2/me with the account, and imports with every reform added or with a failure. */ function answerImports(imports: 'accept' | 'fail') { fetchMock.mockImplementation(async (url: string, init: RequestInit) => { - if (new URL(url).pathname !== '/v2/me/imports') { + const path = new URL(url).pathname; + if (path === '/v2/me') { + return itemResponse(mockMeItem); + } + if (path !== '/v2/me/imports') { return new Response('', { status: 404 }); } if (imports === 'fail') { @@ -90,7 +99,8 @@ describe('AuthCallbackPage', () => { // Then await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); - expect(requestPaths()).toEqual(['/v2/me/imports']); + // The account is set up in the sign-in country first. + expect(requestPaths()).toEqual(['/v2/me', '/v2/me/imports']); expect(await screen.findByRole('alert')).toHaveTextContent( 'Moved 1 reform and 0 reports from this browser to your account.' ); @@ -127,7 +137,7 @@ describe('AuthCallbackPage', () => { // Then expect(await screen.findByText(/^Moved 1 reform/)).toBeInTheDocument(); - expect(requestPaths()).toEqual(['/v2/me/imports', '/v2/me/imports']); + expect(requestPaths().filter((path) => path === '/v2/me/imports')).toHaveLength(2); expect(browserReformCount()).toBe(0); }); From 6b984475fa83225dddc083faed524dc50322a7b1 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:30:59 +0400 Subject: [PATCH 34/48] Run only reports that change the law, and offer sign-in only in the US and UK - A saved reform whose values all match current law can no longer start "View full impact report" (which failed with "Could not start the report"); the button is disabled and the page says why, as Build does. The report page's Recompute likewise waits until some value differs from current law. Bill reports are unchanged: an enacted bill is compared with the law before it. - The account page checks the country before the sign-in state, so a signed-out visitor on ca, ng or il is pointed to the US and UK calculators rather than offered a sign-in Auth0 would refuse. - The clean-slate test also checks the legacy geography key. Co-Authored-By: Claude Opus 5.5 --- .../components/flagship/ReportAdjustPanel.tsx | 19 ++++++++- app/src/pages/flagship/Account.page.tsx | 39 ++++++++++--------- app/src/pages/flagship/Reforms.page.tsx | 29 ++++++++++---- .../flagship/ReportAdjustPanel.test.tsx | 17 ++++++++ .../unit/libs/flagship/v3CleanSlate.test.tsx | 1 + .../unit/pages/flagship/Account.page.test.tsx | 13 +++++++ .../unit/pages/flagship/Reforms.test.tsx | 35 +++++++++++++++++ 7 files changed, 125 insertions(+), 28 deletions(-) diff --git a/app/src/components/flagship/ReportAdjustPanel.tsx b/app/src/components/flagship/ReportAdjustPanel.tsx index c18fb2a15..728da56cc 100644 --- a/app/src/components/flagship/ReportAdjustPanel.tsx +++ b/app/src/components/flagship/ReportAdjustPanel.tsx @@ -1,15 +1,17 @@ import { useState } from 'react'; import { IconChartBar, IconX } from '@tabler/icons-react'; import { useQueryClient } from '@tanstack/react-query'; +import { useSelector } from 'react-redux'; import { Button, Stack, Text } from '@/components/ui'; import { CURRENT_YEAR, FOREVER, MOCK_USER_ID } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useReformStore } from '@/hooks/useFlagshipStores'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; -import { sameParameterValue } from '@/libs/draftReform'; +import { provisionAdjusts, sameParameterValue } from '@/libs/draftReform'; import { reportProvisionName } from '@/libs/flagship/draftLabels'; import { RunReportProvision } from '@/libs/flagship/runReport'; +import { RootState } from '@/store'; import { Reform } from '@/types/ingredients/Reform'; import { ParameterMetadata } from '@/types/metadata/parameterMetadata'; import { formatValue } from '@/utils/parameterValues'; @@ -69,9 +71,17 @@ export default function ReportAdjustPanel({ Object.fromEntries(provisions.map((provision) => [provision.path, provision.value])) ); + const parameters = useSelector((state: RootState) => state.metadata.parameters); const active = provisions.filter((provision) => !removed.has(provision.path)); const isDirty = removed.size > 0 || provisions.some((provision) => values[provision.path] !== provision.value); + // A recompute with nothing differing from current law has no report to run. + const changesLaw = active.some((provision) => + provisionAdjusts( + { ...provision, value: values[provision.path] }, + parameters?.[provision.path]?.values + ) + ); const busy = isReconciling || runReport.isRunning; if (provisions.length === 0) { @@ -264,7 +274,12 @@ export default function ReportAdjustPanel({ {reconcileError || runReport.error} )} - diff --git a/app/src/pages/flagship/Account.page.tsx b/app/src/pages/flagship/Account.page.tsx index 2c8ecbb30..4ea6d79d7 100644 --- a/app/src/pages/flagship/Account.page.tsx +++ b/app/src/pages/flagship/Account.page.tsx @@ -97,6 +97,27 @@ export default function AccountPage() { ); } + // Accounts are for the US and UK: elsewhere there is nothing to sign in to. + if (!isAssociationCountry(countryId)) { + return ( + + + Accounts store work for the US and UK calculators. Work in the{' '} + {getCountryLabel(countryId)} calculator stays on this device. Manage your account from{' '} + the US calculator or{' '} + the UK calculator. + + {auth.status === 'signed_in' && ( +
+ +
+ )} +
+ ); + } + if (auth.status === 'signed_out') { return ( @@ -116,24 +137,6 @@ export default function AccountPage() { ); } - if (!isAssociationCountry(countryId)) { - return ( - - - Accounts store work for the US and UK calculators. Work in the{' '} - {getCountryLabel(countryId)} calculator stays on this device. Manage your account from{' '} - the US calculator or{' '} - the UK calculator. - -
- -
-
- ); - } - return ; } diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index 7b0ec8ff6..a96bf2649 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -622,6 +622,14 @@ export default function ReformsPage() { // ---- Detail: one of your reforms ---- if (selectedReform) { + // What the report would run, as Build runs it (dated changes + // included); a reform that changes nothing has no report to run. + const runProvisions = provisionsToRun( + reformToProvisions(selectedReform, provisionDetails), + Number(CURRENT_YEAR), + parameters + ); + const changesNothing = !selectedReform.parametersUnavailable && runProvisions.length === 0; return ( @@ -713,20 +721,18 @@ export default function ReformsPage() { > - - )} - - ); -} diff --git a/app/src/components/auth/AccountUpgradeNotice.tsx b/app/src/components/auth/AccountUpgradeNotice.tsx new file mode 100644 index 000000000..3d451a7f5 --- /dev/null +++ b/app/src/components/auth/AccountUpgradeNotice.tsx @@ -0,0 +1,29 @@ +import { FloatingAlert } from '@/components/common/FloatingAlert'; +import { Button } from '@/components/ui'; +import { dismissAccountUpgradeNotice, useAccountUpgradeNotice } from '@/libs/auth/accountUpgrade'; + +/** + * Reports the upgrade at sign-in (see runAccountUpgrade). Mounted once in + * the v3 workspace, so it stays up after the callback page has moved on. + */ +export default function AccountUpgradeNotice() { + const notice = useAccountUpgradeNotice(); + if (!notice) { + return null; + } + return ( + + {notice.message} + {notice.retry && ( + + + + )} + + ); +} diff --git a/app/src/hooks/useFlagshipStores.ts b/app/src/hooks/useFlagshipStores.ts index 72813b2ef..f0c322501 100644 --- a/app/src/hooks/useFlagshipStores.ts +++ b/app/src/hooks/useFlagshipStores.ts @@ -7,22 +7,27 @@ import type { ApiStoreContext } from '@/api/v2Associations'; import { MOCK_USER_ID } from '@/constants'; import { useCountryContext } from '@/contexts/CountryContext'; import { useAuthStatus } from '@/libs/auth/AuthContext'; +import { getBrowserUserId } from '@/libs/auth/browserUser'; import { useV2UserClient } from '@/libs/auth/useV2UserClient'; import type { RootState } from '@/store'; import { isAssociationCountry } from '@/types/v2User'; /** - * Where the v3 workspace keeps saved work: the account when signed in (US - * and UK), this browser otherwise. Nothing else decides it, and nothing - * falls back: an account request that fails is an ordinary error. + * Where the v3 workspace keeps saved work. Wherever accounts exist (sign-in + * configured, US and UK) it is the PolicyEngine API: as the signed-in + * account, or signed out as this browser's unauthenticated user. Without + * sign-in (and outside the US and UK) it stays in this browser. Nothing + * else decides it, and nothing falls back: a request that fails is an + * ordinary error. */ export interface V3SavedWork { - /** The account's routes when signed in; null keeps saved work in this browser. */ + /** The API routes for this session's user; null keeps saved work in this browser. */ account: ApiStoreContext | null; /** - * Scopes cached saved work to the session: the Auth0 subject when - * signed in, 'browser' otherwise. Signing in or out reloads the page; a - * session that ends without a reload moves every query to new keys. + * Scopes cached saved work to whose it is: the Auth0 subject when signed + * in, this browser's unauthenticated user when signed out, 'browser' + * otherwise. A session that ends without a reload moves every query to + * new keys. */ scope: string; } @@ -31,16 +36,19 @@ export function useV3SavedWork(): V3SavedWork { const { status, subject } = useAuthStatus(); const countryId = useCountryContext(); const client = useV2UserClient(); - return useMemo( - () => - status === 'signed_in' && subject && isAssociationCountry(countryId) - ? { - account: { client, countryId }, - scope: `account:${subject}`, - } - : { account: null, scope: 'browser' }, - [status, subject, countryId, client] - ); + const browserUserId = status === 'signed_out' ? getBrowserUserId() : null; + return useMemo(() => { + if (!isAssociationCountry(countryId)) { + return { account: null, scope: 'browser' }; + } + if (status === 'signed_in' && subject) { + return { account: { client, countryId }, scope: `account:${subject}` }; + } + if (browserUserId) { + return { account: { client, countryId }, scope: `browser-user:${browserUserId}` }; + } + return { account: null, scope: 'browser' }; + }, [status, subject, browserUserId, countryId, client]); } /** A v1 policy's raw parameter map (`policy_json`), per country. */ diff --git a/app/src/libs/auth/accountImport.ts b/app/src/libs/auth/accountImport.ts deleted file mode 100644 index 9f410bd0f..000000000 --- a/app/src/libs/auth/accountImport.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { useSyncExternalStore } from 'react'; -import { - FLAGSHIP_REPORTS_KEY, - provisionsToWire, - type FlagshipReportRecord, -} from '@/api/flagshipReportStore'; -import { isLegacyId } from '@/api/v2Associations'; -import { fetchMe, importToAccount } from '@/api/v2Me'; -import type { V2UserClient } from '@/api/v2UserClient'; -import { getDraftReform, setDraftEditingReform } from '@/libs/draftReform'; -import { validationToWire } from '@/libs/flagship/reportValidation'; -import { REPORT_META_KEY, type FlagshipReportMeta } from '@/libs/flagship/runReport'; -import type { Reform } from '@/types/ingredients/Reform'; -import { - ASSOCIATION_COUNTRY_IDS, - isAssociationCountry, - type AssociationCountryId, - type ImportPolicyItem, - type ImportReportItem, - type ImportRequestBody, -} from '@/types/v2User'; - -/** - * The import at sign-in: every sign-in moves the v3 reforms and reports - * saved on this browser while signed out (US and UK) into the account with - * `POST /v2/me/imports`. Each item the account confirms (its `client_ref` - * comes back in `id_map`) is removed from this browser; everything else - * stays here and is sent again at the next sign-in. Only v3's own keys are - * read. The server deduplicates by `client_ref` (the browser ID), so an - * item sent again returns its existing ID. - * - * Browser reforms already carry their v1 policy (saving one creates it), - * so the import creates nothing in v1. A reform saved by an earlier build - * without a policy is not sent; it stays here until it is saved again. - */ - -/** The v3 keys the import reads; v3 never reads the legacy calculator's keys. */ -const REFORMS_KEY = 'reforms'; - -/** Server limits on one import request. */ -const IMPORT_MAX_ITEMS = 500; -/** Kept under the 1 MiB body cap to leave room for the envelope. */ -const IMPORT_MAX_BYTES = 950_000; - -function readArray(key: string): T[] { - try { - const parsed = JSON.parse(localStorage.getItem(key) ?? '[]'); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -function readObject(key: string): Record { - try { - const parsed = JSON.parse(localStorage.getItem(key) ?? '{}'); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; - } catch { - return {}; - } -} - -function text(value: unknown): string | null { - return typeof value === 'string' && value.trim() ? value : null; -} - -type PlannedItem = - | { kind: 'policies'; clientRef: string; body: ImportPolicyItem } - | { kind: 'reports'; clientRef: string; body: ImportReportItem }; - -/** This browser's v3 reforms and reports, per account country. */ -function planBrowserWorkImport(): Record { - const reforms = readArray(REFORMS_KEY); - const records = readArray(FLAGSHIP_REPORTS_KEY); - const meta = readObject(REPORT_META_KEY); - const plan: Record = { us: [], uk: [] }; - - for (const reform of reforms) { - if (!reform.id || !isAssociationCountry(reform.countryId) || !isLegacyId(reform.policyId)) { - continue; - } - plan[reform.countryId].push({ - kind: 'policies', - clientRef: reform.id, - body: { - client_ref: reform.id, - legacy_policy_id: Number(reform.policyId), - name: text(reform.label), - description: null, - baseline: reform.baseline ?? null, - provenance: reform.provenance - ? { - source: reform.provenance.source, - ref: reform.provenance.ref ?? null, - confidence: reform.provenance.confidence ?? null, - } - : null, - }, - }); - } - - for (const record of records) { - if (!record.id || !isAssociationCountry(record.countryId) || !isLegacyId(record.apiReportId)) { - continue; - } - const runMeta = meta[record.apiReportId]; - const provisions = record.provisions?.length ? record.provisions : runMeta?.provisions; - plan[record.countryId].push({ - kind: 'reports', - clientRef: record.id, - body: { - client_ref: record.id, - legacy_report_id: Number(record.apiReportId), - name: text(record.title) ?? text(runMeta?.title), - description: null, - // Linked to its reform by the reform's browser ID (its client_ref). - // The account resolves it against reforms in the same request or - // moved at an earlier sign-in, and leaves the report unlinked - // when there is none. - user_policy_client_ref: text(record.reformId), - source_note: text(record.sourceNote) ?? text(runMeta?.sourceNote), - provisions: provisions?.length ? provisionsToWire(provisions) : null, - validation: record.validation ? validationToWire(record.validation) : null, - last_run_at: null, - }, - }); - } - return plan; -} - -/** - * Removes the items the account confirmed from this browser: the reforms - * and report records with those IDs, and the run details of reports no - * remaining record points at. An item left behind by a failed write is - * sent again next time and answers its existing ID. - */ -function removeMovedItems(movedClientRefs: ReadonlySet): void { - try { - const reforms = readArray(REFORMS_KEY); - const keptReforms = reforms.filter((reform) => !(reform.id && movedClientRefs.has(reform.id))); - if (keptReforms.length !== reforms.length) { - localStorage.setItem(REFORMS_KEY, JSON.stringify(keptReforms)); - } - - const records = readArray(FLAGSHIP_REPORTS_KEY); - const keptRecords = records.filter((record) => !movedClientRefs.has(record.id)); - if (keptRecords.length === records.length) { - return; - } - localStorage.setItem(FLAGSHIP_REPORTS_KEY, JSON.stringify(keptRecords)); - - const stillReferenced = new Set(keptRecords.map((record) => record.apiReportId)); - const movedReportIds = records - .filter((record) => movedClientRefs.has(record.id)) - .map((record) => record.apiReportId) - .filter((apiReportId) => !stillReferenced.has(apiReportId)); - const meta = readObject(REPORT_META_KEY); - if (movedReportIds.some((apiReportId) => apiReportId in meta)) { - movedReportIds.forEach((apiReportId) => delete meta[apiReportId]); - localStorage.setItem(REPORT_META_KEY, JSON.stringify(meta)); - } - } catch { - // Storage unavailable: the items stay and are sent again next time. - } -} - -function byteLength(value: unknown): number { - return new TextEncoder().encode(JSON.stringify(value)).length; -} - -/** Request bodies of at most `maxItems` items and about `maxBytes`, reforms first. */ -function chunkImportItems( - items: PlannedItem[], - { maxItems = IMPORT_MAX_ITEMS, maxBytes = IMPORT_MAX_BYTES } = {} -): ImportRequestBody[] { - const chunks: PlannedItem[][] = []; - let current: PlannedItem[] = []; - let currentBytes = 0; - for (const item of items) { - const size = byteLength(item.body) + 1; - if (current.length > 0 && (current.length >= maxItems || currentBytes + size > maxBytes)) { - chunks.push(current); - current = []; - currentBytes = 0; - } - current.push(item); - currentBytes += size; - } - if (current.length > 0) { - chunks.push(current); - } - return chunks.map((chunk) => ({ - policies: chunk.flatMap((item) => (item.kind === 'policies' ? [item.body] : [])), - reports: chunk.flatMap((item) => (item.kind === 'reports' ? [item.body] : [])), - })); -} - -/** - * Points the draft at the account copy of the browser reform it edits, so - * saving it after sign-in changes that reform rather than failing. - */ -function followMovedDraftReform(idMap: Record): void { - const editingReformId = getDraftReform()?.editingReformId; - const accountId = editingReformId ? idMap[editingReformId] : undefined; - if (accountId) { - setDraftEditingReform(accountId); - } -} - -export interface BrowserWorkImportOutcome { - reformsMoved: number; - reportsMoved: number; - /** Items still on this browser: skipped by the account, or not sent. */ - leftOnBrowser: number; - /** True when an import request failed, which stops the run. */ - requestFailed: boolean; -} - -export interface ImportBrowserWorkOptions { - client: V2UserClient; - /** - * The country signed in from. The account is set up there before - * anything moves, so a first sign-in records it as the account's - * country whichever countries the browser's work is in. - */ - signInCountryId?: string; -} - -/** - * Moves this browser's signed-out v3 work into the account, removing each - * item the account confirms. A failed request stops the run; everything - * not yet moved stays. - */ -export async function importBrowserWork({ - client, - signInCountryId, -}: ImportBrowserWorkOptions): Promise { - const plan = planBrowserWorkImport(); - const planned = ASSOCIATION_COUNTRY_IDS.reduce( - (total, countryId) => total + plan[countryId].length, - 0 - ); - const outcome: BrowserWorkImportOutcome = { - reformsMoved: 0, - reportsMoved: 0, - leftOnBrowser: planned, - requestFailed: false, - }; - - if (planned > 0 && isAssociationCountry(signInCountryId)) { - // Creates the account in the sign-in country on a first sign-in. If - // it fails, the import below creates it instead. - await fetchMe(client, signInCountryId).catch(() => null); - } - - for (const countryId of ASSOCIATION_COUNTRY_IDS) { - for (const body of chunkImportItems(plan[countryId])) { - const result = await importToAccount(client, countryId, body).catch((error: unknown) => { - console.error('[accountImport] Import request failed:', error); - return null; - }); - if (!result) { - outcome.requestFailed = true; - return outcome; - } - const moved = new Set( - [...(body.policies ?? []), ...(body.reports ?? [])] - .map((item) => item.client_ref) - .filter((clientRef) => result.id_map[clientRef]) - ); - removeMovedItems(moved); - followMovedDraftReform(result.id_map); - const countMoved = (refs: Array<{ client_ref: string }> | undefined) => - (refs ?? []).filter((item) => moved.has(item.client_ref)).length; - outcome.reformsMoved += countMoved(body.policies); - outcome.reportsMoved += countMoved(body.reports); - outcome.leftOnBrowser -= moved.size; - } - } - return outcome; -} - -/** Whether this browser holds any v3 work the import would move. */ -export function hasBrowserWorkToImport(): boolean { - const plan = planBrowserWorkImport(); - return ASSOCIATION_COUNTRY_IDS.some((countryId) => plan[countryId].length > 0); -} - -/* The notice shown after the import, which outlives the callback page. */ - -export interface AccountImportNotice { - kind: 'success' | 'warning' | 'failure'; - message: string; - /** Runs the import again; present after a failed request. */ - retry: (() => void) | null; -} - -let currentNotice: AccountImportNotice | null = null; -const listeners = new Set<() => void>(); - -function publishNotice(notice: AccountImportNotice | null): void { - currentNotice = notice; - listeners.forEach((listener) => listener()); -} - -export function dismissAccountImportNotice(): void { - publishNotice(null); -} - -export function useAccountImportNotice(): AccountImportNotice | null { - return useSyncExternalStore( - (listener) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, - () => currentNotice, - () => null - ); -} - -function count(amount: number, noun: string): string { - return `${amount} ${noun}${amount === 1 ? '' : 's'}`; -} - -const TRIED_AGAIN = 'will be tried again the next time you sign in'; - -/** - * "Moved N reforms and M reports from this browser to your account.", - * plus how many items stay on this browser for the next sign-in. - */ -export function importOutcomeMessage(outcome: BrowserWorkImportOutcome): string { - const movedAny = outcome.reformsMoved + outcome.reportsMoved > 0; - const moved = `Moved ${count(outcome.reformsMoved, 'reform')} and ${count( - outcome.reportsMoved, - 'report' - )} from this browser to your account.`; - if (outcome.leftOnBrowser === 0) { - return moved; - } - if (!movedAny) { - return outcome.requestFailed - ? `Your reforms and reports saved on this browser could not be moved to your account. They are still on this browser and ${TRIED_AGAIN}.` - : `${count(outcome.leftOnBrowser, 'item')} saved on this browser could not be moved to your account. ${ - outcome.leftOnBrowser === 1 ? 'It is' : 'They are' - } still on this browser and ${TRIED_AGAIN}.`; - } - return `${moved} ${count(outcome.leftOnBrowser, 'item')} ${ - outcome.leftOnBrowser === 1 ? 'is' : 'are' - } still on this browser and ${TRIED_AGAIN}.`; -} - -/** - * Moves this browser's v3 work into the account at sign-in and publishes - * the outcome as a notice. A failed request offers "Try again", which runs - * the import again for whatever is still on this browser. - */ -export async function runSignInImport(options: ImportBrowserWorkOptions): Promise { - if (!hasBrowserWorkToImport()) { - return; - } - const outcome = await importBrowserWork(options); - const retry = () => { - dismissAccountImportNotice(); - void runSignInImport(options); - }; - publishNotice({ - kind: outcome.leftOnBrowser === 0 ? 'success' : outcome.requestFailed ? 'failure' : 'warning', - message: importOutcomeMessage(outcome), - retry: outcome.requestFailed ? retry : null, - }); -} diff --git a/app/src/libs/auth/accountUpgrade.ts b/app/src/libs/auth/accountUpgrade.ts new file mode 100644 index 000000000..97c8ddce0 --- /dev/null +++ b/app/src/libs/auth/accountUpgrade.ts @@ -0,0 +1,83 @@ +import { useSyncExternalStore } from 'react'; +import { upgradeAccount } from '@/api/v2Me'; +import type { V2UserClient } from '@/api/v2UserClient'; +import type { AssociationCountryId } from '@/types/v2User'; +import { getBrowserUserId, replaceBrowserUserId } from './browserUser'; + +/** + * The upgrade at sign-in. Signed out, saved work belongs to this browser's + * unauthenticated user in the PolicyEngine API; signing in upgrades that + * user (`POST /v2/me/upgrade`). On a first sign-in it becomes the account, + * so everything saved stays where it is; for an existing account its work + * moves into that account. Then the browser starts over with a new + * unauthenticated user, so whoever is next here signed out starts empty. + * A failed upgrade keeps the browser's user, so the next sign-in tries again. + */ + +export interface AccountUpgradeNotice { + kind: 'success' | 'failure'; + message: string; + /** Runs the upgrade again; present after a failure. */ + retry: (() => void) | null; +} + +let currentNotice: AccountUpgradeNotice | null = null; +const listeners = new Set<() => void>(); + +function publishNotice(notice: AccountUpgradeNotice | null): void { + currentNotice = notice; + listeners.forEach((listener) => listener()); +} + +export function dismissAccountUpgradeNotice(): void { + publishNotice(null); +} + +/** The notice after the upgrade, which outlives the callback page. */ +export function useAccountUpgradeNotice(): AccountUpgradeNotice | null { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => currentNotice, + () => null + ); +} + +export const UPGRADE_SUCCESS_MESSAGE = + 'The reforms and reports you saved on this browser are now in your account.'; + +export const UPGRADE_FAILURE_MESSAGE = + 'The reforms and reports you saved on this browser could not be added to your account. They will be added the next time you sign in.'; + +export interface RunAccountUpgradeOptions { + /** A client for the signed-in session. */ + client: V2UserClient; + /** The country signed in from; a new account records it. */ + countryId: AssociationCountryId; +} + +/** + * Upgrades this browser's unauthenticated user into the signed-in account + * and publishes a notice when work moved, or when the upgrade failed. + */ +export async function runAccountUpgrade(options: RunAccountUpgradeOptions): Promise { + try { + const { outcome } = await upgradeAccount(options.client, options.countryId, getBrowserUserId()); + replaceBrowserUserId(); + publishNotice( + outcome === 'none' ? null : { kind: 'success', message: UPGRADE_SUCCESS_MESSAGE, retry: null } + ); + } catch (error) { + console.error('[accountUpgrade] Upgrade failed:', error); + publishNotice({ + kind: 'failure', + message: UPGRADE_FAILURE_MESSAGE, + retry: () => { + dismissAccountUpgradeNotice(); + void runAccountUpgrade(options); + }, + }); + } +} diff --git a/app/src/libs/auth/browserUser.ts b/app/src/libs/auth/browserUser.ts new file mode 100644 index 000000000..0b7841007 --- /dev/null +++ b/app/src/libs/auth/browserUser.ts @@ -0,0 +1,54 @@ +/** + * This browser's unauthenticated user: a random UUID made on the first + * visit and kept with no expiry. Signed out, the v3 workspace saves work + * to the PolicyEngine API as this user (the `PolicyEngine-Unauthenticated-User` + * header), so nothing saved lives only in the browser. The UUID is the + * credential for that work: it never goes in a URL or a log. + * + * Signing in upgrades this user into the account (`POST /v2/me/upgrade`), + * after which the browser starts over with a new UUID, so the next person + * signed out on this browser starts with nothing. + */ + +const BROWSER_USER_KEY = 'pe-browser-user-id'; + +/** Used only when localStorage is unavailable: lasts for this page. */ +let pageOnlyUserId: string | null = null; + +function newUserId(): string { + return crypto.randomUUID(); +} + +function storeUserId(userId: string): void { + try { + localStorage.setItem(BROWSER_USER_KEY, userId); + pageOnlyUserId = null; + } catch { + pageOnlyUserId = userId; + } +} + +/** This browser's unauthenticated user ID, made on first use. */ +export function getBrowserUserId(): string { + try { + const stored = localStorage.getItem(BROWSER_USER_KEY); + if (stored) { + return stored; + } + } catch { + // Storage unavailable: fall back to an ID for this page. + } + if (pageOnlyUserId) { + return pageOnlyUserId; + } + const userId = newUserId(); + storeUserId(userId); + return userId; +} + +/** Starts this browser over with a new unauthenticated user (after an upgrade). */ +export function replaceBrowserUserId(): string { + const userId = newUserId(); + storeUserId(userId); + return userId; +} diff --git a/app/src/libs/auth/useV2UserClient.ts b/app/src/libs/auth/useV2UserClient.ts index 24b795cfc..1b27e93c1 100644 --- a/app/src/libs/auth/useV2UserClient.ts +++ b/app/src/libs/auth/useV2UserClient.ts @@ -1,9 +1,22 @@ import { useMemo } from 'react'; import { createV2UserClient, type V2UserClient } from '@/api/v2UserClient'; import { useAuthStatus } from './AuthContext'; +import { getBrowserUserId } from './browserUser'; -/** A PolicyEngine API client that sends the session's access token. */ +/** + * A PolicyEngine API client for this session: signed in, it sends the + * session's access token; otherwise this browser's unauthenticated user. + */ export function useV2UserClient(): V2UserClient { - const { getAccessToken } = useAuthStatus(); - return useMemo(() => createV2UserClient({ getAccessToken }), [getAccessToken]); + const { status, getAccessToken } = useAuthStatus(); + const browserUserId = status === 'signed_in' ? null : getBrowserUserId(); + return useMemo( + () => + createV2UserClient({ + credentials: browserUserId + ? { kind: 'unauthenticated', userId: browserUserId } + : { kind: 'session', getAccessToken }, + }), + [browserUserId, getAccessToken] + ); } diff --git a/app/src/pages/flagship/Account.page.tsx b/app/src/pages/flagship/Account.page.tsx index 4ea6d79d7..b5ef4a914 100644 --- a/app/src/pages/flagship/Account.page.tsx +++ b/app/src/pages/flagship/Account.page.tsx @@ -125,8 +125,8 @@ export default function AccountPage() { Sign in to keep your work - Signed in, the reforms and reports you save are kept in your account and available on - any device. + The reforms and reports you save now are kept for this browser. Sign in to keep them + in your account and use them on any device. @@ -216,7 +216,10 @@ function SignedInAccount({ countryId }: { countryId: AssociationCountryId }) { - + diff --git a/app/src/pages/flagship/AuthCallback.page.tsx b/app/src/pages/flagship/AuthCallback.page.tsx index 3cdbcc239..d1d67454f 100644 --- a/app/src/pages/flagship/AuthCallback.page.tsx +++ b/app/src/pages/flagship/AuthCallback.page.tsx @@ -1,20 +1,21 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { Button, Spinner, Stack, Text, Title } from '@/components/ui'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import { hasBrowserWorkToImport, runSignInImport } from '@/libs/auth/accountImport'; +import { runAccountUpgrade } from '@/libs/auth/accountUpgrade'; import { useAuthStatus } from '@/libs/auth/AuthContext'; import { takeSignInReturnTo } from '@/libs/auth/AuthProvider'; import { useV2UserClient } from '@/libs/auth/useV2UserClient'; +import { isAssociationCountry } from '@/types/v2User'; /** * Where Auth0 sends the browser after sign-in. The AuthProvider finishes - * the code exchange (showing its loading state meanwhile); this page moves - * the v3 work saved on this browser while signed out into the account - * (see runSignInImport), then returns to where sign-in started. The - * account's first request creates the PolicyEngine user. It shows the - * error when sign-in did not complete. + * the code exchange (showing its loading state meanwhile); this page + * upgrades this browser's unauthenticated user into the account (see + * runAccountUpgrade), which also creates the account on a first sign-in, + * then returns to where sign-in started. It shows the error when sign-in + * did not complete. */ export default function AuthCallbackPage() { const countryId = useCurrentCountry(); @@ -22,7 +23,6 @@ export default function AuthCallbackPage() { const { status, error } = useAuthStatus(); const client = useV2UserClient(); const started = useRef(false); - const [importing, setImporting] = useState(false); useEffect(() => { if (started.current || (status === 'signed_out' && error)) { @@ -36,9 +36,8 @@ export default function AuthCallbackPage() { } const returnTo = takeSignInReturnTo() ?? home; void (async () => { - if (hasBrowserWorkToImport()) { - setImporting(true); - await runSignInImport({ client, signInCountryId: countryId }); + if (isAssociationCountry(countryId)) { + await runAccountUpgrade({ client, countryId }); } nav.replace(returnTo); })(); @@ -71,9 +70,7 @@ export default function AuthCallbackPage() { style={{ padding: spacing['3xl'], gap: spacing.md, alignItems: 'center' }} > - - {importing ? 'Moving the work saved on this browser to your account…' : 'Signing you in…'} - + Signing you in… ); } diff --git a/app/src/tests/fixtures/api/v2UserMocks.ts b/app/src/tests/fixtures/api/v2UserMocks.ts index e99a09e4b..b60e800d4 100644 --- a/app/src/tests/fixtures/api/v2UserMocks.ts +++ b/app/src/tests/fixtures/api/v2UserMocks.ts @@ -5,6 +5,8 @@ import type { MeItem, UserPolicyItem, UserReportItem } from '@/types/v2User'; export const TEST_API_BASE = 'https://api.test.policyengine.org'; export const TEST_ACCESS_TOKEN = 'test-access-token'; +/** This browser's unauthenticated user in tests. */ +export const BROWSER_USER_ID = '6f1c2a9e-3b4d-4e5f-8a7b-9c0d1e2f3a4b'; export const PE_USER_ID = '7c1e5a8e-2b1f-4c55-9a3e-1f2d3c4b5a69'; export const ASSOCIATION_IDS = { @@ -25,6 +27,7 @@ export const TIMESTAMPS = { export const mockMeItem: MeItem = { id: PE_USER_ID, + user_type: 'standard', primary_country: 'us', first_name: 'Ada', last_name: 'Lovelace', @@ -37,7 +40,6 @@ const base = { user_id: PE_USER_ID, name: null, description: null, - client_ref: null, created_at: TIMESTAMPS.CREATED, updated_at: TIMESTAMPS.UPDATED, }; @@ -103,7 +105,7 @@ export function createTestApiContext(countryId: 'us' | 'uk' = 'us'): { } { const fetchMock = vi.fn(); const client = createV2UserClient({ - getAccessToken: async () => TEST_ACCESS_TOKEN, + credentials: { kind: 'session', getAccessToken: async () => TEST_ACCESS_TOKEN }, baseUrl: TEST_API_BASE, fetchImpl: fetchMock as unknown as typeof fetch, }); diff --git a/app/src/tests/unit/api/reformStore.test.ts b/app/src/tests/unit/api/reformStore.test.ts index 9fc14f8d0..5202d4383 100644 --- a/app/src/tests/unit/api/reformStore.test.ts +++ b/app/src/tests/unit/api/reformStore.test.ts @@ -213,7 +213,7 @@ describe('ApiReformStore', () => { ], ['deleted', (store: ApiReformStore, id: string) => store.delete(id)], ])( - 'given a reform saved on this browser while signed out, when %s signed in, then it is refused', + 'given a reform saved only in this browser, when %s through the API, then it is refused', async (_, operate) => { // Given const local = await new LocalStorageReformStore({ @@ -223,7 +223,7 @@ describe('ApiReformStore', () => { // When / Then: refused before any v1 policy is made for it await expect(operate(store, local.id!)).rejects.toThrow( - 'This was saved on this browser while signed out. Sign out to open it.' + 'This was saved only in this browser, so it cannot be opened here.' ); expect(fetchMock).not.toHaveBeenCalled(); expect(materializePolicy).not.toHaveBeenCalled(); diff --git a/app/src/tests/unit/api/v2UserClient.test.ts b/app/src/tests/unit/api/v2UserClient.test.ts index 5213a8ccc..4d3d02dc1 100644 --- a/app/src/tests/unit/api/v2UserClient.test.ts +++ b/app/src/tests/unit/api/v2UserClient.test.ts @@ -1,13 +1,20 @@ import { describe, expect, test, vi } from 'vitest'; import { fetchMe } from '@/api/v2Me'; -import { createV2UserClient, isV2ApiError, V2ApiError } from '@/api/v2UserClient'; import { + createV2UserClient, + isV2ApiError, + UNAUTHENTICATED_USER_HEADER, + V2ApiError, +} from '@/api/v2UserClient'; +import { + BROWSER_USER_ID, createTestApiContext, errorResponse, fetchCall, itemResponse, mockMeItem, noContentResponse, + okResponse, pageResponse, policyItem, TEST_ACCESS_TOKEN, @@ -114,7 +121,7 @@ describe('v2UserClient', () => { // Given const fetchMock = vi.fn(); const client = createV2UserClient({ - getAccessToken: async () => null, + credentials: { kind: 'session', getAccessToken: async () => null }, baseUrl: TEST_API_BASE, fetchImpl: fetchMock as unknown as typeof fetch, }); @@ -128,8 +135,11 @@ describe('v2UserClient', () => { // Given const fetchMock = vi.fn(); const client = createV2UserClient({ - getAccessToken: async () => { - throw new Error('timeout'); + credentials: { + kind: 'session', + getAccessToken: async () => { + throw new Error('timeout'); + }, }, baseUrl: TEST_API_BASE, fetchImpl: fetchMock as unknown as typeof fetch, @@ -143,6 +153,40 @@ describe('v2UserClient', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + test('given an unauthenticated browser user then requests name it, with no bearer token', async () => { + // Given + const fetchMock = vi.fn().mockResolvedValue(pageResponse([])); + const client = createV2UserClient({ + credentials: { kind: 'unauthenticated', userId: BROWSER_USER_ID }, + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + // When + await client.request('GET', 'user-policies', { query: { country_id: 'us' } }); + + // Then + const { headers } = fetchCall(fetchMock, 0); + expect(headers[UNAUTHENTICATED_USER_HEADER]).toBe(BROWSER_USER_ID); + expect(headers.Authorization).toBeUndefined(); + }); + + test('given extra headers then they go with the session token', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(okResponse({})); + + // When + await context.client.request('POST', 'me/upgrade', { + headers: { [UNAUTHENTICATED_USER_HEADER]: BROWSER_USER_ID }, + }); + + // Then + const { headers } = fetchCall(fetchMock, 0); + expect(headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(headers[UNAUTHENTICATED_USER_HEADER]).toBe(BROWSER_USER_ID); + }); + test('given a network failure then it throws status 0', async () => { // Given const { fetchMock, context } = createTestApiContext(); diff --git a/app/src/tests/unit/hooks/useFlagshipStores.test.tsx b/app/src/tests/unit/hooks/useFlagshipStores.test.tsx index 79fb3c3da..2fde95ad1 100644 --- a/app/src/tests/unit/hooks/useFlagshipStores.test.tsx +++ b/app/src/tests/unit/hooks/useFlagshipStores.test.tsx @@ -5,6 +5,7 @@ import { Provider as ReduxProvider } from 'react-redux'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { ApiFlagshipReportStore, LocalStorageFlagshipReportStore } from '@/api/flagshipReportStore'; import { ApiReformStore, LocalStorageReformStore } from '@/api/reformStore'; +import { UNAUTHENTICATED_USER_HEADER } from '@/api/v2UserClient'; import { CountryProvider } from '@/contexts/CountryContext'; import { useFlagshipReportStore, @@ -13,6 +14,7 @@ import { useV3SavedWork, } from '@/hooks/useFlagshipStores'; import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import { getBrowserUserId } from '@/libs/auth/browserUser'; import { store } from '@/store'; import { ASSOCIATION_IDS, @@ -93,9 +95,30 @@ describe('v3 store choice', () => { expect(savedWork.scope).toBe(`account:${TEST_AUTH0_SUBJECT}`); }); + test("given signed out then saved work goes to the API as this browser's user", async () => { + // Given + localStorage.clear(); + const browserUserId = getBrowserUserId(); + const wrapper = wrapperFor(makeAuthValue()); + + // When + const reforms = renderHook(() => useReformStore(), { wrapper }).result.current; + const reports = renderHook(() => useFlagshipReportStore(), { wrapper }).result.current; + await reforms.findByUser('anonymous', 'us'); + + // Then + expect(reforms).toBeInstanceOf(ApiReformStore); + expect(reports.store).toBeInstanceOf(ApiFlagshipReportStore); + expect(reports.isAccount).toBe(true); + expect(reports.scope).toBe(`browser-user:${browserUserId}`); + const headers = fetchMock.mock.calls[0][1].headers as Record; + expect(headers[UNAUTHENTICATED_USER_HEADER]).toBe(browserUserId); + expect(headers.Authorization).toBeUndefined(); + }); + test.each([ - ['signed out', makeAuthValue(), 'us'], - ['sign-in disabled', makeAuthValue({ status: 'disabled' }), 'uk'], + ['sign-in off', makeAuthValue({ status: 'disabled' }), 'uk'], + ['signed out outside the US and UK', makeAuthValue(), 'ca'], ['signed in outside the US and UK', signedInAuthValue(), 'ca'], ] as const)('given %s then reforms and reports stay in this browser', (_, auth, countryId) => { // Given @@ -139,7 +162,7 @@ describe('useReformStore', () => { }); describe('useReportRecord', () => { - test('given a signed-out run on this browser then its record names the reform it was run from', async () => { + test('given a run kept in this browser then its record names the reform it was run from', async () => { // Given localStorage.clear(); await new LocalStorageFlagshipReportStore().create({ @@ -153,9 +176,9 @@ describe('useReportRecord', () => { reformId: 'rf-ran', }); - // When + // When: sign-in off, so records are this browser's const { result } = renderHook(() => useReportRecord('615'), { - wrapper: wrapperFor(makeAuthValue()), + wrapper: wrapperFor(makeAuthValue({ status: 'disabled' })), }); // Then diff --git a/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx b/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx index 1541c920f..e7ecaf660 100644 --- a/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx +++ b/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx @@ -67,13 +67,25 @@ describe('useRunFlagshipReport', () => { expect(accountReportStore).toBeInstanceOf(ApiFlagshipReportStore); }); - test('given a signed-out session then the run records in this browser', async () => { + test("given a signed-out session then the run records as this browser's user in the API", async () => { // Given const { result } = renderRun(makeAuthValue()); // When await act(() => result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS)); + // Then + const { accountReportStore } = vi.mocked(runFlagshipReport).mock.calls[0][0]; + expect(accountReportStore).toBeInstanceOf(ApiFlagshipReportStore); + }); + + test('given sign-in off then the run records in this browser', async () => { + // Given + const { result } = renderRun(makeAuthValue({ status: 'disabled' })); + + // When + await act(() => result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS)); + // Then await waitFor(() => expect(runFlagshipReport).toHaveBeenCalledTimes(1)); expect(vi.mocked(runFlagshipReport).mock.calls[0][0].accountReportStore).toBeNull(); diff --git a/app/src/tests/unit/libs/auth/accountImport.test.ts b/app/src/tests/unit/libs/auth/accountImport.test.ts deleted file mode 100644 index 7acd633b2..000000000 --- a/app/src/tests/unit/libs/auth/accountImport.test.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { LocalStorageFlagshipReportStore } from '@/api/flagshipReportStore'; -import { LocalStorageReformStore } from '@/api/reformStore'; -import { MOCK_USER_ID } from '@/constants'; -import { - dismissAccountImportNotice, - importBrowserWork, - importOutcomeMessage, - runSignInImport, - useAccountImportNotice, -} from '@/libs/auth/accountImport'; -import { getDraftReform, setDraftEditingReform, startDraftReform } from '@/libs/draftReform'; -import { readReportMeta, saveReportMeta } from '@/libs/flagship/runReport'; -import { - createTestApiContext, - errorResponse, - fetchCall, - itemResponse, - mockMeItem, - okResponse, -} from '@/tests/fixtures/api/v2UserMocks'; -import type { Reform } from '@/types/ingredients/Reform'; - -const LEGACY_KEYS = [ - 'user-policy-associations', - 'user-population-households', - 'user-simulation-associations', - 'user-report-associations', -]; - -const MATERIALIZED_POLICY_ID = 95801; - -const REFORM_FIELDS: Omit = { - userId: MOCK_USER_ID, - label: 'CTC expansion', - parameters: [ - { - name: 'gov.irs.credits.ctc.amount.base[0]', - values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3000 }], - }, - ], - baseline: 'current-law', - provenance: { source: 'manual' }, -}; - -/** A US reform run as a report, a saved UK reform, and a Canada reform (not moved). */ -async function seedBrowserWork() { - // Saving a reform creates its v1 policy. - const reforms = new LocalStorageReformStore({ - materializePolicy: async () => MATERIALIZED_POLICY_ID, - }); - const usReform = await reforms.create({ ...REFORM_FIELDS, countryId: 'us' }); - const ukReform = await reforms.create({ - ...REFORM_FIELDS, - countryId: 'uk', - label: 'Personal allowance', - policyId: '501', - }); - await reforms.create({ ...REFORM_FIELDS, countryId: 'ca' }); - const record = await new LocalStorageFlagshipReportStore().create({ - userId: MOCK_USER_ID, - countryId: 'us', - apiReportId: '615', - title: null, - sourceNote: null, - provisions: [], - year: '2026', - reformId: usReform.id, - }); - saveReportMeta('615', { - title: 'CTC expansion', - sourceNote: 'Hand-built draft', - provisions: [ - { - path: 'gov.irs.credits.ctc.amount.base[0]', - breadcrumb: 'CTC', - unit: null, - baselineValue: 2000, - value: 3000, - }, - ], - createdAt: '2026-10-13T00:00:00Z', - }); - return { usReform, ukReform, record }; -} - -/** Answers each import with an id_map entry for every client_ref it was sent, except `refusedRefs`. */ -function acceptAllBut(...refusedRefs: Array) { - const refused = new Set(refusedRefs); - return async (_url: string, init: RequestInit) => { - const body = JSON.parse(String(init.body)); - const refs: string[] = [...(body.policies ?? []), ...(body.reports ?? [])].map( - (item: { client_ref: string }) => item.client_ref - ); - return okResponse({ - id_map: Object.fromEntries( - refs.filter((ref) => !refused.has(ref)).map((ref, index) => [ref, `uuid-${index}-${ref}`]) - ), - skipped: refs - .filter((ref) => refused.has(ref)) - .map((ref) => ({ client_ref: ref, reason: 'unknown_legacy_reference' })), - }); - }; -} - -const acceptEverything = acceptAllBut(); - -const storedIds = (key: string) => - JSON.parse(localStorage.getItem(key) ?? '[]').map((item: { id: string }) => item.id); - -beforeEach(() => { - localStorage.clear(); - act(() => dismissAccountImportNotice()); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe('importBrowserWork', () => { - test('given v3 work on this browser then reforms and reports go in per country, linked by client_ref', async () => { - // Given - const { usReform, ukReform, record } = await seedBrowserWork(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(acceptEverything); - - // When - const outcome = await importBrowserWork({ client: context.client }); - - // Then - expect(outcome).toEqual({ - reformsMoved: 2, - reportsMoved: 1, - leftOnBrowser: 0, - requestFailed: false, - }); - const usImport = fetchCall(fetchMock, 0); - expect(usImport.query).toEqual({ country_id: 'us' }); - expect(usImport.body.policies).toEqual([ - expect.objectContaining({ - client_ref: usReform.id, - legacy_policy_id: MATERIALIZED_POLICY_ID, - }), - ]); - expect(usImport.body.reports).toEqual([ - expect.objectContaining({ - client_ref: record.id, - legacy_report_id: 615, - name: 'CTC expansion', - source_note: 'Hand-built draft', - user_policy_client_ref: usReform.id, - }), - ]); - const ukImport = fetchCall(fetchMock, 1); - expect(ukImport.query).toEqual({ country_id: 'uk' }); - expect(ukImport.body.policies).toEqual([ - expect.objectContaining({ client_ref: ukReform.id, legacy_policy_id: 501 }), - ]); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - test('given everything moves then this browser keeps only work outside the US and UK', async () => { - // Given - await seedBrowserWork(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(acceptEverything); - - // When - await importBrowserWork({ client: context.client }); - - // Then - const remaining = JSON.parse(localStorage.getItem('reforms') ?? '[]'); - expect(remaining.map((reform: Reform) => reform.countryId)).toEqual(['ca']); - expect(storedIds('pe-flagship-reports')).toEqual([]); - expect(readReportMeta('615')).toBeNull(); - }); - - test('given the account skips an item then only that item stays, and goes again next time', async () => { - // Given - const { usReform, ukReform, record } = await seedBrowserWork(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(acceptAllBut(ukReform.id)); - - // When - const outcome = await importBrowserWork({ client: context.client }); - - // Then - expect(outcome).toMatchObject({ reformsMoved: 1, reportsMoved: 1, leftOnBrowser: 1 }); - expect(storedIds('reforms')).not.toContain(usReform.id); - expect(storedIds('reforms')).toContain(ukReform.id); - expect(storedIds('pe-flagship-reports')).not.toContain(record.id); - - // When: the next sign-in - fetchMock.mockClear(); - fetchMock.mockImplementation(acceptEverything); - const next = await importBrowserWork({ client: context.client }); - - // Then - expect(next).toMatchObject({ reformsMoved: 1, reportsMoved: 0, leftOnBrowser: 0 }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchCall(fetchMock, 0).body.policies).toEqual([ - expect.objectContaining({ client_ref: ukReform.id }), - ]); - }); - - test('given a request fails then the run stops and only the work already moved is removed', async () => { - // Given - const { usReform, ukReform } = await seedBrowserWork(); - const { fetchMock, context } = createTestApiContext(); - fetchMock - .mockImplementationOnce(acceptEverything) - .mockResolvedValueOnce(errorResponse(503, 'Saved work is temporarily unavailable.')); - vi.spyOn(console, 'error').mockImplementation(() => {}); - - // When - const outcome = await importBrowserWork({ client: context.client }); - - // Then - expect(outcome).toEqual({ - reformsMoved: 1, - reportsMoved: 1, - leftOnBrowser: 1, - requestFailed: true, - }); - expect(storedIds('reforms')).not.toContain(usReform.id); - expect(storedIds('reforms')).toContain(ukReform.id); - }); - - test('given the sign-in country then the account is set up there before anything moves', async () => { - // Given: work in both countries, signing in on the UK calculator - await seedBrowserWork(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(async (url: string, init: RequestInit) => - new URL(url).pathname === '/v2/me' ? itemResponse(mockMeItem) : acceptEverything(url, init) - ); - - // When - await importBrowserWork({ client: context.client, signInCountryId: 'uk' }); - - // Then - expect(fetchCall(fetchMock, 0)).toMatchObject({ path: '/v2/me', query: { country_id: 'uk' } }); - expect(fetchCall(fetchMock, 1)).toMatchObject({ - path: '/v2/me/imports', - query: { country_id: 'us' }, - }); - }); - - test('given setting up the account fails then the work still moves', async () => { - // Given - await seedBrowserWork(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(async (url: string, init: RequestInit) => - new URL(url).pathname === '/v2/me' - ? errorResponse(503, 'Saved work is temporarily unavailable.') - : acceptEverything(url, init) - ); - - // When - const outcome = await importBrowserWork({ client: context.client, signInCountryId: 'uk' }); - - // Then - expect(outcome).toMatchObject({ reformsMoved: 2, reportsMoved: 1, leftOnBrowser: 0 }); - }); - - test('given the draft edits a browser reform that moves then the draft follows it to the account', async () => { - // Given - const { ukReform } = await seedBrowserWork(); - startDraftReform('uk', 'manual'); - setDraftEditingReform(ukReform.id!); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(acceptEverything); - - // When - await importBrowserWork({ client: context.client }); - - // Then: acceptEverything names it uuid-- - expect(getDraftReform()?.editingReformId).toBe(`uuid-0-${ukReform.id}`); - }); - - test('given the import runs then it reads only the v3 keys, never the legacy ones', async () => { - // Given - await seedBrowserWork(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(acceptEverything); - const readKeys = new Set(); - const originalGetItem = localStorage.getItem.bind(localStorage); - vi.spyOn(localStorage, 'getItem').mockImplementation((key: string) => { - readKeys.add(key); - return originalGetItem(key); - }); - - // When - await importBrowserWork({ client: context.client }); - - // Then - // v3's saved work, and its draft (which follows a moved reform). - expect([...readKeys].sort()).toEqual( - ['pe-draft-reform', 'pe-flagship-report-meta', 'pe-flagship-reports', 'reforms'].sort() - ); - expect(LEGACY_KEYS.filter((key) => readKeys.has(key))).toEqual([]); - }); - - test('given a reform saved without a v1 policy by an earlier build then it is not sent and stays', async () => { - // Given - const { usReform } = await seedBrowserWork(); - const stored = JSON.parse(localStorage.getItem('reforms') ?? '[]'); - localStorage.setItem( - 'reforms', - JSON.stringify( - stored.map((reform: Reform) => - reform.id === usReform.id ? { ...reform, policyId: null } : reform - ) - ) - ); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(acceptEverything); - - // When - const outcome = await importBrowserWork({ client: context.client }); - - // Then - expect(outcome).toMatchObject({ reformsMoved: 1, reportsMoved: 1, leftOnBrowser: 0 }); - const sentRefs = [0, 1].flatMap((call) => - fetchCall(fetchMock, call).body.policies.map( - (item: { client_ref: string }) => item.client_ref - ) - ); - expect(sentRefs).not.toContain(usReform.id); - expect(storedIds('reforms')).toContain(usReform.id); - }); -}); - -describe('runSignInImport', () => { - test('given it succeeds then the notice says what moved', async () => { - // Given - await seedBrowserWork(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(acceptEverything); - const notice = renderHook(() => useAccountImportNotice()); - - // When - await act(() => runSignInImport({ client: context.client })); - - // Then - expect(notice.result.current).toEqual({ - kind: 'success', - message: 'Moved 2 reforms and 1 report from this browser to your account.', - retry: null, - }); - }); - - test('given a request fails then the notice says the work is still here, and Try again moves it', async () => { - // Given - await seedBrowserWork(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockResolvedValue(errorResponse(503, 'Saved work is temporarily unavailable.')); - vi.spyOn(console, 'error').mockImplementation(() => {}); - const notice = renderHook(() => useAccountImportNotice()); - await act(() => runSignInImport({ client: context.client })); - expect(notice.result.current).toMatchObject({ - kind: 'failure', - message: - 'Your reforms and reports saved on this browser could not be moved to your account. They are still on this browser and will be tried again the next time you sign in.', - }); - - // When - fetchMock.mockImplementation(acceptEverything); - await act(async () => { - notice.result.current!.retry!(); - await vi.waitFor(() => expect(notice.result.current?.kind).toBe('success')); - }); - - // Then - expect(notice.result.current?.message).toMatch(/^Moved 2 reforms and 1 report/); - }); - - test('given the account skips items then the notice warns, without Try again', async () => { - // Given - const { ukReform } = await seedBrowserWork(); - const { fetchMock, context } = createTestApiContext(); - fetchMock.mockImplementation(acceptAllBut(ukReform.id)); - const notice = renderHook(() => useAccountImportNotice()); - - // When - await act(() => runSignInImport({ client: context.client })); - - // Then - expect(notice.result.current).toEqual({ - kind: 'warning', - message: - 'Moved 1 reform and 1 report from this browser to your account. 1 item is still on this browser and will be tried again the next time you sign in.', - retry: null, - }); - }); - - test('given nothing saved on this browser then nothing is sent and no notice shows', async () => { - // Given - const { fetchMock, context } = createTestApiContext(); - const notice = renderHook(() => useAccountImportNotice()); - - // When - await act(() => runSignInImport({ client: context.client })); - - // Then - expect(fetchMock).not.toHaveBeenCalled(); - expect(notice.result.current).toBeNull(); - }); -}); - -describe('importOutcomeMessage', () => { - test('given nothing moved and no request failed then the wording agrees with the count', () => { - expect( - importOutcomeMessage({ - reformsMoved: 0, - reportsMoved: 0, - leftOnBrowser: 2, - requestFailed: false, - }) - ).toBe( - '2 items saved on this browser could not be moved to your account. They are still on this browser and will be tried again the next time you sign in.' - ); - }); -}); diff --git a/app/src/tests/unit/libs/auth/browserUser.test.ts b/app/src/tests/unit/libs/auth/browserUser.test.ts new file mode 100644 index 000000000..5791ba4a8 --- /dev/null +++ b/app/src/tests/unit/libs/auth/browserUser.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { getBrowserUserId, replaceBrowserUserId } from '@/libs/auth/browserUser'; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('browserUser', () => { + test('given a first visit then the browser gets a random user ID that it keeps', () => { + // When + const first = getBrowserUserId(); + + // Then + expect(first).toMatch(UUID_PATTERN); + expect(getBrowserUserId()).toBe(first); + expect(localStorage.getItem('pe-browser-user-id')).toBe(first); + }); + + test('given an upgrade then the browser starts over with a new user ID', () => { + // Given + const before = getBrowserUserId(); + + // When + const after = replaceBrowserUserId(); + + // Then + expect(after).toMatch(UUID_PATTERN); + expect(after).not.toBe(before); + expect(getBrowserUserId()).toBe(after); + }); + + test('given storage is unavailable then one ID serves the rest of the page', () => { + // Given + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blocked'); + }); + + // When + const first = getBrowserUserId(); + + // Then + expect(first).toMatch(UUID_PATTERN); + expect(getBrowserUserId()).toBe(first); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx b/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx index 1eda6c996..6e3547046 100644 --- a/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx +++ b/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx @@ -1,24 +1,24 @@ import { render, screen, userEvent, waitFor } from '@test-utils'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { LocalStorageReformStore } from '@/api/reformStore'; -import AccountImportNotice from '@/components/auth/AccountImportNotice'; -import { MOCK_USER_ID } from '@/constants'; +import { UNAUTHENTICATED_USER_HEADER } from '@/api/v2UserClient'; +import AccountUpgradeNotice from '@/components/auth/AccountUpgradeNotice'; import { NavigationProvider } from '@/contexts/NavigationContext'; -import { dismissAccountImportNotice } from '@/libs/auth/accountImport'; +import { + dismissAccountUpgradeNotice, + UPGRADE_FAILURE_MESSAGE, + UPGRADE_SUCCESS_MESSAGE, +} from '@/libs/auth/accountUpgrade'; import type { AuthContextValue } from '@/libs/auth/AuthContext'; import { takeSignInReturnTo } from '@/libs/auth/AuthProvider'; +import { getBrowserUserId } from '@/libs/auth/browserUser'; import AuthCallbackPage from '@/pages/flagship/AuthCallback.page'; -import { - errorResponse, - itemResponse, - mockMeItem, - okResponse, -} from '@/tests/fixtures/api/v2UserMocks'; +import { errorResponse, mockMeItem, okResponse } from '@/tests/fixtures/api/v2UserMocks'; import { AuthTestProvider, makeAuthValue, signedInAuthValue, } from '@/tests/fixtures/libs/auth/authMocks'; +import type { UpgradeOutcome } from '@/types/v2User'; vi.mock('@/libs/auth/AuthProvider', () => ({ takeSignInReturnTo: vi.fn(), @@ -32,57 +32,35 @@ function renderCallback(auth: AuthContextValue) { - + ); } -function requestPaths(): string[] { - return fetchMock.mock.calls.map(([url]) => new URL(String(url)).pathname); -} - -/** Answers /v2/me with the account, and imports with every reform added or with a failure. */ -function answerImports(imports: 'accept' | 'fail') { - fetchMock.mockImplementation(async (url: string, init: RequestInit) => { - const path = new URL(url).pathname; - if (path === '/v2/me') { - return itemResponse(mockMeItem); - } - if (path !== '/v2/me/imports') { +/** Answers the upgrade with `outcome`, or with a failure. */ +function answerUpgrade(answer: UpgradeOutcome | 'fail') { + fetchMock.mockImplementation(async (url: string) => { + if (new URL(url).pathname !== '/v2/me/upgrade') { return new Response('', { status: 404 }); } - if (imports === 'fail') { - return errorResponse(503, 'Saved work is temporarily unavailable.'); - } - const body = JSON.parse(String(init.body)); - return okResponse({ - id_map: Object.fromEntries( - (body.policies ?? []).map((item: { client_ref: string }) => [item.client_ref, 'uuid-1']) - ), - skipped: [], - }); + return answer === 'fail' + ? errorResponse(503, 'Saved work is temporarily unavailable.') + : okResponse({ item: mockMeItem, outcome: answer }); }); } -const browserReformCount = () => JSON.parse(localStorage.getItem('reforms') ?? '[]').length; +function upgradeRequest(index = 0) { + const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit]; + return { url: new URL(url), headers: init.headers as Record }; +} -beforeEach(async () => { +beforeEach(() => { localStorage.clear(); vi.clearAllMocks(); vi.stubGlobal('fetch', fetchMock); vi.mocked(takeSignInReturnTo).mockReturnValue('/us/reforms'); - dismissAccountImportNotice(); - // A reform saved on this browser while signed out, with its v1 policy. - await new LocalStorageReformStore().create({ - userId: MOCK_USER_ID, - countryId: 'us', - label: 'CTC expansion', - policyId: '95762', - parameters: [], - baseline: 'current-law', - provenance: { source: 'manual' }, - }); + dismissAccountUpgradeNotice(); }); afterEach(() => { @@ -90,55 +68,57 @@ afterEach(() => { }); describe('AuthCallbackPage', () => { - test('given work saved on this browser then it moves to the account, then it returns', async () => { + test("given this browser's user had saved work then it is upgraded into the account, then it returns", async () => { // Given - answerImports('accept'); + const browserUserId = getBrowserUserId(); + answerUpgrade('converted'); // When renderCallback(signedInAuthValue()); // Then await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); - // The account is set up in the sign-in country first. - expect(requestPaths()).toEqual(['/v2/me', '/v2/me/imports']); - expect(await screen.findByRole('alert')).toHaveTextContent( - 'Moved 1 reform and 0 reports from this browser to your account.' - ); - expect(browserReformCount()).toBe(0); + const request = upgradeRequest(); + expect(request.url.searchParams.get('country_id')).toBe('us'); + expect(request.headers[UNAUTHENTICATED_USER_HEADER]).toBe(browserUserId); + expect(request.headers.Authorization).toMatch(/^Bearer /); + expect(await screen.findByRole('alert')).toHaveTextContent(UPGRADE_SUCCESS_MESSAGE); + // The browser starts over, so whoever is next here signed out starts empty. + expect(getBrowserUserId()).not.toBe(browserUserId); }); - test('given nothing saved on this browser then nothing is called', async () => { + test('given nothing to upgrade then it returns without a notice', async () => { // Given - localStorage.clear(); - answerImports('accept'); + answerUpgrade('none'); // When renderCallback(signedInAuthValue()); // Then await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); - expect(fetchMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); - test('given the import fails then the work stays, and Try again moves it', async () => { + test('given the upgrade fails then the browser keeps its user, and Try again upgrades it', async () => { // Given - answerImports('fail'); + const browserUserId = getBrowserUserId(); + answerUpgrade('fail'); vi.spyOn(console, 'error').mockImplementation(() => {}); const user = userEvent.setup(); renderCallback(signedInAuthValue()); - expect(await screen.findByRole('alert')).toHaveTextContent(/still on this browser/); + expect(await screen.findByRole('alert')).toHaveTextContent(UPGRADE_FAILURE_MESSAGE); await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); - expect(browserReformCount()).toBe(1); + expect(getBrowserUserId()).toBe(browserUserId); // When - answerImports('accept'); + answerUpgrade('merged'); await user.click(screen.getByRole('button', { name: 'Try again' })); // Then - expect(await screen.findByText(/^Moved 1 reform/)).toBeInTheDocument(); - expect(requestPaths().filter((path) => path === '/v2/me/imports')).toHaveLength(2); - expect(browserReformCount()).toBe(0); + expect(await screen.findByText(UPGRADE_SUCCESS_MESSAGE)).toBeInTheDocument(); + expect(upgradeRequest(1).headers[UNAUTHENTICATED_USER_HEADER]).toBe(browserUserId); + expect(getBrowserUserId()).not.toBe(browserUserId); }); test('given sign-in did not complete then the error shows and nothing is called', () => { diff --git a/app/src/types/v2User.ts b/app/src/types/v2User.ts index 60146739b..0081b02e7 100644 --- a/app/src/types/v2User.ts +++ b/app/src/types/v2User.ts @@ -1,7 +1,8 @@ /** - * Wire types for the PolicyEngine API's signed-in routes (`/v2/me*` and + * Wire types for the PolicyEngine API's saved-work routes (`/v2/me*` and * `/v2/user-*`). Field names are snake_case, as on the wire. The server - * derives the user from the bearer token, so request bodies never carry a + * derives the user from the session's bearer token, or signed out from + * this browser's unauthenticated user, so request bodies never carry a * `user_id`; responses do. */ @@ -34,10 +35,15 @@ export interface V2PageResult { has_more: boolean; } +/** An unauthenticated user is this browser's; a standard user signs in with Auth0. */ +export type UserType = 'unauthenticated' | 'standard'; + export interface MeItem { /** The PolicyEngine user ID that owns every association. */ id: string; - primary_country: AssociationCountryId; + user_type: UserType; + /** Null for an unauthenticated user. */ + primary_country: AssociationCountryId | null; first_name: string | null; last_name: string | null; email: string | null; @@ -58,7 +64,6 @@ interface AssociationItemBase { user_id: string; name: string | null; description: string | null; - client_ref: string | null; created_at: string; updated_at: string; } @@ -106,7 +111,6 @@ export interface UserPolicyCreateBody { description?: string | null; baseline?: BaselineWire | null; provenance?: ProvenanceWire | null; - client_ref?: string | null; } export type UserPolicyPatchBody = Partial< @@ -126,55 +130,30 @@ export interface UserReportCreateBody { provisions?: ReportProvisionWire[] | null; validation?: unknown | null; last_run_at?: string | null; - client_ref?: string | null; } /* /v2/me/export */ /** Everything saved to the account, in every country; downloaded as is. */ export interface MeExportResult { - user: MeItem; + /** Null for a signed-out browser that has saved nothing yet. */ + user: MeItem | null; policies: UserPolicyItem[]; reports: UserReportItem[]; [otherRecords: string]: unknown; } -/* /v2/me/imports */ - -export interface ImportPolicyItem { - client_ref: string; - legacy_policy_id: number; - name: string | null; - description: string | null; - baseline: BaselineWire | null; - provenance: ProvenanceWire | null; -} - -export interface ImportReportItem { - client_ref: string; - legacy_report_id: number; - name: string | null; - description: string | null; - user_policy_client_ref: string | null; - source_note: string | null; - provisions: ReportProvisionWire[] | null; - validation: unknown | null; - last_run_at: string | null; -} - -/** v3 imports its reforms (as policies) and its reports only. */ -export interface ImportRequestBody { - policies?: ImportPolicyItem[]; - reports?: ImportReportItem[]; -} +/* /v2/me/upgrade */ -export interface ImportSkippedItem { - /** Null when the server could not parse the item at all. */ - client_ref: string | null; - reason: string; -} +/** + * What signing in did with this browser's unauthenticated user: made it + * the new account ("converted"), moved its work into an existing account + * ("merged"), or found nothing to upgrade ("none"). + */ +export type UpgradeOutcome = 'converted' | 'merged' | 'none'; -export interface ImportResult { - id_map: Record; - skipped: ImportSkippedItem[]; +export interface MeUpgradeResult { + /** The signed-in account. */ + item: MeItem; + outcome: UpgradeOutcome; } diff --git a/calculator-app/.env.example b/calculator-app/.env.example index fc05fc010..907993782 100644 --- a/calculator-app/.env.example +++ b/calculator-app/.env.example @@ -4,16 +4,17 @@ # so the VITE_* spellings work here too. # --- PolicyEngine API -------------------------------------------------------- -# Origin of the PolicyEngine API (v1 artifacts and the signed-in /v2 routes). +# Origin of the PolicyEngine API (v1 artifacts and the /v2 saved-work routes). # Defaults to https://api.policyengine.org. Staging builds must point at the # staging API so staging artifacts never land in production. # NEXT_PUBLIC_API_URL=https:// -# --- Sign-in (Auth0 social sign-on, v3 workspace only) ----------------------- -# Sign-in is inert unless NEXT_PUBLIC_AUTH_ENABLED=true AND all three Auth0 -# values are set; otherwise the app behaves exactly as it does without -# sign-in (everything saved in localStorage). The legacy calculator never -# signs in. +# --- Accounts and sign-in (Auth0 social sign-on, v3 workspace only) --------- +# Inert unless NEXT_PUBLIC_AUTH_ENABLED=true AND all three Auth0 values are +# set; otherwise the app behaves exactly as it does without accounts +# (everything saved in localStorage). When on, the US and UK workspace saves +# everything to the PolicyEngine API: signed in as the account, signed out as +# this browser's unauthenticated user. The legacy calculator never signs in. # NEXT_PUBLIC_AUTH_ENABLED=true # Auth0 (custom) domain, e.g. auth.policyengine.org # NEXT_PUBLIC_AUTH0_DOMAIN= diff --git a/calculator-app/src/app/[countryId]/(workspace)/layout.tsx b/calculator-app/src/app/[countryId]/(workspace)/layout.tsx index b1fed4019..5377f2b16 100644 --- a/calculator-app/src/app/[countryId]/(workspace)/layout.tsx +++ b/calculator-app/src/app/[countryId]/(workspace)/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import AccountImportNotice from "@/components/auth/AccountImportNotice"; +import AccountUpgradeNotice from "@/components/auth/AccountUpgradeNotice"; import { AuthProvider } from "@/libs/auth/AuthProvider"; import WorkspaceNavigation from "./WorkspaceNavigation"; @@ -8,9 +8,10 @@ import WorkspaceNavigation from "./WorkspaceNavigation"; * Flagship workspace shell. Kept in its own route group so the local * screen switching never wraps the legacy calculator routes. * - * Sign-in belongs to the workspace alone: the legacy calculator routes + * Accounts belong to the workspace alone: the legacy calculator routes * have no AuthProvider and keep saved work in the browser. The provider - * is inert unless NEXT_PUBLIC_AUTH_ENABLED=true and Auth0 is configured. + * is inert unless NEXT_PUBLIC_AUTH_ENABLED=true and Auth0 is configured; + * then saved work goes to the PolicyEngine API, signed in or not. */ export default function WorkspaceLayout({ children, @@ -20,7 +21,7 @@ export default function WorkspaceLayout({ return ( {children} - +
); From b4755627f535374eee845ce6a2ed7d9063e21e73 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:00:12 +0400 Subject: [PATCH 36/48] Send no label with the base policies v3 creates Names and titles belong only to the user's records (user_policies and user_report_associations), never to the shared v1 policy. The v1 policy route deduplicates on country, change hash and label, so labelling a policy with a report title or reform name split identical changes into separate policies, and therefore separate simulations and reports. createReformPolicy and materializeReformPolicy no longer take a label, and POST /{country}/policy is sent the changes alone, from report runs, saved reforms and the bill page's calculation. Identical changes now share one policy, one pair of simulations and one report per year. Simulations and reports were already created without labels. Co-Authored-By: Claude Opus 5.5 --- app/src/api/reformStore.ts | 23 +++++++------------ app/src/hooks/useBillEconomy.ts | 2 +- app/src/libs/flagship/runReport.ts | 9 ++++---- app/src/tests/unit/api/reformStore.test.ts | 20 ++++------------ .../tests/unit/hooks/useBillEconomy.test.tsx | 6 +++-- .../unit/libs/flagship/runReport.test.ts | 2 +- 6 files changed, 23 insertions(+), 39 deletions(-) diff --git a/app/src/api/reformStore.ts b/app/src/api/reformStore.ts index 5704a7ea7..451e5c856 100644 --- a/app/src/api/reformStore.ts +++ b/app/src/api/reformStore.ts @@ -65,7 +65,7 @@ export class LocalStorageReformStore implements ReformStore { async create(reform: Omit): Promise { const policyId = isLegacyId(reform.policyId) ? String(reform.policyId) - : String(await this.materializePolicy(reform.countryId, reform.label, reform.parameters)); + : String(await this.materializePolicy(reform.countryId, reform.parameters)); // Generate a unique ID for local storage // Format: "rf-[short-timestamp][random]" @@ -105,13 +105,7 @@ export class LocalStorageReformStore implements ReformStore { // Changed parameters are a new v1 policy; a rename keeps the policy. const policyId = updates.parameters !== undefined - ? String( - await this.materializePolicy( - existing.countryId, - updates.label !== undefined ? updates.label : existing.label, - updates.parameters - ) - ) + ? String(await this.materializePolicy(existing.countryId, updates.parameters)) : (updates.policyId ?? existing.policyId); // Read again after the request, so a change made meanwhile is kept. @@ -211,10 +205,9 @@ function provenanceFromWire(wire: ProvenanceWire | null): ReformProvenance { }; } -/** Materializes parameters as a v1 policy; returns its ID. */ +/** Materializes parameters as an unlabelled v1 policy; returns its ID. */ export type MaterializeReformPolicy = ( countryId: string, - label: string | null | undefined, parameters: Parameter[] ) => Promise; @@ -226,15 +219,16 @@ export interface ApiReformStoreOptions { fetchPolicyJson?: (countryId: string, policyId: string) => Promise; } -/** The v1 policy for a set of parameters, via the run bridge's policy creation. */ +/** + * The v1 policy for a set of parameters, via the run bridge's policy + * creation. The reform's name stays on its user record, never the policy. + */ export function materializeReformPolicy( countryId: string, - label: string | null | undefined, parameters: Parameter[] ): Promise { return createReformPolicy( countryId as Reform['countryId'], - label ?? '', parameters.map((parameter) => ({ path: parameter.name, value: parameter.values[0]?.value, @@ -280,7 +274,7 @@ export class ApiReformStore implements ReformStore { const countryId = this.routes.writeCountry(reform.countryId); const legacyPolicyId = isLegacyId(reform.policyId) ? Number(reform.policyId) - : await this.materializePolicy(countryId, reform.label, reform.parameters); + : await this.materializePolicy(countryId, reform.parameters); return { country_id: countryId, legacy_policy_id: legacyPolicyId, @@ -333,7 +327,6 @@ export class ApiReformStore implements ReformStore { if (updates.parameters !== undefined) { body.legacy_policy_id = await this.materializePolicy( this.context.countryId, - updates.label, updates.parameters ); } else if (updates.policyId !== undefined && isLegacyId(updates.policyId)) { diff --git a/app/src/hooks/useBillEconomy.ts b/app/src/hooks/useBillEconomy.ts index 472699d5d..0ec5d46eb 100644 --- a/app/src/hooks/useBillEconomy.ts +++ b/app/src/hooks/useBillEconomy.ts @@ -57,7 +57,7 @@ export function useBillEconomy( const policy = useQuery({ queryKey: ['flagship-bill-policy', countryId, bill?.id, provisions], - queryFn: () => createReformPolicy(countryId, bill!.title, provisions), + queryFn: () => createReformPolicy(countryId, provisions), enabled: enabled && !!bill && provisions.length > 0, staleTime: Infinity, }); diff --git a/app/src/libs/flagship/runReport.ts b/app/src/libs/flagship/runReport.ts index cc3eeca3e..2ff784f48 100644 --- a/app/src/libs/flagship/runReport.ts +++ b/app/src/libs/flagship/runReport.ts @@ -118,15 +118,16 @@ export function provisionPeriods( /** * Creates (or, since the API dedupes identical policies, finds) the reform * policy for a set of provisions — each with its dated changes, or its - * single value from the current year onward. + * single value from the current year onward. The policy carries only the + * changes, never a label: names and titles belong to the user's records, + * so identical changes always share one policy. */ export async function createReformPolicy( countryId: CountryId, - title: string, provisions: Array> ): Promise { const data = Object.fromEntries(provisions.map((p) => [p.path, provisionPeriods(p)])); - const policyResponse = await createPolicy(countryId, { data, label: title || undefined }); + const policyResponse = await createPolicy(countryId, { data }); return Number(policyResponse.result.policy_id); } @@ -144,7 +145,7 @@ export async function runFlagshipReport({ throw new Error('Cannot run a report with no provisions'); } - const reformPolicyId = await createReformPolicy(countryId, title, provisions); + const reformPolicyId = await createReformPolicy(countryId, provisions); const [baseline, reform] = await Promise.all([ createSimulation(countryId, { diff --git a/app/src/tests/unit/api/reformStore.test.ts b/app/src/tests/unit/api/reformStore.test.ts index 5202d4383..010110df1 100644 --- a/app/src/tests/unit/api/reformStore.test.ts +++ b/app/src/tests/unit/api/reformStore.test.ts @@ -69,11 +69,7 @@ describe('ApiReformStore', () => { const created = await store.create(mockReformInput); // Then - expect(materializePolicy).toHaveBeenCalledWith( - 'us', - mockReformInput.label, - mockReformInput.parameters - ); + expect(materializePolicy).toHaveBeenCalledWith('us', mockReformInput.parameters); const call = fetchCall(fetchMock); expect(call.path).toBe('/v2/user-policies'); expect(call.body).toEqual({ @@ -272,11 +268,7 @@ describe('ApiReformStore', () => { await store.create({ ...mockReformInput, countryId: 'uk' }); // Then - expect(materializePolicy).toHaveBeenCalledWith( - 'uk', - mockReformInput.label, - mockReformInput.parameters - ); + expect(materializePolicy).toHaveBeenCalledWith('uk', mockReformInput.parameters); const call = fetchCall(fetchMock); expect(call.query).toEqual({ country_id: 'uk' }); expect(call.body.country_id).toBe('uk'); @@ -354,11 +346,7 @@ describe('LocalStorageReformStore', () => { it('given a reform, when created, then its v1 policy is created and its ID kept in this browser', async () => { const created = await store.create(mockReformInput); - expect(materializePolicy).toHaveBeenCalledWith( - 'us', - mockReformInput.label, - mockReformInput.parameters - ); + expect(materializePolicy).toHaveBeenCalledWith('us', mockReformInput.parameters); expect(created.policyId).toBe(String(MATERIALIZED_POLICY_ID)); expect((await store.findById(created.id!))?.policyId).toBe(String(MATERIALIZED_POLICY_ID)); }); @@ -410,7 +398,7 @@ describe('LocalStorageReformStore', () => { const updated = await store.update(created.id!, { parameters }); - expect(materializePolicy).toHaveBeenLastCalledWith('us', mockReformInput.label, parameters); + expect(materializePolicy).toHaveBeenLastCalledWith('us', parameters); expect(updated.policyId).toBe(String(REPLACEMENT_POLICY_ID)); expect((await store.findById(created.id!))?.parameters).toEqual(parameters); }); diff --git a/app/src/tests/unit/hooks/useBillEconomy.test.tsx b/app/src/tests/unit/hooks/useBillEconomy.test.tsx index 98e9fc7cc..2a0bcf38c 100644 --- a/app/src/tests/unit/hooks/useBillEconomy.test.tsx +++ b/app/src/tests/unit/hooks/useBillEconomy.test.tsx @@ -52,8 +52,9 @@ describe('useBillEconomy', () => { await waitFor(() => expect(result.current.status).toBe('complete')); expect(mockCreatePolicy).toHaveBeenCalledWith('us', { data: { 'gov.states.ut.tax.income.rate': { [`${CURRENT_YEAR}-01-01.2100-12-31`]: 0.0445 } }, - label: 'Utah SB60', }); + // The policy carries only the changes; the bill's title is never sent. + expect(mockCreatePolicy.mock.calls[0][1]).not.toHaveProperty('label'); expect(mockFetchSocietyWide).toHaveBeenCalledWith('us', '98557', '2', { region: 'state/ut', time_period: CURRENT_YEAR, @@ -82,8 +83,9 @@ describe('useBillEconomy', () => { // A 2027 change scored in 2026 would show an effect the bill does not have then. expect(mockCreatePolicy).toHaveBeenCalledWith('us', { data: { 'gov.states.ut.tax.income.rate': { '2027-01-01.2100-12-31': 0.044 } }, - label: 'Utah SB60', }); + // The policy carries only the changes; the bill's title is never sent. + expect(mockCreatePolicy.mock.calls[0][1]).not.toHaveProperty('label'); expect(mockFetchSocietyWide).toHaveBeenCalledWith('us', '98557', '2', { region: 'state/ut', time_period: '2027', diff --git a/app/src/tests/unit/libs/flagship/runReport.test.ts b/app/src/tests/unit/libs/flagship/runReport.test.ts index c54fa7647..f94c7679a 100644 --- a/app/src/tests/unit/libs/flagship/runReport.test.ts +++ b/app/src/tests/unit/libs/flagship/runReport.test.ts @@ -92,8 +92,8 @@ describe('runFlagshipReport', () => { }); expect(userReportId).toBe('55'); + // The base policy carries only the changes; the title stays on the record. expect(mockCreatePolicy).toHaveBeenCalledWith('us', { - label: 'CTC expansion', data: { 'gov.irs.credits.ctc.amount.base[0].amount': { '2026-01-01.2100-12-31': 2500 }, }, From abddbbb1fa1d58d1d15cd09301ba427ac2aeb855 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:01:43 +0400 Subject: [PATCH 37/48] Style the sign-in upgrade notice as a v3 panel The notice used the legacy calculator's FloatingAlert, which sat at the legacy header's offset and looked out of place in the v3 workspace. It is now a v3 panel like the report's calculation-error panel: a white card with a light border, 12px corners and a soft shadow, floating just below the v3 top bar; an icon in a tinted circle (teal check when work moved, warning triangle when it did not); a heading ("Added to your account" / "Not added to your account yet") over the message; a v3 "Try again" button after a failure; and a ghost dismiss button. Success is announced as a status, failure as an alert. Co-Authored-By: Claude Opus 5.5 --- .../components/auth/AccountUpgradeNotice.tsx | 120 +++++++++++++++--- .../components/flagship/FlagshipTopBar.tsx | 2 +- .../pages/flagship/AuthCallback.page.test.tsx | 26 +++- 3 files changed, 129 insertions(+), 19 deletions(-) diff --git a/app/src/components/auth/AccountUpgradeNotice.tsx b/app/src/components/auth/AccountUpgradeNotice.tsx index 3d451a7f5..d866ce619 100644 --- a/app/src/components/auth/AccountUpgradeNotice.tsx +++ b/app/src/components/auth/AccountUpgradeNotice.tsx @@ -1,29 +1,119 @@ -import { FloatingAlert } from '@/components/common/FloatingAlert'; +import type { ReactNode } from 'react'; +import { IconAlertTriangle, IconCheck, IconX } from '@tabler/icons-react'; +import { FLAGSHIP_TOP_BAR_HEIGHT } from '@/components/flagship/FlagshipTopBar'; import { Button } from '@/components/ui'; -import { dismissAccountUpgradeNotice, useAccountUpgradeNotice } from '@/libs/auth/accountUpgrade'; +import { colors, spacing, typography } from '@/designTokens'; +import { + dismissAccountUpgradeNotice, + useAccountUpgradeNotice, + type AccountUpgradeNotice as Notice, +} from '@/libs/auth/accountUpgrade'; + +/** The heading, icon and its tint for each outcome, in the v3 panel style. */ +const NOTICE_LOOK: Record< + Notice['kind'], + { title: string; icon: ReactNode; tint: string; iconColor: string } +> = { + success: { + title: 'Added to your account', + icon: , + tint: colors.primary[50], + iconColor: colors.primary[600], + }, + failure: { + title: 'Not added to your account yet', + icon: , + // The warning yellow, tinted, as on a failed report. + tint: `${colors.warning}33`, + iconColor: colors.text.warning, + }, +}; /** * Reports the upgrade at sign-in (see runAccountUpgrade). Mounted once in - * the v3 workspace, so it stays up after the callback page has moved on. + * the v3 workspace, so it stays up after the callback page has moved on; + * it floats below the top bar in the workspace's panel style. */ export default function AccountUpgradeNotice() { const notice = useAccountUpgradeNotice(); if (!notice) { return null; } + const look = NOTICE_LOOK[notice.kind]; return ( - - {notice.message} - {notice.retry && ( - - - - )} - + + {look.icon} + +
+
+

+ {look.title} +

+

+ {notice.message} +

+
+ {notice.retry && ( +
+ +
+ )} +
+ + ); } diff --git a/app/src/components/flagship/FlagshipTopBar.tsx b/app/src/components/flagship/FlagshipTopBar.tsx index 79852fbcd..968250538 100644 --- a/app/src/components/flagship/FlagshipTopBar.tsx +++ b/app/src/components/flagship/FlagshipTopBar.tsx @@ -21,7 +21,7 @@ const NAV_ITEMS = [ ]; /** Height of the bar. */ -const FLAGSHIP_TOP_BAR_HEIGHT = 56; +export const FLAGSHIP_TOP_BAR_HEIGHT = 56; /** * The flagship shell's navigation: brand, the two sections, and the diff --git a/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx b/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx index 6e3547046..02f89b847 100644 --- a/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx +++ b/app/src/tests/unit/pages/flagship/AuthCallback.page.test.tsx @@ -82,7 +82,9 @@ describe('AuthCallbackPage', () => { expect(request.url.searchParams.get('country_id')).toBe('us'); expect(request.headers[UNAUTHENTICATED_USER_HEADER]).toBe(browserUserId); expect(request.headers.Authorization).toMatch(/^Bearer /); - expect(await screen.findByRole('alert')).toHaveTextContent(UPGRADE_SUCCESS_MESSAGE); + expect(await screen.findByRole('status', { name: 'Added to your account' })).toHaveTextContent( + UPGRADE_SUCCESS_MESSAGE + ); // The browser starts over, so whoever is next here signed out starts empty. expect(getBrowserUserId()).not.toBe(browserUserId); }); @@ -97,7 +99,7 @@ describe('AuthCallbackPage', () => { // Then await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByRole('status', { name: 'Added to your account' })).not.toBeInTheDocument(); }); test('given the upgrade fails then the browser keeps its user, and Try again upgrades it', async () => { @@ -107,7 +109,9 @@ describe('AuthCallbackPage', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); const user = userEvent.setup(); renderCallback(signedInAuthValue()); - expect(await screen.findByRole('alert')).toHaveTextContent(UPGRADE_FAILURE_MESSAGE); + expect( + await screen.findByRole('alert', { name: 'Not added to your account yet' }) + ).toHaveTextContent(UPGRADE_FAILURE_MESSAGE); await waitFor(() => expect(navigation.replace).toHaveBeenCalledWith('/us/reforms')); expect(getBrowserUserId()).toBe(browserUserId); @@ -131,3 +135,19 @@ describe('AuthCallbackPage', () => { expect(navigation.replace).not.toHaveBeenCalled(); }); }); + +describe('AccountUpgradeNotice', () => { + test('given the notice then it can be dismissed', async () => { + // Given + answerUpgrade('converted'); + const user = userEvent.setup(); + renderCallback(signedInAuthValue()); + await screen.findByRole('status', { name: 'Added to your account' }); + + // When + await user.click(screen.getByRole('button', { name: 'Dismiss' })); + + // Then + expect(screen.queryByRole('status', { name: 'Added to your account' })).not.toBeInTheDocument(); + }); +}); From 1332e603286a80b190f55dbb909e634e6db36b94 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:14:54 +0400 Subject: [PATCH 38/48] Group the More menu with Build and Reforms in the v3 top bar "More" sat apart on the right, pushed there with an automatic margin. It now sits in the primary navigation group straight after Build and Reforms, styled like an inactive nav item (same height, padding and weight), and its menu opens from its left edge. The draft chip and the sign-in / user menu stay together on the right. Co-Authored-By: Claude Opus 5.5 --- .../components/flagship/FlagshipTopBar.tsx | 104 +++++++++--------- .../flagship/FlagshipTopBar.test.tsx | 5 +- 2 files changed, 57 insertions(+), 52 deletions(-) diff --git a/app/src/components/flagship/FlagshipTopBar.tsx b/app/src/components/flagship/FlagshipTopBar.tsx index 968250538..4a4065a78 100644 --- a/app/src/components/flagship/FlagshipTopBar.tsx +++ b/app/src/components/flagship/FlagshipTopBar.tsx @@ -24,8 +24,8 @@ const NAV_ITEMS = [ export const FLAGSHIP_TOP_BAR_HEIGHT = 56; /** - * The flagship shell's navigation: brand, the two sections, and the - * website links, in one slim bar across the top. Two destinations do + * The flagship shell's navigation: brand, then the two sections and the + * website links as one group, in one slim bar across the top. Two destinations do * not earn a sidebar's width, and they fit a phone's bar as they are, * so there is no drawer to open on any viewport. */ @@ -113,70 +113,72 @@ export default function FlagshipTopBar() { ); })} - - {draftCount > 0 && ( - - )} + + + + + + {moreLinks.map((link) => ( + + + {link.label} + + + ))} + + + - - + {/* The draft chip and the sign-in / user menu sit together on the right. */} +
+ {draftCount > 0 && ( - - - {moreLinks.map((link) => ( - - - {link.label} - - - ))} - - + )} - {/* Sign in / user menu; renders nothing unless sign-in is enabled. */} - + {/* Sign in / user menu; renders nothing unless sign-in is enabled. */} + +
); } diff --git a/app/src/tests/unit/components/flagship/FlagshipTopBar.test.tsx b/app/src/tests/unit/components/flagship/FlagshipTopBar.test.tsx index 2d6231ad8..481b7dfb0 100644 --- a/app/src/tests/unit/components/flagship/FlagshipTopBar.test.tsx +++ b/app/src/tests/unit/components/flagship/FlagshipTopBar.test.tsx @@ -25,12 +25,15 @@ describe('FlagshipTopBar', () => { clearDraftReform(); }); - test('given the bar renders then both sections are in the primary navigation', () => { + test('given the bar renders then both sections and the more menu are in the primary navigation', () => { renderTopBar(); const primary = screen.getByRole('navigation', { name: 'Primary' }); expect(primary).toContainElement(screen.getByRole('button', { name: 'Build' })); expect(primary).toContainElement(screen.getByRole('button', { name: 'Reforms' })); + expect(primary).toContainElement( + screen.getByRole('button', { name: /more policyengine links/i }) + ); }); test('given the Ask page is paused then it has no nav item', () => { From 6e18e87acebfb23ca854f252def52be7b43d1562 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:58:21 +0400 Subject: [PATCH 39/48] Keep a report's budget window on its saved record In the US and UK the budget window asked for with a run was held only in memory, so a reload or another device lost the report's window section. Save its years on the /v2/user-reports record (budget_window_years) and rebuild the run details from the record, window included, from the report's year. Co-Authored-By: Claude Opus 5.5 --- app/src/api/flagshipReportStore.ts | 6 ++ app/src/libs/flagship/runReport.ts | 28 +++++++++ app/src/pages/flagship/Report.page.tsx | 11 +--- app/src/tests/fixtures/api/v2UserMocks.ts | 1 + .../unit/api/flagshipReportStore.test.ts | 30 +++++++++ .../unit/libs/flagship/runReport.test.ts | 62 ++++++++++++++++++- app/src/types/v2User.ts | 3 + 7 files changed, 131 insertions(+), 10 deletions(-) diff --git a/app/src/api/flagshipReportStore.ts b/app/src/api/flagshipReportStore.ts index 6f7a61b33..23379b26f 100644 --- a/app/src/api/flagshipReportStore.ts +++ b/app/src/api/flagshipReportStore.ts @@ -36,6 +36,8 @@ export interface FlagshipReportRecord { reformId?: string | null; /** What validation matched this report against, pinned at match time. */ validation?: ReportValidationSnapshot | null; + /** Years in the budget window run with the report, from its year; none for one year. */ + budgetWindowYears?: number | null; } export type NewFlagshipReport = Omit; @@ -107,6 +109,7 @@ export function recordFromItem(item: UserReportItem): FlagshipReportRecord { createdAt: item.created_at, reformId: item.user_policy_id, validation: validationFromWire(item.validation), + budgetWindowYears: item.budget_window_years ?? null, }; } @@ -133,6 +136,9 @@ export class ApiFlagshipReportStore implements FlagshipReportStore { source_note: clampStoreText(report.sourceNote, MAX_STORE_NOTE_LENGTH) || null, provisions: provisionsToWire(report.provisions), ...(userPolicyId ? { user_policy_id: userPolicyId } : {}), + ...(report.budgetWindowYears && report.budgetWindowYears > 1 + ? { budget_window_years: report.budgetWindowYears } + : {}), last_run_at: new Date().toISOString(), }; } diff --git a/app/src/libs/flagship/runReport.ts b/app/src/libs/flagship/runReport.ts index 49bb7c6d8..6f976286f 100644 --- a/app/src/libs/flagship/runReport.ts +++ b/app/src/libs/flagship/runReport.ts @@ -1,6 +1,7 @@ import { fetchBudgetWindow } from '@/api/budgetWindow'; import { LocalStorageFlagshipReportStore, + type FlagshipReportRecord, type FlagshipReportStore, } from '@/api/flagshipReportStore'; import { createPolicy } from '@/api/policy'; @@ -82,6 +83,31 @@ export function readReportMeta(userReportId: string): FlagshipReportMeta | null return accountReportMeta.get(userReportId) ?? readAllMeta()[userReportId] ?? null; } +/** + * A report record's provenance, as the run's own details would give it: + * how a report opened on another device, or after a reload, still shows + * its title, source, changes and budget window. A record without + * provisions has nothing to show, so it gives none. + */ +export function reportMetaFromRecord( + record: FlagshipReportRecord | null | undefined, + reportYear: string | undefined +): FlagshipReportMeta | null { + if (!record || record.provisions.length === 0) { + return null; + } + const startYear = Number(reportYear); + return { + title: record.title ?? '', + sourceNote: record.sourceNote ?? '', + provisions: record.provisions, + createdAt: record.createdAt, + ...(record.budgetWindowYears && record.budgetWindowYears > 1 && startYear + ? { budgetWindow: { startYear, windowSize: record.budgetWindowYears } } + : {}), + }; +} + export interface RunFlagshipReportArgs { countryId: CountryId; title: string; @@ -203,6 +229,8 @@ export async function runFlagshipReport({ provisions, year: String(year), reformId: reformId ?? null, + // Kept on the record so the report shows its window on any device. + budgetWindowYears: budgetWindow?.windowSize ?? null, }; if (accountReportStore) { diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index e56f6d4f1..29f8d7e31 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -19,7 +19,7 @@ import { useReportValidationSnapshot } from '@/hooks/useReportValidationSnapshot import { useStartCalculationOnLoad } from '@/hooks/useStartCalculationOnLoad'; import { clearDraftAfterRun, loadProvisionsIntoDraft } from '@/libs/draftReform'; import { provenanceFromPolicy } from '@/libs/flagship/reportProvenance'; -import { readReportMeta } from '@/libs/flagship/runReport'; +import { readReportMeta, reportMetaFromRecord } from '@/libs/flagship/runReport'; import { RootState } from '@/store'; import type { CalcStartConfig } from '@/types/calculation'; import { allSimulationsLoaded } from '@/utils/reportSimulations'; @@ -57,14 +57,7 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep // baseline, so they count as still loading until it arrives. const meta = readReportMeta(userReportId) ?? - (accountRecord && accountRecord.provisions.length > 0 - ? { - title: accountRecord.title ?? '', - sourceNote: accountRecord.sourceNote ?? '', - provisions: accountRecord.provisions, - createdAt: accountRecord.createdAt, - } - : null) ?? + reportMetaFromRecord(accountRecord, report?.year) ?? (parameters ? provenanceFromPolicy(reformPolicy, parameters, report?.label) : null); const calcStatus = useCalculationStatus(report?.id || '', 'report'); diff --git a/app/src/tests/fixtures/api/v2UserMocks.ts b/app/src/tests/fixtures/api/v2UserMocks.ts index b60e800d4..e0dc41887 100644 --- a/app/src/tests/fixtures/api/v2UserMocks.ts +++ b/app/src/tests/fixtures/api/v2UserMocks.ts @@ -66,6 +66,7 @@ export function reportItem(overrides: Partial = {}): UserReportI source_note: null, provisions: null, validation: null, + budget_window_years: null, last_run_at: null, ...overrides, }; diff --git a/app/src/tests/unit/api/flagshipReportStore.test.ts b/app/src/tests/unit/api/flagshipReportStore.test.ts index c2268580b..f9f4a9956 100644 --- a/app/src/tests/unit/api/flagshipReportStore.test.ts +++ b/app/src/tests/unit/api/flagshipReportStore.test.ts @@ -173,6 +173,36 @@ describe('ApiFlagshipReportStore', () => { }); }); + test('given a budget window then the row carries its years and reads them back', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockResolvedValue(itemResponse(reportItem({ budget_window_years: 10 }), 201)); + const store = new ApiFlagshipReportStore(context); + + // When + const record = await store.create({ ...newReport, budgetWindowYears: 10 }); + + // Then + expect(fetchCall(fetchMock).body.budget_window_years).toBe(10); + expect(record.budgetWindowYears).toBe(10); + }); + + test('given a one-year run then the row has no budget window', async () => { + // Given + const { fetchMock, context } = createTestApiContext(); + fetchMock.mockImplementation(async () => itemResponse(reportItem(), 201)); + const store = new ApiFlagshipReportStore(context); + + // When + await store.create({ ...newReport, budgetWindowYears: 1 }); + const record = await store.create({ ...newReport, budgetWindowYears: null }); + + // Then + expect(fetchCall(fetchMock, 0).body).not.toHaveProperty('budget_window_years'); + expect(fetchCall(fetchMock, 1).body).not.toHaveProperty('budget_window_years'); + expect(record.budgetWindowYears).toBeNull(); + }); + test('given a reform saved in this browser then the account row is not linked to it', async () => { // Given const { fetchMock, context } = createTestApiContext(); diff --git a/app/src/tests/unit/libs/flagship/runReport.test.ts b/app/src/tests/unit/libs/flagship/runReport.test.ts index d7b038957..23946fa09 100644 --- a/app/src/tests/unit/libs/flagship/runReport.test.ts +++ b/app/src/tests/unit/libs/flagship/runReport.test.ts @@ -1,9 +1,10 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import type { FlagshipReportStore } from '@/api/flagshipReportStore'; +import type { FlagshipReportRecord, FlagshipReportStore } from '@/api/flagshipReportStore'; import { forgetAccountReportMeta, readReportMeta, REPORT_META_KEY, + reportMetaFromRecord, runFlagshipReport, } from '@/libs/flagship/runReport'; @@ -308,6 +309,7 @@ describe('runFlagshipReport', () => { provisions: [PROVISION], year: '2027', reformId: REFORM_ROW_ID, + budgetWindowYears: null, }); expect(localStorage.getItem('pe-flagship-reports')).toBeNull(); for (const legacyKey of LEGACY_KEYS) { @@ -316,6 +318,29 @@ describe('runFlagshipReport', () => { expect(readReportMeta('55')?.title).toBe('CTC expansion'); }); + test('given a run with a budget window then the account record keeps the window', async () => { + // Given + const accountStore = { + create: vi.fn().mockResolvedValue({ id: 'row-1' }), + } as unknown as FlagshipReportStore; + + // When + await runFlagshipReport({ + countryId: 'us', + title: 'CTC expansion', + sourceNote: '', + provisions: [PROVISION], + currentLawId: 2, + windowYears: 10, + accountReportStore: accountStore, + }); + + // Then: a reload on any device reads it back from the record + expect(accountStore.create).toHaveBeenCalledWith( + expect.objectContaining({ budgetWindowYears: 10 }) + ); + }); + test('given a signed-in run then its provenance stays out of localStorage and goes on sign-out', async () => { // Given const accountStore = { @@ -358,3 +383,38 @@ describe('runFlagshipReport', () => { ).rejects.toThrow('Saved work is temporarily unavailable.'); }); }); + +describe('reportMetaFromRecord', () => { + const accountRecord: FlagshipReportRecord = { + id: 'row-1', + countryId: 'us', + apiReportId: '55', + title: 'CTC expansion', + sourceNote: 'Hand-built draft', + provisions: [PROVISION], + createdAt: '2026-10-14T00:00:00Z', + budgetWindowYears: 10, + }; + + test('given a record with a budget window then the window starts in the report year', () => { + expect(reportMetaFromRecord(accountRecord, '2027')).toEqual({ + title: 'CTC expansion', + sourceNote: 'Hand-built draft', + provisions: [PROVISION], + createdAt: '2026-10-14T00:00:00Z', + budgetWindow: { startYear: 2027, windowSize: 10 }, + }); + }); + + test('given no window, or no report year yet, then the details carry no window', () => { + expect( + reportMetaFromRecord({ ...accountRecord, budgetWindowYears: null }, '2027') + ).not.toHaveProperty('budgetWindow'); + expect(reportMetaFromRecord(accountRecord, undefined)).not.toHaveProperty('budgetWindow'); + }); + + test('given no record, or one without provisions, then there are no details', () => { + expect(reportMetaFromRecord(null, '2027')).toBeNull(); + expect(reportMetaFromRecord({ ...accountRecord, provisions: [] }, '2027')).toBeNull(); + }); +}); diff --git a/app/src/types/v2User.ts b/app/src/types/v2User.ts index 0081b02e7..cc24e61cd 100644 --- a/app/src/types/v2User.ts +++ b/app/src/types/v2User.ts @@ -99,6 +99,8 @@ export interface UserReportItem extends AssociationItemBase { provisions: ReportProvisionWire[] | null; /** ReportValidationWire on the wire; validated by validationFromWire. */ validation: unknown | null; + /** Years in the budget window run with the report, from its year (2–75). */ + budget_window_years: number | null; last_run_at: string | null; } @@ -129,6 +131,7 @@ export interface UserReportCreateBody { source_note?: string | null; provisions?: ReportProvisionWire[] | null; validation?: unknown | null; + budget_window_years?: number | null; last_run_at?: string | null; } From 06f65a39398cf942b337b3c9e2a1b80967370b0d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:00:20 +0400 Subject: [PATCH 40/48] Link v3 reports by their saved report, which anyone can open A report's link is now its /v2/user-reports row ID, not the v1 report's. Whoever opens it, signed in, signed out or another user, reads the saved report through the Public API's credential-free /v2/shared-reports route and sees the owner's title, source note, provisions, budget window, comparison and validation pin over the v1 report the row points at. Only the session that ran it links the reform it was run from and pins the validation. Identical changes share one v1 report, so the v1 report's number never named one run. Where accounts exist, such a link is an old one and no longer opens. Without accounts (sign-in off, or outside the US and UK) nothing is shared, so the link stays the v1 report's number. Co-Authored-By: Claude Opus 5.5 --- app/src/api/flagshipReportStore.ts | 53 +++++- app/src/api/sharedReport.ts | 33 ++++ app/src/api/v2UserClient.ts | 12 +- app/src/components/flagship/ReformReports.tsx | 10 +- .../components/flagship/ReportComputing.tsx | 9 +- app/src/hooks/useFlagshipReport.ts | 13 +- app/src/hooks/useFlagshipStores.ts | 72 +++++--- app/src/hooks/useReportValidationSnapshot.ts | 25 +-- app/src/libs/flagship/runReport.ts | 29 +-- app/src/pages/flagship/Report.page.tsx | 79 +++++--- app/src/tests/fixtures/api/v2UserMocks.ts | 41 ++++- app/src/tests/unit/api/sharedReport.test.ts | 62 +++++++ .../flagship/ReformReports.test.tsx | 51 ++++- .../flagship/ReportComputing.test.tsx | 4 +- .../unit/hooks/useFlagshipStores.test.tsx | 79 ++++++++ .../hooks/useReportValidationSnapshot.test.ts | 21 ++- .../unit/hooks/useRunFlagshipReport.test.tsx | 18 +- .../unit/libs/flagship/runReport.test.ts | 18 +- .../unit/pages/flagship/ReportLinks.test.tsx | 174 ++++++++++++++++++ app/src/types/v2User.ts | 22 +++ 20 files changed, 713 insertions(+), 112 deletions(-) create mode 100644 app/src/api/sharedReport.ts create mode 100644 app/src/tests/unit/api/sharedReport.test.ts create mode 100644 app/src/tests/unit/pages/flagship/ReportLinks.test.tsx diff --git a/app/src/api/flagshipReportStore.ts b/app/src/api/flagshipReportStore.ts index 45ffddab4..8edc125c4 100644 --- a/app/src/api/flagshipReportStore.ts +++ b/app/src/api/flagshipReportStore.ts @@ -19,7 +19,12 @@ import { } from '@/libs/flagship/reportValidation'; import type { RunReportProvision } from '@/libs/flagship/runReport'; import { clampStoreText, MAX_STORE_NOTE_LENGTH } from '@/libs/flagship/storeText'; -import type { ReportProvisionWire, UserReportCreateBody, UserReportItem } from '@/types/v2User'; +import type { + ReportProvisionWire, + SharedReportItem, + UserReportCreateBody, + UserReportItem, +} from '@/types/v2User'; /** * Store for flagship report records: a pointer to the computed output on @@ -29,6 +34,7 @@ import type { ReportProvisionWire, UserReportCreateBody, UserReportItem } from ' * row for the report, carrying the v3 fields. */ export interface FlagshipReportRecord { + /** The account row's ID, which is the report's link (see reportLink); `lfr-…` in this browser. */ id: string; countryId: CountryId; /** Report id on api.policyengine.org — the pointer to the outputs. */ @@ -150,6 +156,51 @@ export function parseReportItem(item: UserReportItem): ReportParseResult { + return parseReportRecord( + stampReportRecord({ + id: item.id, + countryId: item.country_id, + apiReportId: String(item.legacy_report_id), + title: item.name, + sourceNote: item.source_note, + provisions: provisionsFromWire(item.provisions), + createdAt: item.created_at, + reformId: null, + validation: validationFromWire(item.validation), + budgetWindowYears: item.budget_window_years ?? null, + baselineReformId: null, + }) + ); +} + +/** + * A report's link. An account row's ID opens for anyone, through + * `/v2/shared-reports`; a record kept only in this browser has nothing to + * share, so its link is the v1 report's number. + */ +export function reportLink(record: FlagshipReportRecord): string { + return isAccountId(record.id) ? record.id : record.apiReportId; +} + +/** + * This session's record a report link names: the account row by its ID, + * or, in this browser, the first record of the v1 report the link numbers. + */ +export function findReportRecordByLink( + store: FlagshipReportStore, + link: string +): Promise { + return isAccountId(link) ? store.findById(link) : store.findByApiReportId(link); +} + /** An account row as a record; throws ReportSchemaError when unreadable. */ export function recordFromItem(item: UserReportItem): FlagshipReportRecord { return unwrapReport(parseReportItem(item)); diff --git a/app/src/api/sharedReport.ts b/app/src/api/sharedReport.ts new file mode 100644 index 000000000..b62f63529 --- /dev/null +++ b/app/src/api/sharedReport.ts @@ -0,0 +1,33 @@ +import { createV2UserClient, isV2ApiError, type V2UserClient } from '@/api/v2UserClient'; +import type { CountryId } from '@/libs/countries'; +import { isAssociationCountry, type SharedReportItem, type V2ItemResult } from '@/types/v2User'; + +/** + * A saved report by its link, the report's user-report ID, read with no + * credential: anyone holding the link opens the report with its owner's + * title, source note, provisions and comparison. Null when no saved report + * has that ID in the country (deleted, or a bad link). + */ +export async function fetchSharedReport( + countryId: CountryId, + userReportId: string, + client: V2UserClient = createV2UserClient({ credentials: { kind: 'none' } }) +): Promise { + // Only the US and UK keep saved reports in the API. + if (!isAssociationCountry(countryId)) { + return null; + } + try { + const result = await client.request>( + 'GET', + `shared-reports/${encodeURIComponent(userReportId)}`, + { query: { country_id: countryId } } + ); + return result.item; + } catch (error) { + if (isV2ApiError(error, 404)) { + return null; + } + throw error; + } +} diff --git a/app/src/api/v2UserClient.ts b/app/src/api/v2UserClient.ts index 2524ca5ce..11cbdddac 100644 --- a/app/src/api/v2UserClient.ts +++ b/app/src/api/v2UserClient.ts @@ -8,6 +8,7 @@ import type { V2Envelope, V2PageResult } from '@/types/v2User'; * or, signed out, this browser's unauthenticated user * (`PolicyEngine-Unauthenticated-User: `). The server derives the * owner from that, so callers never send a user ID in a body or query. + * Shared reports (`/v2/shared-reports`) are read with no credential. * Responses are the API's `{status, message, result}` envelope, unwrapped * to `result`. */ @@ -35,10 +36,14 @@ export function isV2ApiError(error: unknown, status?: number): error is V2ApiErr export type AccessTokenGetter = () => Promise; -/** Who requests are made as: a signed-in session, or this browser's unauthenticated user. */ +/** + * Who requests are made as: a signed-in session, this browser's + * unauthenticated user, or no one (routes anyone may call). + */ export type V2Credentials = | { kind: 'session'; getAccessToken: AccessTokenGetter } - | { kind: 'unauthenticated'; userId: string }; + | { kind: 'unauthenticated'; userId: string } + | { kind: 'none' }; type QueryValue = string | number | boolean | null | undefined; export type V2Query = Record; @@ -111,6 +116,9 @@ async function readEnvelope(response: Response): Promise | n /** The header naming who a request is made as. */ async function credentialHeaders(credentials: V2Credentials): Promise> { + if (credentials.kind === 'none') { + return {}; + } if (credentials.kind === 'unauthenticated') { return { [UNAUTHENTICATED_USER_HEADER]: credentials.userId }; } diff --git a/app/src/components/flagship/ReformReports.tsx b/app/src/components/flagship/ReformReports.tsx index 870436c31..c3bc3549c 100644 --- a/app/src/components/flagship/ReformReports.tsx +++ b/app/src/components/flagship/ReformReports.tsx @@ -1,7 +1,7 @@ import dayjs from 'dayjs'; import { IconArrowRight } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; -import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; +import { reportLink, type FlagshipReportRecord } from '@/api/flagshipReportStore'; import { fetchReportById } from '@/api/report'; import { Badge, Button, Stack, Text } from '@/components/ui'; import { useAppNavigate } from '@/contexts/NavigationContext'; @@ -50,8 +50,8 @@ function ReportStatus({ countryId, apiReportId }: { countryId: CountryId; apiRep * What a report compared the reform with, when not current law: the same * reform against a saved reform is another report, and reads as one. */ -function ReportBaseline({ apiReportId }: { apiReportId: string }) { - const baseline = readReportMeta(apiReportId)?.baseline; +function ReportBaseline({ link }: { link: string }) { + const baseline = readReportMeta(link)?.baseline; if (!baseline) { return null; } @@ -123,7 +123,7 @@ export default function ReformReports({ > - + nav.push(`/${countryId}/report/${report.apiReportId}`)} + onClick={() => nav.push(`/${countryId}/report/${reportLink(report)}`)} > Open diff --git a/app/src/components/flagship/ReportComputing.tsx b/app/src/components/flagship/ReportComputing.tsx index e0bd11056..c8d38548d 100644 --- a/app/src/components/flagship/ReportComputing.tsx +++ b/app/src/components/flagship/ReportComputing.tsx @@ -234,7 +234,10 @@ export function ReportComputingScreen({ ); } -/** A link this browser cannot resolve: the report id belongs to another browser's local records. */ +/** + * A link that no longer opens: a report's link is its saved report's ID, + * and older links carried the v1 report's number or a browser-only ID. + */ export function ReportUnresolvable() { return ( @@ -248,8 +251,8 @@ export function ReportUnresolvable() { Report not found. - This is an old link that only worked in the browser that created it. Report links now carry - the report's number and open anywhere; run the reform again from Build to get one. + This is an old report link, which no longer opens. Run the reform again from Build to get a + new link. ); diff --git a/app/src/hooks/useFlagshipReport.ts b/app/src/hooks/useFlagshipReport.ts index 616c5e46a..c3d143e76 100644 --- a/app/src/hooks/useFlagshipReport.ts +++ b/app/src/hooks/useFlagshipReport.ts @@ -14,10 +14,11 @@ import { policyKeys, reportKeys, simulationKeys } from '../libs/queryKeys'; import { extractUniqueIds, useParallelQueries } from './utils/queryUtils'; /** - * A flagship report URL carries the API report id, which anyone can - * resolve. Links minted before that carried a legacy browser association - * id ("sur-…"); v3 does not read the legacy calculator's storage, so they - * no longer open. + * Whether an ID is a v1 report's number. With accounts, a flagship report + * URL carries the saved report's ID instead (see reportLink); without + * them, it carries the v1 report's number. Links from the legacy + * calculator carried its browser association ID ("sur-…"); v3 does not + * read that storage, so they no longer open. */ export function isApiReportId(id: string): boolean { return /^\d+$/.test(id); @@ -44,8 +45,8 @@ export interface FlagshipReportData { /** * Resolves a flagship report by its API report id: the report, its * simulations, and their policies come straight from the PolicyEngine - * API, so a shared link opens with full provenance on any device. Any - * other id resolves to nothing. + * API, so the report opens on any device. Any other id resolves to + * nothing. */ export function useFlagshipReport(id: string): FlagshipReportData { const country = useCurrentCountry(); diff --git a/app/src/hooks/useFlagshipStores.ts b/app/src/hooks/useFlagshipStores.ts index 11e28ad21..a318baad4 100644 --- a/app/src/hooks/useFlagshipStores.ts +++ b/app/src/hooks/useFlagshipStores.ts @@ -1,13 +1,22 @@ import { useMemo, useRef } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import { getFlagshipReportStore, type FlagshipReportStore } from '@/api/flagshipReportStore'; +import { + findReportRecordByLink, + getFlagshipReportStore, + parseSharedReportItem, + type FlagshipReportRecord, + type FlagshipReportStore, +} from '@/api/flagshipReportStore'; import { fetchV1PolicyJson, getReformStore, type ReformStore } from '@/api/reformStore'; -import type { ApiStoreContext } from '@/api/v2Associations'; +import { fetchSharedReport } from '@/api/sharedReport'; +import { isAccountId, type ApiStoreContext } from '@/api/v2Associations'; import { useCountryContext } from '@/contexts/CountryContext'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useAuthStatus } from '@/libs/auth/AuthContext'; import { getBrowserUserId } from '@/libs/auth/browserUser'; import { useV2UserClient } from '@/libs/auth/useV2UserClient'; +import { unwrapReport } from '@/libs/flagship/reportSchema'; import type { RootState } from '@/store'; import { isAssociationCountry } from '@/types/v2User'; @@ -104,47 +113,52 @@ export function useFlagshipReportStore(): FlagshipReportStoreSelection { } /** - * This session's record of an API report — the account's when signed in - * (US/UK), this browser's otherwise — with its title, source note, - * provisions and the saved reform it was run from. Null when there is - * none (a report shared from elsewhere). + * This session's record a report link names — the account's row when + * signed in (US/UK), signed out this browser's unauthenticated user's, and + * otherwise this browser's — with the saved reform it was run from. Null + * when there is none: a report someone else ran. */ -export function useReportRecord(apiReportId: string | undefined) { +export function useReportRecord(link: string | undefined) { const { store, scope } = useFlagshipReportStore(); const { data } = useQuery({ - queryKey: ['flagship-report-record', scope, apiReportId ?? ''], - queryFn: () => store.findByApiReportId(apiReportId!), - enabled: !!apiReportId, + queryKey: ['flagship-report-record', scope, link ?? ''], + queryFn: () => findReportRecordByLink(store, link!), + enabled: !!link, staleTime: 5 * 60 * 1000, retry: 1, }); return data ?? null; } -/** - * The account's record for an API report, so a report opened on another - * device shows the same header. Signed-in (US/UK) sessions only; null - * otherwise. - */ -export function useAccountReportRecord(apiReportId: string | undefined) { - const { isAccount } = useFlagshipReportStore(); - const record = useReportRecord(apiReportId); - return isAccount ? record : null; +/** A saved report as anyone holding its link reads it. */ +export interface SharedReport { + /** The owner's title, source note, provisions, budget window and validation. */ + record: FlagshipReportRecord; + /** The owner's saved reform the report was compared with; null for current law. */ + baselineReform: { name: string | null } | null; } /** - * One of this session's saved reforms, for naming it (a report's - * comparison, say); null while it loads, when unset, or once deleted. + * The saved report a link names (an account row's ID), read with no + * credential, so whoever opens the link sees the report as its owner ran + * it. Null data when no saved report has that ID; an unreadable one is an + * error. */ -export function useSavedReform(reformId: string | null | undefined) { - const store = useReformStore(); - const { scope } = useV3SavedWork(); - const { data } = useQuery({ - queryKey: ['saved-reform', scope, reformId ?? ''], - queryFn: () => store.findById(reformId!), - enabled: !!reformId, +export function useSharedReport(link: string | undefined) { + const countryId = useCurrentCountry(); + return useQuery({ + queryKey: ['shared-report', countryId, link ?? ''], + queryFn: async (): Promise => { + const item = await fetchSharedReport(countryId, link!); + return item + ? { + record: unwrapReport(parseSharedReportItem(item)), + baselineReform: item.baseline_reform, + } + : null; + }, + enabled: !!link && isAccountId(link), staleTime: 5 * 60 * 1000, retry: 1, }); - return data ?? null; } diff --git a/app/src/hooks/useReportValidationSnapshot.ts b/app/src/hooks/useReportValidationSnapshot.ts index 1fcd73fa5..798033a3d 100644 --- a/app/src/hooks/useReportValidationSnapshot.ts +++ b/app/src/hooks/useReportValidationSnapshot.ts @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { findReportRecordByLink } from '@/api/flagshipReportStore'; import { useFlagshipReportStore } from '@/hooks/useFlagshipStores'; import { isLiveValidationSettled, @@ -16,15 +17,17 @@ export interface ReportValidationState { } /** - * Pins the live validation to the report's store record the first time - * both checks settle, and reports drift between that pin and what the - * checks see now. Best-effort: a report without a record in this - * browser (another browser ran it, or storage was unavailable at run + * Pins the live validation to the record the report's link names the + * first time both checks settle, and reports drift between that pin and + * what the checks see now. Only the session that ran the report pins; + * anyone else sees the owner's pin (`sharedSnapshot`), when there is one. + * Best-effort: a report without a record (storage was unavailable at run * time) simply never pins. */ export function useReportValidationSnapshot( - apiReportId: string | undefined, - live: LiveValidation + userReportId: string | undefined, + live: LiveValidation, + sharedSnapshot: ReportValidationSnapshot | null = null ): ReportValidationState { const { store } = useFlagshipReportStore(); const [snapshot, setSnapshot] = useState(null); @@ -36,11 +39,10 @@ export function useReportValidationSnapshot( pinned.current = false; setSnapshot(null); setRecordId(null); - if (!apiReportId) { + if (!userReportId) { return; } - store - .findByApiReportId(apiReportId) + findReportRecordByLink(store, userReportId) .then((record) => { if (cancelled || !record) { return; @@ -57,7 +59,7 @@ export function useReportValidationSnapshot( return () => { cancelled = true; }; - }, [store, apiReportId]); + }, [store, userReportId]); const settled = isLiveValidationSettled(live); useEffect(() => { @@ -75,5 +77,6 @@ export function useReportValidationSnapshot( // `live` is read once at pin time; re-running on its identity would re-pin. }, [recordId, settled]); - return { snapshot, drift: snapshot ? validationDrift(snapshot, live) : [] }; + const shown = snapshot ?? sharedSnapshot; + return { snapshot: shown, drift: shown ? validationDrift(shown, live) : [] }; } diff --git a/app/src/libs/flagship/runReport.ts b/app/src/libs/flagship/runReport.ts index da3c5bacf..517388592 100644 --- a/app/src/libs/flagship/runReport.ts +++ b/app/src/libs/flagship/runReport.ts @@ -18,10 +18,11 @@ import { parseReportMeta, stampReportMeta, type ReportParseResult } from './repo /** * The run bridge: turns a set of provisions (from a draft reform or a * tracked bill) into a full society-wide report — policy → baseline + - * reform simulations → report record — and returns the API report id + * reform simulations → report record — and returns the report's link, * whose page auto-starts the calculation and renders the chart - * dashboard. The API id is the durable one: a link carrying it opens - * on any device, where the local association id would not. + * dashboard. With accounts the link is the report's user-report ID, + * which anyone can open with its title and provisions; otherwise it is + * the v1 report's ID. */ export interface RunReportProvision { path: string; @@ -75,8 +76,8 @@ export const REPORT_META_KEY = 'pe-flagship-report-meta'; */ const accountReportMeta = new Map(); -function rememberAccountReportMeta(reportId: string, meta: FlagshipReportMeta): void { - accountReportMeta.set(reportId, meta); +function rememberAccountReportMeta(userReportId: string, meta: FlagshipReportMeta): void { + accountReportMeta.set(userReportId, meta); } /** Drops the signed-in session's report provenance (on sign-out). */ @@ -258,6 +259,10 @@ export async function createReformPolicy( return Number(policyResponse.result.policy_id); } +/** + * Runs a report and records it. Answers the report's link: the account + * row's ID with accounts, otherwise the v1 report's (see reportLink). + */ export async function runFlagshipReport({ countryId, title, @@ -303,9 +308,10 @@ export async function runFlagshipReport({ year: String(year), }; - // The v1 report comes first; its ID is the report's link. Then exactly - // one record of it: through the API with accounts, otherwise this - // browser's flagship record (v3 never writes the legacy report keys). + // The v1 report comes first. Then exactly one record of it: through the + // API with accounts, whose ID is the report's link (anyone can open it), + // otherwise this browser's flagship record, with the v1 report's ID as + // the link (v3 never writes the legacy report keys). const reportMetadata = await createReport(countryId, payload); const reportId = String(reportMetadata.id); const budgetWindow = @@ -338,10 +344,11 @@ export async function runFlagshipReport({ baselineReformId: comparison?.reformId ?? null, }; + let link = reportId; if (accountReportStore) { // A failure fails the run, so a report that opens is one that was saved. - rememberAccountReportMeta(reportId, meta); - await accountReportStore.create(record); + link = (await accountReportStore.create(record)).id; + rememberAccountReportMeta(link, meta); } else { saveReportMeta(reportId, meta); // Best-effort: the run already succeeded, and the link opens without it. @@ -378,5 +385,5 @@ export async function runFlagshipReport({ }); } - return reportId; + return link; } diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index b32629750..a9f04f84d 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import type { SocietyWideReportOutput as SocietyWideOutput } from '@/api/societyWideCalculation'; +import { isAccountId } from '@/api/v2Associations'; import { useCalibrationMatches } from '@/components/flagship/CalibrationMatches'; import { populationLabel } from '@/components/flagship/reform/PopulationPicker'; import ReportCalculationError from '@/components/flagship/report/ReportCalculationError'; @@ -18,7 +19,11 @@ import { colors, spacing } from '@/designTokens'; import { useCalculationStatus } from '@/hooks/useCalculationStatus'; import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; import { isApiReportId, useFlagshipReport } from '@/hooks/useFlagshipReport'; -import { useAccountReportRecord, useReportRecord, useSavedReform } from '@/hooks/useFlagshipStores'; +import { + useFlagshipReportStore, + useReportRecord, + useSharedReport, +} from '@/hooks/useFlagshipStores'; import { useReportValidationSnapshot } from '@/hooks/useReportValidationSnapshot'; import { useStartCalculationOnLoad } from '@/hooks/useStartCalculationOnLoad'; import { useUnreadableReportMeta } from '@/hooks/useUnreadableReportMeta'; @@ -45,45 +50,58 @@ interface FlagshipReportPageProps { export default function FlagshipReportPage({ userReportId: propId }: FlagshipReportPageProps) { const params = useParams<{ userReportId: string }>(); const userReportId = propId ?? params.userReportId ?? ''; + // A report's link is its saved report's ID, which anyone can open with its + // owner's details. Without accounts (sign-in off, or outside the US and + // UK) nothing is shared, so the link is the v1 report's number; where + // accounts exist, such a link is an old one and no longer opens, nor does + // an old browser-only "sur-" link. + const { isAccount } = useFlagshipReportStore(); + const sharedLink = isAccountId(userReportId); + const browserLink = !isAccount && isApiReportId(userReportId); + const unresolvable = !!userReportId && !sharedLink && !browserLink; + const sharedQuery = useSharedReport(sharedLink ? userReportId : undefined); + const shared = sharedQuery.data ?? null; const { report, simulations, policies, isLoading, - error: loadError, - notFound, - retry, - } = useFlagshipReport(userReportId); - // Only API report ids open; an old browser-only "sur-" link is not found. - const unresolvable = !isLoading && !report && !!userReportId && !isApiReportId(userReportId); + error: reportLoadError, + notFound: reportNotFound, + retry: retryReport, + } = useFlagshipReport( + (sharedLink ? shared?.record.apiReportId : browserLink ? userReportId : undefined) ?? '' + ); + // No saved report has the link's ID (deleted, or a bad link). + const sharedNotFound = sharedLink && sharedQuery.isSuccess && !shared; + const sharedLoadError = sharedLink ? sharedQuery.error : null; + const loadError = sharedLoadError ?? reportLoadError; + const notFound = sharedNotFound || reportNotFound; + const retry = sharedLoadError ? sharedQuery.refetch : retryReport; const parameters = useSelector((state: RootState) => state.metadata.parameters); const economyRegions = useSelector((state: RootState) => state.metadata.economyOptions.region); - const apiReportId = - report?.id !== undefined && report?.id !== null ? String(report.id) : undefined; - // With accounts, this user's record carries the provenance to every device. - const accountRecord = useAccountReportRecord(apiReportId); - // This session's record names the saved reform the report was run from. - const reportRecord = useReportRecord(apiReportId); - // Provenance: the run's details, else the account's record, else rebuilt - // from the reform policy so a shared link validates like the original. + // This session's own record, when it ran the report: it names the saved + // reform the report was run from, and holds the validation pin. + const reportRecord = useReportRecord(unresolvable ? undefined : userReportId); + // Provenance: the run's details, else the saved report's (the same for + // anyone holding the link), else rebuilt from the reform policy. const reformPolicy = policies.find((policy) => policy.id === simulations?.[1]?.policyId); const baselinePolicyId = simulations?.[0]?.policyId; - // The account's record links the saved reform it was compared with; - // that reform's own name is the comparison's. - const baselineReform = useSavedReform(accountRecord?.baselineReformId); - const recordBaseline = - accountRecord?.baselineReformId && baselineReform && baselinePolicyId + // The saved report names the reform it was compared with; only its owner + // links to that reform. + const sharedBaseline = + shared?.baselineReform && baselinePolicyId ? { - reformId: accountRecord.baselineReformId, + reformId: reportRecord?.baselineReformId ?? null, policyId: String(baselinePolicyId), - label: baselineReform.label || 'Untitled reform', + label: shared.baselineReform.name || 'Untitled reform', } : undefined; // Without metadata the rebuilt provisions would show raw paths and no // baseline, so they count as still loading until it arrives. const meta = readReportMeta(userReportId) ?? - reportMetaFromRecord(accountRecord, report?.year, recordBaseline) ?? + reportMetaFromRecord(shared?.record, report?.year, sharedBaseline) ?? (parameters ? provenanceFromPolicy(reformPolicy, parameters, report?.label) : null); const calcStatus = useCalculationStatus(report?.id || '', 'report'); @@ -151,11 +169,14 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const provisionPaths = meta?.provisions.map((p) => p.path) ?? []; const trackRecord = useModelTrackRecord(provisionPaths); const calibration = useCalibrationMatches(provisionPaths, region); - // The store keys records by the API report id as a string. - const pin = useReportValidationSnapshot(apiReportId, { - calibration, - programs: trackRecordPrograms(trackRecord), - }); + const pin = useReportValidationSnapshot( + unresolvable ? undefined : userReportId, + { + calibration, + programs: trackRecordPrograms(trackRecord), + }, + shared?.record.validation ?? null + ); const title = meta?.title || report?.label || 'Impact report'; @@ -200,7 +221,7 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep ); } // A report that failed to load would otherwise wait here for good. - if (loadError) { + if (loadError || notFound) { return layout( diff --git a/app/src/tests/fixtures/api/v2UserMocks.ts b/app/src/tests/fixtures/api/v2UserMocks.ts index 349b77b26..7dd29e702 100644 --- a/app/src/tests/fixtures/api/v2UserMocks.ts +++ b/app/src/tests/fixtures/api/v2UserMocks.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest'; import type { ApiStoreContext } from '@/api/v2Associations'; import { createV2UserClient } from '@/api/v2UserClient'; -import type { MeItem, UserPolicyItem, UserReportItem } from '@/types/v2User'; +import type { MeItem, SharedReportItem, UserPolicyItem, UserReportItem } from '@/types/v2User'; export const TEST_API_BASE = 'https://api.test.policyengine.org'; export const TEST_ACCESS_TOKEN = 'test-access-token'; @@ -73,6 +73,45 @@ export function reportItem(overrides: Partial<UserReportItem> = {}): UserReportI }; } +/** A saved report as anyone holding its link reads it. */ +export function sharedReportItem(overrides: Partial<SharedReportItem> = {}): SharedReportItem { + return { + id: ASSOCIATION_IDS.REPORT, + country_id: 'us', + legacy_report_id: LEGACY_IDS.REPORT, + name: 'CTC to $3,600', + source_note: 'From chat', + provisions: [ + { + path: 'gov.irs.credits.ctc.amount.base[0].amount', + breadcrumb: 'Child Tax Credit → Base amount', + unit: 'currency-USD', + baseline_value: 2000, + value: 3600, + intervals: null, + }, + ], + validation: null, + budget_window_years: null, + baseline_reform: null, + last_run_at: null, + created_at: TIMESTAMPS.CREATED, + updated_at: TIMESTAMPS.UPDATED, + ...overrides, + }; +} + +/** A client that calls the credential-free routes, and its fetch mock. */ +export function createTestPublicClient() { + const fetchMock = vi.fn(); + const client = createV2UserClient({ + credentials: { kind: 'none' }, + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + return { fetchMock, client }; +} + /** `{"status": "ok", "message": null, "result": …}` as a fetch Response. */ export function okResponse(result: unknown, status = 200): Response { return new Response(JSON.stringify({ status: 'ok', message: null, result }), { diff --git a/app/src/tests/unit/api/sharedReport.test.ts b/app/src/tests/unit/api/sharedReport.test.ts new file mode 100644 index 000000000..6721fd996 --- /dev/null +++ b/app/src/tests/unit/api/sharedReport.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'vitest'; +import { fetchSharedReport } from '@/api/sharedReport'; +import { isV2ApiError } from '@/api/v2UserClient'; +import { + ASSOCIATION_IDS, + createTestPublicClient, + errorResponse, + fetchCall, + itemResponse, + sharedReportItem, +} from '@/tests/fixtures/api/v2UserMocks'; + +describe('fetchSharedReport', () => { + test('given a report link then it reads the saved report with no credential', async () => { + // Given + const { fetchMock, client } = createTestPublicClient(); + fetchMock.mockResolvedValue(itemResponse(sharedReportItem())); + + // When + const item = await fetchSharedReport('us', ASSOCIATION_IDS.REPORT, client); + + // Then + expect(item?.name).toBe('CTC to $3,600'); + const call = fetchCall(fetchMock); + expect(call.method).toBe('GET'); + expect(call.path).toBe(`/v2/shared-reports/${ASSOCIATION_IDS.REPORT}`); + expect(call.query).toEqual({ country_id: 'us' }); + expect(call.headers).toEqual({ Accept: 'application/json' }); + }); + + test('given no saved report has the ID then it answers null', async () => { + // Given + const { fetchMock, client } = createTestPublicClient(); + fetchMock.mockResolvedValue(errorResponse(404, 'not found')); + + // When / Then + await expect(fetchSharedReport('uk', ASSOCIATION_IDS.REPORT, client)).resolves.toBeNull(); + }); + + test('given the API is unavailable then the read fails', async () => { + // Given + const { fetchMock, client } = createTestPublicClient(); + fetchMock.mockResolvedValue(errorResponse(503, 'V2 user persistence is unavailable')); + + // When + const failure = await fetchSharedReport('us', ASSOCIATION_IDS.REPORT, client).catch( + (error: unknown) => error + ); + + // Then + expect(isV2ApiError(failure, 503)).toBe(true); + }); + + test('given a country without saved reports then it answers null without asking', async () => { + // Given + const { fetchMock, client } = createTestPublicClient(); + + // When / Then + await expect(fetchSharedReport('ca', ASSOCIATION_IDS.REPORT, client)).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/tests/unit/components/flagship/ReformReports.test.tsx b/app/src/tests/unit/components/flagship/ReformReports.test.tsx index e52d6b073..f18913d03 100644 --- a/app/src/tests/unit/components/flagship/ReformReports.test.tsx +++ b/app/src/tests/unit/components/flagship/ReformReports.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, within } from '@test-utils'; +import { render, screen, userEvent, within } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; import ReformReports from '@/components/flagship/ReformReports'; @@ -9,6 +9,16 @@ vi.mock('@/api/report', () => ({ fetchReportById: vi.fn(async () => ({ status: 'complete' })), })); +const mockPush = vi.fn(); + +vi.mock('@/contexts/NavigationContext', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/contexts/NavigationContext')>(); + return { ...actual, useAppNavigate: () => ({ push: mockPush, replace: vi.fn(), back: vi.fn() }) }; +}); + +/** An account row's ID: the report's shareable link. */ +const REPORT_ROW_ID = '44444444-4444-4444-8444-444444444444'; + const record = (overrides: Partial<FlagshipReportRecord>): FlagshipReportRecord => ({ id: 'rec-1', countryId: 'us', @@ -41,6 +51,45 @@ function renderReports(reports: FlagshipReportRecord[]) { describe('ReformReports', () => { beforeEach(() => { localStorage.clear(); + mockPush.mockReset(); + }); + + test('given an account report then Open follows its saved report link, not the v1 report', async () => { + // Given + const user = userEvent.setup(); + renderReports([record({ id: REPORT_ROW_ID, apiReportId: '55' })]); + + // When + await user.click(screen.getByRole('button', { name: /open/i })); + + // Then + expect(mockPush).toHaveBeenCalledWith(`/us/report/${REPORT_ROW_ID}`); + }); + + test('given a report kept only in this browser then Open follows the v1 report', async () => { + // Given + const user = userEvent.setup(); + renderReports([record({ id: 'lfr-local1', apiReportId: '55' })]); + + // When + await user.click(screen.getByRole('button', { name: /open/i })); + + // Then + expect(mockPush).toHaveBeenCalledWith('/us/report/55'); + }); + + test('given an account report compared with a saved reform then it reads so from its link', () => { + // Given: a run this session made keeps its details under the report's link + saveReportMeta(REPORT_ROW_ID, { + ...META, + baseline: { reformId: 'rf-plan-b', policyId: '88', label: 'Plan B' }, + }); + + // When + renderReports([record({ id: REPORT_ROW_ID, apiReportId: '55' })]); + + // Then + expect(screen.getByText('Compared with Plan B')).toBeInTheDocument(); }); test('given the same reform run against current law and a saved reform then the two read apart', () => { diff --git a/app/src/tests/unit/components/flagship/ReportComputing.test.tsx b/app/src/tests/unit/components/flagship/ReportComputing.test.tsx index 8a43f2f49..14019f917 100644 --- a/app/src/tests/unit/components/flagship/ReportComputing.test.tsx +++ b/app/src/tests/unit/components/flagship/ReportComputing.test.tsx @@ -76,11 +76,11 @@ describe('ReportComputingScreen', () => { }); describe('ReportUnresolvable', () => { - test('given an old sur- link then the page says the report is not found and how to get a link', () => { + test('given an old link then the page says the report is not found and how to get a link', () => { render(<ReportUnresolvable />); expect(screen.getByText('Report not found.')).toBeInTheDocument(); - expect(screen.getByText(/old link that only worked in the browser/i)).toBeInTheDocument(); + expect(screen.getByText(/old report link, which no longer opens/i)).toBeInTheDocument(); expect(screen.getByText(/run the reform again from build/i)).toBeInTheDocument(); }); }); diff --git a/app/src/tests/unit/hooks/useFlagshipStores.test.tsx b/app/src/tests/unit/hooks/useFlagshipStores.test.tsx index a35e4c0db..00d0689e1 100644 --- a/app/src/tests/unit/hooks/useFlagshipStores.test.tsx +++ b/app/src/tests/unit/hooks/useFlagshipStores.test.tsx @@ -11,6 +11,7 @@ import { useFlagshipReportStore, useReformStore, useReportRecord, + useSharedReport, useV3SavedWork, } from '@/hooks/useFlagshipStores'; import type { AuthContextValue } from '@/libs/auth/AuthContext'; @@ -18,9 +19,14 @@ import { getBrowserUserId } from '@/libs/auth/browserUser'; import { store } from '@/store'; import { ASSOCIATION_IDS, + errorResponse, + itemResponse, + LEGACY_IDS, okResponse, pageResponse, policyItem, + reportItem, + sharedReportItem, } from '@/tests/fixtures/api/v2UserMocks'; import { AuthTestProvider, @@ -184,3 +190,76 @@ describe('useReportRecord', () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + +describe('useReportRecord by link', () => { + test("given a signed-in session and its saved report's link then it reads the account's row", async () => { + // Given + fetchMock.mockImplementation(async (url: string) => + new URL(url).pathname === `/v2/user-reports/${ASSOCIATION_IDS.REPORT}` + ? itemResponse(reportItem({ user_policy_id: ASSOCIATION_IDS.REFORM })) + : errorResponse(404, 'not found') + ); + + // When + const { result } = renderHook(() => useReportRecord(ASSOCIATION_IDS.REPORT), { + wrapper: wrapperFor(signedInAuthValue()), + }); + + // Then + await waitFor(() => expect(result.current?.reformId).toBe(ASSOCIATION_IDS.REFORM)); + }); + + test("given someone else's saved report then this session has no record of it", async () => { + // Given: the owner routes answer 404 for another user's row + fetchMock.mockResolvedValue(errorResponse(404, 'not found')); + + // When + const { result } = renderHook(() => useReportRecord(ASSOCIATION_IDS.REPORT), { + wrapper: wrapperFor(signedInAuthValue()), + }); + + // Then + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(result.current).toBeNull(); + }); +}); + +describe('useSharedReport', () => { + test("given a saved report's link then anyone reads it with no credential", async () => { + // Given + fetchMock.mockImplementation(async () => + itemResponse(sharedReportItem({ baseline_reform: { name: 'Plan B' } })) + ); + + // When: even a signed-in session reads it without its token + const { result } = renderHook(() => useSharedReport(ASSOCIATION_IDS.REPORT), { + wrapper: wrapperFor(signedInAuthValue()), + }); + + // Then + await waitFor(() => expect(result.current.data).toBeTruthy()); + expect(result.current.data?.record).toMatchObject({ + id: ASSOCIATION_IDS.REPORT, + apiReportId: String(LEGACY_IDS.REPORT), + title: 'CTC to $3,600', + reformId: null, + baselineReformId: null, + }); + expect(result.current.data?.baselineReform).toEqual({ name: 'Plan B' }); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(new URL(url).pathname).toBe(`/v2/shared-reports/${ASSOCIATION_IDS.REPORT}`); + expect(init.headers).toEqual({ Accept: 'application/json' }); + }); + + test("given a v1 report's number then nothing is asked: only saved reports are shared", async () => { + // When + const { result } = renderHook(() => useSharedReport(String(LEGACY_IDS.REPORT)), { + wrapper: wrapperFor(makeAuthValue({ status: 'disabled' })), + }); + + // Then + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(result.current.fetchStatus).toBe('idle'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts b/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts index 503c59ae2..c2a4ba1be 100644 --- a/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts +++ b/app/src/tests/unit/hooks/useReportValidationSnapshot.test.ts @@ -2,7 +2,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { LocalStorageFlagshipReportStore } from '@/api/flagshipReportStore'; import { useReportValidationSnapshot } from '@/hooks/useReportValidationSnapshot'; -import type { LiveValidation } from '@/libs/flagship/reportValidation'; +import { snapshotFromLive, type LiveValidation } from '@/libs/flagship/reportValidation'; import { mockCalibrationMatches } from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks'; import { NEWER_RELEASE_ID } from '@/tests/fixtures/libs/flagship/reportValidationMocks'; @@ -84,4 +84,23 @@ describe('useReportValidationSnapshot', () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(result.current.snapshot).toBeNull(); }); + + test("given a report someone else ran then the owner's pin shows and nothing is pinned", async () => { + // Given: no record in this session, and the shared report's pin + const ownersPin = snapshotFromLive({ + ...settledLive, + calibration: { ...mockCalibrationMatches, releaseId: NEWER_RELEASE_ID }, + }); + + // When + const { result } = renderHook(() => + useReportValidationSnapshot(API_REPORT_ID, settledLive, ownersPin) + ); + + // Then: the drift is measured against the owner's pin + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(result.current.snapshot).toEqual(ownersPin); + expect(result.current.drift).toHaveLength(1); + expect(await new LocalStorageFlagshipReportStore().findMine()).toEqual([]); + }); }); diff --git a/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx b/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx index e7ecaf660..a28324b77 100644 --- a/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx +++ b/app/src/tests/unit/hooks/useRunFlagshipReport.test.tsx @@ -33,12 +33,12 @@ const PROVISIONS: RunReportProvision[] = [ }, ]; -function renderRun(auth: AuthContextValue) { +function renderRun(auth: AuthContextValue, push = vi.fn()) { const wrapper = ({ children }: { children: React.ReactNode }) => ( <ReduxProvider store={store}> <CountryProvider value="us"> <AuthTestProvider value={auth}> - <NavigationProvider value={{ push: vi.fn(), replace: vi.fn(), back: vi.fn() }}> + <NavigationProvider value={{ push, replace: vi.fn(), back: vi.fn() }}> {children} </NavigationProvider> </AuthTestProvider> @@ -91,4 +91,18 @@ describe('useRunFlagshipReport', () => { expect(vi.mocked(runFlagshipReport).mock.calls[0][0].accountReportStore).toBeNull(); expect(result.current.error).toBeNull(); }); + + test("given a signed-in run then the report opens at its saved report's link", async () => { + // Given: the run answers the account row's ID, which anyone can open + const reportRowId = '44444444-4444-4444-8444-444444444444'; + vi.mocked(runFlagshipReport).mockResolvedValue(reportRowId); + const push = vi.fn(); + const { result } = renderRun(signedInAuthValue(), push); + + // When + await act(() => result.current.run('CTC expansion', 'Hand-built draft', PROVISIONS)); + + // Then + expect(push).toHaveBeenCalledWith(`/us/report/${reportRowId}`); + }); }); diff --git a/app/src/tests/unit/libs/flagship/runReport.test.ts b/app/src/tests/unit/libs/flagship/runReport.test.ts index b42c4b8e0..7e4302893 100644 --- a/app/src/tests/unit/libs/flagship/runReport.test.ts +++ b/app/src/tests/unit/libs/flagship/runReport.test.ts @@ -39,6 +39,8 @@ const LEGACY_KEYS = [ ]; const REFORM_ROW_ID = '55555555-5555-4555-8555-555555555555'; +/** The account row a signed-in run writes; its ID is the report's link. */ +const REPORT_ROW_ID = '44444444-4444-4444-8444-444444444444'; const PROVISION = { path: 'gov.irs.credits.ctc.amount.base[0].amount', @@ -462,7 +464,7 @@ describe('runFlagshipReport', () => { test('given a signed-in run then exactly one account association is written', async () => { // Given const accountStore = { - create: vi.fn().mockResolvedValue({ id: 'row-1' }), + create: vi.fn().mockResolvedValue({ id: REPORT_ROW_ID }), } as unknown as FlagshipReportStore; // When @@ -477,8 +479,8 @@ describe('runFlagshipReport', () => { accountReportStore: accountStore, }); - // Then - expect(reportId).toBe('55'); + // Then: the account row's ID is the report's link, which anyone can open + expect(reportId).toBe(REPORT_ROW_ID); expect(mockCreateReport).toHaveBeenCalledWith('us', { simulation_1_id: 101, simulation_2_id: 102, @@ -500,13 +502,13 @@ describe('runFlagshipReport', () => { for (const legacyKey of LEGACY_KEYS) { expect(localStorage.getItem(legacyKey)).toBeNull(); } - expect(readReportMeta('55')?.title).toBe('CTC expansion'); + expect(readReportMeta(REPORT_ROW_ID)?.title).toBe('CTC expansion'); }); test('given a run with a budget window then the account record keeps the window', async () => { // Given const accountStore = { - create: vi.fn().mockResolvedValue({ id: 'row-1' }), + create: vi.fn().mockResolvedValue({ id: REPORT_ROW_ID }), } as unknown as FlagshipReportStore; // When @@ -529,7 +531,7 @@ describe('runFlagshipReport', () => { test('given a signed-in run then its provenance stays out of localStorage and goes on sign-out', async () => { // Given const accountStore = { - create: vi.fn().mockResolvedValue({ id: 'row-1' }), + create: vi.fn().mockResolvedValue({ id: REPORT_ROW_ID }), } as unknown as FlagshipReportStore; // When @@ -544,9 +546,9 @@ describe('runFlagshipReport', () => { // Then: the next person on this browser cannot find it expect(localStorage.getItem(REPORT_META_KEY)).toBeNull(); - expect(readReportMeta('55')?.title).toBe('CTC expansion'); + expect(readReportMeta(REPORT_ROW_ID)?.title).toBe('CTC expansion'); forgetAccountReportMeta(); - expect(readReportMeta('55')).toBeNull(); + expect(readReportMeta(REPORT_ROW_ID)).toBeNull(); }); test('given the account association fails then the signed-in run fails', async () => { diff --git a/app/src/tests/unit/pages/flagship/ReportLinks.test.tsx b/app/src/tests/unit/pages/flagship/ReportLinks.test.tsx new file mode 100644 index 000000000..232f19218 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/ReportLinks.test.tsx @@ -0,0 +1,174 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { FlagshipReportData } from '@/hooks/useFlagshipReport'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import FlagshipReportPage from '@/pages/flagship/Report.page'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { + ASSOCIATION_IDS, + errorResponse, + itemResponse, + LEGACY_IDS, + sharedReportItem, +} from '@/tests/fixtures/api/v2UserMocks'; +import { + EXISTING_REPORT, + EXISTING_SIMULATIONS, +} from '@/tests/fixtures/hooks/useFlagshipReportMocks'; +import { AuthTestProvider, makeAuthValue } from '@/tests/fixtures/libs/auth/authMocks'; + +const mockUseFlagshipReport = vi.fn(); +const mockCalcStatus = vi.fn(); +const fetchMock = vi.fn(); + +// The real isApiReportId: which links open is what these tests are about. +vi.mock('@/hooks/useFlagshipReport', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/hooks/useFlagshipReport')>()), + useFlagshipReport: (apiReportId: string) => mockUseFlagshipReport(apiReportId), +})); + +vi.mock('@/hooks/useCalculationStatus', () => ({ + useCalculationStatus: () => mockCalcStatus(), +})); + +vi.mock('@/api/metadata', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/api/metadata')>()), + fetchMetadata: () => new Promise(() => {}), +})); + +vi.mock('@/hooks/useStartCalculationOnLoad', () => ({ + useStartCalculationOnLoad: () => undefined, +})); + +vi.mock('@/hooks/useReportValidationSnapshot', () => ({ + useReportValidationSnapshot: () => undefined, +})); + +vi.mock('@/components/flagship/CalibrationMatches', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/components/flagship/CalibrationMatches')>()), + useCalibrationMatches: () => null, +})); + +vi.mock('@/components/flagship/ValidationPanel', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/components/flagship/ValidationPanel')>()), + useModelTrackRecord: () => null, +})); + +/** A run that failed: the page then shows the report's title over the error. */ +const FAILED_RUN = { + status: 'error', + isComplete: false, + isError: true, + error: { message: 'The simulation failed' }, + result: null, +}; + +const SHARED_PATH = `/v2/shared-reports/${ASSOCIATION_IDS.REPORT}`; + +function reportData(overrides: Partial<FlagshipReportData> = {}): FlagshipReportData { + return { + report: undefined, + simulations: [], + policies: [], + isLoading: false, + error: null, + notFound: false, + retry: vi.fn(), + ...overrides, + }; +} + +/** Opens a report link; sign-in off unless a session is given. */ +function renderPage(link: string, auth?: AuthContextValue) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const page = ( + <QueryClientProvider client={queryClient}> + <FlagshipReportPage userReportId={link} /> + </QueryClientProvider> + ); + return render(auth ? <AuthTestProvider value={auth}>{page}</AuthTestProvider> : page); +} + +describe('FlagshipReportPage links', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + store.dispatch(clearMetadata()); + mockCalcStatus.mockReturnValue(FAILED_RUN); + mockUseFlagshipReport.mockImplementation((apiReportId: string) => + apiReportId + ? reportData({ report: EXISTING_REPORT, simulations: EXISTING_SIMULATIONS }) + : reportData() + ); + fetchMock.mockImplementation(async (url: string) => + new URL(url).pathname === SHARED_PATH + ? itemResponse(sharedReportItem()) + : errorResponse(404, 'not found') + ); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("given a saved report's link then anyone opens it with its owner's details", async () => { + // When: opened by someone who never ran it, with sign-in off + renderPage(ASSOCIATION_IDS.REPORT); + + // Then: the owner's title, over the v1 report the saved report names + expect(await screen.findByRole('heading', { name: 'CTC to $3,600' })).toBeInTheDocument(); + expect(mockUseFlagshipReport).toHaveBeenLastCalledWith(String(LEGACY_IDS.REPORT)); + const sharedRead = fetchMock.mock.calls.find(([url]) => new URL(url).pathname === SHARED_PATH)!; + expect(sharedRead[1].headers).not.toHaveProperty('Authorization'); + expect(sharedRead[1].headers).not.toHaveProperty('PolicyEngine-Unauthenticated-User'); + }); + + test('given a signed-out browser that did not run it then the link opens the same way', async () => { + // When + renderPage(ASSOCIATION_IDS.REPORT, makeAuthValue()); + + // Then + expect(await screen.findByRole('heading', { name: 'CTC to $3,600' })).toBeInTheDocument(); + }); + + test('given a link to a deleted report then it says not found', async () => { + // Given + fetchMock.mockResolvedValue(errorResponse(404, 'not found')); + + // When + renderPage(ASSOCIATION_IDS.REPORT); + + // Then + expect(await screen.findByRole('heading', { name: 'Report not found' })).toBeInTheDocument(); + expect(mockUseFlagshipReport).not.toHaveBeenCalledWith(String(LEGACY_IDS.REPORT)); + }); + + test('given an old v1 report link where accounts exist then it no longer opens', () => { + // When + renderPage(String(LEGACY_IDS.REPORT), makeAuthValue()); + + // Then + expect(screen.getByText(/old report link, which no longer opens/i)).toBeInTheDocument(); + expect(mockUseFlagshipReport).not.toHaveBeenCalledWith(String(LEGACY_IDS.REPORT)); + }); + + test('given a v1 report link with sign-in off then it opens, as nothing is shared', () => { + // When + renderPage(String(LEGACY_IDS.REPORT)); + + // Then + expect(mockUseFlagshipReport).toHaveBeenLastCalledWith(String(LEGACY_IDS.REPORT)); + expect(screen.queryByText(/old report link/i)).not.toBeInTheDocument(); + }); + + test('given an old browser-only link then it no longer opens', () => { + // When + renderPage('sur-mtqcsp78hwc7'); + + // Then + expect(screen.getByText(/old report link, which no longer opens/i)).toBeInTheDocument(); + }); +}); diff --git a/app/src/types/v2User.ts b/app/src/types/v2User.ts index 7a682e78b..63059ed0d 100644 --- a/app/src/types/v2User.ts +++ b/app/src/types/v2User.ts @@ -112,6 +112,28 @@ export interface UserReportItem extends AssociationItemBase { last_run_at: string | null; } +/** + * A saved report as anyone holding its link reads it (`/v2/shared-reports`): + * the owner and the owner's saved-reform IDs are left out, and the reform it + * was compared with is named instead. + */ +export interface SharedReportItem { + id: string; + country_id: AssociationCountryId; + legacy_report_id: number | null; + name: string | null; + source_note: string | null; + provisions: ReportProvisionWire[] | null; + /** ReportValidationWire on the wire; validated by validationFromWire. */ + validation: unknown | null; + budget_window_years: number | null; + /** The owner's saved reform the report was compared with; null for current law. */ + baseline_reform: { name: string | null } | null; + last_run_at: string | null; + created_at: string; + updated_at: string; +} + /* Create and patch bodies (strict on the server: unknown keys are a 400). */ export interface UserPolicyCreateBody { From 37d123d3b7c543e8ff660d62b2aecb6b158a1ebf Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:46:40 +0400 Subject: [PATCH 41/48] Re-run and reopen account reports with their year, window and comparison Your reforms' Re-run and "View full impact report" came from main reading a report's details by its v1 report's number, which finds nothing for an account report: its details are kept under its link (the row ID), and only in the session that ran it. A re-run of one ran this year's report against current law, and an existing report of the same values never reopened. - Re-run reads the run's details by the report's link first. Without them it takes the year from the v1 report, the budget window from the record (budgetWindowYears) and the comparison from the record's baselineReformId: the baseline policy the report ran (from its simulation), named from the saved reform. When that policy can't be read it uses the saved reform's own policy, and when neither is known it runs nothing rather than compare with current law. The new run is linked to the same saved reform. A report kept in this browser still reads its details by the v1 report's number, which is its link. - "View full impact report" reads an account report's year from the v1 report before looking for one to reopen, and counts a report linked to a compared reform as one against a saved reform. - The report list names the reform an account report was compared with from this session's saved reforms when the run's details are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- app/src/components/flagship/ReformReports.tsx | 27 ++- app/src/libs/flagship/latestReport.ts | 17 +- app/src/libs/flagship/reportRerun.ts | 68 ++++++-- app/src/pages/flagship/Reforms.page.tsx | 71 ++++++-- .../flagship/ReformReports.test.tsx | 17 +- .../unit/libs/flagship/latestReport.test.ts | 19 +++ .../unit/libs/flagship/reportRerun.test.ts | 121 ++++++++++++++ .../unit/pages/flagship/Reforms.test.tsx | 155 ++++++++++++++++++ 8 files changed, 460 insertions(+), 35 deletions(-) diff --git a/app/src/components/flagship/ReformReports.tsx b/app/src/components/flagship/ReformReports.tsx index ad3ac88c5..90807a7e5 100644 --- a/app/src/components/flagship/ReformReports.tsx +++ b/app/src/components/flagship/ReformReports.tsx @@ -51,11 +51,20 @@ function ReportStatus({ status }: { status?: string }) { /** * What a report compared the reform with, when not current law: the same * reform against a saved reform is another report, and reads as one. The - * run's details are kept under the report's link. + * run's details name it in the session that ran it; otherwise an account + * record links the saved reform, named from this session's reforms. */ -function ReportBaseline({ link }: { link: string }) { - const baseline = readReportMeta(link)?.baseline; - if (!baseline) { +function ReportBaseline({ + report, + reformName, +}: { + report: FlagshipReportRecord; + reformName?: (reformId: string) => string | null | undefined; +}) { + const label = + readReportMeta(reportLink(report))?.baseline?.label ?? + (report.baselineReformId ? reformName?.(report.baselineReformId) || 'a saved reform' : null); + if (!label) { return null; } return ( @@ -63,7 +72,7 @@ function ReportBaseline({ link }: { link: string }) { className="tw:truncate" style={{ minWidth: 0, fontSize: typography.fontSize.xs, color: colors.text.secondary }} > - Compared with {baseline.label} + Compared with {label} </span> ); } @@ -80,6 +89,7 @@ function ReportRow({ onOpen, onRerun, rerunning, + reformName, }: { countryId: CountryId; report: FlagshipReportRecord; @@ -87,6 +97,7 @@ function ReportRow({ onOpen: () => void; onRerun?: () => void; rerunning: boolean; + reformName?: (reformId: string) => string | null | undefined; }) { const api = useApiReport(countryId, report.apiReportId); // A record saved through the API keeps no year (the report itself @@ -130,7 +141,7 @@ function ReportRow({ ? `Results from ${dayjs(finished).format('MMM D, YYYY')}` : `Run ${dayjs(report.createdAt).format('MMM D, YYYY')}`} </span> - <ReportBaseline link={reportLink(report)} /> + <ReportBaseline report={report} reformName={reformName} /> </div> <span> <ReportStatus status={api?.status} /> @@ -172,6 +183,7 @@ export default function ReformReports({ reports, onRerun, rerunning = false, + reformName, }: { countryId: CountryId; reports: FlagshipReportRecord[]; @@ -179,6 +191,8 @@ export default function ReformReports({ onRerun?: (report: FlagshipReportRecord) => void; /** A run is starting; Re-run waits for it. */ rerunning?: boolean; + /** A saved reform's name, for the reform a report was compared with. */ + reformName?: (reformId: string) => string | null | undefined; }) { const nav = useAppNavigate(); const sorted = [...reports].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); @@ -215,6 +229,7 @@ export default function ReformReports({ onOpen={() => nav.push(`/${countryId}/report/${reportLink(report)}`)} onRerun={onRerun && (() => onRerun(report))} rerunning={rerunning} + reformName={reformName} /> ))} </Stack> diff --git a/app/src/libs/flagship/latestReport.ts b/app/src/libs/flagship/latestReport.ts index 92fb3904d..cc54aa187 100644 --- a/app/src/libs/flagship/latestReport.ts +++ b/app/src/libs/flagship/latestReport.ts @@ -1,4 +1,4 @@ -import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; +import { reportLink, type FlagshipReportRecord } from '@/api/flagshipReportStore'; import { readReportMeta } from '@/libs/flagship/runReport'; import type { Reform } from '@/types/ingredients/Reform'; @@ -14,12 +14,23 @@ function valueKey( )}`; } +/** + * Whether a report was compared with something other than current law: + * the run's details say so in the session that ran it (kept under the + * report's link), and the record links the saved reform it was compared + * with. + */ +function comparedWithSavedReform(report: FlagshipReportRecord): boolean { + return !!readReportMeta(reportLink(report))?.baseline || !!report.baselineReformId; +} + /** * The report a saved reform's "View full impact report" opens: its newest * report for the year it runs, against current law, that ran the reform's * values as they are now. Null when there is none — then it runs one. * A report still computing is the one to open: its calculation goes on - * on the server, so a new run would only start the wait again. + * on the server, so a new run would only start the wait again. Records + * saved through the API keep no year, so the caller fills it in. */ export function latestMatchingReport( reform: Reform, @@ -31,7 +42,7 @@ export function latestMatchingReport( .sort() .join('|'); const matching = reports.filter((report) => { - if (report.year !== String(year) || readReportMeta(report.apiReportId)?.baseline) { + if (report.year !== String(year) || comparedWithSavedReform(report)) { return false; } const reportKey = report.provisions diff --git a/app/src/libs/flagship/reportRerun.ts b/app/src/libs/flagship/reportRerun.ts index 4a255fc5b..706143eab 100644 --- a/app/src/libs/flagship/reportRerun.ts +++ b/app/src/libs/flagship/reportRerun.ts @@ -1,46 +1,94 @@ -import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; +import { reportLink, type FlagshipReportRecord } from '@/api/flagshipReportStore'; import { fetchReportById } from '@/api/report'; import { fetchSimulationById } from '@/api/simulation'; import type { CountryId } from '@/libs/countries'; import { readReportMeta, type RunReportBaseline } from '@/libs/flagship/runReport'; +import type { Reform } from '@/types/ingredients/Reform'; -/** What a report ran with, beyond its reform and year, to run it again. */ +/** What a report ran with, beyond its reform, to run it again. */ export interface RerunSettings { + /** The year it simulated; unknown when neither its record nor the API's says. */ + year?: number; /** A state or district; absent for the whole country. */ region?: string; windowYears?: number; baseline?: RunReportBaseline; } +export interface RerunSettingsOptions { + /** This session's saved reform by its ID, to name the reform a report was compared with. */ + savedReform?: (reformId: string) => Reform | undefined; +} + /** - * The settings a report ran with: its population from the API's record - * (the report's simulation), its budget window and comparison from the - * details this browser kept. A population that cannot be read runs - * nationwide, as the report would without one. + * The settings a report ran with. Its year (when its record keeps none) + * and population come from the API's record: the report and its baseline + * simulation. Its budget window and comparison come from the run's + * details, which this session keeps under the report's link; otherwise + * from the report's own record, which keeps its window and the saved + * reform it was compared with (an account report run elsewhere, or + * before a reload). That comparison is the baseline policy the report + * ran, named from the saved reform. A population that cannot be read + * runs nationwide, as the report would without one; a comparison whose + * policy cannot be read throws rather than run against current law. */ export async function rerunSettings( countryId: CountryId, - report: FlagshipReportRecord + report: FlagshipReportRecord, + { savedReform }: RerunSettingsOptions = {} ): Promise<RerunSettings> { - const meta = readReportMeta(report.apiReportId); + const meta = readReportMeta(reportLink(report)); + let year = report.year; let region: string | undefined; + let baselinePolicyId: number | undefined; try { const apiReport = await fetchReportById(countryId, report.apiReportId); + year = year || apiReport.year || undefined; const simulation = await fetchSimulationById(countryId, apiReport.simulation_1_id); const population = simulation.population_type === 'geography' ? simulation.population_id : ''; region = population && population !== countryId ? population : undefined; + baselinePolicyId = Number(simulation.policy_id) || undefined; } catch { region = undefined; } + const recordWindow = + report.budgetWindowYears && report.budgetWindowYears > 1 ? report.budgetWindowYears : undefined; return { + year: Number(year) || undefined, region, - windowYears: meta?.budgetWindow?.windowSize, + windowYears: meta?.budgetWindow?.windowSize ?? recordWindow, baseline: meta?.baseline ? { reformId: meta.baseline.reformId, label: meta.baseline.label, policyId: Number(meta.baseline.policyId), } - : undefined, + : savedBaseline(report, baselinePolicyId, savedReform), + }; +} + +/** + * The saved reform a report's record says it was compared with, as the + * baseline to run again: the policy the report ran as its baseline, or, + * when that can't be read, the saved reform's own policy. None for a + * report compared with current law. + */ +function savedBaseline( + report: FlagshipReportRecord, + baselinePolicyId: number | undefined, + savedReform: RerunSettingsOptions['savedReform'] +): RunReportBaseline | undefined { + if (!report.baselineReformId) { + return undefined; + } + const reform = savedReform?.(report.baselineReformId); + const policyId = baselinePolicyId ?? (Number(reform?.policyId) || undefined); + if (!policyId) { + throw new Error('The report’s comparison could not be read'); + } + return { + reformId: report.baselineReformId, + label: reform?.label || 'Untitled reform', + policyId, }; } diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index e834f20cc..0bd139efb 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -12,7 +12,8 @@ import { import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import { billRunYear, TrackedBill } from '@/api/billFeed'; -import { reportLink } from '@/api/flagshipReportStore'; +import { reportLink, type FlagshipReportRecord } from '@/api/flagshipReportStore'; +import { fetchReportById } from '@/api/report'; import BillFeedFilters from '@/components/flagship/bills/BillFeedFilters'; import BillStageBadge from '@/components/flagship/bills/BillStageBadge'; import DeleteReformDialog from '@/components/flagship/DeleteReformDialog'; @@ -311,6 +312,8 @@ export default function ReformsPage() { const reformStore = useReformStore(); const { store: reportStore } = useFlagshipReportStore(); const { scope } = useV3SavedWork(); + // A re-run whose settings could not be read; the run's own errors are runReport's. + const [rerunError, setRerunError] = useState<string | null>(null); const { bills, isConfigured: billsConfigured, @@ -377,6 +380,30 @@ export default function ReformsPage() { baselineValue: getCurrentValue(parameters?.[path]?.values), }); + /** A saved reform of this session's, by its ID. */ + const savedReform = (reformId: string) => reforms?.find((reform) => reform.id === reformId); + + /** + * The year a report scored. A record saved through the API keeps no year + * (the report itself carries it), so it is read from the API's record, + * as the report rows read it; unknown when that fails. + */ + const reportYear = async (report: FlagshipReportRecord): Promise<string | undefined> => { + if (report.year) { + return report.year; + } + try { + const apiReport = await queryClient.fetchQuery({ + queryKey: flagshipReportQueryKeys.status(countryId, report.apiReportId), + queryFn: () => fetchReportById(countryId, report.apiReportId), + staleTime: 60_000, + }); + return apiReport.year ?? undefined; + } catch { + return undefined; + } + }; + // As on the bill's report: stored parameters that contradict the bill's // write-up (UT SB60's reform and baseline are swapped) would score // another change, so neither a report nor a draft starts from them. @@ -446,8 +473,9 @@ export default function ReformsPage() { : null; const openReform = (reform: Reform) => { - // A failed delete belongs to the reform it was tried on. + // A failed delete or re-run belongs to the reform it was tried on. deleteMutation.reset(); + setRerunError(null); setSelectedId(reform.id!); }; @@ -835,15 +863,17 @@ export default function ReformsPage() { }} > <Button - onClick={() => { + onClick={async () => { // A report of these values already exists, done or still // computing: open it at its link rather than starting the // wait again. - const latest = latestMatchingReport( - selectedReform, - reportsFor(selectedReform.id), - runYear + const reformReports = await Promise.all( + reportsFor(selectedReform.id).map(async (report) => ({ + ...report, + year: await reportYear(report), + })) ); + const latest = latestMatchingReport(selectedReform, reformReports, runYear); if (latest) { nav.push(`/${countryId}/report/${reportLink(latest)}`); return; @@ -884,9 +914,9 @@ export default function ReformsPage() { report. </Text> )} - {runReport.error && ( + {(runReport.error || rerunError) && ( <Text role="alert" style={{ fontSize: typography.fontSize.sm, color: colors.error }}> - {runReport.error} + {runReport.error || rerunError} </Text> )} {deleteMutation.isError && ( @@ -899,17 +929,30 @@ export default function ReformsPage() { countryId={countryId} reports={reportsFor(selectedReform.id)} rerunning={runReport.isRunning} + reformName={(reformId) => { + const reform = savedReform(reformId); + return reform ? reform.label || 'Untitled reform' : undefined; + }} onRerun={async (report) => { + setRerunError(null); // The same report on today's model: its reform as it ran, its - // year, population, budget window and comparison. - const settings = await rerunSettings(countryId, report); + // year, population, budget window and comparison, linked to the + // same saved reform. + const settings = await rerunSettings(countryId, report, { savedReform }).catch( + () => null + ); + if (!settings) { + setRerunError('Could not re-run the report. Try again.'); + return; + } + const { year, ...runSettings } = settings; await runReport.run( report.title || selectedReform.label || 'Untitled reform', report.sourceNote || SOURCE_LABELS[selectedReform.provenance.source], report.provisions, - selectedReform.id, - Number(report.year) || undefined, - settings + report.reformId ?? selectedReform.id, + year, + runSettings ); }} /> diff --git a/app/src/tests/unit/components/flagship/ReformReports.test.tsx b/app/src/tests/unit/components/flagship/ReformReports.test.tsx index 585634edc..cadf72714 100644 --- a/app/src/tests/unit/components/flagship/ReformReports.test.tsx +++ b/app/src/tests/unit/components/flagship/ReformReports.test.tsx @@ -52,12 +52,13 @@ const META = { function renderReports( reports: FlagshipReportRecord[], - onRerun?: (report: FlagshipReportRecord) => void + onRerun?: (report: FlagshipReportRecord) => void, + reformName?: (reformId: string) => string | undefined ) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( <QueryClientProvider client={queryClient}> - <ReformReports countryId="us" reports={reports} onRerun={onRerun} /> + <ReformReports countryId="us" reports={reports} onRerun={onRerun} reformName={reformName} /> </QueryClientProvider> ); } @@ -112,6 +113,18 @@ describe('ReformReports', () => { expect(await screen.findByText('2028 report')).toBeInTheDocument(); }); + test('given an account report compared with a saved reform after a reload then it names that reform', () => { + // Given: no run details in this page session, only the row's link + renderReports( + [record({ id: REPORT_ROW_ID, apiReportId: '55', baselineReformId: 'rf-plan-b' })], + undefined, + (reformId) => (reformId === 'rf-plan-b' ? 'Plan B' : undefined) + ); + + // Then + expect(screen.getByText('Compared with Plan B')).toBeInTheDocument(); + }); + test('given an account report compared with a saved reform then it reads so from its link', () => { // Given: a run this session made keeps its details under the report's link saveReportMeta(REPORT_ROW_ID, { diff --git a/app/src/tests/unit/libs/flagship/latestReport.test.ts b/app/src/tests/unit/libs/flagship/latestReport.test.ts index 523d9afb7..aeac25771 100644 --- a/app/src/tests/unit/libs/flagship/latestReport.test.ts +++ b/app/src/tests/unit/libs/flagship/latestReport.test.ts @@ -18,6 +18,9 @@ const REFORM = { provenance: { source: 'manual' }, } as Reform; +/** A `/v2/user-reports` row's ID: an account report's link. */ +const ACCOUNT_REPORT_ID = '44444444-4444-4444-8444-444444444444'; + const report = (overrides: Partial<FlagshipReportRecord>): FlagshipReportRecord => ({ id: 'rec', countryId: 'us', @@ -65,4 +68,20 @@ describe('latestMatchingReport', () => { latestMatchingReport(REFORM, [report({ year: '2027' }), report({ apiReportId: '9' })], 2026) ).toBeNull(); }); + + test('given an account report compared with a saved reform after a reload then it does not open', () => { + // Given: no run details in this page session, only the row's link to the saved reform + const accountReport = report({ id: ACCOUNT_REPORT_ID, baselineReformId: 'rf-plan-b' }); + + // Then + expect(latestMatchingReport(REFORM, [accountReport], 2026)).toBeNull(); + }); + + test('given an account report against current law then it opens', () => { + // Given + const accountReport = report({ id: ACCOUNT_REPORT_ID, baselineReformId: null }); + + // Then + expect(latestMatchingReport(REFORM, [accountReport], 2026)?.id).toBe(ACCOUNT_REPORT_ID); + }); }); diff --git a/app/src/tests/unit/libs/flagship/reportRerun.test.ts b/app/src/tests/unit/libs/flagship/reportRerun.test.ts index d4acf1f34..ec1194ae5 100644 --- a/app/src/tests/unit/libs/flagship/reportRerun.test.ts +++ b/app/src/tests/unit/libs/flagship/reportRerun.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; import { rerunSettings } from '@/libs/flagship/reportRerun'; import { saveReportMeta } from '@/libs/flagship/runReport'; +import type { Reform } from '@/types/ingredients/Reform'; const fetchReportById = vi.fn(); const fetchSimulationById = vi.fn(); @@ -24,6 +25,34 @@ const REPORT: FlagshipReportRecord = { reformId: 'rf-1', }; +/** A `/v2/user-reports` row's ID: an account report's link. */ +const REPORT_ROW_ID = '44444444-4444-4444-8444-444444444444'; + +/** + * An account report: the row keeps no year (its v1 report does), its + * budget window, and the saved reform it was compared with. + */ +const ACCOUNT_REPORT: FlagshipReportRecord = { + ...REPORT, + id: REPORT_ROW_ID, + year: undefined, + budgetWindowYears: 10, + baselineReformId: 'rf-plan-b', +}; + +/** The saved reform the account report was compared with. */ +const PLAN_B: Reform = { + id: 'rf-plan-b', + countryId: 'us', + label: 'Plan B', + policyId: '87', + parameters: [], + baseline: 'current-law', + provenance: { source: 'manual' }, +}; + +const savedReform = (reformId: string) => (reformId === PLAN_B.id ? PLAN_B : undefined); + describe('rerunSettings', () => { beforeEach(() => { localStorage.clear(); @@ -45,6 +74,7 @@ describe('rerunSettings', () => { }); expect(await rerunSettings('us', REPORT)).toEqual({ + year: 2027, region: 'state/md', windowYears: 10, baseline: { reformId: 'rf-plan-b', label: 'Plan B', policyId: 88 }, @@ -55,6 +85,7 @@ describe('rerunSettings', () => { fetchSimulationById.mockResolvedValue({ population_type: 'geography', population_id: 'us' }); expect(await rerunSettings('us', REPORT)).toEqual({ + year: 2027, region: undefined, windowYears: undefined, baseline: undefined, @@ -66,4 +97,94 @@ describe('rerunSettings', () => { expect((await rerunSettings('us', REPORT)).region).toBeUndefined(); }); + + describe('given an account report', () => { + beforeEach(() => { + fetchReportById.mockResolvedValue({ simulation_1_id: '9', year: '2028' }); + fetchSimulationById.mockResolvedValue({ + population_type: 'geography', + population_id: 'state/md', + policy_id: '88', + }); + }); + + test('then its year, window and comparison come from its record and the API', async () => { + // Given: no run details in this page session (another device, or a reload) + + // When + const settings = await rerunSettings('us', ACCOUNT_REPORT, { savedReform }); + + // Then: the baseline is the policy the report ran, named from the saved reform + expect(settings).toEqual({ + year: 2028, + region: 'state/md', + windowYears: 10, + baseline: { reformId: 'rf-plan-b', label: 'Plan B', policyId: 88 }, + }); + }); + + test('then the run details this session kept under its link come first', async () => { + // Given + saveReportMeta(REPORT_ROW_ID, { + title: 'Bigger CTC', + sourceNote: 'Hand-built', + provisions: [], + createdAt: '2026-10-14T00:00:00Z', + budgetWindow: { startYear: 2028, windowSize: 3 }, + baseline: { reformId: 'rf-plan-c', policyId: '90', label: 'Plan C' }, + }); + + // When + const settings = await rerunSettings('us', ACCOUNT_REPORT, { savedReform }); + + // Then + expect(settings).toMatchObject({ + windowYears: 3, + baseline: { reformId: 'rf-plan-c', label: 'Plan C', policyId: 90 }, + }); + }); + + test('then a compared reform that is no longer saved keeps its comparison, untitled', async () => { + // When + const settings = await rerunSettings('us', ACCOUNT_REPORT); + + // Then + expect(settings.baseline).toEqual({ + reformId: 'rf-plan-b', + label: 'Untitled reform', + policyId: 88, + }); + }); + + test('then a report against current law runs against current law', async () => { + // When + const settings = await rerunSettings( + 'us', + { ...ACCOUNT_REPORT, baselineReformId: null }, + { savedReform } + ); + + // Then + expect(settings.baseline).toBeUndefined(); + }); + + test("given the API cannot be read then the comparison is the saved reform's own policy", async () => { + // Given + fetchReportById.mockRejectedValue(new Error('down')); + + // When + const settings = await rerunSettings('us', ACCOUNT_REPORT, { savedReform }); + + // Then + expect(settings.baseline).toEqual({ reformId: 'rf-plan-b', label: 'Plan B', policyId: 87 }); + }); + + test('given neither the API nor the saved reform names the comparison then it does not run', async () => { + // Given + fetchReportById.mockRejectedValue(new Error('down')); + + // When / Then: rather than run against current law + await expect(rerunSettings('us', ACCOUNT_REPORT)).rejects.toThrow(); + }); + }); }); diff --git a/app/src/tests/unit/pages/flagship/Reforms.test.tsx b/app/src/tests/unit/pages/flagship/Reforms.test.tsx index 886169b0f..26076ffb3 100644 --- a/app/src/tests/unit/pages/flagship/Reforms.test.tsx +++ b/app/src/tests/unit/pages/flagship/Reforms.test.tsx @@ -3,6 +3,7 @@ import { render, renderWithCountry, screen, userEvent, waitFor, within } from '@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { CURRENT_YEAR } from '@/constants'; import { clearDraftReform, draftYear, getDraftReform } from '@/libs/draftReform'; +import { saveReportMeta } from '@/libs/flagship/runReport'; import ReformsPage from '@/pages/flagship/Reforms.page'; import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; @@ -55,6 +56,17 @@ vi.mock('@/api/report', async (importOriginal) => ({ id: reportId, status: reportId === '901' ? 'complete' : 'pending', year: reportId === '904' ? '2028' : '2026', + simulation_1_id: '9', + }), +})); + +// Every report's baseline simulation: Maryland, run against policy 88. +vi.mock('@/api/simulation', async (importOriginal) => ({ + ...(await importOriginal<typeof import('@/api/simulation')>()), + fetchSimulationById: async () => ({ + population_type: 'geography', + population_id: 'state/md', + policy_id: '88', }), })); @@ -1004,4 +1016,147 @@ describe('ReformsPage', () => { store.dispatch(clearMetadata()); } }); + + describe('given a report to open again or re-run', () => { + beforeEach(() => localStorage.clear()); + + /** A `/v2/user-reports` row's ID: an account report's link. */ + const ACCOUNT_REPORT_ROW_ID = '44444444-4444-4444-8444-444444444444'; + + /** The saved reform the reports were compared with. */ + const PLAN_B_REFORM: Reform = { + ...CHAT_REFORM, + id: 'rf-plan-b', + label: 'Plan B', + policyId: '87', + }; + + /** CHAT_REFORM's provision, as its report ran it. */ + const RUN_PROVISION = { + path: 'gov.irs.credits.ctc.amount.base[0].amount', + breadcrumb: 'IRS → Credits → CTC → Base amount', + unit: 'currency-USD', + baselineValue: 2000, + value: 3600, + intervals: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3600 }], + }; + + /** + * An account report: the row keeps no year (its v1 report, 904, ran + * 2028), a ten-year window, and the saved reform it was compared with. + */ + const ACCOUNT_REPORT = { + id: ACCOUNT_REPORT_ROW_ID, + countryId: 'us', + apiReportId: '904', + title: 'CTC', + sourceNote: 'Hand-built', + provisions: [RUN_PROVISION], + createdAt: '2026-10-14T09:00:00Z', + reformId: 'rf-1', + budgetWindowYears: 10, + baselineReformId: 'rf-plan-b', + }; + + /** A report kept in this browser: its link is the v1 report's number. */ + const BROWSER_REPORT = { + id: 'lfr-local1', + countryId: 'us', + apiReportId: '902', + title: 'CTC', + sourceNote: 'Hand-built', + provisions: [RUN_PROVISION], + year: '2026', + createdAt: '2026-10-14T09:00:00Z', + reformId: 'rf-1', + }; + + async function openReform(reports: object[]) { + mockFindMine.mockResolvedValue([CHAT_REFORM, PLAN_B_REFORM]); + mockFindReports.mockResolvedValue(reports); + const user = userEvent.setup(); + renderReforms('/us/reforms?filter=yours'); + await user.click(await screen.findByText('CTC to $3,600 for children under 6')); + return user; + } + + test('given an account report then Re-run keeps its year, window and comparison, linked to the same reform', async () => { + // Given: no run details in this page session, only the row + const user = await openReform([ACCOUNT_REPORT]); + const list = screen.getByRole('region', { name: 'Reports' }); + expect(within(list).getByText('Compared with Plan B')).toBeInTheDocument(); + + // When + await user.click(within(list).getByRole('button', { name: /re-run/i })); + + // Then + await waitFor(() => expect(mockRun).toHaveBeenCalledTimes(1)); + const [title, sourceNote, provisions, reformId, year, settings] = mockRun.mock.calls[0]; + expect({ title, sourceNote, provisions, reformId, year }).toEqual({ + title: 'CTC', + sourceNote: 'Hand-built', + provisions: [RUN_PROVISION], + reformId: 'rf-1', + year: 2028, + }); + expect(settings).toEqual({ + region: 'state/md', + windowYears: 10, + baseline: { reformId: 'rf-plan-b', label: 'Plan B', policyId: 88 }, + }); + }); + + test('given a report kept in this browser then Re-run keeps the details stored under its v1 report', async () => { + // Given + saveReportMeta('902', { + title: 'CTC', + sourceNote: 'Hand-built', + provisions: [RUN_PROVISION], + createdAt: '2026-10-14T09:00:00Z', + budgetWindow: { startYear: 2026, windowSize: 3 }, + baseline: { reformId: 'rf-plan-b', policyId: '88', label: 'Plan B' }, + }); + const user = await openReform([BROWSER_REPORT]); + + // When + await user.click(screen.getByRole('button', { name: /re-run/i })); + + // Then + await waitFor(() => expect(mockRun).toHaveBeenCalledTimes(1)); + const [, , , reformId, year, settings] = mockRun.mock.calls[0]; + expect({ reformId, year }).toEqual({ reformId: 'rf-1', year: 2026 }); + expect(settings).toEqual({ + region: 'state/md', + windowYears: 3, + baseline: { reformId: 'rf-plan-b', label: 'Plan B', policyId: 88 }, + }); + }); + + test('given a finished account report then View results opens its link, not the v1 report', async () => { + // Given: report 901 is complete + const user = await openReform([{ ...ACCOUNT_REPORT, apiReportId: '901' }]); + + // When + await user.click(await screen.findByRole('button', { name: /view results/i })); + + // Then + expect(mockNavigate).toHaveBeenCalledWith(`/us/report/${ACCOUNT_REPORT_ROW_ID}`); + }); + + test('given an account report of the same values then View full impact report opens its link', async () => { + // Given: against current law, in the reform's year (its v1 report's: 2026) + const user = await openReform([ + { ...ACCOUNT_REPORT, apiReportId: '905', budgetWindowYears: null, baselineReformId: null }, + ]); + + // When + await user.click(screen.getByRole('button', { name: /view full impact report/i })); + + // Then + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith(`/us/report/${ACCOUNT_REPORT_ROW_ID}`) + ); + expect(mockRun).not.toHaveBeenCalled(); + }); + }); }); From a87463b2a9b9bfae146bca5d375d3cb1aa780eec Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:46:53 +0400 Subject: [PATCH 42/48] Leave no signed-in work in this browser from main's per-country drafts and report years Two things main now keeps in this browser outlived a signed-in session, where the next person on a shared browser would find them: - Each country's draft (#1284) is set aside under its own key when another country's is started. Signing out, a signed-out load after a session that expired, and another account signing in cleared only the draft in hand; they now clear every draft, as they did before there was more than one. - The years asked for in a report's budget window (#1292) are kept in this browser, keyed by the report's link. A saved report's link opens the report for anyone, so those years now stay in memory for the page session instead; a link without accounts keeps them as main does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- app/src/libs/auth/sessionData.ts | 14 +++--- app/src/libs/draftReform.ts | 17 ++++++++ app/src/pages/flagship/Report.page.tsx | 18 +++++++- .../unit/libs/auth/AuthProvider.test.tsx | 40 ++++++++++++++++- .../unit/pages/flagship/ReportLinks.test.tsx | 43 ++++++++++++++++++- 5 files changed, 122 insertions(+), 10 deletions(-) diff --git a/app/src/libs/auth/sessionData.ts b/app/src/libs/auth/sessionData.ts index 0fb9a5f53..5278934cb 100644 --- a/app/src/libs/auth/sessionData.ts +++ b/app/src/libs/auth/sessionData.ts @@ -1,4 +1,4 @@ -import { clearDraftReform } from '@/libs/draftReform'; +import { clearAllDraftReforms } from '@/libs/draftReform'; import { forgetAccountReportMeta } from '@/libs/flagship/runReport'; /** @@ -7,9 +7,9 @@ import { forgetAccountReportMeta } from '@/libs/flagship/runReport'; * nor inherits it: * * - The report provenance stash from signed-in runs (kept in memory only). - * - The draft reform, which may hold an account reform loaded for editing. - * A fingerprint of the account that last had it signed in marks whose - * draft it is. + * - The draft reform, which may hold an account reform loaded for editing, + * and any draft set aside for another country. A fingerprint of the + * account that last had them signed in marks whose drafts they are. * * Signed in, v3 writes nothing else to this browser: reforms and reports * go to the account. @@ -51,7 +51,7 @@ function writeDraftOwner(owner: string | null): void { /** Signing out: everything the session left goes. */ export function clearSignedInSessionData(): void { forgetAccountReportMeta(); - clearDraftReform(); + clearAllDraftReforms(); writeDraftOwner(null); } @@ -78,14 +78,14 @@ export function settleDraftOwner(subject: string | null): void { const owner = readDraftOwner(); if (!subject) { if (owner) { - clearDraftReform(); + clearAllDraftReforms(); writeDraftOwner(null); } return; } const fingerprint = subjectFingerprint(subject); if (owner && owner !== fingerprint) { - clearDraftReform(); + clearAllDraftReforms(); } writeDraftOwner(fingerprint); } diff --git a/app/src/libs/draftReform.ts b/app/src/libs/draftReform.ts index fbe3b7c32..9d29f73e0 100644 --- a/app/src/libs/draftReform.ts +++ b/app/src/libs/draftReform.ts @@ -286,6 +286,23 @@ export function clearDraftReform(): void { writeDraft(null); } +/** + * Every draft this browser holds: the one in hand and any set aside for + * another country. A signed-in session that ends takes them all, so the + * next person on a shared browser inherits none. + */ +export function clearAllDraftReforms(): void { + try { + const setAsideKeys = Array.from({ length: localStorage.length }, (_, index) => + localStorage.key(index) + ).filter((key): key is string => !!key?.startsWith(`${STORAGE_KEY}:`)); + setAsideKeys.forEach((key) => localStorage.removeItem(key)); + } catch { + // Storage unavailable: nothing was set aside either. + } + writeDraft(null); +} + /** * The report a run from the draft started. Running hands the draft over * to its report: the report's page clears it on opening — not the run diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 2fe510356..030d76b20 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -63,6 +63,14 @@ function LeadError({ children }: { children: React.ReactNode }) { ); } +/** + * The budget-window years asked for on reports opened by a saved report's + * link, kept for this page session only: the link opens the report for + * anyone, so it is never left in this browser's storage for the next + * person here to find. + */ +const sharedLinkRequestedYears = new Map<string, string[]>(); + interface FlagshipReportPageProps { /** Passed by the Next.js route bridge; react-router falls back to params. */ userReportId?: string; @@ -245,9 +253,13 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const [view, setView] = useState<string | null>(null); // The years asked for keep running on the server after the page closes, // so the browser remembers them: back on the report, they show where they - // stand rather than as never run. + // stand rather than as never run. A saved report's link keeps them for + // this page session only (see sharedLinkRequestedYears). const yearsKey = `pe-flagship-report-years-${userReportId}`; const [requestedYears, setRequestedYearsState] = useState<string[]>(() => { + if (sharedLink) { + return sharedLinkRequestedYears.get(userReportId) ?? []; + } try { const stored = JSON.parse(localStorage.getItem(yearsKey) ?? '[]'); return Array.isArray(stored) ? stored.filter((year) => typeof year === 'string') : []; @@ -258,6 +270,10 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const setRequestedYears = (update: (current: string[]) => string[]) => setRequestedYearsState((current) => { const next = update(current); + if (sharedLink) { + sharedLinkRequestedYears.set(userReportId, next); + return next; + } try { localStorage.setItem(yearsKey, JSON.stringify(next)); } catch { diff --git a/app/src/tests/unit/libs/auth/AuthProvider.test.tsx b/app/src/tests/unit/libs/auth/AuthProvider.test.tsx index cf7879fe5..cbbdd988b 100644 --- a/app/src/tests/unit/libs/auth/AuthProvider.test.tsx +++ b/app/src/tests/unit/libs/auth/AuthProvider.test.tsx @@ -7,7 +7,13 @@ import { CountryProvider } from '@/contexts/CountryContext'; import { useAuthStatus } from '@/libs/auth/AuthContext'; import { AuthProvider, takeSignInReturnTo } from '@/libs/auth/AuthProvider'; import type { CountryId } from '@/libs/countries'; -import { clearDraftReform, getDraftReform, startDraftReform } from '@/libs/draftReform'; +import { + addDraftProvision, + clearDraftReform, + getDraftReform, + resumeDraftFor, + startDraftReform, +} from '@/libs/draftReform'; import { readReportMeta, runFlagshipReport } from '@/libs/flagship/runReport'; import { TEST_ACCESS_TOKEN } from '@/tests/fixtures/api/v2UserMocks'; import { @@ -17,6 +23,15 @@ import { TEST_PROFILE, } from '@/tests/fixtures/libs/auth/authMocks'; +/** A change in a UK draft, so the draft is kept when it is set aside. */ +const UK_DRAFT_PROVISION = { + path: 'gov.hmrc.income_tax.rates.uk[0].rate', + breadcrumb: 'HMRC → Income tax → Basic rate', + unit: '/1', + baselineValue: 0.2, + value: 0.21, +}; + const auth0 = vi.hoisted(() => ({ state: { isLoading: false, @@ -285,6 +300,25 @@ describe('AuthProvider', () => { expect(getDraftReform()).toBeNull(); }); + test("given another account signs in then the previous account's draft set aside for another country is dropped too", () => { + // Given: a UK draft set aside while a US one was started + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + const firstAccount = renderProvider(); + startDraftReform('uk', 'manual'); + addDraftProvision('uk', UK_DRAFT_PROVISION); + startDraftReform('us', 'manual'); + firstAccount.unmount(); + + // When + auth0.state.user = { sub: 'google-oauth2|someone-else' }; + renderProvider(); + + // Then + expect(resumeDraftFor('uk')).toBeNull(); + }); + test('given a signed-out draft then signing in keeps it, now as the account’s', () => { // Given stubAuthEnv(true); @@ -371,6 +405,9 @@ describe('AuthProvider', () => { create: vi.fn().mockResolvedValue({}), } as unknown as FlagshipReportStore, }); + // A UK draft set aside while the US one was started. + startDraftReform('uk', 'manual'); + addDraftProvision('uk', UK_DRAFT_PROVISION); startDraftReform('us', 'manual'); renderProvider('us'); @@ -380,6 +417,7 @@ describe('AuthProvider', () => { // Then expect(readReportMeta('56')).toBeNull(); expect(getDraftReform()).toBeNull(); + expect(resumeDraftFor('uk')).toBeNull(); expect(localStorage.getItem('pe-draft-reform-owner')).toBeNull(); expect(auth0.logout).toHaveBeenCalledWith({ logoutParams: { returnTo: `${window.location.origin}/us` }, diff --git a/app/src/tests/unit/pages/flagship/ReportLinks.test.tsx b/app/src/tests/unit/pages/flagship/ReportLinks.test.tsx index 553f59142..08c5e8224 100644 --- a/app/src/tests/unit/pages/flagship/ReportLinks.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportLinks.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@test-utils'; +import { render, screen, userEvent } from '@test-utils'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { FlagshipReportData } from '@/hooks/useFlagshipReport'; import type { AuthContextValue } from '@/libs/auth/AuthContext'; @@ -18,6 +18,7 @@ import { EXISTING_SIMULATIONS, } from '@/tests/fixtures/hooks/useFlagshipReportMocks'; import { AuthTestProvider, makeAuthValue } from '@/tests/fixtures/libs/auth/authMocks'; +import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; const mockUseFlagshipReport = vi.fn(); const mockCalcStatus = vi.fn(); @@ -65,6 +66,22 @@ const FAILED_RUN = { result: null, }; +/** A finished run: the page shows the report, with its year switcher. */ +const COMPLETE_RUN = { + status: 'complete', + isComplete: true, + isError: false, + result: createMockSocietyWideOutput(), +}; + +/** Where the report page keeps the budget-window years asked for, per link. */ +const REQUESTED_YEARS_KEY_PREFIX = 'pe-flagship-report-years-'; + +const requestedYearsKeys = () => + Array.from({ length: localStorage.length }, (_, index) => localStorage.key(index)).filter((key) => + key?.startsWith(REQUESTED_YEARS_KEY_PREFIX) + ); + const SHARED_PATH = `/v2/shared-reports/${ASSOCIATION_IDS.REPORT}`; function reportData(overrides: Partial<FlagshipReportData> = {}): FlagshipReportData { @@ -172,4 +189,28 @@ describe('FlagshipReportPage links', () => { // Then expect(screen.getByText(/old report link, which no longer opens/i)).toBeInTheDocument(); }); + + describe('given a finished report with a budget window', () => { + beforeEach(() => { + mockCalcStatus.mockReturnValue(COMPLETE_RUN); + fetchMock.mockImplementation(async (url: string) => + new URL(url).pathname === SHARED_PATH + ? itemResponse(sharedReportItem({ budget_window_years: 3 })) + : errorResponse(404, 'not found') + ); + }); + + test("given a saved report's link then the years asked for stay out of this browser's storage", async () => { + // Given + const user = userEvent.setup(); + renderPage(ASSOCIATION_IDS.REPORT); + + // When: another year of the window is asked for + await user.click(await screen.findByRole('tab', { name: /^2027,/ })); + + // Then: the link, which opens the report for anyone, is not left behind + expect(screen.getByRole('tab', { name: /^2027,/ })).toHaveAttribute('aria-selected', 'true'); + expect(requestedYearsKeys()).toEqual([]); + }); + }); }); From 2e439483644dc39ca8d37daad36d3b13ed3f0d0a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:22:04 +0400 Subject: [PATCH 43/48] Keep a report's comparison when its record no longer names the reform An account report whose compared reform was deleted (the Public API sets its baseline_user_policy_id to null), or a recompute of someone else's report, has no saved-reform link. Re-run then compared with current law, 'View full impact report' could open it as the reform's own report, and Your reforms dropped 'Compared with'. The report's baseline simulation still shows the policy it ran: when that is not current law, re-run keeps it, the match skips the report, and the row reads 'Compared with a saved reform'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- app/src/components/flagship/ReformReports.tsx | 44 +++++++++++++++++-- app/src/libs/flagship/latestReport.ts | 26 ++++++++--- app/src/libs/flagship/reportComparison.ts | 19 ++++++++ app/src/libs/flagship/reportRerun.ts | 20 ++++++--- app/src/libs/flagship/runReport.ts | 3 ++ app/src/pages/flagship/Reforms.page.tsx | 39 ++++++++++++++-- .../flagship/ReformReports.test.tsx | 34 ++++++++++++++ .../unit/libs/flagship/latestReport.test.ts | 17 +++++++ .../unit/libs/flagship/reportRerun.test.ts | 43 ++++++++++++++++++ 9 files changed, 224 insertions(+), 21 deletions(-) create mode 100644 app/src/libs/flagship/reportComparison.ts diff --git a/app/src/components/flagship/ReformReports.tsx b/app/src/components/flagship/ReformReports.tsx index 1e9ded37b..5e36c3321 100644 --- a/app/src/components/flagship/ReformReports.tsx +++ b/app/src/components/flagship/ReformReports.tsx @@ -4,10 +4,12 @@ import { useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import { reportLink, type FlagshipReportRecord } from '@/api/flagshipReportStore'; import { fetchReportById } from '@/api/report'; +import { fetchSimulationById } from '@/api/simulation'; import { Badge, Button, Stack, Text } from '@/components/ui'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; import type { CountryId } from '@/libs/countries'; +import { ranAgainstReform, UNNAMED_COMPARISON } from '@/libs/flagship/reportComparison'; import { flagshipReportQueryKeys, readReportMeta } from '@/libs/flagship/runReport'; import type { RootState } from '@/store'; @@ -69,22 +71,51 @@ export function LatestReportBadge({ return <Badge variant="outline">In progress</Badge>; } +/** + * The policy a report's baseline simulation ran, read only when asked + * (`simulationId` set): whether the report was compared with a reform its + * record no longer names. + */ +function useBaselinePolicyId(countryId: CountryId, simulationId: string | undefined) { + return useQuery({ + queryKey: flagshipReportQueryKeys.baselineSimulation(countryId, simulationId ?? ''), + queryFn: () => fetchSimulationById(countryId, simulationId!), + enabled: !!simulationId, + staleTime: Infinity, + retry: false, + }).data?.policy_id; +} + /** * What a report compared the reform with, when not current law: the same * reform against a saved reform is another report, and reads as one. The * run's details name it in the session that ran it; otherwise an account - * record links the saved reform, named from this session's reforms. + * record links the saved reform, named from this session's reforms. A + * record that no longer links one (the reform was deleted, or the report + * recomputed someone else's) shows its comparison by its baseline policy. */ function ReportBaseline({ + countryId, report, + baselineSimulationId, reformName, }: { + countryId: CountryId; report: FlagshipReportRecord; + baselineSimulationId: string | undefined; reformName?: (reformId: string) => string | null | undefined; }) { - const label = + const namedLabel = readReportMeta(reportLink(report))?.baseline?.label ?? - (report.baselineReformId ? reformName?.(report.baselineReformId) || 'a saved reform' : null); + (report.baselineReformId ? reformName?.(report.baselineReformId) || UNNAMED_COMPARISON : null); + const currentLawPolicyId = useSelector((state: RootState) => state.metadata.currentLawId); + const baselinePolicyId = useBaselinePolicyId( + countryId, + namedLabel ? undefined : baselineSimulationId + ); + const label = + namedLabel ?? + (ranAgainstReform(baselinePolicyId, currentLawPolicyId) ? UNNAMED_COMPARISON : null); if (!label) { return null; } @@ -162,7 +193,12 @@ function ReportRow({ ? `Results from ${dayjs(finished).format('MMM D, YYYY')}` : `Run ${dayjs(report.createdAt).format('MMM D, YYYY')}`} </span> - <ReportBaseline report={report} reformName={reformName} /> + <ReportBaseline + countryId={countryId} + report={report} + baselineSimulationId={api?.simulation_1_id} + reformName={reformName} + /> </div> <span> <ReportStatus status={api?.status} /> diff --git a/app/src/libs/flagship/latestReport.ts b/app/src/libs/flagship/latestReport.ts index cc54aa187..cb35440de 100644 --- a/app/src/libs/flagship/latestReport.ts +++ b/app/src/libs/flagship/latestReport.ts @@ -1,4 +1,5 @@ import { reportLink, type FlagshipReportRecord } from '@/api/flagshipReportStore'; +import { ranAgainstReform } from '@/libs/flagship/reportComparison'; import { readReportMeta } from '@/libs/flagship/runReport'; import type { Reform } from '@/types/ingredients/Reform'; @@ -14,14 +15,24 @@ function valueKey( )}`; } +/** A report with the policy its baseline simulation ran, when the API's record was read. */ +export type ReportWithBaseline = FlagshipReportRecord & { baselinePolicyId?: string }; + /** * Whether a report was compared with something other than current law: * the run's details say so in the session that ran it (kept under the - * report's link), and the record links the saved reform it was compared - * with. + * report's link), the record links the saved reform it was compared with, + * or its baseline simulation ran a policy other than current law. */ -function comparedWithSavedReform(report: FlagshipReportRecord): boolean { - return !!readReportMeta(reportLink(report))?.baseline || !!report.baselineReformId; +function comparedWithSavedReform( + report: ReportWithBaseline, + currentLawPolicyId: number | undefined +): boolean { + return ( + !!readReportMeta(reportLink(report))?.baseline || + !!report.baselineReformId || + ranAgainstReform(report.baselinePolicyId, currentLawPolicyId) === true + ); } /** @@ -34,15 +45,16 @@ function comparedWithSavedReform(report: FlagshipReportRecord): boolean { */ export function latestMatchingReport( reform: Reform, - reports: FlagshipReportRecord[], - year: number + reports: ReportWithBaseline[], + year: number, + currentLawPolicyId?: number ): FlagshipReportRecord | null { const reformKey = reform.parameters .map((parameter) => valueKey(parameter.name, parameter.values)) .sort() .join('|'); const matching = reports.filter((report) => { - if (report.year !== String(year) || comparedWithSavedReform(report)) { + if (report.year !== String(year) || comparedWithSavedReform(report, currentLawPolicyId)) { return false; } const reportKey = report.provisions diff --git a/app/src/libs/flagship/reportComparison.ts b/app/src/libs/flagship/reportComparison.ts new file mode 100644 index 000000000..e2c96326a --- /dev/null +++ b/app/src/libs/flagship/reportComparison.ts @@ -0,0 +1,19 @@ +/** + * Whether a report was compared with something other than current law, by + * the policy its baseline simulation ran. That stays true when its record + * no longer names the saved reform it was compared with: the reform was + * deleted, or the report is someone else's, recomputed. Undefined while + * either policy is unknown. + */ +export function ranAgainstReform( + baselinePolicyId: string | number | null | undefined, + currentLawPolicyId: string | number | null | undefined +): boolean | undefined { + if (baselinePolicyId == null || baselinePolicyId === '' || currentLawPolicyId == null) { + return undefined; + } + return String(baselinePolicyId) !== String(currentLawPolicyId); +} + +/** How a report compared with a reform it can no longer name reads: "Compared with a saved reform". */ +export const UNNAMED_COMPARISON = 'a saved reform'; diff --git a/app/src/libs/flagship/reportRerun.ts b/app/src/libs/flagship/reportRerun.ts index 706143eab..f5615a869 100644 --- a/app/src/libs/flagship/reportRerun.ts +++ b/app/src/libs/flagship/reportRerun.ts @@ -2,6 +2,7 @@ import { reportLink, type FlagshipReportRecord } from '@/api/flagshipReportStore import { fetchReportById } from '@/api/report'; import { fetchSimulationById } from '@/api/simulation'; import type { CountryId } from '@/libs/countries'; +import { ranAgainstReform, UNNAMED_COMPARISON } from '@/libs/flagship/reportComparison'; import { readReportMeta, type RunReportBaseline } from '@/libs/flagship/runReport'; import type { Reform } from '@/types/ingredients/Reform'; @@ -18,6 +19,8 @@ export interface RerunSettings { export interface RerunSettingsOptions { /** This session's saved reform by its ID, to name the reform a report was compared with. */ savedReform?: (reformId: string) => Reform | undefined; + /** The current-law policy, to tell a report compared with a reform from one against current law. */ + currentLawPolicyId?: number; } /** @@ -35,7 +38,7 @@ export interface RerunSettingsOptions { export async function rerunSettings( countryId: CountryId, report: FlagshipReportRecord, - { savedReform }: RerunSettingsOptions = {} + { savedReform, currentLawPolicyId }: RerunSettingsOptions = {} ): Promise<RerunSettings> { const meta = readReportMeta(reportLink(report)); let year = report.year; @@ -63,23 +66,28 @@ export async function rerunSettings( label: meta.baseline.label, policyId: Number(meta.baseline.policyId), } - : savedBaseline(report, baselinePolicyId, savedReform), + : savedBaseline(report, baselinePolicyId, savedReform, currentLawPolicyId), }; } /** * The saved reform a report's record says it was compared with, as the * baseline to run again: the policy the report ran as its baseline, or, - * when that can't be read, the saved reform's own policy. None for a - * report compared with current law. + * when that can't be read, the saved reform's own policy. A record that no + * longer names one (the reform was deleted, or the report recomputed + * someone else's) keeps the comparison its baseline policy shows. None for + * a report compared with current law. */ function savedBaseline( report: FlagshipReportRecord, baselinePolicyId: number | undefined, - savedReform: RerunSettingsOptions['savedReform'] + savedReform: RerunSettingsOptions['savedReform'], + currentLawPolicyId: number | undefined ): RunReportBaseline | undefined { if (!report.baselineReformId) { - return undefined; + return baselinePolicyId && ranAgainstReform(baselinePolicyId, currentLawPolicyId) + ? { reformId: null, label: UNNAMED_COMPARISON, policyId: baselinePolicyId } + : undefined; } const reform = savedReform?.(report.baselineReformId); const policyId = baselinePolicyId ?? (Number(reform?.policyId) || undefined); diff --git a/app/src/libs/flagship/runReport.ts b/app/src/libs/flagship/runReport.ts index 798b498ff..efc417024 100644 --- a/app/src/libs/flagship/runReport.ts +++ b/app/src/libs/flagship/runReport.ts @@ -399,4 +399,7 @@ export const flagshipReportQueryKeys = { statuses: ['flagship-report-status'] as const, status: (countryId: string, apiReportId: string) => ['flagship-report-status', countryId, apiReportId] as const, + /** A report's baseline simulation, as the API's metadata (not the adapted Simulation). */ + baselineSimulation: (countryId: string, simulationId: string) => + ['flagship-report-baseline-simulation', countryId, simulationId] as const, }; diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index ac36d791b..c83ce82c4 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -14,6 +14,7 @@ import { useSelector } from 'react-redux'; import { billRunYear, TrackedBill } from '@/api/billFeed'; import { reportLink, type FlagshipReportRecord } from '@/api/flagshipReportStore'; import { fetchReportById } from '@/api/report'; +import { fetchSimulationById } from '@/api/simulation'; import BillFeedFilters from '@/components/flagship/bills/BillFeedFilters'; import BillStageBadge from '@/components/flagship/bills/BillStageBadge'; import DeleteReformDialog from '@/components/flagship/DeleteReformDialog'; @@ -297,6 +298,8 @@ export default function ReformsPage() { const countryId = useCurrentCountry(); const queryClient = useQueryClient(); const parameters = useSelector((state: RootState) => state.metadata.parameters); + const currentLawPolicyId = + Number(useSelector((state: RootState) => state.metadata.currentLawId)) || undefined; const appLocation = useAppLocation(); const urlParams = useMemo(() => new URLSearchParams(appLocation.search), [appLocation.search]); @@ -407,6 +410,27 @@ export default function ReformsPage() { return undefined; } }; + // The policy a report's baseline simulation ran: current law, or the + // reform it was compared with, even when its record no longer names it. + const reportBaselinePolicyId = async ( + report: FlagshipReportRecord + ): Promise<string | undefined> => { + try { + const apiReport = await queryClient.fetchQuery({ + queryKey: flagshipReportQueryKeys.status(countryId, report.apiReportId), + queryFn: () => fetchReportById(countryId, report.apiReportId), + staleTime: 60_000, + }); + const simulation = await queryClient.fetchQuery({ + queryKey: flagshipReportQueryKeys.baselineSimulation(countryId, apiReport.simulation_1_id), + queryFn: () => fetchSimulationById(countryId, apiReport.simulation_1_id), + staleTime: Infinity, + }); + return simulation.policy_id ? String(simulation.policy_id) : undefined; + } catch { + return undefined; + } + }; // As on the bill's report: stored parameters that contradict the bill's // write-up (UT SB60's reform and baseline are swapped) would score @@ -875,9 +899,15 @@ export default function ReformsPage() { reportsFor(selectedReform.id).map(async (report) => ({ ...report, year: await reportYear(report), + baselinePolicyId: await reportBaselinePolicyId(report), })) ); - const latest = latestMatchingReport(selectedReform, reformReports, runYear); + const latest = latestMatchingReport( + selectedReform, + reformReports, + runYear, + currentLawPolicyId + ); if (latest) { nav.push(`/${countryId}/report/${reportLink(latest)}`); return; @@ -942,9 +972,10 @@ export default function ReformsPage() { // The same report on today's model: its reform as it ran, its // year, population, budget window and comparison, linked to the // same saved reform. - const settings = await rerunSettings(countryId, report, { savedReform }).catch( - () => null - ); + const settings = await rerunSettings(countryId, report, { + savedReform, + currentLawPolicyId, + }).catch(() => null); if (!settings) { setRerunError('Could not re-run the report. Try again.'); return; diff --git a/app/src/tests/unit/components/flagship/ReformReports.test.tsx b/app/src/tests/unit/components/flagship/ReformReports.test.tsx index cadf72714..66fe6556f 100644 --- a/app/src/tests/unit/components/flagship/ReformReports.test.tsx +++ b/app/src/tests/unit/components/flagship/ReformReports.test.tsx @@ -17,6 +17,15 @@ vi.mock('@/api/report', () => ({ finished_at: '2026-10-14T15:12:00Z', // Report 60 ran on the model the app has loaded ("test", from the seed). output: id === '60' ? JSON.stringify({ model_version: 'test' }) : null, + simulation_1_id: `sim-${id}`, + })), +})); + +vi.mock('@/api/simulation', () => ({ + // Report 63's baseline ran a reform (policy 88); every other report's ran + // current law (2, from the seed). + fetchSimulationById: vi.fn(async (_country: string, simulationId: string) => ({ + policy_id: simulationId === 'sim-63' ? '88' : '2', })), })); @@ -196,3 +205,28 @@ describe('ReformReports', () => { store.dispatch(clearMetadata()); }); }); + +describe('ReformReports comparisons without a link', () => { + beforeEach(() => { + localStorage.clear(); + store.dispatch(clearMetadata()); + seedDeductionMetadata(); + }); + + test('given a report whose compared reform is gone then it still reads as compared', async () => { + // Given: no run details and no link, but its baseline ran another reform + renderReports([record({ id: 'rec-63', apiReportId: '63', baselineReformId: null })]); + + // Then + expect(await screen.findByText('Compared with a saved reform')).toBeInTheDocument(); + }); + + test('given a report whose baseline ran current law then no comparison shows', async () => { + // Given + renderReports([record({ id: 'rec-64', apiReportId: '64', baselineReformId: null })]); + + // Then + expect(await screen.findByText('2026 report')).toBeInTheDocument(); + expect(screen.queryByText(/Compared with/)).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/libs/flagship/latestReport.test.ts b/app/src/tests/unit/libs/flagship/latestReport.test.ts index aeac25771..7ee719829 100644 --- a/app/src/tests/unit/libs/flagship/latestReport.test.ts +++ b/app/src/tests/unit/libs/flagship/latestReport.test.ts @@ -84,4 +84,21 @@ describe('latestMatchingReport', () => { // Then expect(latestMatchingReport(REFORM, [accountReport], 2026)?.id).toBe(ACCOUNT_REPORT_ID); }); + + test('given a report whose baseline ran another reform then it does not open, linked or not', () => { + // Given: no run details and no link, but its baseline was not current law + const unlinked = { + ...report({ id: ACCOUNT_REPORT_ID, baselineReformId: null }), + baselinePolicyId: '88', + }; + + // When / Then + expect(latestMatchingReport(REFORM, [unlinked], 2026, 2)).toBeNull(); + }); + + test('given a report whose baseline ran current law then it opens', () => { + const againstCurrentLaw = { ...report({ id: ACCOUNT_REPORT_ID }), baselinePolicyId: '2' }; + + expect(latestMatchingReport(REFORM, [againstCurrentLaw], 2026, 2)?.id).toBe(ACCOUNT_REPORT_ID); + }); }); diff --git a/app/src/tests/unit/libs/flagship/reportRerun.test.ts b/app/src/tests/unit/libs/flagship/reportRerun.test.ts index ec1194ae5..4e37284be 100644 --- a/app/src/tests/unit/libs/flagship/reportRerun.test.ts +++ b/app/src/tests/unit/libs/flagship/reportRerun.test.ts @@ -98,6 +98,49 @@ describe('rerunSettings', () => { expect((await rerunSettings('us', REPORT)).region).toBeUndefined(); }); + describe('given an account report that no longer links the reform it was compared with', () => { + // The compared reform was deleted (its link went null), or the report + // recomputed someone else's: the row names no saved reform. + const UNLINKED_REPORT: FlagshipReportRecord = { ...ACCOUNT_REPORT, baselineReformId: null }; + + beforeEach(() => { + fetchReportById.mockResolvedValue({ simulation_1_id: '9', year: '2028' }); + }); + + test('then it keeps the comparison its baseline policy shows', async () => { + // Given: the report's baseline ran policy 88, not current law (2) + fetchSimulationById.mockResolvedValue({ + population_type: 'geography', + population_id: 'us', + policy_id: '88', + }); + + // When + const settings = await rerunSettings('us', UNLINKED_REPORT, { currentLawPolicyId: 2 }); + + // Then + expect(settings.baseline).toEqual({ + reformId: null, + label: 'a saved reform', + policyId: 88, + }); + }); + + test('then a report against current law still runs against current law', async () => { + // Given + fetchSimulationById.mockResolvedValue({ + population_type: 'geography', + population_id: 'us', + policy_id: '2', + }); + + // When / Then + expect( + (await rerunSettings('us', UNLINKED_REPORT, { currentLawPolicyId: 2 })).baseline + ).toBeUndefined(); + }); + }); + describe('given an account report', () => { beforeEach(() => { fetchReportById.mockResolvedValue({ simulation_1_id: '9', year: '2028' }); From f53a75251a98cc7cb460711b2c560f40cda7bf38 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:22:04 +0400 Subject: [PATCH 44/48] Run the draft as it was saved, whatever is edited during the save 'Save and run' read the draft from the latest render when the save answered (TanStack Query v5 updates a pending mutation's options), so an edit made while saving ran values the linked saved reform did not hold. The draft and its comparison are now fixed when the button is pressed and passed as the mutation's variables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../flagship/reform/DraftHeader.tsx | 60 +++++++++++++------ .../flagship/reform/DraftHeader.test.tsx | 34 +++++++++++ 2 files changed, 75 insertions(+), 19 deletions(-) diff --git a/app/src/components/flagship/reform/DraftHeader.tsx b/app/src/components/flagship/reform/DraftHeader.tsx index 33304baf9..ee22ffef2 100644 --- a/app/src/components/flagship/reform/DraftHeader.tsx +++ b/app/src/components/flagship/reform/DraftHeader.tsx @@ -37,6 +37,7 @@ import { } from '@/libs/draftReform'; import { UNTITLED_REFORM } from '@/libs/flagship/draftLabels'; import { findMatchingReform } from '@/libs/flagship/reformMatch'; +import type { RunReportBaseline } from '@/libs/flagship/runReport'; import { getTaxYears } from '@/libs/metadataUtils'; import { RootState } from '@/store'; import type { Reform } from '@/types/ingredients/Reform'; @@ -267,6 +268,12 @@ export default function DraftHeader({ draft }: { draft: DraftReform }) { * while the reform is longer than the screen, so the run is never a * scroll away, and settles in place under the table once the table ends. */ +/** What "Save and run" saves and runs, fixed when the button is pressed. */ +interface DraftRunRequest { + runDraft: DraftReform; + runBaseline: RunReportBaseline | undefined; +} + export function DraftRunBar({ draft }: { draft: DraftReform }) { const nav = useAppNavigate(); const queryClient = useQueryClient(); @@ -292,11 +299,11 @@ export function DraftRunBar({ draft }: { draft: DraftReform }) { // Saving changes the reform the draft came from, or saves a new one. // When that reform is no longer in this store (deleted, or another // user's), the draft is saved as a new reform instead. - const saveReform = async (): Promise<Reform> => { - const payload = draftToReform(draft, currentLawValues); - if (draft.editingReformId) { + const saveReform = async (draftToSave: DraftReform): Promise<Reform> => { + const payload = draftToReform(draftToSave, currentLawValues); + if (draftToSave.editingReformId) { try { - return await reformStore.update(draft.editingReformId, { + return await reformStore.update(draftToSave.editingReformId, { label: payload.label, parameters: payload.parameters, provenance: payload.provenance, @@ -314,22 +321,24 @@ export function DraftRunBar({ draft }: { draft: DraftReform }) { // reform it edits, or reusing a saved one that sets the same values), // so the report links to it and both can be found again. A reused // reform stays as it is, its name too. A save that fails runs nothing. - const saveForRun = async (): Promise<{ reform: Reform; reused: boolean }> => { - if (!draft.editingReformId) { - const payload = draftToReform(draft, currentLawValues); + const saveForRun = async ( + runDraft: DraftReform + ): Promise<{ reform: Reform; reused: boolean }> => { + if (!runDraft.editingReformId) { + const payload = draftToReform(runDraft, currentLawValues); const match = findMatchingReform( - await reformStore.findMine(draft.countryId), + await reformStore.findMine(runDraft.countryId), payload.parameters ); if (match?.id) { return { reform: match, reused: true }; } } - return { reform: await saveReform(), reused: false }; + return { reform: await saveReform(runDraft), reused: false }; }; const saveMutation = useMutation({ - mutationFn: saveReform, + mutationFn: () => saveReform(draft), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['reforms'] }); clearDraftReform(); @@ -338,22 +347,30 @@ export function DraftRunBar({ draft }: { draft: DraftReform }) { }); // A reform the run saved becomes the one the draft edits, so trying - // again after a failed run changes it rather than saving another. + // again after a failed run changes it rather than saving another. The + // run is the draft as it stood when "Save and run" was pressed, the one + // that was saved, whatever is edited while the save is in flight. const saveAndRunMutation = useMutation({ - mutationFn: saveForRun, - onSuccess: ({ reform: saved, reused }) => { + mutationFn: ({ runDraft }: DraftRunRequest) => saveForRun(runDraft), + onSuccess: ({ reform: saved, reused }, { runDraft, runBaseline }) => { queryClient.invalidateQueries({ queryKey: ['reforms'] }); if (!reused) { setDraftEditingReform(saved.id!); } + const runYear = draftYear(runDraft); void runReport.run( - draft.label.trim() || UNTITLED_REFORM, - SOURCE_NOTES[draft.source] ?? 'Draft reform', + runDraft.label.trim() || UNTITLED_REFORM, + SOURCE_NOTES[runDraft.source] ?? 'Draft reform', // Described as they stand in the year the report simulates. - provisionsToRun(draft.provisions, year, parameters), + provisionsToRun(runDraft.provisions, runYear, parameters), saved.id, - year, - { fromDraft: true, region, windowYears: draft.windowYears, baseline: baseline.forRun } + runYear, + { + fromDraft: true, + region: runDraft.population?.region, + windowYears: runDraft.windowYears, + baseline: runBaseline, + } ); }, }); @@ -440,7 +457,12 @@ export function DraftRunBar({ draft }: { draft: DraftReform }) { {draft.editingReformId ? 'Save changes' : 'Save'} </Button> <Button - onClick={() => saveAndRunMutation.mutate()} + onClick={() => + saveAndRunMutation.mutate({ + runDraft: structuredClone(draft), + runBaseline: baseline.forRun, + }) + } disabled={ adjusted.length === 0 || starting || diff --git a/app/src/tests/unit/components/flagship/reform/DraftHeader.test.tsx b/app/src/tests/unit/components/flagship/reform/DraftHeader.test.tsx index 1d04e53b6..90eb7d8a9 100644 --- a/app/src/tests/unit/components/flagship/reform/DraftHeader.test.tsx +++ b/app/src/tests/unit/components/flagship/reform/DraftHeader.test.tsx @@ -230,6 +230,40 @@ describe('DraftHeader', () => { expect(mockRun.mock.calls[0][0]).toBe('Untitled reform'); }); + test('given an edit while the save is in flight then the run is what was saved', async () => { + // Given: the save waits until released + const user = userEvent.setup(); + addDraftProvision('us', CHANGED); + let releaseSave!: (reform: Reform) => void; + mockCreate.mockReturnValue( + new Promise<Reform>((resolve) => { + releaseSave = resolve; + }) + ); + renderHeader(); + + // When: the value and name change before the save answers + await user.click(screen.getByRole('button', { name: /run 2026 report/i })); + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + act(() => { + updateDraftProvisionValue(CHANGED.path, 1200); + setDraftLabel('Edited after pressing run'); + }); + await act(async () => releaseSave({ id: 'rf-new' } as Reform)); + + // Then: the run matches the saved reform it links to + await waitFor(() => expect(mockRun).toHaveBeenCalledTimes(1)); + const savedValues = JSON.stringify(mockCreate.mock.calls[0][0].parameters); + expect(savedValues).toContain('900'); + expect(savedValues).not.toContain('1200'); + const ranProvision = mockRun.mock.calls[0][2].find( + (provision: { path: string }) => provision.path === CHANGED.path + ); + expect(ranProvision.value).toBe(900); + expect(mockRun.mock.calls[0][0]).toBe('Untitled reform'); + expect(mockRun.mock.calls[0][3]).toBe('rf-new'); + }); + test('then the draft is saved to Your reforms and the report links to it', async () => { const user = userEvent.setup(); addDraftProvision('us', CHANGED); From b8a2fc1157035ae53f82e6ff5ca3bc627fd60edf Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:22:04 +0400 Subject: [PATCH 45/48] Make a browser user only where it is sent: signed out in the US and UK With sign-in off, and outside the US and UK, every v3 page wrote a pe-browser-user-id it never sent. It is now made only for a signed-out session in the US or UK; otherwise the client sends no credential. Where crypto.randomUUID is missing (an insecure context, older Safari) the ID is built from crypto.getRandomValues instead of throwing during render, and a stored value the Public API would refuse is replaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- app/src/hooks/useFlagshipStores.ts | 4 ++- app/src/libs/auth/browserUser.ts | 25 +++++++++++++-- app/src/libs/auth/useV2UserClient.ts | 22 +++++++++---- .../unit/hooks/useFlagshipStores.test.tsx | 16 ++++++++++ .../tests/unit/libs/auth/browserUser.test.ts | 31 +++++++++++++++++++ 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/app/src/hooks/useFlagshipStores.ts b/app/src/hooks/useFlagshipStores.ts index a318baad4..fecc89b22 100644 --- a/app/src/hooks/useFlagshipStores.ts +++ b/app/src/hooks/useFlagshipStores.ts @@ -44,7 +44,9 @@ export function useV3SavedWork(): V3SavedWork { const { status, subject } = useAuthStatus(); const countryId = useCountryContext(); const client = useV2UserClient(); - const browserUserId = status === 'signed_out' ? getBrowserUserId() : null; + // Made only where it is sent: signed out in the US or UK. + const browserUserId = + status === 'signed_out' && isAssociationCountry(countryId) ? getBrowserUserId() : null; return useMemo(() => { if (!isAssociationCountry(countryId)) { return { account: null, scope: 'browser' }; diff --git a/app/src/libs/auth/browserUser.ts b/app/src/libs/auth/browserUser.ts index 0b7841007..2e5ed3b8b 100644 --- a/app/src/libs/auth/browserUser.ts +++ b/app/src/libs/auth/browserUser.ts @@ -12,11 +12,26 @@ const BROWSER_USER_KEY = 'pe-browser-user-id'; +/** The form the API accepts for this user: a lowercase, hyphenated UUID. */ +const CANONICAL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + /** Used only when localStorage is unavailable: lasts for this page. */ let pageOnlyUserId: string | null = null; +/** + * A random (version 4) UUID. `crypto.randomUUID` needs a secure context + * and a recent browser; `crypto.getRandomValues` works everywhere, so it + * builds one when `randomUUID` is missing. + */ function newUserId(): string { - return crypto.randomUUID(); + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } function storeUserId(userId: string): void { @@ -28,11 +43,15 @@ function storeUserId(userId: string): void { } } -/** This browser's unauthenticated user ID, made on first use. */ +/** + * This browser's unauthenticated user ID, made on first use. A stored value + * the API would refuse (damaged, or edited by hand) is replaced, rather than + * failing every signed-out request. + */ export function getBrowserUserId(): string { try { const stored = localStorage.getItem(BROWSER_USER_KEY); - if (stored) { + if (stored && CANONICAL_UUID.test(stored)) { return stored; } } catch { diff --git a/app/src/libs/auth/useV2UserClient.ts b/app/src/libs/auth/useV2UserClient.ts index 1b27e93c1..cee829799 100644 --- a/app/src/libs/auth/useV2UserClient.ts +++ b/app/src/libs/auth/useV2UserClient.ts @@ -1,22 +1,32 @@ import { useMemo } from 'react'; import { createV2UserClient, type V2UserClient } from '@/api/v2UserClient'; +import { useCountryContext } from '@/contexts/CountryContext'; +import { isAssociationCountry } from '@/types/v2User'; import { useAuthStatus } from './AuthContext'; import { getBrowserUserId } from './browserUser'; /** * A PolicyEngine API client for this session: signed in, it sends the - * session's access token; otherwise this browser's unauthenticated user. + * session's access token; signed out in the US or UK, this browser's + * unauthenticated user. With sign-in off, and outside the US and UK, + * nothing is saved to the API, so no browser user is made and the client + * sends no credential. */ export function useV2UserClient(): V2UserClient { const { status, getAccessToken } = useAuthStatus(); - const browserUserId = status === 'signed_in' ? null : getBrowserUserId(); + const countryId = useCountryContext(); + const browserUserId = + status === 'signed_out' && isAssociationCountry(countryId) ? getBrowserUserId() : null; return useMemo( () => createV2UserClient({ - credentials: browserUserId - ? { kind: 'unauthenticated', userId: browserUserId } - : { kind: 'session', getAccessToken }, + credentials: + status === 'signed_in' + ? { kind: 'session', getAccessToken } + : browserUserId + ? { kind: 'unauthenticated', userId: browserUserId } + : { kind: 'none' }, }), - [browserUserId, getAccessToken] + [status, browserUserId, getAccessToken] ); } diff --git a/app/src/tests/unit/hooks/useFlagshipStores.test.tsx b/app/src/tests/unit/hooks/useFlagshipStores.test.tsx index 00d0689e1..c539377d1 100644 --- a/app/src/tests/unit/hooks/useFlagshipStores.test.tsx +++ b/app/src/tests/unit/hooks/useFlagshipStores.test.tsx @@ -138,6 +138,22 @@ describe('v3 store choice', () => { expect(reports.store).toBeInstanceOf(LocalStorageFlagshipReportStore); expect(reports.scope).toBe('browser'); }); + + test.each([ + ['sign-in off', makeAuthValue({ status: 'disabled' }), 'us'], + ['signed out outside the US and UK', makeAuthValue(), 'ca'], + ] as const)('given %s then no browser user ID is made', (_, auth, countryId) => { + // Given + localStorage.clear(); + const wrapper = wrapperFor(auth, countryId); + + // When + renderHook(() => useFlagshipReportStore(), { wrapper }); + renderHook(() => useReformStore(), { wrapper }); + + // Then: nothing is saved to the API, so nothing identifies this browser + expect(localStorage.getItem('pe-browser-user-id')).toBeNull(); + }); }); describe('useReformStore', () => { diff --git a/app/src/tests/unit/libs/auth/browserUser.test.ts b/app/src/tests/unit/libs/auth/browserUser.test.ts index 5791ba4a8..20b663324 100644 --- a/app/src/tests/unit/libs/auth/browserUser.test.ts +++ b/app/src/tests/unit/libs/auth/browserUser.test.ts @@ -51,4 +51,35 @@ describe('browserUser', () => { expect(first).toMatch(UUID_PATTERN); expect(getBrowserUserId()).toBe(first); }); + + test('given a browser without crypto.randomUUID then it still gets a random user ID', () => { + // Given: randomUUID needs a secure context; getRandomValues does not + const realCrypto = globalThis.crypto; + vi.stubGlobal('crypto', { + getRandomValues: (bytes: Uint8Array) => realCrypto.getRandomValues(bytes), + }); + + try { + // When + const userId = getBrowserUserId(); + + // Then + expect(userId).toMatch(UUID_PATTERN); + expect(getBrowserUserId()).toBe(userId); + } finally { + vi.unstubAllGlobals(); + } + }); + + test('given a damaged stored ID then the browser gets a new one the API accepts', () => { + // Given + localStorage.setItem('pe-browser-user-id', 'not-a-uuid'); + + // When + const userId = getBrowserUserId(); + + // Then + expect(userId).toMatch(UUID_PATTERN); + expect(localStorage.getItem('pe-browser-user-id')).toBe(userId); + }); }); From e4a6de6600a03872f1dcf971bbd7f62c58bc86a2 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:22:04 +0400 Subject: [PATCH 46/48] Hold an account's drafts when a load cannot restore its session A page that loaded signed out deleted every draft an account had left, but a restore that only failed (a timeout, a lost connection, Safari's cross-site limits) signs the account back in on the next load. The drafts are now held out of sight for that account instead: they come back when it signs in again, and go when another account signs in, on sign-out, or after seven days. A signed-out session still never sees them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- app/src/libs/auth/sessionData.ts | 26 +++-- app/src/libs/draftReform.ts | 95 +++++++++++++++ .../unit/libs/auth/AuthProvider.test.tsx | 55 ++++++++- .../tests/unit/libs/draftReformHeld.test.ts | 108 ++++++++++++++++++ 4 files changed, 276 insertions(+), 8 deletions(-) create mode 100644 app/src/tests/unit/libs/draftReformHeld.test.ts diff --git a/app/src/libs/auth/sessionData.ts b/app/src/libs/auth/sessionData.ts index 5278934cb..a0c92cecc 100644 --- a/app/src/libs/auth/sessionData.ts +++ b/app/src/libs/auth/sessionData.ts @@ -1,4 +1,9 @@ -import { clearAllDraftReforms } from '@/libs/draftReform'; +import { + clearAllDraftReforms, + dropHeldDraftReforms, + holdAllDraftReforms, + restoreHeldDraftReforms, +} from '@/libs/draftReform'; import { forgetAccountReportMeta } from '@/libs/flagship/runReport'; /** @@ -48,10 +53,11 @@ function writeDraftOwner(owner: string | null): void { } } -/** Signing out: everything the session left goes. */ +/** Signing out: everything the session left goes, held drafts too. */ export function clearSignedInSessionData(): void { forgetAccountReportMeta(); clearAllDraftReforms(); + dropHeldDraftReforms(); writeDraftOwner(null); } @@ -69,17 +75,22 @@ export function endSignedInSession(): void { * Settles whose draft this browser holds once the session is known: * after it is restored on load, and whenever someone signs in. * - * - Signed in: a draft left by another account is dropped; the draft, if - * any, is now this account's (a signed-out draft comes along with it). - * - Signed out on load: a draft left by a signed-in session that was - * never signed out (it expired between visits) is dropped. + * - Signed in: a draft left by another account is dropped; drafts held for + * this account come back; the draft, if any, is now this account's (a + * signed-out draft comes along with it). + * - Signed out on load: drafts left by a signed-in session are held out of + * sight for that account, not deleted. Its session may have expired + * between visits, or only failed to restore (a timeout, a lost + * connection), in which case the next load signs it back in. */ export function settleDraftOwner(subject: string | null): void { const owner = readDraftOwner(); if (!subject) { if (owner) { - clearAllDraftReforms(); + holdAllDraftReforms(owner); writeDraftOwner(null); + } else { + dropHeldDraftReforms({ expiredOnly: true }); } return; } @@ -87,5 +98,6 @@ export function settleDraftOwner(subject: string | null): void { if (owner && owner !== fingerprint) { clearAllDraftReforms(); } + restoreHeldDraftReforms(fingerprint); writeDraftOwner(fingerprint); } diff --git a/app/src/libs/draftReform.ts b/app/src/libs/draftReform.ts index 9d29f73e0..8106f1de5 100644 --- a/app/src/libs/draftReform.ts +++ b/app/src/libs/draftReform.ts @@ -303,6 +303,101 @@ export function clearAllDraftReforms(): void { writeDraft(null); } +/** + * Drafts held out of sight for the account that had them, when a page + * loads signed out and cannot tell whether that account's session ended or + * only failed to restore (a timeout, a lost connection). They return if + * that account signs in again, and go when another does, on sign-out, or + * after a refresh token's idle lifetime. + */ +const HELD_KEY = `${STORAGE_KEY}-held`; +const HELD_DRAFT_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000; + +interface HeldDrafts { + /** The fingerprint of the account that had the drafts signed in. */ + owner: string; + heldAt: number; + /** Stored drafts by storage key: the one in hand and any set aside. */ + drafts: Record<string, string>; +} + +/** Every stored draft key: the one in hand and any set aside for another country. */ +function storedDraftKeys(): string[] { + return Array.from({ length: localStorage.length }, (_, index) => localStorage.key(index)).filter( + (key): key is string => key === STORAGE_KEY || !!key?.startsWith(`${STORAGE_KEY}:`) + ); +} + +/** The held drafts, when there are any and they have not expired. */ +function readHeldDrafts(): HeldDrafts | null { + try { + const held = JSON.parse(localStorage.getItem(HELD_KEY) ?? 'null') as HeldDrafts | null; + if (!held || typeof held.owner !== 'string' || typeof held.heldAt !== 'number') { + return null; + } + return Date.now() - held.heldAt > HELD_DRAFT_LIFETIME_MS ? null : held; + } catch { + return null; + } +} + +/** + * Moves every draft out of sight, held for `owner`. Drafts already held for + * the same account are kept alongside, the ones in hand winning. + */ +export function holdAllDraftReforms(owner: string): void { + try { + const earlier = readHeldDrafts(); + const drafts: Record<string, string> = earlier?.owner === owner ? { ...earlier.drafts } : {}; + for (const key of storedDraftKeys()) { + drafts[key] = localStorage.getItem(key)!; + } + if (Object.keys(drafts).length > 0) { + const held: HeldDrafts = { owner, heldAt: Date.now(), drafts }; + localStorage.setItem(HELD_KEY, JSON.stringify(held)); + } + } catch { + // Storage unavailable: nothing was stored to hold. + } + clearAllDraftReforms(); +} + +/** + * Brings back the drafts held for `owner`, into any slot not taken since; + * drafts held for another account are dropped. Either way none stay held. + */ +export function restoreHeldDraftReforms(owner: string): void { + const held = readHeldDrafts(); + dropHeldDraftReforms(); + if (!held || held.owner !== owner) { + return; + } + try { + for (const [key, stored] of Object.entries(held.drafts)) { + if (localStorage.getItem(key) === null) { + localStorage.setItem(key, stored); + } + } + window.dispatchEvent(new Event(CHANGE_EVENT)); + } catch { + // Storage unavailable: the drafts cannot come back. + } +} + +/** Drops the held drafts: on sign-out, or (`expiredOnly`) once they have waited too long. */ +export function dropHeldDraftReforms({ + expiredOnly = false, +}: { expiredOnly?: boolean } = {}): void { + try { + if (expiredOnly && (readHeldDrafts() || localStorage.getItem(HELD_KEY) === null)) { + return; + } + localStorage.removeItem(HELD_KEY); + } catch { + // Storage unavailable: nothing was held. + } +} + /** * The report a run from the draft started. Running hands the draft over * to its report: the report's page clears it on opening — not the run diff --git a/app/src/tests/unit/libs/auth/AuthProvider.test.tsx b/app/src/tests/unit/libs/auth/AuthProvider.test.tsx index cbbdd988b..a2404370d 100644 --- a/app/src/tests/unit/libs/auth/AuthProvider.test.tsx +++ b/app/src/tests/unit/libs/auth/AuthProvider.test.tsx @@ -264,7 +264,7 @@ describe('AuthProvider', () => { expect(screen.getByTestId('status')).toHaveTextContent('signed_in'); }); - test('given a draft left by a session that expired between visits then a signed-out load drops it', () => { + test('given a draft left by a session that expired between visits then a signed-out load hides it', () => { // Given: a signed-in visit that never signed out stubAuthEnv(true); auth0.state.isAuthenticated = true; @@ -283,6 +283,59 @@ describe('AuthProvider', () => { expect(getDraftReform()).toBeNull(); }); + test("given a restore that fails on load then the account's drafts come back when it signs in again", () => { + // Given: a signed-in visit with a US draft and a UK one set aside + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + const signedInVisit = renderProvider(); + startDraftReform('uk', 'manual'); + addDraftProvision('uk', UK_DRAFT_PROVISION); + startDraftReform('us', 'manual'); + signedInVisit.unmount(); + + // When: one load cannot restore the session (it reads as signed out)... + auth0.state.isAuthenticated = false; + auth0.state.user = undefined; + const failedRestore = renderProvider(); + expect(getDraftReform()).toBeNull(); + failedRestore.unmount(); + + // ...and the next restores it + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + renderProvider(); + + // Then: nothing was lost + expect(getDraftReform()?.countryId).toBe('us'); + expect(resumeDraftFor('uk')?.provisions).toHaveLength(1); + }); + + test('given a signed-out load then another account signing in never gets the held drafts', () => { + // Given + stubAuthEnv(true); + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + const firstAccount = renderProvider(); + startDraftReform('us', 'manual'); + firstAccount.unmount(); + auth0.state.isAuthenticated = false; + auth0.state.user = undefined; + renderProvider().unmount(); + + // When: someone else signs in on this browser, then the first account returns + auth0.state.isAuthenticated = true; + auth0.state.user = { sub: 'google-oauth2|someone-else' }; + const otherAccount = renderProvider(); + expect(getDraftReform()).toBeNull(); + otherAccount.unmount(); + auth0.state.user = { sub: TEST_AUTH0_SUBJECT }; + renderProvider(); + + // Then: the held drafts went when the other account signed in + expect(getDraftReform()).toBeNull(); + }); + test("given another account signs in then the previous account's draft is dropped", () => { // Given stubAuthEnv(true); diff --git a/app/src/tests/unit/libs/draftReformHeld.test.ts b/app/src/tests/unit/libs/draftReformHeld.test.ts new file mode 100644 index 000000000..df6b2f46c --- /dev/null +++ b/app/src/tests/unit/libs/draftReformHeld.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { clearSignedInSessionData } from '@/libs/auth/sessionData'; +import { + addDraftProvision, + clearAllDraftReforms, + dropHeldDraftReforms, + getDraftReform, + holdAllDraftReforms, + restoreHeldDraftReforms, + startDraftReform, +} from '@/libs/draftReform'; + +const OWNER = 'a1b2c3d4'; +const PROVISION = { + path: 'gov.irs.credits.ctc.amount.base', + breadcrumb: 'IRS → Credits → Child tax credit → Base amount', + unit: 'currency-USD', + baselineValue: 2000, + value: 3000, +}; +const DAY_MS = 24 * 60 * 60 * 1000; + +beforeEach(() => { + localStorage.clear(); + clearAllDraftReforms(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-16T00:00:00Z')); + startDraftReform('us', 'manual'); + addDraftProvision('us', PROVISION); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('held drafts', () => { + test('given drafts held for an account then they are out of sight until it returns', () => { + // When + holdAllDraftReforms(OWNER); + + // Then + expect(getDraftReform()).toBeNull(); + restoreHeldDraftReforms(OWNER); + expect(getDraftReform()?.provisions).toHaveLength(1); + }); + + test('given a draft started since then restoring keeps it and adds only empty slots', () => { + // Given + holdAllDraftReforms(OWNER); + startDraftReform('us', 'chat'); + + // When + restoreHeldDraftReforms(OWNER); + + // Then: the newer draft in hand stays + expect(getDraftReform()?.source).toBe('chat'); + expect(getDraftReform()?.provisions).toHaveLength(0); + }); + + test('given held drafts older than a week then they do not come back', () => { + // Given + holdAllDraftReforms(OWNER); + vi.setSystemTime(Date.now() + 8 * DAY_MS); + + // When + restoreHeldDraftReforms(OWNER); + + // Then + expect(getDraftReform()).toBeNull(); + }); + + test('given a signed-out load after a week then expired held drafts are deleted', () => { + // Given + holdAllDraftReforms(OWNER); + vi.setSystemTime(Date.now() + 8 * DAY_MS); + + // When + dropHeldDraftReforms({ expiredOnly: true }); + + // Then + expect(localStorage.getItem('pe-draft-reform-held')).toBeNull(); + }); + + test('given held drafts within a week then dropping only expired ones keeps them', () => { + // Given + holdAllDraftReforms(OWNER); + + // When + dropHeldDraftReforms({ expiredOnly: true }); + + // Then + restoreHeldDraftReforms(OWNER); + expect(getDraftReform()?.provisions).toHaveLength(1); + }); + + test('given a sign-out then held drafts go too', () => { + // Given + holdAllDraftReforms(OWNER); + + // When + clearSignedInSessionData(); + + // Then + restoreHeldDraftReforms(OWNER); + expect(getDraftReform()).toBeNull(); + expect(localStorage.getItem('pe-draft-reform-held')).toBeNull(); + }); +}); From ca707009a1ac0de95a182a12e4841b2a2f3f8df6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:22:04 +0400 Subject: [PATCH 47/48] Clamp saved names by character, never splitting an emoji clampStoreText cut by UTF-16 code units, so a name over 255 characters with an emoji at the cut sent half of one, which the Public API refuses with 422 after the run's v1 report exists. It now counts and cuts code points, as the API counts characters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- app/src/libs/flagship/storeText.ts | 15 ++++++++++++--- .../tests/unit/libs/flagship/storeText.test.ts | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/src/libs/flagship/storeText.ts b/app/src/libs/flagship/storeText.ts index 7bb6c1d74..6fa2d5f3d 100644 --- a/app/src/libs/flagship/storeText.ts +++ b/app/src/libs/flagship/storeText.ts @@ -7,13 +7,22 @@ export const MAX_STORE_NAME_LENGTH = 255; export const MAX_STORE_NOTE_LENGTH = 10_000; -/** The text cut to `maxLength`, with an ellipsis; null and undefined pass through. */ +/** + * The text cut to `maxLength` characters, with an ellipsis; null and + * undefined pass through. Characters are code points, as the API counts + * them, so an emoji is one character and is never cut in half (half of one + * is not valid text, and the API refuses it). + */ export function clampStoreText<T extends string | null | undefined>( text: T, maxLength: number = MAX_STORE_NAME_LENGTH ): T { - if (typeof text !== 'string' || text.length <= maxLength) { + if (typeof text !== 'string') { + return text; + } + const characters = Array.from(text); + if (characters.length <= maxLength) { return text; } - return `${text.slice(0, maxLength - 1)}…` as T; + return `${characters.slice(0, maxLength - 1).join('')}…` as T; } diff --git a/app/src/tests/unit/libs/flagship/storeText.test.ts b/app/src/tests/unit/libs/flagship/storeText.test.ts index 2226d4ab1..d84b5ffac 100644 --- a/app/src/tests/unit/libs/flagship/storeText.test.ts +++ b/app/src/tests/unit/libs/flagship/storeText.test.ts @@ -26,4 +26,22 @@ describe('clampStoreText', () => { expect(clampStoreText(note, MAX_STORE_NOTE_LENGTH)).toHaveLength(MAX_STORE_NOTE_LENGTH); }); + + test('given an emoji at the cut then it is kept whole, never split into half a character', () => { + // 253 letters put the emoji (two UTF-16 units) across the old cut point. + const title = `${'A'.repeat(MAX_STORE_NAME_LENGTH - 2)}🎉${'B'.repeat(20)}`; + + const clamped = clampStoreText(title); + + expect(Array.from(clamped)).toHaveLength(MAX_STORE_NAME_LENGTH); + expect(clamped.endsWith('🎉…')).toBe(true); + expect(clamped.isWellFormed()).toBe(true); + }); + + test('given a name with emoji within the limit in characters then it is not cut', () => { + // 250 emoji are 500 UTF-16 units but 250 characters, as the API counts. + const title = '🎉'.repeat(MAX_STORE_NAME_LENGTH - 5); + + expect(clampStoreText(title)).toBe(title); + }); }); From 179563779f095b8e52e491a201bed659cd17e1b1 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:42:32 +0400 Subject: [PATCH 48/48] Treat current law as unknown until the model's metadata loads Current law reads 0 until the model's metadata loads, and the report list renders before then. Comparing a report's baseline policy with that 0 made every current-law report read 'Compared with a saved reform' until the metadata arrived, or for good if it failed. A current-law ID that is not a real policy now leaves the comparison unknown, for every caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- app/src/libs/flagship/reportComparison.ts | 5 ++-- .../flagship/ReformReports.test.tsx | 19 +++++++++++++- .../libs/flagship/reportComparison.test.ts | 26 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 app/src/tests/unit/libs/flagship/reportComparison.test.ts diff --git a/app/src/libs/flagship/reportComparison.ts b/app/src/libs/flagship/reportComparison.ts index e2c96326a..724458581 100644 --- a/app/src/libs/flagship/reportComparison.ts +++ b/app/src/libs/flagship/reportComparison.ts @@ -3,13 +3,14 @@ * the policy its baseline simulation ran. That stays true when its record * no longer names the saved reform it was compared with: the reform was * deleted, or the report is someone else's, recomputed. Undefined while - * either policy is unknown. + * either policy is unknown, as current law is until the model's metadata + * loads (it reads 0 until then). */ export function ranAgainstReform( baselinePolicyId: string | number | null | undefined, currentLawPolicyId: string | number | null | undefined ): boolean | undefined { - if (baselinePolicyId == null || baselinePolicyId === '' || currentLawPolicyId == null) { + if (!Number(baselinePolicyId) || !Number(currentLawPolicyId)) { return undefined; } return String(baselinePolicyId) !== String(currentLawPolicyId); diff --git a/app/src/tests/unit/components/flagship/ReformReports.test.tsx b/app/src/tests/unit/components/flagship/ReformReports.test.tsx index 66fe6556f..eab542648 100644 --- a/app/src/tests/unit/components/flagship/ReformReports.test.tsx +++ b/app/src/tests/unit/components/flagship/ReformReports.test.tsx @@ -1,7 +1,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, userEvent, within } from '@test-utils'; +import { act, render, screen, userEvent, waitFor, within } from '@test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { FlagshipReportRecord } from '@/api/flagshipReportStore'; +import { fetchSimulationById } from '@/api/simulation'; import ReformReports from '@/components/flagship/ReformReports'; import { saveReportMeta } from '@/libs/flagship/runReport'; import { clearMetadata } from '@/reducers/metadataReducer'; @@ -229,4 +230,20 @@ describe('ReformReports comparisons without a link', () => { expect(await screen.findByText('2026 report')).toBeInTheDocument(); expect(screen.queryByText(/Compared with/)).not.toBeInTheDocument(); }); + + test('given metadata still loading then a current-law report shows no comparison', async () => { + // Given: current law reads 0 until the model's metadata loads + store.dispatch(clearMetadata()); + renderReports([record({ id: 'rec-64', apiReportId: '64', baselineReformId: null })]); + + // When: its baseline simulation has been read + await waitFor(() => expect(fetchSimulationById).toHaveBeenCalledWith('us', 'sim-64')); + await act(async () => { + await vi.mocked(fetchSimulationById).mock.results.at(-1)?.value; + }); + + // Then + expect(screen.getByText('2026 report')).toBeInTheDocument(); + expect(screen.queryByText(/Compared with/)).not.toBeInTheDocument(); + }); }); diff --git a/app/src/tests/unit/libs/flagship/reportComparison.test.ts b/app/src/tests/unit/libs/flagship/reportComparison.test.ts new file mode 100644 index 000000000..aa83e56c2 --- /dev/null +++ b/app/src/tests/unit/libs/flagship/reportComparison.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest'; +import { ranAgainstReform } from '@/libs/flagship/reportComparison'; + +/** The US current-law policy. */ +const CURRENT_LAW_POLICY_ID = 2; + +describe('ranAgainstReform', () => { + test('given a baseline that ran current law then it was not compared with a reform', () => { + expect(ranAgainstReform('2', CURRENT_LAW_POLICY_ID)).toBe(false); + }); + + test('given a baseline that ran another policy then it was compared with a reform', () => { + expect(ranAgainstReform('88', CURRENT_LAW_POLICY_ID)).toBe(true); + }); + + test('given metadata still loading (current law reads 0) then it is unknown', () => { + expect(ranAgainstReform('2', 0)).toBeUndefined(); + expect(ranAgainstReform('88', 0)).toBeUndefined(); + }); + + test('given no baseline policy then it is unknown', () => { + expect(ranAgainstReform(undefined, CURRENT_LAW_POLICY_ID)).toBeUndefined(); + expect(ranAgainstReform('', CURRENT_LAW_POLICY_ID)).toBeUndefined(); + expect(ranAgainstReform(null, CURRENT_LAW_POLICY_ID)).toBeUndefined(); + }); +});