diff --git a/CLAUDE.md b/CLAUDE.md index 896f9958..a7b54f85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # OpenFrame Frontend - Claude Development Guide -**Next.js 16 + React 19 + TypeScript 5.8 + @flamingo-stack/openframe-frontend-core (^0.0.586)** +**Next.js 16 + React 19 + TypeScript 5.8 + @flamingo-stack/openframe-frontend-core (^0.0.632)** > Comprehensive instructions for Claude when working with the OpenFrame Frontend service. @@ -127,7 +127,7 @@ modal** on the billing page (`billing-usage/components/upgrade-plan-modal.tsx`), | UI Library | React | 19 (^19.2.0) | | Auto-memoization | React Compiler (`reactCompiler: true` + babel-plugin-react-compiler) | 1.0 | | Type System | TypeScript | 5.8 (^5.8.3) | -| Component Library | @flamingo-stack/openframe-frontend-core | ^0.0.586 (npm registry) | +| Component Library | @flamingo-stack/openframe-frontend-core | ^0.0.632 (npm registry) | | GraphQL Data Fetching | react-relay + relay-runtime + relay-compiler | 20.1 | | REST / Legacy Data Fetching | @tanstack/react-query | 5.90 | | Forms | react-hook-form + @hookform/resolvers | 7.71 + 5.2 | @@ -151,7 +151,7 @@ modal** on the billing page (`billing-usage/components/upgrade-plan-modal.tsx`), **Key Facts:** - **Source repo**: `openframe-oss-lib/openframe-frontend-core/` - **Ownership**: Shared across Flamingo Stack projects (OpenFrame, OpenMSP, Flamingo, TMCG, hubs, openframe-chat) -- **Normal state**: installed from the **npm registry** (`"@flamingo-stack/openframe-frontend-core": "^0.0.586"`); the lib repo's own `package.json` version lags the registry (CI bumps at publish) +- **Normal state**: installed from the **npm registry** (`"@flamingo-stack/openframe-frontend-core": "^0.0.632"`); the lib repo's own `package.json` version lags the registry (CI bumps at publish) - **Local lib development**: link via **yalc** — `npm run core:link` here, and in the lib repo `npm run build && yalc push` after every change (consumers see `dist/`, not `src/`) - **Updates**: Changes affect ALL Flamingo Stack projects diff --git a/package-lock.json b/package-lock.json index 7e79b419..854787a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.9", - "@flamingo-stack/openframe-frontend-core": "^0.0.630", + "@flamingo-stack/openframe-frontend-core": "^0.0.632", "@hookform/resolvers": "^5.2.2", "@lezer/highlight": "^1.2.3", "@tanstack/react-query": "^5.90.16", @@ -93,7 +93,7 @@ "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -110,7 +110,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -127,7 +127,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -137,7 +137,7 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@atlaskit/pragmatic-drag-and-drop": { @@ -449,7 +449,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "css-tree": "^3.0.0" @@ -631,7 +631,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -651,7 +651,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -675,7 +675,7 @@ "version": "4.1.10", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -703,7 +703,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -726,7 +726,7 @@ "version": "1.1.7", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -751,7 +751,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -938,7 +938,7 @@ "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -953,9 +953,9 @@ } }, "node_modules/@flamingo-stack/openframe-frontend-core": { - "version": "0.0.630", - "resolved": "https://registry.npmjs.org/@flamingo-stack/openframe-frontend-core/-/openframe-frontend-core-0.0.630.tgz", - "integrity": "sha512-9PmHxuOPZHDuvvKJ/XDTGULMjmUPH+XUypA/PGN8OS9AtQ12J9effVm8Y8IZtrdTn9VfBrfq+C+Vsu2gr99Nbg==", + "version": "0.0.632", + "resolved": "https://registry.npmjs.org/@flamingo-stack/openframe-frontend-core/-/openframe-frontend-core-0.0.632.tgz", + "integrity": "sha512-jhEGo+jVMb5oFu4mRQRM8cDMUL8oksksZmt4Q0GhujcET+RMQfdg875o8WHPZL1zYEFnsj7U2qDrZdjRaj4nKA==", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^3.0.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.0.1", @@ -5304,7 +5304,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5321,7 +5320,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5338,7 +5336,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5355,7 +5352,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5372,7 +5368,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5389,7 +5384,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5406,7 +5400,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5423,7 +5416,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5440,7 +5432,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5457,7 +5448,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5474,7 +5464,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5491,7 +5480,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5508,7 +5496,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5525,7 +5512,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5999,7 +5985,7 @@ }, "node_modules/@types/node": { "version": "22.19.11", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -7264,7 +7250,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -7707,7 +7693,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mdn-data": "2.27.1", @@ -8233,7 +8219,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", @@ -8247,7 +8233,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -8260,7 +8246,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" @@ -8270,7 +8256,7 @@ "version": "16.0.1", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", @@ -10142,7 +10128,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.6.0" @@ -10621,7 +10607,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/is-regex": { @@ -10824,7 +10810,7 @@ "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^5.1.11", @@ -10865,7 +10851,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -10878,7 +10864,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "entities": "^8.0.0" @@ -10891,7 +10877,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -10904,7 +10890,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" @@ -10914,7 +10900,7 @@ "version": "16.0.1", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", @@ -11109,7 +11095,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11130,7 +11115,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11151,7 +11135,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11172,7 +11155,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11193,7 +11175,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11214,7 +11195,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11235,7 +11215,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11256,7 +11235,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11277,7 +11255,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11298,7 +11275,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11319,7 +11295,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11409,7 +11384,7 @@ "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, + "devOptional": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -11715,7 +11690,7 @@ "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, + "devOptional": true, "license": "CC0-1.0" }, "node_modules/media-chrome": { @@ -14370,7 +14345,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14564,7 +14539,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -15103,7 +15078,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tabbable": { @@ -15278,7 +15253,7 @@ "version": "7.4.10", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tldts-core": "^7.4.10" @@ -15291,7 +15266,7 @@ "version": "7.4.10", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/to-regex-range": { @@ -15336,7 +15311,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -15605,7 +15580,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -15613,7 +15588,7 @@ }, "node_modules/undici-types": { "version": "6.21.0", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unified": { @@ -16180,7 +16155,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -16243,7 +16218,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20" @@ -16405,7 +16380,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -16415,7 +16390,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/yallist": { diff --git a/package.json b/package.json index 98278fad..4f01fa30 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.9", - "@flamingo-stack/openframe-frontend-core": "^0.0.630", + "@flamingo-stack/openframe-frontend-core": "^0.0.632", "@hookform/resolvers": "^5.2.2", "@lezer/highlight": "^1.2.3", "@tanstack/react-query": "^5.90.16", diff --git a/schema.graphql b/schema.graphql index e6a27247..5ffda165 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,6 +1,6 @@ # Auto-generated by scripts/fetch-schema.mjs via introspection -# Source: https://test-env.qa.openframe.build/api/graphql -# Generated: 2026-09-03T17:50:03.101Z +# Source: https://tst-notifications.dev.openframe.build/api/graphql +# Generated: 2026-09-14T16:34:07.687Z # # Do not edit manually. Re-run: npm run fetch-schema directive @extends on OBJECT | INTERFACE @@ -40,47 +40,6 @@ type Address { country: String } -type AdminAiMessageContext implements NotificationContext { - type: String! - dialogId: ID! -} - -type AdminAiTicketMessageContext implements NotificationContext { - type: String! - ticketId: ID! - ticketNumber: Int - - """ - Ticket title snapshotted at emission, so attribution needs no extra fetch. - """ - ticketTitle: String - dialogId: ID! -} - -type AdminApprovalRequestContext implements NotificationContext { - type: String! - approvalRequestId: ID! - dialogId: ID! - ticketId: ID - ticketNumber: Int - approvalType: ApprovalType! - toolCalls: [ApprovalToolCall!]! - resolution: ApprovalResolution - resolvedByUserId: ID - resolvedByName: String - resolvedAt: Instant -} - -type AdminMessagePublishedContext implements NotificationContext { - type: String! - ticketId: ID! - - """ - Ticket title snapshotted at emission, so attribution needs no extra fetch. - """ - ticketTitle: String -} - """ Rate for converting provider tokens of a single model into OpenFrame tokens. """ @@ -98,31 +57,6 @@ type AiModelRate { outputTokenRate: Float! } -enum ApprovalResolution { - PENDING - APPROVED - REJECTED - CANCELLED -} - -" A single planned tool invocation awaiting approval.\n `toolCallArguments` is an opaque JSON object whose shape varies by `toolName`; the\n `JSON` scalar is contributed by the runtime that hosts this schema (e.g. saas-api)." -type ApprovalToolCall { - toolExecutionRequestId: String - toolName: String! - toolTitle: String - toolExplanation: String - toolType: ToolType - requiresApproval: Boolean! - approvalType: ApprovalType - toolCallArguments: JSON -} - -" SaaS-specific NotificationContext subtypes.\n The base interface `NotificationContext` is declared in the OSS notification schema\n (openframe-api-service-core/.../schema/notification.graphqls); we only contribute the\n concrete SaaS types here. DGS picks up all `classpath*:schema/*.graphqls` and merges them." -enum ApprovalType { - CLIENT - ADMIN -} - interface AssignableTarget { id: ID! } @@ -309,22 +243,6 @@ type CheckoutResult { subscription: SubscriptionDetail } -type ClientAiMessageContext implements NotificationContext { - type: String! - dialogId: ID! - - """ - Set when the client dialog belongs to a ticket; a Fae chat can also run without one. - """ - ticketId: ID - ticketNumber: Int - - """ - Ticket title snapshotted at emission, so attribution needs no extra fetch. - """ - ticketTitle: String -} - enum ConnectionStatus { CONNECTED DISCONNECTED @@ -421,11 +339,48 @@ input CreateScriptScheduleInput { startAt: Instant """ - Recurrence interval in seconds. Must be a whole number of 30-minute slots (1800, 3600, 5400, …); the runner ticks on that grid. Null means run once at startAt. Not supported for DEVICE_LOCAL (one-shot only). + Recurrence interval in seconds. Must be a whole number of 30-minute slots (1800, 3600, 5400, …); the runner ticks on that grid. Null means run once at startAt. For DEVICE_LOCAL it advances the local wall-clock by this many seconds each occurrence (e.g. 86400 = daily at the same local time). """ repeat: Long } +input CreateSoftwareScheduleInput { + name: String! + description: String + action: SoftwareAction! + + """Packages to install/update. At least one.""" + packages: [SoftwareSchedulePackageInput!]! + + """ + How the schedule reads its startAt: SERVER (an absolute instant) or DEVICE_LOCAL (a wall-clock re-based per device). Null defaults to SERVER. + """ + timeReference: ScheduleTimeReference + + """ + What to do when a target device is offline at the scheduled time. Null defaults to SKIP. + """ + offlineBehavior: ScheduleOfflineBehavior + + """ + Max seconds to wait for an offline device to reconnect. Set only when offlineBehavior is RETRY_ON_RECONNECT. + """ + reconnectWindowSeconds: Long + + """ + The scheduled run time. For SERVER: an absolute UTC instant. For DEVICE_LOCAL: the picked Date + Time with NO zone offset applied, re-based per device. Required; must fall on a 30-minute boundary (xx:00 or xx:30). + """ + startAt: Instant! + + """ + Recurrence interval in seconds — a whole number of 30-minute slots (1800, 3600, …). Null = run once. + """ + repeat: Long + + """SPECIFIC target device ids (Machine global ids).""" + machineIds: [ID!] +} + input CreateTimeEntryInput { userId: ID! ticketId: ID @@ -443,16 +398,6 @@ input CursorPaginationInput { limit: Int } -type CustomerMessagePublishedContext implements NotificationContext { - type: String! - ticketId: ID! - - """ - Ticket title snapshotted at emission, so attribution needs no extra fetch. - """ - ticketTitle: String -} - """""" scalar Date @@ -855,11 +800,6 @@ type FreeTokenGrants { devicePackage: Long } -type GenericContext implements NotificationContext { - type: String! - payload: String -} - """ Image projection (metadata, not bytes) shared by image-bearing GraphQL types (e.g. TenantInfo.image, the AI assistant avatar, User.image). @@ -1341,6 +1281,7 @@ type Mutation { Update the current authenticated tenant's name and/or website. saas-api only. """ updateTenantInfo(input: UpdateTenantInfoInput!): TenantInfoPayload! + updateDeviceNickname(machineId: String!, nickname: String): Machine! """ Heartbeat from an active human session: marks the caller present for the presence TTL. @@ -1348,9 +1289,6 @@ type Mutation { An optional context argument can be added later without breaking existing clients. """ recordPresence: Boolean! - assignItem(itemId: ID!, itemType: AssignmentItemType!, targetType: AssignmentTargetType!, targetId: ID!): ItemAssignment! - unassignItem(itemId: ID!, targetType: AssignmentTargetType!, targetId: ID!): Boolean! - unassignAllByType(itemId: ID!, targetType: AssignmentTargetType!): Boolean! """ Create a new schedule. Returns the created schedule with its server-assigned id. @@ -1423,36 +1361,18 @@ type Mutation { Returns that one DispatchResponse. """ runScheduleJobNow(scheduleId: ID!): DispatchResponse! - startTimer(input: StartTimerInput): TimeEntry! - pauseTimer: TimeEntry! - resumeTimer: TimeEntry! - stopTimer(input: StopTimerInput): TimeEntry! - cancelTimer: Boolean! - createTimeEntry(input: CreateTimeEntryInput!): TimeEntry! - updateTimeEntry(input: UpdateTimeEntryInput!): TimeEntry! - unlinkTicketFromTimeEntry(id: ID!): TimeEntry! - deleteTimeEntry(id: ID!): Boolean! """ - The modal saves full state; omitting typeSettings keeps the stored group overrides. + Install one or more catalog packages on the given devices, immediately. Each package is + dispatched as its own RMM execution; returns one result (with its executionId) per package. """ - updateNotificationSettings(enabled: Boolean!, typeSettings: [NotificationTypeSettingInput!]): NotificationSettings! - createTag(key: String!, entityType: String!, description: String, color: String): Tag! - updateTag(id: ID!, key: String, description: String, color: String): Tag! - deleteTag(id: ID!): Boolean! - runCommand(input: RunCommandInput!): DispatchResponse! - batchRunCommand(input: BatchRunCommandInput!): DispatchResponse! - cancelExecution(input: CancelExecutionInput!): DispatchResponse! - markNotificationAsRead(notificationId: ID!): Boolean! - markAllNotificationsAsRead: Int! - deleteNotification(notificationId: ID!): Boolean! - deleteAllReadNotifications: Int! + installSoftware(input: SoftwareManagementInput!): [SoftwareDispatchResult!]! """ - Cards stay in the list as read; only the counts drop. The returned number spans categories — refetch, do not subtract it. + Update one or more catalog packages on the given devices, immediately. Each package is + dispatched as its own RMM execution; returns one result (with its executionId) per package. """ - markNotificationsReadForEntity(entityType: NotificationEntityType!, entityId: ID!): Int! - updateDeviceNickname(machineId: String!, nickname: String): Machine! + updateSoftware(input: SoftwareManagementInput!): [SoftwareDispatchResult!]! """ Create a new script. Returns the created script with its server-assigned id. @@ -1492,6 +1412,21 @@ type Mutation { resolved server-side. Returns the shared executionId. """ batchRunScript(input: BatchRunScriptInput!): DispatchResponse! + runCommand(input: RunCommandInput!): DispatchResponse! + batchRunCommand(input: BatchRunCommandInput!): DispatchResponse! + cancelExecution(input: CancelExecutionInput!): DispatchResponse! + assignItem(itemId: ID!, itemType: AssignmentItemType!, targetType: AssignmentTargetType!, targetId: ID!): ItemAssignment! + unassignItem(itemId: ID!, targetType: AssignmentTargetType!, targetId: ID!): Boolean! + unassignAllByType(itemId: ID!, targetType: AssignmentTargetType!): Boolean! + startTimer(input: StartTimerInput): TimeEntry! + pauseTimer: TimeEntry! + resumeTimer: TimeEntry! + stopTimer(input: StopTimerInput): TimeEntry! + cancelTimer: Boolean! + createTimeEntry(input: CreateTimeEntryInput!): TimeEntry! + updateTimeEntry(input: UpdateTimeEntryInput!): TimeEntry! + unlinkTicketFromTimeEntry(id: ID!): TimeEntry! + deleteTimeEntry(id: ID!): Boolean! createFolder(name: String!, parentId: ID): KnowledgeBaseItem! renameFolder(id: ID!, name: String!): KnowledgeBaseItem! createArticle(input: CreateArticleInput!): KnowledgeBaseItem! @@ -1509,6 +1444,65 @@ type Mutation { linkKnowledgeBaseTempAttachmentsToArticle(input: LinkKnowledgeBaseTempAttachmentsInput!): [KnowledgeBaseItemAttachment!]! createKnowledgeBaseAttachmentUploadUrl(input: CreateKnowledgeBaseAttachmentInput!): KnowledgeBaseAttachmentUploadPayload! deleteKnowledgeBaseAttachment(input: MutationDeleteInput!): MutationDeletePayload! + markNotificationAsRead(notificationId: ID!): Boolean! + markAllNotificationsAsRead: Int! + deleteNotification(notificationId: ID!): Boolean! + deleteAllReadNotifications: Int! + + """ + Cards stay in the list as read; only the counts drop. The returned number spans categories — refetch, do not subtract it. + """ + markNotificationsReadForEntity(entityType: NotificationEntityType!, entityId: ID!): Int! + + """ + Create a new software schedule. Returns the created schedule with its server-assigned id. + """ + createSoftwareSchedule(input: CreateSoftwareScheduleInput!): SoftwareSchedule! + + """ + Full replacement (PUT) of an existing software schedule. The target id travels inside the input. + machineIds null leaves the device assignment untouched; a list (including empty) replaces it. + """ + updateSoftwareSchedule(input: UpdateSoftwareScheduleInput!): SoftwareSchedule! + + """ + Soft-delete a software schedule (sets status to DELETED). Returns the id; idempotent. + """ + deleteSoftwareSchedule(id: ID!): ID! + + """ + Archive a software schedule (sets status to ARCHIVED). Idempotent. Returns the updated schedule. + """ + archiveSoftwareSchedule(id: ID!): SoftwareSchedule! + + """ + Restore an archived software schedule back to ACTIVE. Idempotent. Returns the updated schedule. + """ + unarchiveSoftwareSchedule(id: ID!): SoftwareSchedule! + + """ + Replace the full set of devices assigned to a software schedule (PUT — backs "Edit Devices"). + machineIds are Machine global ids. Returns the updated schedule. + """ + setSoftwareScheduleDevices(scheduleId: ID!, machineIds: [ID!]!): SoftwareSchedule! + + """ + Incrementally assign the given devices (the "+"/"Add selected" actions). Idempotent. + """ + addDevicesToSoftwareSchedule(scheduleId: ID!, machineIds: [ID!]!): SoftwareSchedule! + + """ + Incrementally unassign the given devices (the trash / "Remove selected" actions). Missing ids are no-ops. + """ + removeDevicesFromSoftwareSchedule(scheduleId: ID!, machineIds: [ID!]!): SoftwareSchedule! + createTag(key: String!, entityType: String!, description: String, color: String): Tag! + updateTag(id: ID!, key: String, description: String, color: String): Tag! + deleteTag(id: ID!): Boolean! + + """ + The modal saves full state; omitting typeSettings keeps the stored group overrides. + """ + updateNotificationSettings(enabled: Boolean!, typeSettings: [NotificationTypeSettingInput!]): NotificationSettings! """ Advance this tenant's Stripe test clock by N days (dev/stage only). Attaches a clock to the customer on first call, flushes current metered usage to Stripe, then advances. @@ -1555,20 +1549,10 @@ type Notification implements Node { read: Boolean! category: NotificationCategory! - """ - Nullable during the context→attributes migration: rows written before it lack context once the - typed context types are retired, and clients on `attributes` never read it. - """ - context: NotificationContext - - """ - Spec-driven notification type; null on rows written by the legacy dispatcher path. - """ + """Spec-driven notification type.""" type: String - """ - Flat string map of the notification's facts, snapshotted at emission. Null on legacy rows. - """ + """Flat string map of the notification's facts, snapshotted at emission.""" attributes: JSON } @@ -1590,10 +1574,6 @@ type NotificationConnection { pageInfo: PageInfo! } -interface NotificationContext { - type: String! -} - type NotificationEdge { node: Notification! cursor: String! @@ -1782,17 +1762,19 @@ enum PackageManagerType { WINGET } -input PackageSearchInput { - packageManager: PackageManagerType! - - """At least 2 characters after trimming.""" - query: String! +type PackageSearchConnection { + edges: [PackageSearchEdge!]! + pageInfo: PageInfo! """ - Defaults to 25; values above 39 are clamped, not rejected (Chocolatey's server page cap of 40 minus the row used to compute hasMore). + Total matches for the query (same field name as every other connection). """ - limit: Int - offset: Int + filteredCount: Int! +} + +type PackageSearchEdge { + node: PackageSearchItem! + cursor: String! } type PackageSearchItem { @@ -1828,20 +1810,6 @@ type PackageSearchItem { packageManager: PackageManagerType! } -type PackageSearchResult { - items: [PackageSearchItem!]! - - """ - Null when the source cannot report a total without an extra request (Chocolatey). - """ - total: Int - - """ - Exact for every source — drive pagination off this field, not off total. - """ - hasMore: Boolean! -} - input PackageUpdateInput { productName: OpenframeProduct! packageOptionId: String! @@ -2015,8 +1983,62 @@ type Query { dev/stage test-clock reset). """ billingProvisioningStatus: BillingProvisioningStatus! - assignedItemCounts(itemId: ID!): [AssignedItemCount!]! - assignedItems(itemId: ID!, targetType: AssignmentTargetType!, search: String, sort: SortInput, first: Int, after: String): ItemAssignmentConnection! + deviceFilters(filter: DeviceFilterInput): DeviceFilters! + devices(filter: DeviceFilterInput, first: Int, after: String, last: Int, before: String, search: String, sort: SortInput): DeviceConnection! + + """ Get single device by Relay global ID""" + deviceById(id: ID!): Machine + + """ Get single device by machineId""" + device(machineId: String!): Machine + + " Get all integrated tools with optional filtering\n Returns a simple list since tools are limited in number (configuration data)" + integratedTools(filter: ToolFilterInput, search: String, sort: SortInput): ToolList! + + """ Get available tool filters""" + toolFilters: ToolFilters! + + """ + A single execution by its Relay id. Typed refetch — the alternative to node(id). + Throws (not null) if the id is absent or in another tenant. + """ + scriptExecution(id: ID!): ScriptExecution! + scriptExecutions(scriptId: ID!, filter: ScriptExecutionFilterInput, search: String, sort: SortInput, first: Int, after: String, last: Int, before: String): ScriptExecutionConnection! + scriptExecutionFilters(scriptId: ID!, filter: ScriptExecutionFilterInput, search: String): ScriptExecutionFilters! + + """ + Execution History rows for one schedule — parallel to scriptExecutions but + scoped by scheduleId. Same ScriptExecutionConnection shape (rows are the same + ScriptExecution rows the schedule produced), same filter / search / sort options. + """ + scheduleExecutions(scheduleId: ID!, filter: ScriptExecutionFilterInput, search: String, sort: SortInput, first: Int, after: String, last: Int, before: String): ScriptExecutionConnection! + + """ + Facet options for a schedule's Execution History — parallel to scriptExecutionFilters. + """ + scheduleExecutionFilters(scheduleId: ID!, filter: ScriptExecutionFilterInput, search: String): ScriptExecutionFilters! + + """ + Execution History rows for one software package's install/update runs — parallel to + scriptExecutions but scoped by (packageManager, packageName, action). All packages of a manager + share one generic script, so the package identity (not scriptId) narrows the rows. Same + ScriptExecutionConnection shape, same filter / search / sort options. + """ + softwareExecutions(packageManager: PackageManagerType!, packageName: String!, action: SoftwareAction!, filter: ScriptExecutionFilterInput, search: String, sort: SortInput, first: Int, after: String, last: Int, before: String): ScriptExecutionConnection! + + """ + Facet options for a software package's Execution History — parallel to scriptExecutionFilters. + """ + softwareExecutionFilters(packageManager: PackageManagerType!, packageName: String!, action: SoftwareAction!, filter: ScriptExecutionFilterInput, search: String): ScriptExecutionFilters! + + """ Get all organizations with optional filtering and pagination""" + organizations(filter: OrganizationFilterInput, first: Int, after: String, last: Int, before: String, search: String, orderBy: OrganizationSortInput): OrganizationConnection! + + """ Get single organization by ID""" + organization(id: ID!): Organization + + """ Get organization by organizationId""" + organizationByOrganizationId(organizationId: String!): Organization """ Get a single schedule by id within the current tenant. Throws (not null) if the id is absent, soft-deleted, or in another tenant. @@ -2034,6 +2056,22 @@ type Query { """ scriptSchedules(filter: ScriptScheduleFilterInput, search: String, sort: SortInput, first: Int, after: String, last: Int, before: String): ScriptScheduleConnection! scriptScheduleFilters(filter: ScriptScheduleFilterInput): ScriptScheduleFilters! + + """ + Get a single script by id within the current tenant. Throws (not null) if the id is absent, soft-deleted, or in another tenant. + """ + script(id: ID!): Script! + + """ + Cursor-paginated list of scripts within the current tenant (Relay Connection Spec). + Default order is newest-first by _id. Optional filter / search / sort. + Sortable fields: _id (default), name, createdAt, updatedAt. Search is a + case-insensitive substring match on name. + """ + scripts(filter: ScriptFilterInput, search: String, sort: SortInput, first: Int, after: String, last: Int, before: String): ScriptConnection! + scriptFilters(filter: ScriptFilterInput): ScriptFilters! + assignedItemCounts(itemId: ID!): [AssignedItemCount!]! + assignedItems(itemId: ID!, targetType: AssignmentTargetType!, search: String, sort: SortInput, first: Int, after: String): ItemAssignmentConnection! currentTimer: TimeEntry myTimeEntries(period: DateRangeInput, search: String, sort: SortInput, first: Int, after: String): TimeEntryConnection! @@ -2045,66 +2083,36 @@ type Query { """ Search packages in a public package-manager repository (Homebrew, Chocolatey, winget). Homebrew and winget results are ranked by match quality with popularity/canonical-id - tiebreaks; Chocolatey results come in the repository's own download-count order. + tiebreaks; Chocolatey results come in the repository's own relevance order. + Forward cursor pagination only: pass `pageInfo.endCursor` back as `after`. """ - searchPackages(input: PackageSearchInput!): PackageSearchResult! + searchPackages(packageManager: PackageManagerType!, search: String, first: Int, after: String, last: Int, before: String): PackageSearchConnection! """ Package details by identifier, including available versions (newest first). Errors with NOT_FOUND when the id is unknown to the package manager. """ packageDetails(packageManager: PackageManagerType!, packageId: ID!, packageType: BrewPackageType): PackageDetails! + knowledgeBaseItems(filter: KnowledgeBaseFilterInput, search: String, first: Int, after: String): KnowledgeBaseItemConnection! + knowledgeBaseItem(id: ID!): KnowledgeBaseItem + knowledgeBaseTags(folderId: ID, archived: Boolean): [Tag!]! + knowledgeBaseFolderTree: [KnowledgeBaseItem!]! + knowledgeBaseArticleTree: [KnowledgeBaseItem!]! + archivedArticles(search: String, tagIds: [ID], first: Int, after: String): KnowledgeBaseItemConnection! + knowledgeBaseAttachmentDownloadUrl(attachmentId: ID!): String! + notifications(filter: NotificationFilterInput, search: String, first: Int, after: String, last: Int, before: String, sort: SortInput): NotificationConnection! + hasUnreadNotifications: Boolean! + unreadCountsByCategory: [UnreadCategoryCount!]! """ - A single schedule fire by its Relay id. Typed refetch — the alternative to node(id). - Throws (not null) if the id is absent or in another tenant. - """ - scheduleRun(id: ID!): ScheduleRun! - - """ - Cursor-paginated list of individual schedule fires (Schedule Runs tab). - Each row is one dispatch of the schedule to its assigned devices. Newest first - by dispatchedAt (default). Optional statuses filter + case-insensitive substring - search on executionId. - """ - scheduleRuns(scheduleId: ID!, filter: ScheduleRunFilterInput, search: String, sort: SortInput, first: Int, after: String, last: Int, before: String): ScheduleRunConnection! - - """ - Available filter options (with live counts) for the scheduleRuns list of one schedule — the - facet panel paired with the scheduleRuns connection. statuses = run outcomes; initiators = users - who triggered fires (value is a User global id). Same scheduleId/filter/search scope as scheduleRuns. - """ - scheduleRunFilters(scheduleId: ID!, filter: ScheduleRunFilterInput, search: String): ScheduleRunFilters! - - """ - A single execution by its Relay id. Typed refetch — the alternative to node(id). - Throws (not null) if the id is absent or in another tenant. - """ - scriptExecution(id: ID!): ScriptExecution! - scriptExecutions(scriptId: ID!, filter: ScriptExecutionFilterInput, search: String, sort: SortInput, first: Int, after: String, last: Int, before: String): ScriptExecutionConnection! - scriptExecutionFilters(scriptId: ID!, filter: ScriptExecutionFilterInput, search: String): ScriptExecutionFilters! - - """ - Execution History rows for one schedule — parallel to scriptExecutions but - scoped by scheduleId. Same ScriptExecutionConnection shape (rows are the same - ScriptExecution rows the schedule produced), same filter / search / sort options. + Get a single software schedule by id within the current tenant. Throws (not null) if the id is absent, soft-deleted, or in another tenant. """ - scheduleExecutions(scheduleId: ID!, filter: ScriptExecutionFilterInput, search: String, sort: SortInput, first: Int, after: String, last: Int, before: String): ScriptExecutionConnection! + softwareSchedule(id: ID!): SoftwareSchedule! """ - Facet options for a schedule's Execution History — parallel to scriptExecutionFilters. + All software schedules in the current tenant, newest-first (by _id). Soft-deleted schedules are excluded. """ - scheduleExecutionFilters(scheduleId: ID!, filter: ScriptExecutionFilterInput, search: String): ScriptExecutionFilters! - notificationSettings: NotificationSettings! - - """ Get all organizations with optional filtering and pagination""" - organizations(filter: OrganizationFilterInput, first: Int, after: String, last: Int, before: String, search: String, orderBy: OrganizationSortInput): OrganizationConnection! - - """ Get single organization by ID""" - organization(id: ID!): Organization - - """ Get organization by organizationId""" - organizationByOrganizationId(organizationId: String!): Organization + softwareSchedules: [SoftwareSchedule!]! """ Tag key queries (tenant-wide, scoped to DEVICE entity type)""" tags: [Tag!]! @@ -2123,44 +2131,28 @@ type Query { " Tag keys assigned to scripts. When archived=true returns tags assigned to\n archived scripts; when false/null returns tags assigned to active scripts." scriptsTags(archived: Boolean): [Tag!]! - notifications(filter: NotificationFilterInput, search: String, first: Int, after: String, last: Int, before: String, sort: SortInput): NotificationConnection! - hasUnreadNotifications: Boolean! - unreadCountsByCategory: [UnreadCategoryCount!]! - deviceFilters(filter: DeviceFilterInput): DeviceFilters! - devices(filter: DeviceFilterInput, first: Int, after: String, last: Int, before: String, search: String, sort: SortInput): DeviceConnection! - - """ Get single device by Relay global ID""" - deviceById(id: ID!): Machine - - """ Get single device by machineId""" - device(machineId: String!): Machine - - " Get all integrated tools with optional filtering\n Returns a simple list since tools are limited in number (configuration data)" - integratedTools(filter: ToolFilterInput, search: String, sort: SortInput): ToolList! + notificationSettings: NotificationSettings! - """ Get available tool filters""" - toolFilters: ToolFilters! + """ + A single schedule fire by its Relay id. Typed refetch — the alternative to node(id). + Throws (not null) if the id is absent or in another tenant. + """ + scheduleRun(id: ID!): ScheduleRun! """ - Get a single script by id within the current tenant. Throws (not null) if the id is absent, soft-deleted, or in another tenant. + Cursor-paginated list of individual schedule fires (Schedule Runs tab). + Each row is one dispatch of the schedule to its assigned devices. Newest first + by dispatchedAt (default). Optional statuses filter + case-insensitive substring + search on executionId. """ - script(id: ID!): Script! + scheduleRuns(scheduleId: ID!, filter: ScheduleRunFilterInput, search: String, sort: SortInput, first: Int, after: String, last: Int, before: String): ScheduleRunConnection! """ - Cursor-paginated list of scripts within the current tenant (Relay Connection Spec). - Default order is newest-first by _id. Optional filter / search / sort. - Sortable fields: _id (default), name, createdAt, updatedAt. Search is a - case-insensitive substring match on name. + Available filter options (with live counts) for the scheduleRuns list of one schedule — the + facet panel paired with the scheduleRuns connection. statuses = run outcomes; initiators = users + who triggered fires (value is a User global id). Same scheduleId/filter/search scope as scheduleRuns. """ - scripts(filter: ScriptFilterInput, search: String, sort: SortInput, first: Int, after: String, last: Int, before: String): ScriptConnection! - scriptFilters(filter: ScriptFilterInput): ScriptFilters! - knowledgeBaseItems(filter: KnowledgeBaseFilterInput, search: String, first: Int, after: String): KnowledgeBaseItemConnection! - knowledgeBaseItem(id: ID!): KnowledgeBaseItem - knowledgeBaseTags(folderId: ID, archived: Boolean): [Tag!]! - knowledgeBaseFolderTree: [KnowledgeBaseItem!]! - knowledgeBaseArticleTree: [KnowledgeBaseItem!]! - archivedArticles(search: String, tagIds: [ID], first: Int, after: String): KnowledgeBaseItemConnection! - knowledgeBaseAttachmentDownloadUrl(attachmentId: ID!): String! + scheduleRunFilters(scheduleId: ID!, filter: ScheduleRunFilterInput, search: String): ScheduleRunFilters! logFilters(filter: LogFilterInput): LogFilters! logs(filter: LogFilterInput, first: Int, after: String, last: Int, before: String, search: String, sort: LogSortInput): LogConnection! @@ -2423,6 +2415,11 @@ type ScriptExecution implements Node { """ Resolved at read time from scriptId""" scriptName: String + " Software install/update identity — set only for software executions, null for ordinary\n script/command runs. Lets History label the row by its package instead of the shared script." + packageManager: PackageManagerType + packageName: String + softwareAction: SoftwareAction + " Schedule this execution originated from, stamped at dispatch.\n Null for ad-hoc runs (runScript / batchRunScript). Backs \"execution history per schedule job\"." scheduleId: ID machine: Machine @@ -2683,6 +2680,147 @@ enum ScriptStatus { DELETED } +""" +Install vs update — the two software management actions. Also scopes a package's Execution +History (softwareExecutions). +""" +enum SoftwareAction { + INSTALL + UPDATE +} + +""" +The dispatch outcome for one package: its shared executionId across the targeted devices. +""" +type SoftwareDispatchResult { + packageManager: PackageManagerType! + packageName: String! + executionId: String! +} + +"""A set of catalog packages to install/update on a set of devices.""" +input SoftwareManagementInput { + """Target device machineIds (subject-safe tokens). 1..100.""" + machineIds: [String!]! + + """Packages to act on. 1..50.""" + packages: [SoftwarePackageInput!]! +} + +""" +One package chosen from the catalog, identified exactly as package search returns it. +""" +input SoftwarePackageInput { + packageManager: PackageManagerType! + packageName: String! + + """ + Brew-only sub-type (CASK/FORMULA). Required for brew (enforced server-side), ignored by managers that do not distinguish. + """ + brewPackageType: BrewPackageType +} + +type SoftwareSchedule implements Node { + id: ID! + name: String! + description: String + + """Install vs update — applied to every package in this schedule.""" + action: SoftwareAction! + + """The packages this schedule installs/updates.""" + packages: [SoftwareSchedulePackage!]! + + """ + How this schedule targets devices. Always SPECIFIC for now (criteria out of scope). + """ + selectionMode: ScheduleDeviceSelectionMode! + + """ + How a DATE_TIME schedule reads its startAt: SERVER (an absolute instant) or DEVICE_LOCAL (a wall-clock re-based into each device's own timezone). Never null; defaults to SERVER. + """ + timeReference: ScheduleTimeReference! + + """ + What to do when a target device is offline at the scheduled time. Never null; defaults to SKIP. + """ + offlineBehavior: ScheduleOfflineBehavior! + + """ + Max seconds to wait for an offline device to reconnect. Set only when offlineBehavior is RETRY_ON_RECONNECT. + """ + reconnectWindowSeconds: Long + + """ + The scheduled run time. For SERVER: an absolute UTC instant. For DEVICE_LOCAL: the picked Date + Time with no zone offset, re-based into each device's own timezone at run time. + """ + startAt: Instant + + """Recurrence interval in seconds. Null for a one-shot schedule.""" + repeat: Long + + """ + Next instant the runner will fire this schedule. Null when not scheduled (DEVICE_LOCAL, archived, or a one-shot that already fired). + """ + nextRunAt: Instant + + """Instant of the most recent run. Null until the first fire.""" + lastRunAt: Instant + + """ + Machines assigned to this schedule — same filter/search/sort/pagination as the top-level `devices` query, scoped to this schedule's assignments. + """ + assignedDevices(filter: DeviceFilterInput, first: Int, after: String, last: Int, before: String, search: String, sort: SortInput): DeviceConnection! + + """ + Devices selectable for this schedule — the "Available Devices" picker. Each edge carries `assigned`. + """ + availableDevices(filter: DeviceFilterInput, first: Int, after: String, last: Int, before: String, search: String, sort: SortInput): AvailableDeviceConnection! + + """Faceted filter options over this schedule's assigned devices.""" + assignedDeviceFilters(filter: DeviceFilterInput, search: String): DeviceFilters! + + """Faceted filter options over this schedule's selectable device set.""" + availableDeviceFilters(filter: DeviceFilterInput, search: String): DeviceFilters! + + """Number of machines assigned to this schedule (the DEVICES column).""" + deviceCount: Int! + + """ + Lifecycle status: ACTIVE | ARCHIVED | DELETED. DELETED is a soft-delete, hidden from default queries. + """ + status: ScriptStatus! + statusChangedAt: Instant + createdAt: Instant + updatedAt: Instant + + """ + The creating user (resolved from the internal createdBy id via the user DataLoader). + """ + author: User +} + +"""One package a software schedule installs/updates.""" +type SoftwareSchedulePackage { + packageManager: PackageManagerType! + packageName: String! + + """ + Brew-only sub-type (CASK/FORMULA); null for managers that do not distinguish. + """ + brewPackageType: BrewPackageType +} + +input SoftwareSchedulePackageInput { + packageManager: PackageManagerType! + packageName: String! + + """ + Brew-only sub-type (CASK/FORMULA). Required for brew (enforced server-side), ignored by other managers. + """ + brewPackageType: BrewPackageType +} + """ Sorting support""" enum SortDirection { ASC @@ -2989,76 +3127,6 @@ type Ticket implements AssignableTarget & Node { resolvedAt: Instant } -type TicketAssignedContext implements NotificationContext { - type: String! - ticketId: ID! - ticketNumber: Int - - """ - Ticket title snapshotted at emission, so attribution needs no extra fetch. - """ - ticketTitle: String - assigneeUserId: ID! - assignedByUserId: ID - assigneeName: String - assignedByName: String -} - -type TicketCreatedByClientContext implements NotificationContext { - type: String! - ticketId: ID! - ticketNumber: Int - - """ - Ticket title snapshotted at emission, so attribution needs no extra fetch. - """ - ticketTitle: String - machineId: ID - - """ - Device nickname when one is set, hostname otherwise — whoever the ticket is attributed to. - """ - createdByName: String -} - -type TicketEscalatedByUserContext implements NotificationContext { - type: String! - ticketId: ID! - ticketNumber: Int - - """ - Ticket title snapshotted at emission, so attribution needs no extra fetch. - """ - ticketTitle: String - dialogId: ID - machineId: ID -} - -type TicketReopenedContext implements NotificationContext { - type: String! - ticketId: ID! - ticketNumber: Int - dialogId: ID - targetStatusKind: String - reason: String -} - -type TicketStatusChangedContext implements NotificationContext { - type: String! - ticketId: ID! - ticketNumber: Int - - """ - Ticket title snapshotted at emission, so attribution needs no extra fetch. - """ - ticketTitle: String - previousStatus: String - newStatus: String - - """ nullable: system-driven status transitions have no acting principal""" - changedByUserId: ID -} - type TimeEntry implements Node { id: ID! userId: ID! @@ -3261,11 +3329,30 @@ input UpdateScriptScheduleInput { startAt: Instant """ - Recurrence interval in seconds. Must be a whole number of 30-minute slots (1800, 3600, 5400, …). Null clears recurrence (one-shot). Not supported for DEVICE_LOCAL. + Recurrence interval in seconds. Must be a whole number of 30-minute slots (1800, 3600, 5400, …). Null clears recurrence (one-shot). For DEVICE_LOCAL it advances the local wall-clock each occurrence (e.g. 86400 = daily at the same local time). """ repeat: Long } +"""Full-replacement (PUT) payload for an existing software schedule.""" +input UpdateSoftwareScheduleInput { + id: ID! + name: String! + description: String + action: SoftwareAction! + packages: [SoftwareSchedulePackageInput!]! + timeReference: ScheduleTimeReference + offlineBehavior: ScheduleOfflineBehavior + reconnectWindowSeconds: Long + startAt: Instant! + repeat: Long + + """ + Full replacement of the assigned device set (Machine global ids). Null leaves the assignment untouched. + """ + machineIds: [ID!] +} + input UpdateSubscriptionInput { packageUpdates: [PackageUpdateInput!] discountCode: String diff --git a/src/app/components/native-push-initializer.tsx b/src/app/components/native-push-initializer.tsx index 25e56a1c..c6a53599 100644 --- a/src/app/components/native-push-initializer.tsx +++ b/src/app/components/native-push-initializer.tsx @@ -13,7 +13,7 @@ import { useSubscriptionOpen } from './subscription-lock/subscription-guard'; * permission, obtains the FCM registration token, and deep-links notification * taps through the client router. Renders nothing; no-ops outside the native shell. * - * The route comes from the payload's context type + entity ids, resolved by the + * The route comes from the payload's `type` + entity ids, resolved by the * same table the in-app drawer uses — the push payload carries no route, so the * backend stays ignorant of the frontend's URL structure (which it could not * know anyway: detail pages are query params on prerendered paths, and a Mingo diff --git a/src/app/components/notifications/notification-navigation.test.ts b/src/app/components/notifications/notification-navigation.test.ts index 7e8a793c..d516f1a7 100644 --- a/src/app/components/notifications/notification-navigation.test.ts +++ b/src/app/components/notifications/notification-navigation.test.ts @@ -9,9 +9,9 @@ import { } from './notification-navigation'; /** - * The push payload is a FLAT map; the NATS envelope nests the same fields under - * `context`. Both feed one mapping, and the push half is the one the backend can - * truncate — these pin the shape contract rather than the routes themselves. + * The push payload is a FLAT map; the NATS envelope carries `type` + `attributes`. Both feed + * one mapping, and the push half is the one the backend can truncate — these pin the shape + * contract rather than the routes themselves. */ describe('resolvePushNotificationRoute', () => { it('routes a ticket notification from the flat FCM data keys', () => { @@ -24,8 +24,8 @@ describe('resolvePushNotificationRoute', () => { ); }); - it('routes an approval request to its ticket', () => { - const data = { type: 'ADMIN_APPROVAL_REQUEST', ticketId: 't-1', dialogId: 'd-1', approvalRequestId: 'a-1' }; + it('routes a ticket-linked approval request to its ticket', () => { + const data = { type: 'TICKET_APPROVAL_REQUEST', ticketId: 't-1', dialogId: 'd-1', approvalRequestId: 'a-1' }; expect(resolvePushNotificationRoute(data)).toBe('/tickets/dialog?id=t-1'); }); @@ -35,26 +35,23 @@ describe('resolvePushNotificationRoute', () => { ); }); - // buildData drops the serialized context whole once the payload outgrows FCM's - // budget. Routing must survive that, which is why it reads the flat ids only. - it('ignores the context blob and routes on the flat ids alone', () => { - const withContext = { + // buildData drops any serialized blob whole once the payload outgrows FCM's budget. + // Routing must survive that, which is why it reads the flat ids only. + it('ignores a serialized context blob and routes on the flat ids alone', () => { + const withBlob = { type: 'TICKET_ASSIGNED', ticketId: 't-1', context: JSON.stringify({ type: 'TICKET_ASSIGNED', ticketId: 't-OTHER' }), }; - expect(resolvePushNotificationRoute(withContext)).toBe('/tickets/dialog?id=t-1'); - const { context, ...dropped } = withContext; + expect(resolvePushNotificationRoute(withBlob)).toBe('/tickets/dialog?id=t-1'); + const { context, ...dropped } = withBlob; expect(resolvePushNotificationRoute(dropped)).toBe('/tickets/dialog?id=t-1'); }); - // CHANGED by the type + attributes migration, and worth a second opinion on review. - // This used to yield null for an unrecognised type and let the caller fall back to the - // notifications page. The spec contract asks for more than that: "an unfamiliar string - // still routes by ids ... new types will appear without a client release". A ticket id - // names a ticket whatever the type is called, so an unknown type now opens it. - // Deliberately narrow: only `ticketId` degrades. A bare `dialogId` still yields null - // unless the category says Mingo — see the unknown-type tests below. + // The spec contract asks that "an unfamiliar string still routes by ids ... new types will + // appear without a client release". A ticket id names a ticket whatever the type is called, + // so an unknown type opens it. Deliberately narrow: only `ticketId` degrades. A bare + // `dialogId` still yields null unless the category says Mingo — see the unknown-type tests below. it('routes an unrecognised type by its ticket id', () => { expect(resolvePushNotificationRoute({ type: 'SOMETHING_SHIPPED_LATER', ticketId: 't-1' })).toBe( '/tickets/dialog?id=t-1', @@ -70,12 +67,25 @@ describe('resolvePushNotificationRoute', () => { }); describe('resolveNatsNotificationRoute', () => { - it('reads the same fields from the nested envelope', () => { - expect(resolveNatsNotificationRoute({ context: { type: 'TICKET_ASSIGNED', ticketId: 't-1' } })).toBe( + it('reads the ids out of the envelope attributes', () => { + expect(resolveNatsNotificationRoute({ type: 'TICKET_ASSIGNED', attributes: { ticketId: 't-1' } })).toBe( '/tickets/dialog?id=t-1', ); expect(resolveNatsNotificationRoute({})).toBeNull(); }); + + it('ignores a legacy context on the envelope', () => { + // The typed context is retired; a push that still carries one (or only one) routes on + // `type` + `attributes` alone rather than on what the context says. + expect(resolveNatsNotificationRoute({ context: { type: 'TICKET_ASSIGNED', ticketId: 't-1' } })).toBeNull(); + expect( + resolveNatsNotificationRoute({ + type: 'TICKET_ASSIGNED', + attributes: { ticketId: 'from-attributes' }, + context: { type: 'TICKET_ASSIGNED', ticketId: 'from-context' }, + }), + ).toBe('/tickets/dialog?id=from-attributes'); + }); }); /** @@ -96,16 +106,16 @@ describe('mingo dialog deep links', () => { expect(resolvePushNotificationRoute({ type: 'ADMIN_AI_MESSAGE', dialogId: 'd-1' })).toBe( '/dashboard?mingoDialog=d-1', ); - expect(resolveNatsNotificationRoute({ context: { type: 'ADMIN_AI_MESSAGE', dialogId: 'd-1' } })).toBe( + expect(resolveNatsNotificationRoute({ type: 'ADMIN_AI_MESSAGE', attributes: { dialogId: 'd-1' } })).toBe( '/dashboard?mingoDialog=d-1', ); }); it('routes a dialog-only approval request to the dialog, and a ticket-linked one to its ticket', () => { - expect(resolvePushNotificationRoute({ type: 'ADMIN_APPROVAL_REQUEST', dialogId: 'd-1' })).toBe( + expect(resolvePushNotificationRoute({ type: 'MINGO_APPROVAL_REQUEST', dialogId: 'd-1' })).toBe( '/dashboard?mingoDialog=d-1', ); - expect(resolvePushNotificationRoute({ type: 'ADMIN_APPROVAL_REQUEST', dialogId: 'd-1', ticketId: 't-1' })).toBe( + expect(resolvePushNotificationRoute({ type: 'TICKET_APPROVAL_REQUEST', dialogId: 'd-1', ticketId: 't-1' })).toBe( '/tickets/dialog?id=t-1', ); }); @@ -121,7 +131,7 @@ describe('mingoDrawerDialogId', () => { // an unresolvable ADMIN_AI_MESSAGE is a failure of the fixture, not of the case. const mingoAction = () => { const action = resolveNotificationAction({ - meta: { contextType: 'ADMIN_AI_MESSAGE', dialogId: 'd-1' }, + meta: { notificationType: 'ADMIN_AI_MESSAGE', dialogId: 'd-1' }, } as unknown as Notification); if (!action) throw new Error('resolveNotificationAction did not resolve the mingo fixture'); return action; @@ -146,7 +156,7 @@ describe('mingoDrawerDialogId', () => { it('yields null for an action that names no dialog, drawer or not', () => { useMingoLauncherStore.setState({ canOpen: true }); const ticket = resolveNotificationAction({ - meta: { contextType: 'TICKET_ASSIGNED', ticketId: 't-1' }, + meta: { notificationType: 'TICKET_ASSIGNED', ticketId: 't-1' }, } as unknown as Notification); if (!ticket) throw new Error('resolveNotificationAction did not resolve the ticket fixture'); expect(mingoDrawerDialogId(ticket)).toBeNull(); @@ -154,24 +164,18 @@ describe('mingoDrawerDialogId', () => { }); /** - * The spec contract (`type` + `attributes`) alongside the legacy `context`. Both shapes reach - * these resolvers, and an unfamiliar type must still reach its entity — that promise is the - * whole point of the flat attribute map. + * The `type` + `attributes` contract on a mapped row: an unfamiliar type must still reach + * its entity — that promise is the whole point of the flat attribute map. */ describe('the type + attributes contract', () => { const notification = (meta: Record, category?: string): Notification => ({ id: 'n-1', title: 'x', createdAt: 0, read: false, category, meta }) as Notification; - it('routes off the spec type carried on meta', () => { + it('routes off the type carried on meta', () => { const action = resolveNotificationAction(notification({ notificationType: 'TICKET_ASSIGNED', ticketId: 't-1' })); expect(action?.route).toBe('/tickets/dialog?id=t-1'); }); - it('still routes a legacy row that only has contextType', () => { - const action = resolveNotificationAction(notification({ contextType: 'TICKET_REOPENED', ticketId: 't-2' })); - expect(action?.route).toBe('/tickets/dialog?id=t-2'); - }); - it('routes both halves of the approval split by ticket linkage', () => { expect( resolveNotificationAction(notification({ notificationType: 'TICKET_APPROVAL_REQUEST', ticketId: 't-3' }))?.route, @@ -191,12 +195,6 @@ describe('the type + attributes contract', () => { expect(route).toBe('/tickets/dialog?id=t-4'); }); - it('still reads a legacy envelope carrying only context', () => { - expect(resolveNatsNotificationRoute({ context: { type: 'TICKET_STATUS_CHANGED', ticketId: 't-5' } })).toBe( - '/tickets/dialog?id=t-5', - ); - }); - it("opens an unknown type's bare dialog only when the category says Mingo", () => { expect( resolveNotificationAction(notification({ notificationType: 'SOME_NEW_MINGO', dialogId: 'd-2' }, 'MINGO')) diff --git a/src/app/components/notifications/notification-navigation.ts b/src/app/components/notifications/notification-navigation.ts index 625a1d12..80874ac9 100644 --- a/src/app/components/notifications/notification-navigation.ts +++ b/src/app/components/notifications/notification-navigation.ts @@ -1,4 +1,4 @@ -import { ADMIN_APPROVAL_REQUEST_CONTEXT_TYPE, type Notification } from '@flamingo-stack/openframe-frontend-core'; +import type { Notification } from '@flamingo-stack/openframe-frontend-core'; import { useMingoLauncherStore } from '@/app/(app)/mingo/stores/mingo-launcher-store'; import { isApprovalNotificationType, @@ -7,65 +7,46 @@ import { } from '@/graphql/notifications/notification-attributes'; import { mingoDialogLink, routes } from '@/lib/routes'; -// Backend `NotificationContext.type` discriminators (the string `type` field; the same set the -// concrete `__typename` subtypes carry in schema.graphql). NATS payloads carry only this string, -// so it is the single source of truth for both routing and reconstructing store records live. -export const ADMIN_AI_MESSAGE_CONTEXT_TYPE = 'ADMIN_AI_MESSAGE'; -export const ADMIN_AI_TICKET_MESSAGE_CONTEXT_TYPE = 'ADMIN_AI_TICKET_MESSAGE'; -export const CLIENT_AI_MESSAGE_CONTEXT_TYPE = 'CLIENT_AI_MESSAGE'; -export const TICKET_STATUS_CHANGED_CONTEXT_TYPE = 'TICKET_STATUS_CHANGED'; +// Backend notification `type` discriminators (`TenantNotificationType`). GraphQL rows and NATS +// payloads carry the same string, so it is the single source of truth for routing. +export const ADMIN_AI_MESSAGE_TYPE = 'ADMIN_AI_MESSAGE'; +export const ADMIN_AI_TICKET_MESSAGE_TYPE = 'ADMIN_AI_TICKET_MESSAGE'; +export const CLIENT_AI_MESSAGE_TYPE = 'CLIENT_AI_MESSAGE'; +export const TICKET_STATUS_CHANGED_TYPE = 'TICKET_STATUS_CHANGED'; // A reopen transition REPLACES the generic status-change notification server-side — // without this mapping reopens would be invisible (no navigation, no auto-read). -export const TICKET_REOPENED_CONTEXT_TYPE = 'TICKET_REOPENED'; -export const TICKET_ASSIGNED_CONTEXT_TYPE = 'TICKET_ASSIGNED'; -export const TICKET_ESCALATED_BY_USER_CONTEXT_TYPE = 'TICKET_ESCALATED_BY_USER'; -export const CUSTOMER_MESSAGE_PUBLISHED_CONTEXT_TYPE = 'CUSTOMER_MESSAGE_PUBLISHED'; -export const ADMIN_MESSAGE_PUBLISHED_CONTEXT_TYPE = 'ADMIN_MESSAGE_PUBLISHED'; +export const TICKET_REOPENED_TYPE = 'TICKET_REOPENED'; +export const TICKET_ASSIGNED_TYPE = 'TICKET_ASSIGNED'; +export const TICKET_ESCALATED_BY_USER_TYPE = 'TICKET_ESCALATED_BY_USER'; +export const CUSTOMER_MESSAGE_PUBLISHED_TYPE = 'CUSTOMER_MESSAGE_PUBLISHED'; +export const ADMIN_MESSAGE_PUBLISHED_TYPE = 'ADMIN_MESSAGE_PUBLISHED'; /** - * Context `type` → GraphQL `__typename`, so the NATS live path can rebuild typed context records. - * LEGACY ONLY: a spec-shaped push carries `attributes` and needs no typed context record. + * Types whose entity is a ticket; they navigate to the ticket dialog via `ticketId`. + * CLIENT_AI_MESSAGE belongs here only when its dialog is ticket-linked — a Fae chat can run + * without a ticket, and without one the notification resolves to no action. */ -export const CONTEXT_TYPENAME_BY_TYPE: Record = { - [ADMIN_APPROVAL_REQUEST_CONTEXT_TYPE]: 'AdminApprovalRequestContext', - [ADMIN_AI_MESSAGE_CONTEXT_TYPE]: 'AdminAiMessageContext', - [ADMIN_AI_TICKET_MESSAGE_CONTEXT_TYPE]: 'AdminAiTicketMessageContext', - [CLIENT_AI_MESSAGE_CONTEXT_TYPE]: 'ClientAiMessageContext', - [TICKET_STATUS_CHANGED_CONTEXT_TYPE]: 'TicketStatusChangedContext', - [TICKET_REOPENED_CONTEXT_TYPE]: 'TicketReopenedContext', - [TICKET_ASSIGNED_CONTEXT_TYPE]: 'TicketAssignedContext', - [TICKET_ESCALATED_BY_USER_CONTEXT_TYPE]: 'TicketEscalatedByUserContext', - [CUSTOMER_MESSAGE_PUBLISHED_CONTEXT_TYPE]: 'CustomerMessagePublishedContext', - [ADMIN_MESSAGE_PUBLISHED_CONTEXT_TYPE]: 'AdminMessagePublishedContext', -}; - -/** - * Context types whose entity is a ticket; they navigate to the ticket dialog via `ticketId`. - * CLIENT_AI_MESSAGE belongs here only when its dialog is ticket-linked — `ticketId` is - * nullable on that context (a Fae chat can run without a ticket), and without one the - * notification resolves to no action, same as before the field existed. - */ -const TICKET_CONTEXT_TYPES = new Set([ - ADMIN_AI_TICKET_MESSAGE_CONTEXT_TYPE, - TICKET_STATUS_CHANGED_CONTEXT_TYPE, - TICKET_REOPENED_CONTEXT_TYPE, - TICKET_ASSIGNED_CONTEXT_TYPE, - TICKET_ESCALATED_BY_USER_CONTEXT_TYPE, - CUSTOMER_MESSAGE_PUBLISHED_CONTEXT_TYPE, - ADMIN_MESSAGE_PUBLISHED_CONTEXT_TYPE, - CLIENT_AI_MESSAGE_CONTEXT_TYPE, +const TICKET_TYPES = new Set([ + ADMIN_AI_TICKET_MESSAGE_TYPE, + TICKET_STATUS_CHANGED_TYPE, + TICKET_REOPENED_TYPE, + TICKET_ASSIGNED_TYPE, + TICKET_ESCALATED_BY_USER_TYPE, + CUSTOMER_MESSAGE_PUBLISHED_TYPE, + ADMIN_MESSAGE_PUBLISHED_TYPE, + CLIENT_AI_MESSAGE_TYPE, ]); /** - * Ticket contexts announcing a new message in the ticket's client chat; they land on the + * Ticket types announcing a new message in the ticket's client chat; they land on the * Chat tab instead of Details. Mingo ticket messages (`ADMIN_AI_TICKET_MESSAGE`) are * excluded — that conversation lives in the sidebar drawer, not the page's Client Chat * tab. */ -const TICKET_CHAT_CONTEXT_TYPES = new Set([ - CUSTOMER_MESSAGE_PUBLISHED_CONTEXT_TYPE, - ADMIN_MESSAGE_PUBLISHED_CONTEXT_TYPE, - CLIENT_AI_MESSAGE_CONTEXT_TYPE, +const TICKET_CHAT_TYPES = new Set([ + CUSTOMER_MESSAGE_PUBLISHED_TYPE, + ADMIN_MESSAGE_PUBLISHED_TYPE, + CLIENT_AI_MESSAGE_TYPE, ]); /** @@ -125,19 +106,18 @@ function resolveAction( category: string | null, ): NotificationAction | null { // Approval requests live in their ticket when one exists, otherwise the mingo dialog. - // Covers the legacy discriminator and both spec types the catalog split it into. if (isApprovalNotificationType(type)) { if (ticketId) return { label: 'Ticket Details', route: ticketRoute(ticketId) }; if (dialogId) return mingoDialogAction(dialogId); return null; } - if (type && TICKET_CONTEXT_TYPES.has(type) && ticketId) { - const tab = TICKET_CHAT_CONTEXT_TYPES.has(type) ? 'chat' : undefined; + if (type && TICKET_TYPES.has(type) && ticketId) { + const tab = TICKET_CHAT_TYPES.has(type) ? 'chat' : undefined; return { label: 'Ticket Details', route: ticketRoute(ticketId, tab) }; } - if (type === ADMIN_AI_MESSAGE_CONTEXT_TYPE && dialogId) { + if (type === ADMIN_AI_MESSAGE_TYPE && dialogId) { return mingoDialogAction(dialogId); } @@ -160,10 +140,8 @@ function resolveAction( */ export function resolveNotificationAction(notification: Notification): NotificationAction | null { const meta = notification.meta ?? {}; - // `notificationType` is the precise spec type; `contextType` is the legacy discriminator - // (and the approval split folded onto it). Either identifies a route the same way. return resolveAction( - nonEmptyString(meta.notificationType) ?? nonEmptyString(meta.contextType), + nonEmptyString(meta.notificationType), nonEmptyString(meta.ticketId), nonEmptyString(meta.dialogId), nonEmptyString(notification.category), @@ -182,15 +160,9 @@ function actionRoute(action: NotificationAction | null): string | null { * replaced the old `startsWith('/')` check on a server-supplied route string. */ function routeFromWireFields(fields: Record): string | null { - // `attributes` is the spec contract's home for the ids; the flat keys are where the legacy - // shape puts them. Both transports may carry either, so read the spec one first and fall back. - // - // NOT the row mapper's rule: `mapNotificationNode` reads ONE shape, chosen by the - // `notifications-legacy-path` flag, and never falls back. This path deliberately keeps the - // fallback — it runs on cold-start taps (a desktop OS-toast click, an FCM tap) where no - // flags are loaded yet, so keying on one would silently drop the route of every push - // carrying the other. A push carries one shape anyway; the backend's own kill-switch - // decides which, and routing on whichever arrived costs nothing here. + // `attributes` is the contract's home for the ids; the flat keys are where an FCM push + // puts them (see resolvePushNotificationRoute). Read the map first and fall back to the + // flat keys. const attributes = readNotificationAttributes(fields.attributes); return actionRoute( resolveAction( @@ -203,26 +175,18 @@ function routeFromWireFields(fields: Record): string | null { } /** - * Route for a raw NATS notification envelope (`context.type/ticketId/dialogId`), before it has - * been shaped into a store record — the desktop shell's OS-toast click path - * (`notification:click` from the Rust notification plane) hands the wire payload over as-is. + * Route for a NATS notification envelope (`type`/`attributes`/`category` at the top + * level), before it has been shaped into a store record — the desktop shell's OS-toast + * click path (`notification:click` from the Rust notification plane) hands over the + * envelope narrowed to `type` + `attributes` (its `click_payload`). Anything else on + * the envelope is ignored. */ export function resolveNatsNotificationRoute(payload: unknown): string | null { - const envelope = (payload ?? {}) as { - type?: unknown; - attributes?: unknown; - category?: unknown; - context?: Record; - }; - const context = envelope.context ?? {}; - // `type`/`attributes`/`category` sit at the TOP of the spec envelope, while the legacy ids - // live inside `context` — flatten both into one bag for the shared resolver. + const envelope = (payload ?? {}) as { type?: unknown; attributes?: unknown; category?: unknown }; return routeFromWireFields({ - type: envelope.type ?? context.type, + type: envelope.type, attributes: envelope.attributes, - ticketId: context.ticketId, - dialogId: context.dialogId, - category: envelope.category ?? context.category, + category: envelope.category, }); } @@ -230,11 +194,10 @@ export function resolveNatsNotificationRoute(payload: unknown): string | null { * Route for a push notification's FCM `data` payload — a FLAT string map, not the nested NATS * envelope, and the mobile shell's tap path. * - * Reads the top-level keys only, never the serialized `context`: the backend - * (`FcmPushSender.buildData`) DROPS that blob whole when the payload would exceed FCM's size - * budget, and writes `type` plus the `PushActionable` ids (`ticketId`/`dialogId`) as flat keys - * for exactly that reason. Every notification context implements `PushActionable`, so the flat - * ids are the guaranteed half of the payload and the only half worth routing on. + * Reads the top-level keys only: the backend (`FcmPushSender.buildData`) writes `type` plus + * the `PushActionable` ids (`ticketId`/`dialogId`) as flat keys, and drops any larger blob + * whole when the payload would exceed FCM's size budget — so the flat ids are the guaranteed + * half of the payload and the only half worth routing on. */ export function resolvePushNotificationRoute(data: unknown): string | null { return routeFromWireFields((data ?? {}) as Record); diff --git a/src/app/components/notifications/notifications-data-provider.tsx b/src/app/components/notifications/notifications-data-provider.tsx index f47fb520..c07d5539 100644 --- a/src/app/components/notifications/notifications-data-provider.tsx +++ b/src/app/components/notifications/notifications-data-provider.tsx @@ -1,8 +1,6 @@ 'use client'; import { - ADMIN_APPROVAL_REQUEST_CONTEXT_TYPE, - type ApprovalNotificationMeta, ApprovalRequestNotificationTile, getApprovalMeta, isApprovalNotification, @@ -60,7 +58,6 @@ import { mapNotificationNode, NOTIFICATION_ATTR, NOTIFICATIONS_CONNECTION_KEY, - normalizeToolCalls, parseCreatedAt, parseSeverity, readNotificationAttributes, @@ -82,7 +79,6 @@ import { routes } from '@/lib/routes'; import { ATTENTION_IDLE_MS, isSessionActive, subscribeSessionActivity } from '@/lib/session-activity'; import { withCategoryIcon } from './notification-category-icons'; import { - CONTEXT_TYPENAME_BY_TYPE, mingoDrawerDialogId, type NotificationAction, notificationTargetsDialog, @@ -102,135 +98,42 @@ const POPUP_OFFSET_CLASS = 'top-16 md:top-[4.5rem]'; const NOTIFICATIONS_HISTORY_HREF = routes.notifications({ tab: 'history' }); const DRAWER_FILTER_PAIRS = [UNFILTERED_NOTIFICATION_PAIR]; -const NATS_CONTEXT_TYPENAME = 'GenericContext'; -const APPROVAL_CONTEXT_TYPENAME = 'AdminApprovalRequestContext'; - -/** Extract the approval payload from a LEGACY NATS notification context, or null if it isn't one. */ -function parseApprovalContext(context: NatsNotificationPayload['context']): ApprovalNotificationMeta | null { - if (!context || context.type !== ADMIN_APPROVAL_REQUEST_CONTEXT_TYPE) return null; - const approvalRequestId = context.approvalRequestId; - if (typeof approvalRequestId !== 'string') return null; - return { - approvalRequestId, - dialogId: typeof context.dialogId === 'string' ? context.dialogId : null, - ticketId: typeof context.ticketId === 'string' ? context.ticketId : null, - approvalType: typeof context.approvalType === 'string' ? context.approvalType : null, - resolution: typeof context.resolution === 'string' ? context.resolution : null, - resolvedByName: typeof context.resolvedByName === 'string' ? context.resolvedByName : null, - toolCalls: normalizeToolCalls(context.toolCalls), - }; -} - /** Write a JSON custom-scalar field: RecordProxy.setValue rejects objects, so use the normalizer's unsafe setter. */ function setJsonScalar(record: unknown, name: string, value: Record | null) { (record as Record void>).setValue__UNSAFE(value, name); } -/** Get-or-create a Notification context record and stamp its GraphQL `__typename`. */ -function upsertContextRecord(store: RecordSourceSelectorProxy, id: string, typename: string): RecordProxy { - const record = store.get(id) ?? store.create(id, typename); - record.setValue(typename, '__typename'); - return record; -} - -function writeToolCallRecord( - store: RecordSourceSelectorProxy, - id: string, - call: ApprovalNotificationMeta['toolCalls'][number], -): RecordProxy { - const record = store.get(id) ?? store.create(id, 'ApprovalToolCall'); - record.setValue(call.toolExecutionRequestId ?? null, 'toolExecutionRequestId'); - record.setValue(call.toolName ?? '', 'toolName'); - record.setValue(call.toolTitle ?? null, 'toolTitle'); - record.setValue(call.toolExplanation ?? null, 'toolExplanation'); - record.setValue(call.toolType ?? null, 'toolType'); - record.setValue(Boolean(call.requiresApproval), 'requiresApproval'); - record.setValue(call.approvalType ?? null, 'approvalType'); - setJsonScalar(record, 'toolCallArguments', call.toolCallArguments ?? null); - return record; -} - -/** Build the Notification.context record for a NATS payload: approval, any typed context, or a generic fallback. */ -function writeNotificationContext( - store: RecordSourceSelectorProxy, - contextRecordId: string, - payload: NatsNotificationPayload, -): RecordProxy { - const approval = parseApprovalContext(payload.context); - if (approval) { - const record = upsertContextRecord(store, contextRecordId, APPROVAL_CONTEXT_TYPENAME); - record.setValue(ADMIN_APPROVAL_REQUEST_CONTEXT_TYPE, 'type'); - record.setValue(approval.approvalRequestId, 'approvalRequestId'); - record.setValue(approval.dialogId ?? null, 'dialogId'); - record.setValue(approval.ticketId ?? null, 'ticketId'); - record.setValue(approval.approvalType ?? null, 'approvalType'); - record.setValue(approval.resolution ?? null, 'resolution'); - record.setValue(approval.resolvedByName ?? null, 'resolvedByName'); - record.setLinkedRecords( - approval.toolCalls.map((call, i) => writeToolCallRecord(store, `${contextRecordId}:toolCall:${i}`, call)), - 'toolCalls', - ); - return record; - } - - // Any other known context: rebuild a typed record carrying the entity ids the route mapping reads - // (dialogId / ticketId), so the live tile navigates and auto-reads exactly like a fetched one. - const type = payload.context?.type; - const typename = type ? CONTEXT_TYPENAME_BY_TYPE[type] : undefined; - if (type && typename) { - const record = upsertContextRecord(store, contextRecordId, typename); - record.setValue(type, 'type'); - const dialogId = payload.context?.dialogId; - const ticketId = payload.context?.ticketId; - if (typeof dialogId === 'string') record.setValue(dialogId, 'dialogId'); - if (typeof ticketId === 'string') record.setValue(ticketId, 'ticketId'); - return record; - } - - const record = upsertContextRecord(store, contextRecordId, NATS_CONTEXT_TYPENAME); - record.setValue(type ?? 'UNKNOWN', 'type'); - return record; -} - -/** The payload's flat attribute map, or an empty one for a legacy push that carries none. */ +/** The payload's flat attribute map, or an empty one for a push that carries none. */ function payloadAttributes(payload: NatsNotificationPayload): Record { return readNotificationAttributes(payload.attributes); } -/** Entity id off either shape — spec attributes first, legacy context second. */ function payloadDialogId(payload: NatsNotificationPayload): string | null { - const fromAttributes = payloadAttributes(payload)[NOTIFICATION_ATTR.dialogId]; - if (fromAttributes) return fromAttributes; - return typeof payload.context?.dialogId === 'string' ? payload.context.dialogId : null; + return payloadAttributes(payload)[NOTIFICATION_ATTR.dialogId] ?? null; } function payloadTicketId(payload: NatsNotificationPayload): string | null { - const fromAttributes = payloadAttributes(payload)[NOTIFICATION_ATTR.ticketId]; - if (fromAttributes) return fromAttributes; - return typeof payload.context?.ticketId === 'string' ? payload.context.ticketId : null; + return payloadAttributes(payload)[NOTIFICATION_ATTR.ticketId] ?? null; } /** - * Write both shapes of the notification's facts onto the store record. + * Write the notification's `type` + `attributes` onto the store record. * * Every field the row fragment selects has to end up present, even as null — a field left * unwritten reads back as missing data, which makes Relay refetch the row (or blank it), - * and a live push must not depend on the network to render. So each of the three is either - * written from the push, kept from what the record already had, or explicitly nulled. + * and a live push must not depend on the network to render. So each is either written from + * the push, kept from what the record already had, or explicitly nulled. * * Attributes MERGE rather than replace: an UPDATED push is expected to carry the full map, * but a partial one (say a resolve sending only the resolution keys) should top the record * up instead of blanking the ids the tile navigates by. * - * The legacy context is rebuilt only when the push actually carries one — deriving it from - * a spec-shaped push would overwrite a real context with an UNKNOWN placeholder. + * Nothing else on the payload is written. In particular the retired typed `context` is not + * selected by the fragment, so it is neither rebuilt nor nulled here — writing a `null` + * LINK for it is what Relay's `setLinkedRecord` throws on, and a throwing updater stays in + * the queue and fails every later store commit until a reload. */ -function writeNotificationShapes( - store: RecordSourceSelectorProxy, - node: RecordProxy, - relayId: string, - payload: NatsNotificationPayload, -): void { +function writeNotificationShapes(node: RecordProxy, payload: NatsNotificationPayload): void { if (payload.type) node.setValue(payload.type, 'type'); else if (node.getValue('type') === undefined) node.setValue(null, 'type'); @@ -243,12 +146,6 @@ function writeNotificationShapes( } else if (node.getValue('attributes') === undefined) { setJsonScalar(node, 'attributes', null); } - - if (payload.context) { - node.setLinkedRecord(writeNotificationContext(store, `${relayId}:context`, payload), 'context'); - } else if (node.getLinkedRecord('context') === undefined) { - node.setLinkedRecord(null, 'context'); - } } /** Prepend a notification node to the unread connection, skipping if it's already present. */ @@ -276,12 +173,10 @@ interface NatsNotificationPayload { // CREATED is the initial push; UPDATED supersedes an earlier push with the same id // (e.g. an approval request whose status changed). Absent → treat as CREATED. eventType?: 'CREATED' | 'UPDATED'; - // Spec-catalog contract. Present once the backend emits on the spec path; absent on - // legacy pushes and if the `notifications.legacy-path` kill-switch is flipped back on. + // The notification's facts: the backend type string and the flat attribute map (entity + // ids at fixed keys, approval fields, whatever else the catalog declares for the type). type?: string; attributes?: Record; - // Legacy typed context. Deprecated, still the only shape some pushes carry. - context?: { type?: string; resolution?: string; [k: string]: unknown }; } interface PaginationState { @@ -611,7 +506,7 @@ interface NotificationsLiveBridgeProps { /** * True when the notification points at a dialog the user is watching live - * (mingo page or chat drawer) in a visible tab — any context carrying a + * (mingo page or chat drawer) in a visible tab — any notification carrying a * `dialogId`, i.e. Mingo messages, their ticket-linked variant, and approval * requests. Such notifications are redundant — the message or approval card is * already rendering in the chat — so the popup is skipped and the notification @@ -662,7 +557,7 @@ function maybeShowDesktopNotification( createdAt: Date.now(), category: payload.category, meta: { - notificationType: payload.type ?? payload.context?.type, + notificationType: payload.type, dialogId: payloadDialogId(payload) ?? undefined, ticketId: payloadTicketId(payload) ?? undefined, }, @@ -724,8 +619,7 @@ function NotificationsLiveBridge({ userId }: NotificationsLiveBridgeProps) { const category = payload.category ?? null; const isUpdate = payload.eventType === 'UPDATED'; const suppress = isWatchingNotificationDialog(payload); - const resolution = - payloadAttributes(payload)[NOTIFICATION_ATTR.resolution] ?? payload.context?.resolution ?? null; + const resolution = payloadAttributes(payload)[NOTIFICATION_ATTR.resolution] ?? null; let resolutionAutoRead = false; commitLocalUpdate(environmentRef.current, store => { @@ -740,7 +634,7 @@ function NotificationsLiveBridge({ userId }: NotificationsLiveBridgeProps) { node.setValue(title, 'title'); node.setValue(description, 'description'); node.setValue(category, 'category'); - writeNotificationShapes(store, node, relayId, payload); + writeNotificationShapes(node, payload); if (isUpdate) { // A TERMINAL resolution means the approval was handled (this tab's chat card, another diff --git a/src/graphql/notifications/notification-attributes.ts b/src/graphql/notifications/notification-attributes.ts index 8e21d1f9..e28297a9 100644 --- a/src/graphql/notifications/notification-attributes.ts +++ b/src/graphql/notifications/notification-attributes.ts @@ -1,29 +1,24 @@ import { - ADMIN_APPROVAL_REQUEST_CONTEXT_TYPE, type ApprovalToolCallMeta, + isApprovalNotificationType, + MINGO_APPROVAL_REQUEST_TYPE, + TICKET_APPROVAL_REQUEST_TYPE, } from '@flamingo-stack/openframe-frontend-core'; /** * The notification contract as plain data: the flat `type` + `attributes` pair the backend - * spec catalog emits, and the legacy discriminators it replaces. + * spec catalog emits. * * Deliberately free of Relay. `notifications-helpers` owns the fragment and evaluates a * `graphql` tag at module scope, which anything importing it inherits — including the route * mapping, which runs on transports (a cold-start push tap) that have no Relay in play. */ + /** - * Spec-catalog approval types. The backend splits the single legacy approval by ticket - * linkage, but keeps `context.type` at `ADMIN_APPROVAL_REQUEST` on both — so only the - * top-level `type` tells them apart. + * The approval types and the gate on them live in the core lib, because its approval tile + * is what reads them; re-exported so the app has one import for the contract. */ -export const TICKET_APPROVAL_REQUEST_TYPE = 'TICKET_APPROVAL_REQUEST'; -export const MINGO_APPROVAL_REQUEST_TYPE = 'MINGO_APPROVAL_REQUEST'; - -const APPROVAL_TYPES: ReadonlySet = new Set([ - ADMIN_APPROVAL_REQUEST_CONTEXT_TYPE, - TICKET_APPROVAL_REQUEST_TYPE, - MINGO_APPROVAL_REQUEST_TYPE, -]); +export { isApprovalNotificationType, MINGO_APPROVAL_REQUEST_TYPE, TICKET_APPROVAL_REQUEST_TYPE }; /** * Attribute keys this app reads out of the flat `attributes` map. Every other key the @@ -56,32 +51,12 @@ export function readNotificationAttributes(value: unknown): Record = new Set(['APPROVED', 'REJECTED', 'CANCELLED']); @@ -106,11 +81,6 @@ function normalizeToolCall(raw: unknown): ApprovalToolCallMeta { }; } -/** Normalize tool calls arriving as objects — the legacy typed context and the legacy NATS payload. */ -export function normalizeToolCalls(raw: unknown): ApprovalToolCallMeta[] { - return Array.isArray(raw) ? raw.map(normalizeToolCall) : []; -} - /** * `attributes.toolCalls` is a JSON-encoded array inside a string (every attribute value is * a string). Malformed input yields an empty list rather than throwing: a broken tool list @@ -119,7 +89,8 @@ export function normalizeToolCalls(raw: unknown): ApprovalToolCallMeta[] { export function parseAttributeToolCalls(raw: string | undefined): ApprovalToolCallMeta[] { if (!raw) return []; try { - return normalizeToolCalls(JSON.parse(raw)); + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.map(normalizeToolCall) : []; } catch { return []; } diff --git a/src/graphql/notifications/notification-fields.ts b/src/graphql/notifications/notification-fields.ts index 58b23916..82444c4d 100644 --- a/src/graphql/notifications/notification-fields.ts +++ b/src/graphql/notifications/notification-fields.ts @@ -5,31 +5,20 @@ import { graphql } from 'react-relay'; * (unread only) and the full `/notifications` section. * * The two documents differ only in their arguments; the node selection is the - * same, and it was written out twice before, which meant a new context type had - * to be added in both places or the drawer would silently render a row the - * section could navigate from. Spreading one fragment makes that structural, and + * same, and it was written out twice before, which meant a new field had to be + * added in both places or the drawer would silently render a row the section + * could navigate from. Spreading one fragment makes that structural, and * `mapNotificationNode` reads a generated type instead of a hand-written mirror * of what the two documents happened to select. * - * Two shapes of the same facts are selected side by side, and `mapNotificationNode` - * reads exactly one of them — whichever the `notifications-legacy-path` lever selects: - * - * 1. `type` + `attributes` — the spec-catalog contract (flat `string -> string` map), - * read by default. Entity ids live under fixed keys regardless of the type, so a type - * this release has never heard of still navigates and auto-reads. - * 2. `context` — the legacy typed union, read when the lever is on. Kept because it is - * what the backend still writes until the spec path ships, and what it writes again if - * the `notifications.legacy-path` kill-switch is flipped back on. Rows written before - * the backfill migration carry only this. - * - * Both are selected here even though only one is read, because the lever is a runtime - * value and flipping it must not need a new query. Neither is guaranteed on the wire: - * `context` is nullable on the new path, `type`/`attributes` are null on legacy rows — - * and since the read is exclusive, a row carrying only the unselected shape maps without - * type or entity ids rather than falling back (see `mapNotificationNode`). - * - * `context` is a union: Relay flattens the inline fragments into one object - * keyed by `__typename`, which is exactly what the mapper switches on. + * The notification's facts arrive as `type` + `attributes` — the spec-catalog + * contract, a flat `string -> string` map. Entity ids live under fixed keys + * regardless of the type, so a type this release has never heard of still + * navigates and auto-reads. The typed `context` union the backend used to write + * is deliberately NOT selected: it is retired, and a live push that omitted it + * had to be written into the store as an explicit `null` link, which Relay + * rejects — one failed updater then poisoned every later store commit until a + * reload. * * `@inline` because the consumer is `mapNotificationNode`, a plain function * feeding the core lib's notification components — not a component of its own. @@ -45,63 +34,5 @@ export const notificationFieldsFragment = graphql` category type attributes - context { - __typename - type - ... on AdminAiMessageContext { - dialogId - } - ... on AdminAiTicketMessageContext { - ticketId - dialogId - } - # ticketId is aliased: nullable ID on this context (a Fae chat can run without - # a ticket) cannot merge with the ID! selections above. The mapper folds it - # into meta.ticketId for ticket navigation. - ... on ClientAiMessageContext { - dialogId - clientTicketId: ticketId - } - ... on TicketStatusChangedContext { - ticketId - } - # dialogId is deliberately NOT selected: the wire declares it nullable while - # the message contexts declare dialogId: ID!, and same-named fields of - # different nullability cannot merge into one selection set. Navigation - # needs only ticketId. - ... on TicketReopenedContext { - ticketId - } - ... on TicketEscalatedByUserContext { - ticketId - } - ... on TicketAssignedContext { - ticketId - } - ... on CustomerMessagePublishedContext { - ticketId - } - ... on AdminMessagePublishedContext { - ticketId - } - ... on AdminApprovalRequestContext { - approvalRequestId - dialogId - approvalTicketId: ticketId - approvalType - resolution - resolvedByName - toolCalls { - toolExecutionRequestId - toolName - toolTitle - toolExplanation - toolType - requiresApproval - approvalType - toolCallArguments - } - } - } } `; diff --git a/src/graphql/notifications/notifications-contract.test.ts b/src/graphql/notifications/notifications-contract.test.ts index 6cd3b710..2ecbd896 100644 --- a/src/graphql/notifications/notifications-contract.test.ts +++ b/src/graphql/notifications/notifications-contract.test.ts @@ -1,6 +1,6 @@ import { getApprovalMeta, isApprovalNotification } from '@flamingo-stack/openframe-frontend-core'; import type ReactRelay from 'react-relay'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; // `graphql` tags are compiled away by the relay babel transform, which vitest doesn't run; // the tag would throw at module scope on import. The mapper under test takes already-read @@ -11,30 +11,14 @@ vi.mock('react-relay', async importOriginal => ({ })); import type { notificationFields_notification$data as NotificationFieldsData } from '@/__generated__/notificationFields_notification.graphql'; -import { useFeatureFlagsStore } from '@/stores/feature-flags-store'; import { isApprovalResolved, mapNotificationNode } from './notifications-helpers'; /** - * The backend is mid-migration from a typed `context` union to a flat `type` + `attributes` - * pair, and both shapes are on the wire at once: legacy rows carry only `context`, spec-path - * rows carry only `type`/`attributes`, and a kill-switch can flip emission back at any time. - * - * The mapper reads exactly ONE of them, chosen by the `notifications-legacy-path` lever, and - * never mixes the two on a row — so these tests come in pairs: what each shape yields on its - * own path, and what it yields (nothing) on the other one. That exclusivity is the contract, - * and the tests are the only thing pinning it: `attributes` is an untyped JSON scalar, so - * nothing else catches a wrong key or a fallback creeping back in. + * The row contract is the flat `type` + `attributes` pair. `attributes` is an untyped JSON + * scalar, so nothing but these tests catches a wrong key, a dropped fact, or a shape the + * core lib's approval tile cannot read. */ -/** - * The lever the mapper reads. Hoisted out of its own suite because it now decides which - * shape EVERY test here is exercising — a legacy-context row is only read on the legacy path. - */ -const setLegacyPath = (value: boolean) => - useFeatureFlagsStore.setState({ isLoaded: true, flags: { 'notifications-legacy-path': value } }); - -const resetFlags = () => useFeatureFlagsStore.setState({ isLoaded: false, flags: {} }); - const BASE = { id: 'Tm90aWZpY2F0aW9uOjE=', severity: 'INFO', @@ -46,89 +30,17 @@ const BASE = { } as const; function node(overrides: Record): NotificationFieldsData { - return { ...BASE, type: null, attributes: null, context: null, ...overrides } as NotificationFieldsData; -} - -/** A legacy typed context record, keyed by its GraphQL `__typename` the way Relay flattens it. */ -function legacyContext(typename: string, fields: Record) { - // __typename is GraphQL's discriminator, not ours - return { __typename: typename, ...fields }; + return { ...BASE, type: null, attributes: null, ...overrides } as NotificationFieldsData; } -describe('legacy context rows, on the legacy path', () => { - beforeEach(() => setLegacyPath(true)); - afterEach(resetFlags); - - it('reads entity ids off the typed context', () => { - const mapped = mapNotificationNode( - node({ context: legacyContext('TicketAssignedContext', { type: 'TICKET_ASSIGNED', ticketId: 't-1' }) }), - ); - expect(mapped.meta?.ticketId).toBe('t-1'); - expect(mapped.meta?.contextType).toBe('TICKET_ASSIGNED'); - }); - - it('keeps the approval tile working off context.toolCalls', () => { - const mapped = mapNotificationNode( - node({ - category: 'MINGO', - context: legacyContext('AdminApprovalRequestContext', { - type: 'ADMIN_APPROVAL_REQUEST', - approvalRequestId: 'a-1', - dialogId: 'd-1', - approvalType: 'TOOL', - resolution: null, - resolvedByName: null, - toolCalls: [{ toolName: 'run_script', requiresApproval: true }], - }), - }), - ); - expect(isApprovalNotification(mapped)).toBe(true); - expect(getApprovalMeta(mapped)?.toolCalls).toHaveLength(1); - expect(getApprovalMeta(mapped)?.dialogId).toBe('d-1'); - }); -}); - -describe('legacy context rows, on the spec path', () => { - afterEach(resetFlags); - - it('are read for nothing but their plain fields', () => { - // The exclusivity trade-off, stated as a test: until the backfill has swept these rows, - // the default path renders them as plain tiles rather than reading a contract it is not on. - setLegacyPath(false); - const mapped = mapNotificationNode( - node({ context: legacyContext('TicketAssignedContext', { type: 'TICKET_ASSIGNED', ticketId: 't-1' }) }), - ); - expect(mapped.title).toBe('A title'); - expect(mapped.meta?.ticketId).toBeUndefined(); - expect(mapped.meta?.notificationType).toBeUndefined(); - expect(mapped.meta?.contextType).toBeUndefined(); - expect(mapped.type).toBeUndefined(); - }); - - it('do not reach the approval tile through context.toolCalls', () => { - setLegacyPath(false); - const mapped = mapNotificationNode( - node({ - category: 'MINGO', - context: legacyContext('AdminApprovalRequestContext', { - type: 'ADMIN_APPROVAL_REQUEST', - approvalRequestId: 'a-1', - dialogId: 'd-1', - toolCalls: [{ toolName: 'run_script', requiresApproval: true }], - }), - }), - ); - expect(isApprovalNotification(mapped)).toBe(false); - }); -}); - -describe('spec attribute rows', () => { - it('reads entity ids off attributes when no context is present', () => { +describe('type + attributes rows', () => { + it('reads entity ids off attributes', () => { const mapped = mapNotificationNode( node({ type: 'TICKET_ASSIGNED', attributes: { ticketId: 't-2', ticketNumber: '238', assigneeName: 'Ann' } }), ); expect(mapped.meta?.ticketId).toBe('t-2'); expect(mapped.meta?.notificationType).toBe('TICKET_ASSIGNED'); + expect(mapped.type).toBe('Ticket Assigned'); }); it('carries through attributes this release has no code for', () => { @@ -143,21 +55,10 @@ describe('spec attribute rows', () => { expect(mapped.meta?.ticketId).toBeUndefined(); }); - it('ignores the legacy context entirely on a dual-shape row', () => { - const mapped = mapNotificationNode( - node({ - type: 'TICKET_REOPENED', - attributes: { ticketId: 'from-attributes' }, - context: legacyContext('TicketReopenedContext', { - type: 'TICKET_REOPENED', - ticketId: 'from-context', - dialogId: 'from-context', - }), - }), - ); - expect(mapped.meta?.ticketId).toBe('from-attributes'); - // Not merely outranked — a fact only the unselected shape carries stays absent. - expect(mapped.meta?.dialogId).toBeUndefined(); + it('keeps the type when the attribute map is missing', () => { + const mapped = mapNotificationNode(node({ type: 'TICKET_ASSIGNED' })); + expect(mapped.meta?.notificationType).toBe('TICKET_ASSIGNED'); + expect(mapped.meta?.ticketId).toBeUndefined(); }); }); @@ -169,7 +70,7 @@ describe('the approval split', () => { toolCalls: JSON.stringify([{ toolName: 'run_script', requiresApproval: true, toolCallArguments: { cmd: 'ls' } }]), }; - it('folds both spec approval types onto the discriminator the core lib gates on', () => { + it('gates the approval tile on either approval type', () => { for (const type of ['TICKET_APPROVAL_REQUEST', 'MINGO_APPROVAL_REQUEST']) { const mapped = mapNotificationNode(node({ type, attributes: { ...attributes, ticketId: 't-4' } })); expect(isApprovalNotification(mapped), type).toBe(true); @@ -177,11 +78,20 @@ describe('the approval split', () => { } }); + it('does not gate a non-approval type on the approval tile', () => { + const mapped = mapNotificationNode( + node({ type: 'TICKET_ASSIGNED', attributes: { ...attributes, ticketId: 't-4' } }), + ); + expect(isApprovalNotification(mapped)).toBe(false); + }); + it('parses the JSON-encoded tool call array out of its string', () => { const mapped = mapNotificationNode( node({ type: 'MINGO_APPROVAL_REQUEST', attributes: { ...attributes, dialogId: 'd-2' } }), ); const approval = getApprovalMeta(mapped); + expect(approval?.dialogId).toBe('d-2'); + expect(approval?.approvalType).toBe('TOOL'); expect(approval?.toolCalls).toHaveLength(1); expect(approval?.toolCalls[0].toolName).toBe('run_script'); expect(approval?.toolCalls[0].toolCallArguments).toEqual({ cmd: 'ls' }); @@ -206,12 +116,14 @@ describe('the approval split', () => { }); }); -describe('rows with neither shape', () => { - it('still maps, offering no entity metadata', () => { +describe('rows with neither type nor attributes', () => { + it('still map, offering no entity metadata', () => { const mapped = mapNotificationNode(node({})); expect(mapped.title).toBe('A title'); + expect(mapped.type).toBeUndefined(); expect(mapped.meta?.ticketId).toBeUndefined(); - expect(mapped.meta?.contextType).toBeUndefined(); + expect(mapped.meta?.notificationType).toBeUndefined(); + expect(isApprovalNotification(mapped)).toBe(false); }); }); @@ -223,90 +135,11 @@ describe('approval resolution', () => { }); it('does not treat a freshly emitted PENDING request as resolved', () => { - // The attribute map carries `resolution` from the start, unlike the legacy context which - // left it null — a truthiness check here would retire live approvals to the read list. + // The attribute map carries `resolution` from the start — a truthiness check here + // would retire live approvals to the read list. expect(isApprovalResolved('PENDING')).toBe(false); expect(isApprovalResolved(null)).toBe(false); expect(isApprovalResolved(undefined)).toBe(false); expect(isApprovalResolved('')).toBe(false); }); }); - -describe('the notifications-legacy-path rollback lever', () => { - const setFlag = setLegacyPath; - - afterEach(resetFlags); - - const dualShapeRow = () => - node({ - type: 'TICKET_REOPENED', - attributes: { ticketId: 'from-attributes' }, - context: legacyContext('TicketReopenedContext', { type: 'TICKET_REOPENED', ticketId: 'from-context' }), - }); - - it('reads attributes while off', () => { - setFlag(false); - expect(mapNotificationNode(dualShapeRow()).meta?.ticketId).toBe('from-attributes'); - }); - - it('reads the legacy context while on', () => { - setFlag(true); - expect(mapNotificationNode(dualShapeRow()).meta?.ticketId).toBe('from-context'); - }); - - it('keeps unknown attribute keys off the legacy path', () => { - // `meta` is spread from the attribute bag, so exclusivity has to hold for keys this - // release has no code for too — otherwise the unselected contract leaks in wholesale. - setFlag(true); - const mapped = mapNotificationNode( - node({ - type: 'TICKET_STATUS_CHANGED', - attributes: { ticketId: 'from-attributes', newStatusLabel: 'In Progress' }, - context: legacyContext('TicketStatusChangedContext', { - type: 'TICKET_STATUS_CHANGED', - ticketId: 'from-context', - }), - }), - ); - expect(mapped.meta?.newStatusLabel).toBeUndefined(); - }); - - it('is off when the server has never heard of the flag', () => { - // It ships before the backend declares it — an undeclared flag must read as off, - // not as "unknown", or turning the lever on later would be the only safe state. - useFeatureFlagsStore.setState({ isLoaded: true, flags: {} }); - expect(mapNotificationNode(dualShapeRow()).meta?.ticketId).toBe('from-attributes'); - }); - - it('never falls back to the shape it did not select', () => { - // Both directions, because both are load-bearing: with the lever ON a spec-only row is - // not read off `attributes`, and with it OFF a legacy-only row is not read off `context`. - setFlag(true); - const specOnly = node({ type: 'TICKET_ASSIGNED', attributes: { ticketId: 't-1' } }); - expect(mapNotificationNode(specOnly).meta?.ticketId).toBeUndefined(); - expect(mapNotificationNode(specOnly).meta?.notificationType).toBeUndefined(); - - setFlag(false); - const legacyOnly = node({ - context: legacyContext('TicketAssignedContext', { type: 'TICKET_ASSIGNED', ticketId: 't-2' }), - }); - expect(mapNotificationNode(legacyOnly).meta?.ticketId).toBeUndefined(); - expect(mapNotificationNode(legacyOnly).meta?.notificationType).toBeUndefined(); - }); - - it('reads approval tool calls from the legacy context while on, not from attributes', () => { - setFlag(true); - const mapped = mapNotificationNode( - node({ - type: 'MINGO_APPROVAL_REQUEST', - attributes: { approvalRequestId: 'a-1', toolCalls: JSON.stringify([{ toolName: 'from_attributes' }]) }, - context: legacyContext('AdminApprovalRequestContext', { - type: 'ADMIN_APPROVAL_REQUEST', - approvalRequestId: 'a-1', - toolCalls: [{ toolName: 'from_context' }], - }), - }), - ); - expect(getApprovalMeta(mapped)?.toolCalls[0].toolName).toBe('from_context'); - }); -}); diff --git a/src/graphql/notifications/notifications-helpers.ts b/src/graphql/notifications/notifications-helpers.ts index 9a7f9ee3..73a552da 100644 --- a/src/graphql/notifications/notifications-helpers.ts +++ b/src/graphql/notifications/notifications-helpers.ts @@ -5,14 +5,7 @@ import type { notificationFields_notification$key as NotificationFieldsKey, } from '@/__generated__/notificationFields_notification.graphql'; import type { NotificationSeverity } from '@/generated/schema-enums'; -import { featureFlags } from '@/lib/feature-flags'; -import { - NOTIFICATION_ATTR, - normalizeToolCalls, - parseAttributeToolCalls, - readNotificationAttributes, - toLegacyContextType, -} from './notification-attributes'; +import { NOTIFICATION_ATTR, parseAttributeToolCalls, readNotificationAttributes } from './notification-attributes'; import { notificationFieldsFragment } from './notification-fields'; export { @@ -20,11 +13,9 @@ export { isApprovalResolved, MINGO_APPROVAL_REQUEST_TYPE, NOTIFICATION_ATTR, - normalizeToolCalls, parseAttributeToolCalls, readNotificationAttributes, TICKET_APPROVAL_REQUEST_TYPE, - toLegacyContextType, } from './notification-attributes'; export const NOTIFICATIONS_CONNECTION_KEY = 'NotificationsList_notifications'; @@ -241,14 +232,13 @@ export function parseSeverity( } /** - * Human label for a notification type discriminator: SNAKE_CASE → Title Case + * Human label for a notification `type`: SNAKE_CASE → Title Case * (e.g. TICKET_STATUS_CHANGED → "Ticket Status Changed"). Data-driven so new backend - * types label themselves; the catch-all discriminators carry no meaning → undefined. - * Fed the spec `type` when present, the legacy `context.type` otherwise. + * types label themselves. */ -export function contextTypeLabel(contextType: string | null | undefined): string | undefined { - if (!contextType || contextType === 'UNKNOWN' || contextType === 'GENERIC') return undefined; - return contextType +export function notificationTypeLabel(type: string | null | undefined): string | undefined { + if (!type) return undefined; + return type .toLowerCase() .split('_') .filter(Boolean) @@ -310,81 +300,35 @@ export function readNotificationNode(ref: NotificationFieldsKey): NotificationFi * data rather than the fragment reference, so a caller that also needs the raw * fields (the section table's own columns) reads the node once. * - * Reads exactly ONE of the two contracts — the spec pair (`type` + `attributes`) by - * default, the legacy typed `context` when the rollback lever is on — never a mix of - * both. A row that carries only the other shape still maps: it keeps its title, body, - * severity and timestamp, and offers no type or entity metadata (so: a plain tile, no - * navigation). See the lever comment inside for why that is the intended outcome. + * Reads the `type` + `attributes` contract only. A row carrying neither (nothing the + * backfill migration has swept) still maps: it keeps its title, body, severity and + * timestamp, and offers no type or entity metadata — a plain tile, no navigation. */ export function mapNotificationNode(node: NotificationFieldsData): Notification { const severity = normalizeSeverity(node.severity); + const attributes = readNotificationAttributes(node.attributes); + const notificationType = node.type ?? undefined; - /** - * Which contract this release reads. Normally the spec one; the `notifications-legacy-path` - * flag switches back to the typed `context` without a release, should attributes turn out - * wrong in production. - * - * The switch is EXCLUSIVE: the shape the lever does not select is not read on any field, - * and a row carrying only that shape maps with no type and no entity ids rather than - * quietly answering from the other contract. That is the point — what the UI shows is - * always the shape the lever names, so a rollback is a clean swap and never a per-row - * mixture nobody can reason about. The cost is real and expected: with the lever OFF, - * rows the backfill migration has not swept yet (no `attributes`) lose their navigation - * until it has, and with it ON, spec-path rows that carry no context lose theirs. - * - * Zeroing the unselected side ONCE, here, is what makes that hold for the whole map — - * the `...attributes` spread below included, so unknown spec keys cannot leak into `meta` - * behind the lever's back. - */ - const readLegacy = featureFlags.notificationsLegacyPath.enabled(); - const context = readLegacy ? node.context : null; - const attributes: Record = readLegacy ? {} : readNotificationAttributes(node.attributes); - /** One fact, read off the selected shape only — there is no cross-shape fallback. */ - const pick = (spec: T | undefined | null, legacy: T | undefined | null): T | undefined => - (readLegacy ? legacy : spec) ?? undefined; - - const notificationType = pick(node.type, context?.type); - + // Entity ids (`ticketId`, `dialogId`) drive navigation and auto-read uniformly across + // types (see resolveNotificationAction); they sit at fixed keys for every type, known or + // not, so the spread carries them into `meta` as-is. const meta: Record = { // Every attribute the backend sent, including keys this release has no code for. ...attributes, + // The precise backend type — what the core lib's approval gate and the route mapping read. notificationType, - // What the core lib's approval gate reads — the approval split folded back onto one string. - contextType: toLegacyContextType(notificationType), }; - // Entity ids drive navigation and auto-read uniformly across types (see - // resolveNotificationAction). Under `attributes` they sit at fixed keys for every type, - // known or not; the context aliases below are not a fallback across shapes — they are one - // shape's own spelling variants, since the union declares the same field with different - // nullability per member. - const ticketId = pick( - attributes[NOTIFICATION_ATTR.ticketId], - context?.ticketId ?? context?.approvalTicketId ?? context?.clientTicketId, - ); - const dialogId = pick(attributes[NOTIFICATION_ATTR.dialogId], context?.dialogId); - if (dialogId) meta.dialogId = dialogId; - if (ticketId) meta.ticketId = ticketId; - - const approvalRequestId = pick(attributes[NOTIFICATION_ATTR.approvalRequestId], context?.approvalRequestId); - if (approvalRequestId) { - meta.approvalRequestId = approvalRequestId; - meta.approvalType = pick(attributes[NOTIFICATION_ATTR.approvalType], context?.approvalType) ?? null; - meta.resolution = pick(attributes[NOTIFICATION_ATTR.resolution], context?.resolution) ?? null; - meta.resolvedByName = pick(attributes[NOTIFICATION_ATTR.resolvedByName], context?.resolvedByName) ?? null; + if (attributes[NOTIFICATION_ATTR.approvalRequestId]) { // Must end up an ARRAY: the core lib's `getApprovalMeta` bails on anything else, which - // would silently downgrade the approval tile to a plain one. On the spec path the spread - // above put the raw JSON string here, so this assignment is not optional. The two shapes - // need different readers (a JSON-encoded string vs. typed records), which is why this one - // fact branches instead of going through `pick`. - meta.toolCalls = readLegacy - ? normalizeToolCalls(context?.toolCalls) - : parseAttributeToolCalls(attributes[NOTIFICATION_ATTR.toolCalls]); + // would silently downgrade the approval tile to a plain one. The spread above put the + // raw JSON string here, so this assignment is not optional. + meta.toolCalls = parseAttributeToolCalls(attributes[NOTIFICATION_ATTR.toolCalls]); } return { id: node.id, - type: contextTypeLabel(notificationType), + type: notificationTypeLabel(notificationType), title: stripNotificationMarkup(node.title), description: node.description == null ? undefined : stripNotificationMarkup(node.description), createdAt: parseCreatedAt(node.createdAt), diff --git a/src/lib/feature-flags.ts b/src/lib/feature-flags.ts index 0ce5afa9..41bcf2ad 100644 --- a/src/lib/feature-flags.ts +++ b/src/lib/feature-flags.ts @@ -10,7 +10,6 @@ export const FEATURE_FLAG_NAMES = [ 'billings', 'help-center', 'notifications', - 'notifications-legacy-path', 'debug-nats-chunks', 'mingo-ai-chat-settings', 'customer-ai-assistant-settings', @@ -96,35 +95,6 @@ export const featureFlags = { return getFlagValue('notifications', () => false); }, }, - /** - * Rollback lever for the notification `type` + `attributes` migration: it selects which - * of the two contracts the row mapper reads. OFF (the default, and the normal state) → - * the spec pair `type` + `attributes`; ON → the legacy typed `context`. - * - * The selection is EXCLUSIVE, in both directions: the shape the lever does not name is - * not read at all, so a row carrying only that shape maps with no type and no entity ids - * instead of answering from the other contract. A rollback is therefore a clean swap of - * contracts, never a per-row mixture — at the cost that rows the backfill migration has - * not swept yet lose their navigation while the lever is OFF. `mapNotificationNode` in - * `graphql/notifications/notifications-helpers.ts` is where that is implemented, and - * `notifications-contract.test.ts` pins it. - * - * Mirrors the backend's `notifications.legacy-path` kill-switch by name, but is a - * separate switch for a separate job — that one decides what gets WRITTEN, this one - * what we READ. It exists so a rollback needs no frontend release; the flag is read - * even before it is declared server-side, where it simply resolves to OFF. - * - * NOT covered by this lever: the transport routing path (`notification-navigation.ts` - * `routeFromWireFields`, and the NATS payload helpers in `notifications-data-provider`), - * which reads whichever shape a push happens to carry. Those run on cold-start taps - * where no flags are loaded, and a push carries one shape anyway — the backend's own - * kill-switch decides which. - */ - notificationsLegacyPath: { - enabled(): boolean { - return getFlagValue('notifications-legacy-path', () => false); - }, - }, debugNatsChunks: { enabled(): boolean { // Local override FIRST — see `isDebugChunkLogForced`: a server value of diff --git a/src/lib/native-shell.ts b/src/lib/native-shell.ts index 927d9995..4e437fbc 100644 --- a/src/lib/native-shell.ts +++ b/src/lib/native-shell.ts @@ -452,10 +452,9 @@ export function onNativeTokenUpdate(callback: (tokens: NativeTokens) => void): v /** * Subscribe to OS-notification clicks forwarded by the desktop shell's Rust * notification plane. Resolve a route from the payload with - * resolveNatsNotificationRoute, which reads the envelope's `type`/`attributes` - * when the shell forwards them and its legacy `context` otherwise. Desktop-only - * transport; mobile deep-links notification taps through FCM instead - * (native-push.ts). + * resolveNatsNotificationRoute, which reads the envelope's `type`/`attributes`. + * Desktop-only transport; mobile deep-links notification taps through FCM + * instead (native-push.ts). * * Resolves `true` only once a listener is actually live. Callers must not open * the shell's click gate (takeNativeStartupNotificationClick) otherwise: the