From 9f8d9b3e2eff3bc9879c83d445b1da7b45f1d685 Mon Sep 17 00:00:00 2001 From: methosiea Date: Sat, 14 Feb 2026 10:03:34 +0100 Subject: [PATCH 1/8] perf(platform): migrate to TanStack Query --- package-lock.json | 82 ++++++++--------- .../approvals/components/approvals-client.tsx | 5 +- .../app/features/approvals/hooks/actions.ts | 8 +- .../automation-active-toggle.test.tsx | 10 +- .../components/automation-active-toggle.tsx | 14 ++- .../components/automation-assistant.tsx | 9 +- .../components/automation-create-dialog.tsx | 9 +- .../components/automation-navigation.tsx | 22 +++-- .../components/automation-row-actions.tsx | 15 +-- .../components/automation-sidepanel.tsx | 2 +- .../components/automation-steps.tsx | 2 +- .../components/automation-tester.tsx | 7 +- .../app/features/automations/hooks/actions.ts | 6 +- .../components/schedule-create-dialog.tsx | 7 +- .../triggers/components/webhooks-section.tsx | 7 +- .../automations/triggers/hooks/actions.ts | 6 +- .../automations/triggers/hooks/mutations.ts | 47 +++------- .../features/chat/components/chat-actions.tsx | 2 +- .../chat/components/chat-history-sidebar.tsx | 2 +- .../components/human-input-request-card.tsx | 7 +- .../components/integration-approval-card.tsx | 5 +- .../workflow-creation-approval-card.tsx | 5 +- .../chat/hooks/use-convex-file-upload.ts | 2 +- .../features/chat/hooks/use-send-message.ts | 10 +- .../components/conversation-header.tsx | 21 +++-- .../components/conversation-panel.tsx | 9 +- .../components/conversations-client.tsx | 12 +-- .../components/message-editor.tsx | 2 +- .../hooks/__tests__/mutation-hooks.test.ts | 91 +++++++++---------- .../features/conversations/hooks/actions.ts | 4 +- .../custom-agent-active-toggle.test.tsx | 15 +-- .../components/custom-agent-active-toggle.tsx | 23 +++-- .../components/custom-agent-create-dialog.tsx | 2 +- .../components/custom-agent-navigation.tsx | 30 ++++-- .../components/custom-agent-row-actions.tsx | 30 ++++-- .../custom-agent-version-history-dialog.tsx | 2 +- .../custom-agent-webhook-section.tsx | 7 +- .../components/test-chat-panel.tsx | 6 +- .../features/custom-agents/hooks/mutations.ts | 23 +---- .../components/customers-import-dialog.tsx | 2 +- .../app/features/customers/hooks/mutations.ts | 10 +- .../components/document-row-actions.tsx | 7 +- .../components/onedrive-import-dialog.tsx | 7 +- .../documents/components/rag-status-badge.tsx | 7 +- .../hooks/__tests__/mutation-hooks.test.ts | 41 ++++----- .../app/features/documents/hooks/actions.ts | 6 +- .../app/features/documents/hooks/mutations.ts | 14 +-- .../components/organization-form-client.tsx | 3 +- .../features/organization/hooks/actions.ts | 6 +- .../app/features/products/hooks/mutations.ts | 15 +-- .../components/account-form-client.tsx | 6 +- .../components/api-key-create-dialog.tsx | 2 +- .../components/api-key-revoke-dialog.tsx | 43 +++++---- .../settings/api-keys/hooks/use-api-keys.ts | 31 ++++--- .../components/integration-manage-dialog.tsx | 12 +-- .../integration-upload-dialog.tsx | 4 +- .../components/sso-config-dialog.tsx | 10 +- .../hooks/__tests__/mutation-hooks.test.ts | 21 ++--- .../settings/integrations/hooks/actions.ts | 24 +++-- .../settings/integrations/hooks/mutations.ts | 5 +- .../use-generate-integration-oauth2-url.ts | 4 +- .../hooks/use-save-oauth2-credentials.ts | 6 +- .../components/member-add-dialog.tsx | 7 +- .../components/member-edit-dialog.tsx | 2 +- .../hooks/__tests__/mutation-hooks.test.ts | 49 +++++----- .../settings/organization/hooks/mutations.ts | 13 +-- .../teams/components/team-create-dialog.tsx | 2 +- .../hooks/__tests__/mutation-hooks.test.ts | 40 ++++---- .../settings/teams/hooks/mutations.ts | 10 +- .../components/tone-of-voice-form-client.tsx | 15 +-- .../features/tone-of-voice/hooks/actions.ts | 4 +- .../components/vendor-delete-dialog.tsx | 2 +- .../vendors/components/vendor-edit-dialog.tsx | 2 +- .../components/vendors-import-dialog.tsx | 2 +- .../hooks/__tests__/mutation-hooks.test.ts | 58 ++++++------ .../components/website-add-dialog.tsx | 2 +- .../components/website-delete-dialog.tsx | 2 +- .../components/website-edit-dialog.tsx | 2 +- .../components/website-row-actions.tsx | 9 +- .../app/hooks/use-convex-action-mutation.ts | 15 --- .../platform/app/hooks/use-convex-mutation.ts | 16 +--- .../dashboard/$id/automations/$amId.tsx | 2 +- .../$id/automations/$amId/configuration.tsx | 2 +- services/platform/convex/README.md | 47 ++++++---- 84 files changed, 552 insertions(+), 605 deletions(-) delete mode 100644 services/platform/app/hooks/use-convex-action-mutation.ts diff --git a/package-lock.json b/package-lock.json index c84f36ac7..e02a7b284 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "version": "0.9.31", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@adobe/css-tools": { @@ -167,7 +167,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@csstools/css-calc": "^3.0.0", @@ -181,7 +181,7 @@ "version": "11.2.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -191,7 +191,7 @@ "version": "6.7.8", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.8.tgz", "integrity": "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", @@ -205,7 +205,7 @@ "version": "11.2.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -215,7 +215,7 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@azure-rest/core-client": { @@ -2994,7 +2994,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -3014,7 +3014,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.0.0.tgz", "integrity": "sha512-q4d82GTl8BIlh/dTnVsWmxnbWJeb3kiU8eUH71UxlxnS+WIaALmtzTL8gR15PkYOexMQYVk0CO4qIG93C1IvPA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -3038,7 +3038,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -3066,7 +3066,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==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -3089,7 +3089,7 @@ "version": "1.0.26", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz", "integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -3106,7 +3106,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==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -3579,7 +3579,7 @@ "version": "1.11.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.11.0.tgz", "integrity": "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -12822,7 +12822,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -14472,7 +14472,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mdn-data": "2.12.2", @@ -14492,7 +14492,7 @@ "version": "5.3.7", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^4.1.1", @@ -14508,7 +14508,7 @@ "version": "11.2.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -14741,7 +14741,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", @@ -16818,7 +16818,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==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.6.0" @@ -17665,7 +17665,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==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/is-promise": { @@ -18045,7 +18045,7 @@ "version": "28.0.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@acemir/cssom": "^0.9.31", @@ -18085,7 +18085,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -18095,7 +18095,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -19372,7 +19372,7 @@ "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "devOptional": true, + "dev": true, "license": "CC0-1.0" }, "node_modules/media-typer": { @@ -20935,7 +20935,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -20948,7 +20948,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -21659,7 +21659,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -22677,7 +22677,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==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -23003,7 +23003,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -24119,7 +24119,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==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/tabbable": { @@ -24341,7 +24341,7 @@ "version": "7.0.22", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.22.tgz", "integrity": "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "tldts-core": "^7.0.22" @@ -24354,7 +24354,7 @@ "version": "7.0.22", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.22.tgz", "integrity": "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/tmp": { @@ -24432,7 +24432,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -24445,7 +24445,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -24874,7 +24874,7 @@ "version": "7.20.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.20.0.tgz", "integrity": "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -25575,7 +25575,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -25634,7 +25634,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==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" @@ -25659,7 +25659,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -25669,7 +25669,7 @@ "version": "16.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", @@ -26374,7 +26374,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -26393,7 +26393,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/xtend": { diff --git a/services/platform/app/features/approvals/components/approvals-client.tsx b/services/platform/app/features/approvals/components/approvals-client.tsx index ebfcefbe6..d47ef4a5d 100644 --- a/services/platform/app/features/approvals/components/approvals-client.tsx +++ b/services/platform/app/features/approvals/components/approvals-client.tsx @@ -143,9 +143,8 @@ export function ApprovalsClient({ const { data: memberContext } = useCurrentMemberContext(organizationId); - const { mutateAsync: updateApprovalStatus } = useUpdateApprovalStatus(); - const { mutateAsync: removeRecommendedProduct } = - useRemoveRecommendedProduct(); + const updateApprovalStatus = useUpdateApprovalStatus(); + const removeRecommendedProduct = useRemoveRecommendedProduct(); const handleApprove = useCallback( async (approvalId: string) => { diff --git a/services/platform/app/features/approvals/hooks/actions.ts b/services/platform/app/features/approvals/hooks/actions.ts index d5bc79db8..8be0c0b5e 100644 --- a/services/platform/app/features/approvals/hooks/actions.ts +++ b/services/platform/app/features/approvals/hooks/actions.ts @@ -1,14 +1,12 @@ -import { useConvexActionMutation } from '@/app/hooks/use-convex-action-mutation'; +import { useConvexAction } from '@/app/hooks/use-convex-action'; import { api } from '@/convex/_generated/api'; export function useExecuteApprovedIntegrationOperation() { - return useConvexActionMutation( + return useConvexAction( api.approvals.actions.executeApprovedIntegrationOperation, ); } export function useExecuteApprovedWorkflowCreation() { - return useConvexActionMutation( - api.approvals.actions.executeApprovedWorkflowCreation, - ); + return useConvexAction(api.approvals.actions.executeApprovedWorkflowCreation); } diff --git a/services/platform/app/features/automations/components/automation-active-toggle.test.tsx b/services/platform/app/features/automations/components/automation-active-toggle.test.tsx index e88c71330..5a09ef1e1 100644 --- a/services/platform/app/features/automations/components/automation-active-toggle.test.tsx +++ b/services/platform/app/features/automations/components/automation-active-toggle.test.tsx @@ -12,14 +12,8 @@ const mockUnpublish = vi.fn(); vi.mock('../hooks/mutations', async (importOriginal) => ({ ...(await importOriginal()), - useRepublishAutomation: () => ({ - mutateAsync: mockRepublish, - isPending: false, - }), - useUnpublishAutomation: () => ({ - mutateAsync: mockUnpublish, - isPending: false, - }), + useRepublishAutomation: () => mockRepublish, + useUnpublishAutomation: () => mockUnpublish, })); vi.mock('@/app/hooks/use-convex-auth', () => ({ diff --git a/services/platform/app/features/automations/components/automation-active-toggle.tsx b/services/platform/app/features/automations/components/automation-active-toggle.tsx index 733b25675..8fe00b655 100644 --- a/services/platform/app/features/automations/components/automation-active-toggle.tsx +++ b/services/platform/app/features/automations/components/automation-active-toggle.tsx @@ -30,10 +30,10 @@ export function AutomationActiveToggle({ const [showDeactivateDialog, setShowDeactivateDialog] = useState(false); - const { mutateAsync: republishAutomation, isPending: isRepublishing } = - useRepublishAutomation(); - const { mutateAsync: unpublishAutomation, isPending: isUnpublishing } = - useUnpublishAutomation(); + const republishAutomation = useRepublishAutomation(); + const [isRepublishing, setIsRepublishing] = useState(false); + const unpublishAutomation = useUnpublishAutomation(); + const [isUnpublishing, setIsUnpublishing] = useState(false); const isToggling = isRepublishing || isUnpublishing; @@ -42,6 +42,7 @@ export function AutomationActiveToggle({ const handleActivate = useCallback(async () => { if (!user) return; + setIsRepublishing(true); try { await republishAutomation({ wfDefinitionId: automation._id, @@ -57,11 +58,14 @@ export function AutomationActiveToggle({ title: tToast('error.automationPublishFailed'), variant: 'destructive', }); + } finally { + setIsRepublishing(false); } }, [republishAutomation, automation._id, user, tToast]); const handleDeactivateConfirm = useCallback(async () => { if (!user) return; + setIsUnpublishing(true); try { await unpublishAutomation({ wfDefinitionId: automation._id, @@ -78,6 +82,8 @@ export function AutomationActiveToggle({ title: tToast('error.automationDeactivateFailed'), variant: 'destructive', }); + } finally { + setIsUnpublishing(false); } }, [unpublishAutomation, automation._id, user, tToast]); diff --git a/services/platform/app/features/automations/components/automation-assistant.tsx b/services/platform/app/features/automations/components/automation-assistant.tsx index 15950c694..fd4d27b86 100644 --- a/services/platform/app/features/automations/components/automation-assistant.tsx +++ b/services/platform/app/features/automations/components/automation-assistant.tsx @@ -293,11 +293,10 @@ function AutomationAssistantContent({ }); // Connect to workflow assistant agent - const { mutateAsync: chatWithWorkflowAssistant } = - useChatWithWorkflowAssistant(); - const { mutateAsync: createChatThread } = useCreateThread(); - const { mutateAsync: deleteChatThread } = useDeleteThread(); - const { mutateAsync: updateWorkflowMetadata } = useUpdateAutomationMetadata(); + const chatWithWorkflowAssistant = useChatWithWorkflowAssistant(); + const createChatThread = useCreateThread(); + const deleteChatThread = useDeleteThread(); + const updateWorkflowMetadata = useUpdateAutomationMetadata(); const { data: workflow } = useWorkflow(automationId); diff --git a/services/platform/app/features/automations/components/automation-create-dialog.tsx b/services/platform/app/features/automations/components/automation-create-dialog.tsx index e48eadb68..dd144ac87 100644 --- a/services/platform/app/features/automations/components/automation-create-dialog.tsx +++ b/services/platform/app/features/automations/components/automation-create-dialog.tsx @@ -40,10 +40,9 @@ export function CreateAutomationDialog({ const { t } = useT('automations'); const { t: tCommon } = useT('common'); const { user } = useAuth(); - const { mutateAsync: createChatThread } = useCreateThread(); - const { mutateAsync: updateWorkflowMetadata } = useUpdateAutomationMetadata(); - const { mutateAsync: chatWithWorkflowAssistant } = - useChatWithWorkflowAssistant(); + const createChatThread = useCreateThread(); + const updateWorkflowMetadata = useUpdateAutomationMetadata(); + const chatWithWorkflowAssistant = useChatWithWorkflowAssistant(); const formSchema = useMemo( () => @@ -67,7 +66,7 @@ export function CreateAutomationDialog({ }); const navigate = useNavigate(); - const { mutateAsync: createAutomation } = useCreateAutomation(); + const createAutomation = useCreateAutomation(); const onSubmit = async (data: FormData) => { try { diff --git a/services/platform/app/features/automations/components/automation-navigation.tsx b/services/platform/app/features/automations/components/automation-navigation.tsx index f1a1342aa..5023e1e46 100644 --- a/services/platform/app/features/automations/components/automation-navigation.tsx +++ b/services/platform/app/features/automations/components/automation-navigation.tsx @@ -8,6 +8,7 @@ import { Upload, Pencil, } from 'lucide-react'; +import { useState } from 'react'; import type { Doc } from '@/convex/_generated/dataModel'; @@ -59,12 +60,12 @@ export function AutomationNavigation({ ); const { user } = useAuth(); - const { mutateAsync: publishAutomation, isPending: isPublishing } = - usePublishAutomationDraft(); - const { mutateAsync: createDraftFromActive, isPending: isCreatingDraft } = - useCreateDraftFromActive(); - const { mutateAsync: unpublishAutomation, isPending: isUnpublishing } = - useUnpublishAutomation(); + const publishAutomation = usePublishAutomationDraft(); + const [isPublishing, setIsPublishing] = useState(false); + const createDraftFromActive = useCreateDraftFromActive(); + const [isCreatingDraft, setIsCreatingDraft] = useState(false); + const unpublishAutomation = useUnpublishAutomation(); + const [isUnpublishing, setIsUnpublishing] = useState(false); const { data: versions } = useListWorkflowVersions( organizationId, @@ -106,6 +107,7 @@ export function AutomationNavigation({ return; } + setIsPublishing(true); try { await publishAutomation({ wfDefinitionId: toId<'wfDefinitions'>(automationId), @@ -125,6 +127,8 @@ export function AutomationNavigation({ : t('navigation.toast.publishFailed'), variant: 'destructive', }); + } finally { + setIsPublishing(false); } }; @@ -137,6 +141,7 @@ export function AutomationNavigation({ return; } + setIsCreatingDraft(true); try { const result = await createDraftFromActive({ wfDefinitionId: toId<'wfDefinitions'>(automationId), @@ -170,6 +175,8 @@ export function AutomationNavigation({ : t('navigation.toast.draftFailed'), variant: 'destructive', }); + } finally { + setIsCreatingDraft(false); } }; @@ -182,6 +189,7 @@ export function AutomationNavigation({ return; } + setIsUnpublishing(true); try { await unpublishAutomation({ wfDefinitionId: toId<'wfDefinitions'>(automationId), @@ -201,6 +209,8 @@ export function AutomationNavigation({ : t('navigation.toast.deactivateFailed'), variant: 'destructive', }); + } finally { + setIsUnpublishing(false); } }; diff --git a/services/platform/app/features/automations/components/automation-row-actions.tsx b/services/platform/app/features/automations/components/automation-row-actions.tsx index c059ebacd..3a5592e74 100644 --- a/services/platform/app/features/automations/components/automation-row-actions.tsx +++ b/services/platform/app/features/automations/components/automation-row-actions.tsx @@ -37,12 +37,12 @@ export function AutomationRowActions({ const dialogs = useEntityRowDialogs(['delete', 'rename', 'unpublish']); const [isDeleting, setIsDeleting] = useState(false); - const { mutateAsync: duplicateAutomation } = useDuplicateAutomation(); - const { mutateAsync: deleteAutomation } = useDeleteAutomation(); - const { mutateAsync: republishAutomation } = useRepublishAutomation(); - const { mutateAsync: unpublishAutomation, isPending: isUnpublishing } = - useUnpublishAutomation(); - const { mutateAsync: updateAutomation } = useUpdateAutomation(); + const duplicateAutomation = useDuplicateAutomation(); + const deleteAutomation = useDeleteAutomation(); + const republishAutomation = useRepublishAutomation(); + const unpublishAutomation = useUnpublishAutomation(); + const [isUnpublishing, setIsUnpublishing] = useState(false); + const updateAutomation = useUpdateAutomation(); const handlePublish = useCallback(async () => { if (!user) return; @@ -109,6 +109,7 @@ export function AutomationRowActions({ const handleUnpublishConfirm = useCallback(async () => { if (!user) return; + setIsUnpublishing(true); try { await unpublishAutomation({ wfDefinitionId: automation._id, @@ -125,6 +126,8 @@ export function AutomationRowActions({ title: tToast('error.automationDeactivateFailed'), variant: 'destructive', }); + } finally { + setIsUnpublishing(false); } }, [unpublishAutomation, automation._id, user, dialogs.setOpen, tToast]); diff --git a/services/platform/app/features/automations/components/automation-sidepanel.tsx b/services/platform/app/features/automations/components/automation-sidepanel.tsx index 59bba8f70..215e3c95b 100644 --- a/services/platform/app/features/automations/components/automation-sidepanel.tsx +++ b/services/platform/app/features/automations/components/automation-sidepanel.tsx @@ -87,7 +87,7 @@ export function AutomationSidePanel({ Record >({}); const [isSaving, setIsSaving] = useState(false); - const { mutateAsync: updateStep } = useUpdateStep(); + const updateStep = useUpdateStep(); const originalConfigJson = useMemo( () => (step?.config ? JSON.stringify(step.config, null, 2) : '{}'), diff --git a/services/platform/app/features/automations/components/automation-steps.tsx b/services/platform/app/features/automations/components/automation-steps.tsx index c86109589..d915fcd15 100644 --- a/services/platform/app/features/automations/components/automation-steps.tsx +++ b/services/platform/app/features/automations/components/automation-steps.tsx @@ -81,7 +81,7 @@ function AutomationStepsInner({ }: AutomationStepsProps) { const { t } = useT('automations'); const { user } = useAuth(); - const { mutateAsync: createStep } = useCreateStep(); + const createStep = useCreateStep(); const isDraft = status === 'draft'; const isActive = status === 'active'; const hasSteps = steps && steps.length > 0; diff --git a/services/platform/app/features/automations/components/automation-tester.tsx b/services/platform/app/features/automations/components/automation-tester.tsx index 85605cc9e..32f815702 100644 --- a/services/platform/app/features/automations/components/automation-tester.tsx +++ b/services/platform/app/features/automations/components/automation-tester.tsx @@ -56,8 +56,8 @@ export function AutomationTester({ const [isDryRunning, setIsDryRunning] = useState(false); const [dryRunResult, setDryRunResult] = useState(null); - const { mutateAsync: startWorkflow, isPending: isExecuting } = - useStartWorkflow(); + const startWorkflow = useStartWorkflow(); + const [isExecuting, setIsExecuting] = useState(false); const parsedInput = (() => { try { @@ -105,6 +105,7 @@ export function AutomationTester({ return; } + setIsExecuting(true); try { const executionId = await startWorkflow({ organizationId, @@ -136,6 +137,8 @@ export function AutomationTester({ : t('tester.toast.startFailed'), variant: 'destructive', }); + } finally { + setIsExecuting(false); } }; diff --git a/services/platform/app/features/automations/hooks/actions.ts b/services/platform/app/features/automations/hooks/actions.ts index 2c7054dd4..23593358d 100644 --- a/services/platform/app/features/automations/hooks/actions.ts +++ b/services/platform/app/features/automations/hooks/actions.ts @@ -1,8 +1,6 @@ -import { useConvexActionMutation } from '@/app/hooks/use-convex-action-mutation'; +import { useConvexAction } from '@/app/hooks/use-convex-action'; import { api } from '@/convex/_generated/api'; export function useChatWithWorkflowAssistant() { - return useConvexActionMutation( - api.agents.workflow.actions.chatWithWorkflowAssistant, - ); + return useConvexAction(api.agents.workflow.actions.chatWithWorkflowAssistant); } diff --git a/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx b/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx index 44eac1efc..06c0dad34 100644 --- a/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx +++ b/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx @@ -57,8 +57,8 @@ export function ScheduleCreateDialog({ const { toast } = useToast(); const createSchedule = useCreateSchedule(); const updateSchedule = useUpdateSchedule(); - const { mutateAsync: generateCron, isPending: isGenerating } = - useGenerateCron(); + const generateCron = useGenerateCron(); + const [isGenerating, setIsGenerating] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [naturalLanguage, setNaturalLanguage] = useState(''); @@ -108,6 +108,7 @@ export function ScheduleCreateDialog({ const handleGenerate = useCallback(async () => { if (!naturalLanguage.trim() || isGenerating) return; + setIsGenerating(true); setGenerateError(''); setCronDescription(''); @@ -121,6 +122,8 @@ export function ScheduleCreateDialog({ setCronDescription(result.description); } catch { setGenerateError(t('triggers.schedules.form.ai.generateError')); + } finally { + setIsGenerating(false); } }, [naturalLanguage, isGenerating, generateCron, setValue, t]); diff --git a/services/platform/app/features/automations/triggers/components/webhooks-section.tsx b/services/platform/app/features/automations/triggers/components/webhooks-section.tsx index bf7ac617d..2d1d483e7 100644 --- a/services/platform/app/features/automations/triggers/components/webhooks-section.tsx +++ b/services/platform/app/features/automations/triggers/components/webhooks-section.tsx @@ -43,8 +43,8 @@ export function WebhooksSection({ const webhookCollection = useWebhookCollection(workflowRootId); const { webhooks } = useWebhooks(webhookCollection); - const { mutateAsync: createWebhook, isPending: isCreating } = - useCreateWebhook(); + const createWebhook = useCreateWebhook(); + const [isCreating, setIsCreating] = useState(false); const toggleWebhook = useToggleWebhook(); const deleteWebhookMutation = useDeleteWebhook(); const [createdUrl, setCreatedUrl] = useState(null); @@ -60,6 +60,7 @@ export function WebhooksSection({ ); const handleCreate = useCallback(async () => { + setIsCreating(true); try { const result = await createWebhook({ organizationId, @@ -72,6 +73,8 @@ export function WebhooksSection({ }); } catch { toast({ title: 'Failed to create webhook', variant: 'destructive' }); + } finally { + setIsCreating(false); } }, [createWebhook, organizationId, workflowRootId, toast, t, getWebhookUrl]); diff --git a/services/platform/app/features/automations/triggers/hooks/actions.ts b/services/platform/app/features/automations/triggers/hooks/actions.ts index 474aebe3c..87664220e 100644 --- a/services/platform/app/features/automations/triggers/hooks/actions.ts +++ b/services/platform/app/features/automations/triggers/hooks/actions.ts @@ -1,8 +1,6 @@ -import { useConvexActionMutation } from '@/app/hooks/use-convex-action-mutation'; +import { useConvexAction } from '@/app/hooks/use-convex-action'; import { api } from '@/convex/_generated/api'; export function useGenerateCron() { - return useConvexActionMutation( - api.workflows.triggers.actions.generateCronExpression, - ); + return useConvexAction(api.workflows.triggers.actions.generateCronExpression); } diff --git a/services/platform/app/features/automations/triggers/hooks/mutations.ts b/services/platform/app/features/automations/triggers/hooks/mutations.ts index 1891462df..615900a62 100644 --- a/services/platform/app/features/automations/triggers/hooks/mutations.ts +++ b/services/platform/app/features/automations/triggers/hooks/mutations.ts @@ -2,78 +2,53 @@ import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; import { api } from '@/convex/_generated/api'; export function useCreateSchedule() { - const { mutateAsync } = useConvexMutation( - api.workflows.triggers.mutations.createSchedule, - ); - return mutateAsync; + return useConvexMutation(api.workflows.triggers.mutations.createSchedule); } export function useUpdateSchedule() { - const { mutateAsync } = useConvexMutation( - api.workflows.triggers.mutations.updateSchedule, - ); - return mutateAsync; + return useConvexMutation(api.workflows.triggers.mutations.updateSchedule); } export function useToggleSchedule() { - const { mutateAsync } = useConvexMutation( - api.workflows.triggers.mutations.toggleSchedule, - ); - return mutateAsync; + return useConvexMutation(api.workflows.triggers.mutations.toggleSchedule); } export function useDeleteSchedule() { - const { mutateAsync } = useConvexMutation( - api.workflows.triggers.mutations.deleteSchedule, - ); - return mutateAsync; + return useConvexMutation(api.workflows.triggers.mutations.deleteSchedule); } export function useCreateWebhook() { - const { mutateAsync, isPending } = useConvexMutation( - api.workflows.triggers.mutations.createWebhook, - ); - return { mutateAsync, isPending }; + return useConvexMutation(api.workflows.triggers.mutations.createWebhook); } export function useToggleWebhook() { - const { mutateAsync } = useConvexMutation( - api.workflows.triggers.mutations.toggleWebhook, - ); - return mutateAsync; + return useConvexMutation(api.workflows.triggers.mutations.toggleWebhook); } export function useDeleteWebhook() { - const { mutateAsync } = useConvexMutation( - api.workflows.triggers.mutations.deleteWebhook, - ); - return mutateAsync; + return useConvexMutation(api.workflows.triggers.mutations.deleteWebhook); } export function useCreateEventSubscription() { - const { mutateAsync } = useConvexMutation( + return useConvexMutation( api.workflows.triggers.mutations.createEventSubscription, ); - return mutateAsync; } export function useUpdateEventSubscription() { - const { mutateAsync } = useConvexMutation( + return useConvexMutation( api.workflows.triggers.mutations.updateEventSubscription, ); - return mutateAsync; } export function useToggleEventSubscription() { - const { mutateAsync } = useConvexMutation( + return useConvexMutation( api.workflows.triggers.mutations.toggleEventSubscription, ); - return mutateAsync; } export function useDeleteEventSubscription() { - const { mutateAsync } = useConvexMutation( + return useConvexMutation( api.workflows.triggers.mutations.deleteEventSubscription, ); - return mutateAsync; } diff --git a/services/platform/app/features/chat/components/chat-actions.tsx b/services/platform/app/features/chat/components/chat-actions.tsx index b1a1817d0..563674725 100644 --- a/services/platform/app/features/chat/components/chat-actions.tsx +++ b/services/platform/app/features/chat/components/chat-actions.tsx @@ -42,7 +42,7 @@ export function ChatActions({ const { t: tCommon } = useT('common'); const { t: tChat } = useT('chat'); - const { mutateAsync: deleteThread } = useDeleteThread(); + const deleteThread = useDeleteThread(); const handleDelete = async () => { try { diff --git a/services/platform/app/features/chat/components/chat-history-sidebar.tsx b/services/platform/app/features/chat/components/chat-history-sidebar.tsx index 5e41ef35e..ba43f0f41 100644 --- a/services/platform/app/features/chat/components/chat-history-sidebar.tsx +++ b/services/platform/app/features/chat/components/chat-history-sidebar.tsx @@ -61,7 +61,7 @@ export function ChatHistorySidebar({ const threadCollection = useThreadCollection(); const { threads: threadsData } = useThreads(threadCollection); - const { mutateAsync: updateThread } = useUpdateThread(); + const updateThread = useUpdateThread(); const chats = useMemo( () => diff --git a/services/platform/app/features/chat/components/human-input-request-card.tsx b/services/platform/app/features/chat/components/human-input-request-card.tsx index 46ac82d9b..5fdebd644 100644 --- a/services/platform/app/features/chat/components/human-input-request-card.tsx +++ b/services/platform/app/features/chat/components/human-input-request-card.tsx @@ -41,8 +41,8 @@ function HumanInputRequestCardComponent({ const [selectedValue, setSelectedValue] = useState(''); const [selectedValues, setSelectedValues] = useState([]); - const { mutateAsync: submitResponse, isPending: isSubmitting } = - useSubmitHumanInputResponse(); + const [isSubmitting, setIsSubmitting] = useState(false); + const submitResponse = useSubmitHumanInputResponse(); const isPending = status === 'pending'; @@ -77,6 +77,7 @@ function HumanInputRequestCardComponent({ } setError(null); + setIsSubmitting(true); try { await submitResponse({ @@ -89,6 +90,8 @@ function HumanInputRequestCardComponent({ err instanceof Error ? err.message : 'Failed to submit response', ); console.error('Failed to submit response:', err); + } finally { + setIsSubmitting(false); } }; diff --git a/services/platform/app/features/chat/components/integration-approval-card.tsx b/services/platform/app/features/chat/components/integration-approval-card.tsx index b1bf682bc..ac2cee14c 100644 --- a/services/platform/app/features/chat/components/integration-approval-card.tsx +++ b/services/platform/app/features/chat/components/integration-approval-card.tsx @@ -53,9 +53,8 @@ function IntegrationApprovalCardComponent({ const [isRejecting, setIsRejecting] = useState(false); const [error, setError] = useState(null); - const { mutateAsync: updateApprovalStatus } = useUpdateApprovalStatus(); - const { mutateAsync: executeApprovedOperation } = - useExecuteApprovedIntegrationOperation(); + const updateApprovalStatus = useUpdateApprovalStatus(); + const executeApprovedOperation = useExecuteApprovedIntegrationOperation(); const isPending = status === 'pending'; const isProcessing = isApproving || isRejecting; diff --git a/services/platform/app/features/chat/components/workflow-creation-approval-card.tsx b/services/platform/app/features/chat/components/workflow-creation-approval-card.tsx index 907c0ec60..af1781dcf 100644 --- a/services/platform/app/features/chat/components/workflow-creation-approval-card.tsx +++ b/services/platform/app/features/chat/components/workflow-creation-approval-card.tsx @@ -246,9 +246,8 @@ function WorkflowCreationApprovalCardComponent({ ); const { copied, onClick: handleCopy } = useCopyButton(configJson); - const { mutateAsync: updateApprovalStatus } = useUpdateApprovalStatus(); - const { mutateAsync: executeApprovedWorkflow } = - useExecuteApprovedWorkflowCreation(); + const updateApprovalStatus = useUpdateApprovalStatus(); + const executeApprovedWorkflow = useExecuteApprovedWorkflowCreation(); const isPending = status === 'pending'; const isProcessing = isApproving || isRejecting; diff --git a/services/platform/app/features/chat/hooks/use-convex-file-upload.ts b/services/platform/app/features/chat/hooks/use-convex-file-upload.ts index b2ae4997e..ba9bb5df7 100644 --- a/services/platform/app/features/chat/hooks/use-convex-file-upload.ts +++ b/services/platform/app/features/chat/hooks/use-convex-file-upload.ts @@ -37,7 +37,7 @@ export function useConvexFileUpload(config?: ConvexFileUploadConfig) { const { t } = useT('chat'); const [attachments, setAttachments] = useState([]); const [uploadingFiles, setUploadingFiles] = useState([]); - const { mutateAsync: generateUploadUrl } = useGenerateUploadUrl(); + const generateUploadUrl = useGenerateUploadUrl(); const mergedConfig = useMemo( () => ({ ...DEFAULT_CONFIG, ...config }), diff --git a/services/platform/app/features/chat/hooks/use-send-message.ts b/services/platform/app/features/chat/hooks/use-send-message.ts index 137b38ce2..681d57b3a 100644 --- a/services/platform/app/features/chat/hooks/use-send-message.ts +++ b/services/platform/app/features/chat/hooks/use-send-message.ts @@ -49,11 +49,11 @@ export function useSendMessage({ const { t } = useT('chat'); const navigate = useNavigate(); - const { mutateAsync: createThread } = useCreateThread(); - const { mutateAsync: updateThread } = useUpdateThread(); - const { mutateAsync: chatWithAgent } = useChatWithAgent(); - const { mutateAsync: chatWithBuiltinAgent } = useChatWithBuiltinAgent(); - const { mutateAsync: chatWithCustomAgent } = useChatWithCustomAgent(); + const createThread = useCreateThread(); + const updateThread = useUpdateThread(); + const chatWithAgent = useChatWithAgent(); + const chatWithBuiltinAgent = useChatWithBuiltinAgent(); + const chatWithCustomAgent = useChatWithCustomAgent(); const sendMessage = useCallback( async (message: string, attachments?: FileAttachment[]) => { diff --git a/services/platform/app/features/conversations/components/conversation-header.tsx b/services/platform/app/features/conversations/components/conversation-header.tsx index 10a124106..d037b8935 100644 --- a/services/platform/app/features/conversations/components/conversation-header.tsx +++ b/services/platform/app/features/conversations/components/conversation-header.tsx @@ -55,12 +55,12 @@ export function ConversationHeader({ const { customer } = conversation; const [isCustomerInfoOpen, setIsCustomerInfoOpen] = useState(false); - const { mutateAsync: closeConversation, isPending: isClosing } = - useCloseConversation(); - const { mutateAsync: reopenConversation, isPending: isReopening } = - useReopenConversation(); - const { mutateAsync: markAsSpamMutation, isPending: isMarkingSpam } = - useMarkAsSpam(); + const [isClosing, setIsClosing] = useState(false); + const [isReopening, setIsReopening] = useState(false); + const [isMarkingSpam, setIsMarkingSpam] = useState(false); + const closeConversation = useCloseConversation(); + const reopenConversation = useReopenConversation(); + const markAsSpamMutation = useMarkAsSpam(); const isLoading = isClosing || isReopening || isMarkingSpam; // Lookup full customer document from collection @@ -75,6 +75,7 @@ export function ConversationHeader({ ); const handleResolveConversation = async () => { + setIsClosing(true); try { await closeConversation({ conversationId: toId<'conversations'>(conversation.id), @@ -91,10 +92,13 @@ export function ConversationHeader({ title: t('header.toast.closeFailed'), variant: 'destructive', }); + } finally { + setIsClosing(false); } }; const handleReopenConversation = async () => { + setIsReopening(true); try { await reopenConversation({ conversationId: toId<'conversations'>(conversation.id), @@ -111,10 +115,13 @@ export function ConversationHeader({ title: t('header.toast.reopenFailed'), variant: 'destructive', }); + } finally { + setIsReopening(false); } }; const handleMarkAsSpam = async () => { + setIsMarkingSpam(true); try { await markAsSpamMutation({ conversationId: toId<'conversations'>(conversation.id), @@ -131,6 +138,8 @@ export function ConversationHeader({ title: t('header.toast.markAsSpamFailed'), variant: 'destructive', }); + } finally { + setIsMarkingSpam(false); } }; diff --git a/services/platform/app/features/conversations/components/conversation-panel.tsx b/services/platform/app/features/conversations/components/conversation-panel.tsx index 0ea76ece8..475b3cbcd 100644 --- a/services/platform/app/features/conversations/components/conversation-panel.tsx +++ b/services/platform/app/features/conversations/components/conversation-panel.tsx @@ -61,11 +61,10 @@ export function ConversationPanel({ selectedConversationId, ); - const { mutateAsync: markAsRead } = useMarkAsRead(); - const { mutateAsync: sendMessageViaIntegration } = - useSendMessageViaIntegration(); - const { mutateAsync: generateUploadUrl } = useGenerateUploadUrl(); - const { mutateAsync: downloadAttachments } = useDownloadAttachments(); + const markAsRead = useMarkAsRead(); + const sendMessageViaIntegration = useSendMessageViaIntegration(); + const generateUploadUrl = useGenerateUploadUrl(); + const downloadAttachments = useDownloadAttachments(); const containerRef = useRef(null); const messageComposerRef = useRef(null); diff --git a/services/platform/app/features/conversations/components/conversations-client.tsx b/services/platform/app/features/conversations/components/conversations-client.tsx index 61252f1ab..df3ed2583 100644 --- a/services/platform/app/features/conversations/components/conversations-client.tsx +++ b/services/platform/app/features/conversations/components/conversations-client.tsx @@ -170,9 +170,9 @@ export function ConversationsClient({ }, [paginatedResult.results, searchQuery, initialSearch]); // Convex mutations - const { mutateAsync: bulkResolve } = useBulkCloseConversations(); - const { mutateAsync: bulkReopen } = useBulkReopenConversations(); - const { mutateAsync: addMessage } = useAddMessage(); + const bulkResolve = useBulkCloseConversations(); + const bulkReopen = useBulkReopenConversations(); + const addMessage = useAddMessage(); const [selectionState, setSelectionState] = useState({ type: 'individual', @@ -237,9 +237,9 @@ interface ConversationsClientInnerProps { isOpen: boolean; isSending: boolean; }) => void; - bulkResolve: ReturnType['mutateAsync']; - bulkReopen: ReturnType['mutateAsync']; - addMessage: ReturnType['mutateAsync']; + bulkResolve: ReturnType; + bulkReopen: ReturnType; + addMessage: ReturnType; paginatedResult: UsePaginatedQueryResult; tChat: ReturnType['t']; tConversations: ReturnType['t']; diff --git a/services/platform/app/features/conversations/components/message-editor.tsx b/services/platform/app/features/conversations/components/message-editor.tsx index 484d30d15..9de2da927 100644 --- a/services/platform/app/features/conversations/components/message-editor.tsx +++ b/services/platform/app/features/conversations/components/message-editor.tsx @@ -109,7 +109,7 @@ function MilkdownEditorInner({ const { t: tConversations } = useT('conversations'); const { t: tCommon } = useT('common'); - const { mutateAsync: improveMessage } = useImproveMessage(); + const improveMessage = useImproveMessage(); // Helper function to format file size const formatFileSize = (bytes: number): string => { diff --git a/services/platform/app/features/conversations/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/conversations/hooks/__tests__/mutation-hooks.test.ts index c2754ad2c..94887ae8a 100644 --- a/services/platform/app/features/conversations/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/conversations/hooks/__tests__/mutation-hooks.test.ts @@ -2,13 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { toId } from '@/convex/lib/type_cast_helpers'; -const mockMutateAsync = vi.fn(); +const mockMutationFn = vi.fn(); vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => ({ - mutateAsync: mockMutateAsync, - isPending: false, - }), + useConvexMutation: () => mockMutationFn, })); vi.mock('@/convex/_generated/api', () => ({ @@ -36,28 +33,27 @@ describe('useCloseConversation', () => { vi.clearAllMocks(); }); - it('returns full mutation object from useConvexMutation', () => { - const mutation = useCloseConversation(); - expect(mutation.mutateAsync).toBe(mockMutateAsync); - expect(mutation.isPending).toBe(false); + it('returns the mutation function from useConvexMutation', () => { + const closeConversation = useCloseConversation(); + expect(closeConversation).toBe(mockMutationFn); }); - it('calls mutateAsync with the correct args', async () => { - mockMutateAsync.mockResolvedValueOnce(null); - const { mutateAsync: closeConversation } = useCloseConversation(); + it('calls mutation with the correct args', async () => { + mockMutationFn.mockResolvedValueOnce(null); + const closeConversation = useCloseConversation(); await closeConversation({ conversationId: toId<'conversations'>('conv-123'), }); - expect(mockMutateAsync).toHaveBeenCalledWith({ + expect(mockMutationFn).toHaveBeenCalledWith({ conversationId: toId<'conversations'>('conv-123'), }); }); - it('propagates errors from mutateAsync', async () => { - mockMutateAsync.mockRejectedValueOnce(new Error('Close failed')); - const { mutateAsync: closeConversation } = useCloseConversation(); + it('propagates errors from mutation', async () => { + mockMutationFn.mockRejectedValueOnce(new Error('Close failed')); + const closeConversation = useCloseConversation(); await expect( closeConversation({ @@ -72,28 +68,27 @@ describe('useReopenConversation', () => { vi.clearAllMocks(); }); - it('returns full mutation object from useConvexMutation', () => { - const mutation = useReopenConversation(); - expect(mutation.mutateAsync).toBe(mockMutateAsync); - expect(mutation.isPending).toBe(false); + it('returns the mutation function from useConvexMutation', () => { + const reopenConversation = useReopenConversation(); + expect(reopenConversation).toBe(mockMutationFn); }); - it('calls mutateAsync with the correct args', async () => { - mockMutateAsync.mockResolvedValueOnce(null); - const { mutateAsync: reopenConversation } = useReopenConversation(); + it('calls mutation with the correct args', async () => { + mockMutationFn.mockResolvedValueOnce(null); + const reopenConversation = useReopenConversation(); await reopenConversation({ conversationId: toId<'conversations'>('conv-123'), }); - expect(mockMutateAsync).toHaveBeenCalledWith({ + expect(mockMutationFn).toHaveBeenCalledWith({ conversationId: toId<'conversations'>('conv-123'), }); }); - it('propagates errors from mutateAsync', async () => { - mockMutateAsync.mockRejectedValueOnce(new Error('Reopen failed')); - const { mutateAsync: reopenConversation } = useReopenConversation(); + it('propagates errors from mutation', async () => { + mockMutationFn.mockRejectedValueOnce(new Error('Reopen failed')); + const reopenConversation = useReopenConversation(); await expect( reopenConversation({ @@ -108,28 +103,27 @@ describe('useMarkAsRead', () => { vi.clearAllMocks(); }); - it('returns full mutation object from useConvexMutation', () => { - const mutation = useMarkAsRead(); - expect(mutation.mutateAsync).toBe(mockMutateAsync); - expect(mutation.isPending).toBe(false); + it('returns the mutation function from useConvexMutation', () => { + const markAsRead = useMarkAsRead(); + expect(markAsRead).toBe(mockMutationFn); }); - it('calls mutateAsync with the correct args', async () => { - mockMutateAsync.mockResolvedValueOnce(null); - const { mutateAsync: markAsRead } = useMarkAsRead(); + it('calls mutation with the correct args', async () => { + mockMutationFn.mockResolvedValueOnce(null); + const markAsRead = useMarkAsRead(); await markAsRead({ conversationId: toId<'conversations'>('conv-123'), }); - expect(mockMutateAsync).toHaveBeenCalledWith({ + expect(mockMutationFn).toHaveBeenCalledWith({ conversationId: toId<'conversations'>('conv-123'), }); }); - it('propagates errors from mutateAsync', async () => { - mockMutateAsync.mockRejectedValueOnce(new Error('MarkAsRead failed')); - const { mutateAsync: markAsRead } = useMarkAsRead(); + it('propagates errors from mutation', async () => { + mockMutationFn.mockRejectedValueOnce(new Error('MarkAsRead failed')); + const markAsRead = useMarkAsRead(); await expect( markAsRead({ @@ -144,28 +138,27 @@ describe('useMarkAsSpam', () => { vi.clearAllMocks(); }); - it('returns full mutation object from useConvexMutation', () => { - const mutation = useMarkAsSpam(); - expect(mutation.mutateAsync).toBe(mockMutateAsync); - expect(mutation.isPending).toBe(false); + it('returns the mutation function from useConvexMutation', () => { + const markAsSpam = useMarkAsSpam(); + expect(markAsSpam).toBe(mockMutationFn); }); - it('calls mutateAsync with the correct args', async () => { - mockMutateAsync.mockResolvedValueOnce(null); - const { mutateAsync: markAsSpam } = useMarkAsSpam(); + it('calls mutation with the correct args', async () => { + mockMutationFn.mockResolvedValueOnce(null); + const markAsSpam = useMarkAsSpam(); await markAsSpam({ conversationId: toId<'conversations'>('conv-123'), }); - expect(mockMutateAsync).toHaveBeenCalledWith({ + expect(mockMutationFn).toHaveBeenCalledWith({ conversationId: toId<'conversations'>('conv-123'), }); }); - it('propagates errors from mutateAsync', async () => { - mockMutateAsync.mockRejectedValueOnce(new Error('Spam failed')); - const { mutateAsync: markAsSpam } = useMarkAsSpam(); + it('propagates errors from mutation', async () => { + mockMutationFn.mockRejectedValueOnce(new Error('Spam failed')); + const markAsSpam = useMarkAsSpam(); await expect( markAsSpam({ diff --git a/services/platform/app/features/conversations/hooks/actions.ts b/services/platform/app/features/conversations/hooks/actions.ts index 89e313a12..77f492d93 100644 --- a/services/platform/app/features/conversations/hooks/actions.ts +++ b/services/platform/app/features/conversations/hooks/actions.ts @@ -1,6 +1,6 @@ -import { useConvexActionMutation } from '@/app/hooks/use-convex-action-mutation'; +import { useConvexAction } from '@/app/hooks/use-convex-action'; import { api } from '@/convex/_generated/api'; export function useImproveMessage() { - return useConvexActionMutation(api.conversations.actions.improveMessage); + return useConvexAction(api.conversations.actions.improveMessage); } diff --git a/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.test.tsx b/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.test.tsx index ebb9c4f26..47f8d69a8 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.test.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.test.tsx @@ -13,18 +13,9 @@ const mockUnpublish = vi.fn(); vi.mock('../hooks/mutations', async (importOriginal) => ({ ...(await importOriginal()), - useActivateCustomAgentVersion: () => ({ - mutateAsync: mockActivateVersion, - isPending: false, - }), - usePublishCustomAgent: () => ({ - mutateAsync: mockPublish, - isPending: false, - }), - useUnpublishCustomAgent: () => ({ - mutateAsync: mockUnpublish, - isPending: false, - }), + useActivateCustomAgentVersion: () => mockActivateVersion, + usePublishCustomAgent: () => mockPublish, + useUnpublishCustomAgent: () => mockUnpublish, })); vi.mock('@/app/hooks/use-toast', () => ({ diff --git a/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.tsx b/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.tsx index 7b36a2a60..9212fdfb9 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.tsx @@ -32,13 +32,13 @@ export function CustomAgentActiveToggle({ const { t: tCommon } = useT('common'); const [showDeactivateDialog, setShowDeactivateDialog] = useState(false); + const [isActivating, setIsActivating] = useState(false); + const [isPublishing, setIsPublishing] = useState(false); + const [isUnpublishing, setIsUnpublishing] = useState(false); - const { mutateAsync: activateVersion, isPending: isActivating } = - useActivateCustomAgentVersion(); - const { mutateAsync: publishAgent, isPending: isPublishing } = - usePublishCustomAgent(); - const { mutateAsync: unpublishAgent, isPending: isUnpublishing } = - useUnpublishCustomAgent(); + const activateVersion = useActivateCustomAgentVersion(); + const publishAgent = usePublishCustomAgent(); + const unpublishAgent = useUnpublishCustomAgent(); const isToggling = isActivating || isPublishing || isUnpublishing; @@ -48,6 +48,11 @@ export function CustomAgentActiveToggle({ agent.status === 'draft' && agent.versionNumber === 1; const handleActivate = useCallback(async () => { + if (agent.status === 'draft') { + setIsPublishing(true); + } else { + setIsActivating(true); + } try { if (agent.status === 'draft') { await publishAgent({ @@ -69,6 +74,9 @@ export function CustomAgentActiveToggle({ title: t('customAgents.agentPublishFailed'), variant: 'destructive', }); + } finally { + setIsPublishing(false); + setIsActivating(false); } }, [ activateVersion, @@ -80,6 +88,7 @@ export function CustomAgentActiveToggle({ ]); const handleDeactivateConfirm = useCallback(async () => { + setIsUnpublishing(true); try { await unpublishAgent({ customAgentId: toId<'customAgents'>(rootId), @@ -95,6 +104,8 @@ export function CustomAgentActiveToggle({ title: t('customAgents.agentDeactivateFailed'), variant: 'destructive', }); + } finally { + setIsUnpublishing(false); } }, [unpublishAgent, rootId, t]); diff --git a/services/platform/app/features/custom-agents/components/custom-agent-create-dialog.tsx b/services/platform/app/features/custom-agents/components/custom-agent-create-dialog.tsx index f6caaca54..3784957bf 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-create-dialog.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-create-dialog.tsx @@ -34,7 +34,7 @@ export function CreateCustomAgentDialog({ const { t } = useT('settings'); const { t: tCommon } = useT('common'); const navigate = useNavigate(); - const { mutateAsync: createAgent } = useCreateCustomAgent(); + const createAgent = useCreateCustomAgent(); const formSchema = useMemo( () => diff --git a/services/platform/app/features/custom-agents/components/custom-agent-navigation.tsx b/services/platform/app/features/custom-agents/components/custom-agent-navigation.tsx index 80b15dd07..a4d163cae 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-navigation.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-navigation.tsx @@ -2,7 +2,7 @@ import { useNavigate } from '@tanstack/react-router'; import { ChevronDown, CircleStop, FlaskConical, Pencil } from 'lucide-react'; -import { useMemo, useCallback } from 'react'; +import { useMemo, useCallback, useState } from 'react'; import { Badge } from '@/app/components/ui/feedback/badge'; import { @@ -47,14 +47,14 @@ export function CustomAgentNavigation({ const { agent, versions, hasDraft, draftVersionNumber } = useCustomAgentVersion(); - const { mutateAsync: publishAgent, isPending: isPublishing } = - usePublishCustomAgent(); - const { mutateAsync: unpublishAgent, isPending: isUnpublishing } = - useUnpublishCustomAgent(); - const { mutateAsync: activateVersion, isPending: isActivating } = - useActivateCustomAgentVersion(); - const { mutateAsync: createDraft, isPending: isCreatingDraft } = - useCreateDraftFromVersion(); + const [isPublishing, setIsPublishing] = useState(false); + const [isUnpublishing, setIsUnpublishing] = useState(false); + const [isActivating, setIsActivating] = useState(false); + const [isCreatingDraft, setIsCreatingDraft] = useState(false); + const publishAgent = usePublishCustomAgent(); + const unpublishAgent = useUnpublishCustomAgent(); + const activateVersion = useActivateCustomAgentVersion(); + const createDraft = useCreateDraftFromVersion(); const basePath = `/dashboard/${organizationId}/custom-agents/${agentId}`; const versionSearch = @@ -113,6 +113,7 @@ export function CustomAgentNavigation({ }, [navigate, organizationId, agentId]); const handlePublish = async () => { + setIsPublishing(true); try { await publishAgent({ customAgentId: toId<'customAgents'>(agentId), @@ -127,10 +128,13 @@ export function CustomAgentNavigation({ title: t('customAgents.agentPublishFailed'), variant: 'destructive', }); + } finally { + setIsPublishing(false); } }; const handleUnpublish = async () => { + setIsUnpublishing(true); try { await unpublishAgent({ customAgentId: toId<'customAgents'>(agentId), @@ -145,10 +149,13 @@ export function CustomAgentNavigation({ title: t('customAgents.agentDeactivateFailed'), variant: 'destructive', }); + } finally { + setIsUnpublishing(false); } }; const handleActivate = async () => { + setIsActivating(true); try { await activateVersion({ customAgentId: toId<'customAgents'>(agentId), @@ -164,6 +171,8 @@ export function CustomAgentNavigation({ title: t('customAgents.agentPublishFailed'), variant: 'destructive', }); + } finally { + setIsActivating(false); } }; @@ -173,6 +182,7 @@ export function CustomAgentNavigation({ return; } + setIsCreatingDraft(true); try { await createDraft({ customAgentId: toId<'customAgents'>(agentId), @@ -185,6 +195,8 @@ export function CustomAgentNavigation({ title: t('customAgents.agentUpdateFailed'), variant: 'destructive', }); + } finally { + setIsCreatingDraft(false); } }; diff --git a/services/platform/app/features/custom-agents/components/custom-agent-row-actions.tsx b/services/platform/app/features/custom-agents/components/custom-agent-row-actions.tsx index c50a4fde6..1f4d526bd 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-row-actions.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-row-actions.tsx @@ -1,7 +1,7 @@ 'use client'; import { CircleStop, Copy, Play, Trash2, Upload } from 'lucide-react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { ConfirmDialog } from '@/app/components/ui/dialog/confirm-dialog'; import { @@ -33,19 +33,20 @@ export function CustomAgentRowActions({ agent }: CustomAgentRowActionsProps) { const { t: tCommon } = useT('common'); const { t } = useT('settings'); const dialogs = useEntityRowDialogs(['delete', 'deactivate']); - const { mutateAsync: duplicateAgent, isPending: isDuplicating } = - useDuplicateCustomAgent(); - const { mutateAsync: publishAgent, isPending: isPublishing } = - usePublishCustomAgent(); - const { mutateAsync: unpublishAgent, isPending: isDeactivating } = - useUnpublishCustomAgent(); - const { mutateAsync: activateVersion, isPending: isActivating } = - useActivateCustomAgentVersion(); + const [isDuplicating, setIsDuplicating] = useState(false); + const [isPublishing, setIsPublishing] = useState(false); + const [isDeactivating, setIsDeactivating] = useState(false); + const [isActivating, setIsActivating] = useState(false); + const duplicateAgent = useDuplicateCustomAgent(); + const publishAgent = usePublishCustomAgent(); + const unpublishAgent = useUnpublishCustomAgent(); + const activateVersion = useActivateCustomAgentVersion(); const rootId = agent.rootVersionId ?? agent._id; const handleDuplicate = useCallback(async () => { if (isDuplicating) return; + setIsDuplicating(true); try { await duplicateAgent({ customAgentId: toId<'customAgents'>(agent._id) }); toast({ @@ -58,11 +59,14 @@ export function CustomAgentRowActions({ agent }: CustomAgentRowActionsProps) { title: t('customAgents.agentDuplicateFailed'), variant: 'destructive', }); + } finally { + setIsDuplicating(false); } }, [isDuplicating, duplicateAgent, agent._id, t]); const handlePublish = useCallback(async () => { if (isPublishing) return; + setIsPublishing(true); try { await publishAgent({ customAgentId: toId<'customAgents'>(rootId), @@ -77,10 +81,13 @@ export function CustomAgentRowActions({ agent }: CustomAgentRowActionsProps) { title: t('customAgents.agentPublishFailed'), variant: 'destructive', }); + } finally { + setIsPublishing(false); } }, [isPublishing, publishAgent, rootId, t]); const handleDeactivateConfirm = useCallback(async () => { + setIsDeactivating(true); try { await unpublishAgent({ customAgentId: toId<'customAgents'>(rootId), @@ -96,11 +103,14 @@ export function CustomAgentRowActions({ agent }: CustomAgentRowActionsProps) { title: t('customAgents.agentDeactivateFailed'), variant: 'destructive', }); + } finally { + setIsDeactivating(false); } }, [unpublishAgent, rootId, dialogs.setOpen, t]); const handleActivate = useCallback(async () => { if (isActivating) return; + setIsActivating(true); try { await activateVersion({ customAgentId: toId<'customAgents'>(rootId), @@ -116,6 +126,8 @@ export function CustomAgentRowActions({ agent }: CustomAgentRowActionsProps) { title: t('customAgents.agentPublishFailed'), variant: 'destructive', }); + } finally { + setIsActivating(false); } }, [isActivating, activateVersion, rootId, agent.versionNumber, t]); diff --git a/services/platform/app/features/custom-agents/components/custom-agent-version-history-dialog.tsx b/services/platform/app/features/custom-agents/components/custom-agent-version-history-dialog.tsx index e77f7c2da..19f459692 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-version-history-dialog.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-version-history-dialog.tsx @@ -40,7 +40,7 @@ export function CustomAgentVersionHistoryDialog({ }: CustomAgentVersionHistoryDialogProps) { const { t } = useT('settings'); const { formatDate } = useFormatDate(); - const { mutateAsync: activateVersion } = useActivateCustomAgentVersion(); + const activateVersion = useActivateCustomAgentVersion(); const [activatingVersion, setActivatingVersion] = useState( null, ); diff --git a/services/platform/app/features/custom-agents/components/custom-agent-webhook-section.tsx b/services/platform/app/features/custom-agents/components/custom-agent-webhook-section.tsx index d6c5e9114..ff4812b78 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-webhook-section.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-webhook-section.tsx @@ -53,8 +53,8 @@ export function CustomAgentWebhookSection({ const customAgentWebhookCollection = useCustomAgentWebhookCollection(agentId); const { webhooks } = useCustomAgentWebhooks(customAgentWebhookCollection); - const { mutateAsync: createWebhook, isPending: isCreating } = - useCreateCustomAgentWebhook(); + const [isCreating, setIsCreating] = useState(false); + const createWebhook = useCreateCustomAgentWebhook(); const toggleWebhook = useToggleCustomAgentWebhook(); const deleteWebhookMutation = useDeleteCustomAgentWebhook(); @@ -74,6 +74,7 @@ export function CustomAgentWebhookSection({ ); const handleCreate = useCallback(async () => { + setIsCreating(true); try { const result = await createWebhook({ organizationId, @@ -89,6 +90,8 @@ export function CustomAgentWebhookSection({ title: t('customAgents.webhook.toast.createFailed'), variant: 'destructive', }); + } finally { + setIsCreating(false); } }, [createWebhook, organizationId, agentId, toast, t, getWebhookUrl]); diff --git a/services/platform/app/features/custom-agents/components/test-chat-panel.tsx b/services/platform/app/features/custom-agents/components/test-chat-panel.tsx index 41483f23c..0ea452086 100644 --- a/services/platform/app/features/custom-agents/components/test-chat-panel.tsx +++ b/services/platform/app/features/custom-agents/components/test-chat-panel.tsx @@ -181,9 +181,9 @@ function TestChatPanelContent({ }); const { agent: currentAgent } = useCustomAgentVersion(); - const { mutateAsync: testAgent } = useTestAgent(); - const { mutateAsync: createChatThread } = useCreateThread(); - const { mutateAsync: deleteChatThread } = useDeleteThread(); + const testAgent = useTestAgent(); + const createChatThread = useCreateThread(); + const deleteChatThread = useDeleteThread(); const { approvals: integrationApprovals } = useIntegrationApprovals( organizationId, diff --git a/services/platform/app/features/custom-agents/hooks/mutations.ts b/services/platform/app/features/custom-agents/hooks/mutations.ts index f883fc1b1..7a853789d 100644 --- a/services/platform/app/features/custom-agents/hooks/mutations.ts +++ b/services/platform/app/features/custom-agents/hooks/mutations.ts @@ -36,36 +36,23 @@ export function useCreateCustomAgentWebhook() { } export function useUpdateCustomAgent() { - const { mutateAsync } = useConvexMutation( - api.custom_agents.mutations.updateCustomAgent, - ); - return mutateAsync; + return useConvexMutation(api.custom_agents.mutations.updateCustomAgent); } export function useUpdateCustomAgentMetadata() { - const { mutateAsync } = useConvexMutation( + return useConvexMutation( api.custom_agents.mutations.updateCustomAgentMetadata, ); - return mutateAsync; } export function useDeleteCustomAgent() { - const { mutateAsync } = useConvexMutation( - api.custom_agents.mutations.deleteCustomAgent, - ); - return mutateAsync; + return useConvexMutation(api.custom_agents.mutations.deleteCustomAgent); } export function useToggleCustomAgentWebhook() { - const { mutateAsync } = useConvexMutation( - api.custom_agents.webhooks.mutations.toggleWebhook, - ); - return mutateAsync; + return useConvexMutation(api.custom_agents.webhooks.mutations.toggleWebhook); } export function useDeleteCustomAgentWebhook() { - const { mutateAsync } = useConvexMutation( - api.custom_agents.webhooks.mutations.deleteWebhook, - ); - return mutateAsync; + return useConvexMutation(api.custom_agents.webhooks.mutations.deleteWebhook); } diff --git a/services/platform/app/features/customers/components/customers-import-dialog.tsx b/services/platform/app/features/customers/components/customers-import-dialog.tsx index f5ad50772..19c7e713b 100644 --- a/services/platform/app/features/customers/components/customers-import-dialog.tsx +++ b/services/platform/app/features/customers/components/customers-import-dialog.tsx @@ -104,7 +104,7 @@ export function ImportCustomersDialog({ formState: { isSubmitting }, } = formMethods; - const { mutateAsync: bulkCreateCustomers } = useBulkCreateCustomers(); + const bulkCreateCustomers = useBulkCreateCustomers(); const handleClose = useCallback(() => { formMethods.reset(); diff --git a/services/platform/app/features/customers/hooks/mutations.ts b/services/platform/app/features/customers/hooks/mutations.ts index 8b164524b..d59457edf 100644 --- a/services/platform/app/features/customers/hooks/mutations.ts +++ b/services/platform/app/features/customers/hooks/mutations.ts @@ -6,15 +6,9 @@ export function useBulkCreateCustomers() { } export function useDeleteCustomer() { - const { mutateAsync } = useConvexMutation( - api.customers.mutations.deleteCustomer, - ); - return mutateAsync; + return useConvexMutation(api.customers.mutations.deleteCustomer); } export function useUpdateCustomer() { - const { mutateAsync } = useConvexMutation( - api.customers.mutations.updateCustomer, - ); - return mutateAsync; + return useConvexMutation(api.customers.mutations.updateCustomer); } diff --git a/services/platform/app/features/documents/components/document-row-actions.tsx b/services/platform/app/features/documents/components/document-row-actions.tsx index 31edb0160..f9bc5dffc 100644 --- a/services/platform/app/features/documents/components/document-row-actions.tsx +++ b/services/platform/app/features/documents/components/document-row-actions.tsx @@ -43,8 +43,8 @@ export function DocumentRowActions({ const dialogs = useEntityRowDialogs(['delete', 'deleteFolder', 'teamTags']); const [isDeleting, setIsDeleting] = useState(false); const deleteDocument = useDeleteDocument(); - const { mutateAsync: retryRagIndexing, isPending: isReindexing } = - useRetryRagIndexing(); + const retryRagIndexing = useRetryRagIndexing(); + const [isReindexing, setIsReindexing] = useState(false); // Determine if delete action should be visible const canDelete = @@ -98,6 +98,7 @@ export function DocumentRowActions({ const handleReindex = useCallback(async () => { if (isReindexing) return; + setIsReindexing(true); try { const result = await retryRagIndexing({ documentId: toId<'documents'>(documentId), @@ -120,6 +121,8 @@ export function DocumentRowActions({ title: tDocuments('rag.toast.unexpectedError'), variant: 'destructive', }); + } finally { + setIsReindexing(false); } }, [documentId, retryRagIndexing, tDocuments, isReindexing]); diff --git a/services/platform/app/features/documents/components/onedrive-import-dialog.tsx b/services/platform/app/features/documents/components/onedrive-import-dialog.tsx index 3cbd83768..c655300c7 100644 --- a/services/platform/app/features/documents/components/onedrive-import-dialog.tsx +++ b/services/platform/app/features/documents/components/onedrive-import-dialog.tsx @@ -460,8 +460,8 @@ export function OneDriveImportDialog({ const { t: tCommon } = useT('common'); const { selectedTeamId } = useTeamFilter(); - const { mutateAsync: importFilesAction, isPending: isImporting } = - useImportOneDriveFiles(); + const importFilesAction = useImportOneDriveFiles(); + const [isImporting, setIsImporting] = useState(false); const listOneDriveFiles = useConvexAction(api.onedrive.actions.listFiles); const listSharePointFiles = useConvexAction( api.onedrive.actions.listSharePointFiles, @@ -756,6 +756,7 @@ export function OneDriveImportDialog({ }; const handleImport = async () => { + setIsImporting(true); try { const selectedItemsArray = Array.from(selectedItems.values()); @@ -874,6 +875,8 @@ export function OneDriveImportDialog({ error instanceof Error ? error.message : tCommon('errors.generic'), variant: 'destructive', }); + } finally { + setIsImporting(false); } }; diff --git a/services/platform/app/features/documents/components/rag-status-badge.tsx b/services/platform/app/features/documents/components/rag-status-badge.tsx index 5ec624b7f..5f55e98e2 100644 --- a/services/platform/app/features/documents/components/rag-status-badge.tsx +++ b/services/platform/app/features/documents/components/rag-status-badge.tsx @@ -45,8 +45,8 @@ export function RagStatusBadge({ }: RagStatusBadgeProps) { const { t } = useT('documents'); const { formatDate } = useFormatDate(); - const { mutateAsync: retryRagIndexing, isPending: isRetrying } = - useRetryRagIndexing(); + const retryRagIndexing = useRetryRagIndexing(); + const [isRetrying, setIsRetrying] = useState(false); const [isCompletedDialogOpen, setIsCompletedDialogOpen] = useState(false); const [isFailedDialogOpen, setIsFailedDialogOpen] = useState(false); @@ -77,6 +77,7 @@ export function RagStatusBadge({ return; } + setIsRetrying(true); try { const result = await retryRagIndexing({ documentId: toId<'documents'>(documentId), @@ -98,6 +99,8 @@ export function RagStatusBadge({ title: t('rag.toast.unexpectedError'), variant: 'destructive', }); + } finally { + setIsRetrying(false); } }; diff --git a/services/platform/app/features/documents/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/documents/hooks/__tests__/mutation-hooks.test.ts index 5dd88cd05..d85c7c8a5 100644 --- a/services/platform/app/features/documents/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/documents/hooks/__tests__/mutation-hooks.test.ts @@ -2,13 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { toId } from '@/convex/lib/type_cast_helpers'; -const mockMutateAsync = vi.fn(); +const mockMutationFn = vi.fn(); vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => ({ - mutateAsync: mockMutateAsync, - isPending: false, - }), + useConvexMutation: () => mockMutationFn, })); vi.mock('@/convex/_generated/api', () => ({ @@ -29,24 +26,24 @@ describe('useDeleteDocument', () => { vi.clearAllMocks(); }); - it('returns mutateAsync from useConvexMutation', () => { + it('returns the mutation function from useConvexMutation', () => { const deleteDocument = useDeleteDocument(); - expect(deleteDocument).toBe(mockMutateAsync); + expect(deleteDocument).toBe(mockMutationFn); }); - it('calls mutateAsync with the correct args', async () => { - mockMutateAsync.mockResolvedValueOnce(null); + it('calls mutation with the correct args', async () => { + mockMutationFn.mockResolvedValueOnce(null); const deleteDocument = useDeleteDocument(); await deleteDocument({ documentId: toId<'documents'>('doc-123') }); - expect(mockMutateAsync).toHaveBeenCalledWith({ + expect(mockMutationFn).toHaveBeenCalledWith({ documentId: toId<'documents'>('doc-123'), }); }); - it('propagates errors from mutateAsync', async () => { - mockMutateAsync.mockRejectedValueOnce(new Error('Delete failed')); + it('propagates errors from mutation', async () => { + mockMutationFn.mockRejectedValueOnce(new Error('Delete failed')); const deleteDocument = useDeleteDocument(); await expect( @@ -60,13 +57,13 @@ describe('useUpdateDocument', () => { vi.clearAllMocks(); }); - it('returns mutateAsync from useConvexMutation', () => { + it('returns the mutation function from useConvexMutation', () => { const updateDocument = useUpdateDocument(); - expect(updateDocument).toBe(mockMutateAsync); + expect(updateDocument).toBe(mockMutationFn); }); - it('calls mutateAsync with documentId and teamTags', async () => { - mockMutateAsync.mockResolvedValueOnce(undefined); + it('calls mutation with documentId and teamTags', async () => { + mockMutationFn.mockResolvedValueOnce(undefined); const updateDocument = useUpdateDocument(); await updateDocument({ @@ -74,25 +71,25 @@ describe('useUpdateDocument', () => { teamTags: ['team-1', 'team-2'], }); - expect(mockMutateAsync).toHaveBeenCalledWith({ + expect(mockMutationFn).toHaveBeenCalledWith({ documentId: toId<'documents'>('doc-123'), teamTags: ['team-1', 'team-2'], }); }); - it('calls mutateAsync with documentId only', async () => { - mockMutateAsync.mockResolvedValueOnce(undefined); + it('calls mutation with documentId only', async () => { + mockMutationFn.mockResolvedValueOnce(undefined); const updateDocument = useUpdateDocument(); await updateDocument({ documentId: toId<'documents'>('doc-123') }); - expect(mockMutateAsync).toHaveBeenCalledWith({ + expect(mockMutationFn).toHaveBeenCalledWith({ documentId: toId<'documents'>('doc-123'), }); }); - it('propagates errors from mutateAsync', async () => { - mockMutateAsync.mockRejectedValueOnce(new Error('Update failed')); + it('propagates errors from mutation', async () => { + mockMutationFn.mockRejectedValueOnce(new Error('Update failed')); const updateDocument = useUpdateDocument(); await expect( diff --git a/services/platform/app/features/documents/hooks/actions.ts b/services/platform/app/features/documents/hooks/actions.ts index b1ee92f67..bcfe41361 100644 --- a/services/platform/app/features/documents/hooks/actions.ts +++ b/services/platform/app/features/documents/hooks/actions.ts @@ -1,10 +1,10 @@ -import { useConvexActionMutation } from '@/app/hooks/use-convex-action-mutation'; +import { useConvexAction } from '@/app/hooks/use-convex-action'; import { api } from '@/convex/_generated/api'; export function useRetryRagIndexing() { - return useConvexActionMutation(api.documents.actions.retryRagIndexing); + return useConvexAction(api.documents.actions.retryRagIndexing); } export function useImportOneDriveFiles() { - return useConvexActionMutation(api.onedrive.actions.importFiles); + return useConvexAction(api.onedrive.actions.importFiles); } diff --git a/services/platform/app/features/documents/hooks/mutations.ts b/services/platform/app/features/documents/hooks/mutations.ts index 9383a8ddc..f348ed905 100644 --- a/services/platform/app/features/documents/hooks/mutations.ts +++ b/services/platform/app/features/documents/hooks/mutations.ts @@ -53,10 +53,10 @@ export function useDocumentUpload(options: UploadOptions) { const { t } = useT('documents'); const [isUploading, setIsUploading] = useState(false); const abortControllerRef = useRef(null); - const { mutateAsync: generateUploadUrl } = useConvexMutation( + const generateUploadUrl = useConvexMutation( api.files.mutations.generateUploadUrl, ); - const { mutateAsync: createDocumentFromUpload } = useConvexMutation( + const createDocumentFromUpload = useConvexMutation( api.documents.mutations.createDocumentFromUpload, ); @@ -265,15 +265,9 @@ export function useDocumentUpload(options: UploadOptions) { } export function useDeleteDocument() { - const { mutateAsync } = useConvexMutation( - api.documents.mutations.deleteDocument, - ); - return mutateAsync; + return useConvexMutation(api.documents.mutations.deleteDocument); } export function useUpdateDocument() { - const { mutateAsync } = useConvexMutation( - api.documents.mutations.updateDocument, - ); - return mutateAsync; + return useConvexMutation(api.documents.mutations.updateDocument); } diff --git a/services/platform/app/features/organization/components/organization-form-client.tsx b/services/platform/app/features/organization/components/organization-form-client.tsx index f2d998d9c..e43862898 100644 --- a/services/platform/app/features/organization/components/organization-form-client.tsx +++ b/services/platform/app/features/organization/components/organization-form-client.tsx @@ -42,8 +42,7 @@ export function OrganizationFormClient() { }, }); - const { mutateAsync: initializeDefaultWorkflows } = - useInitializeDefaultWorkflows(); + const initializeDefaultWorkflows = useInitializeDefaultWorkflows(); const handleSubmit = form.handleSubmit(async (data) => { if (!user) { diff --git a/services/platform/app/features/organization/hooks/actions.ts b/services/platform/app/features/organization/hooks/actions.ts index 16f06a27e..d0046a9bf 100644 --- a/services/platform/app/features/organization/hooks/actions.ts +++ b/services/platform/app/features/organization/hooks/actions.ts @@ -1,8 +1,6 @@ -import { useConvexActionMutation } from '@/app/hooks/use-convex-action-mutation'; +import { useConvexAction } from '@/app/hooks/use-convex-action'; import { api } from '@/convex/_generated/api'; export function useInitializeDefaultWorkflows() { - return useConvexActionMutation( - api.organizations.actions.initializeDefaultWorkflows, - ); + return useConvexAction(api.organizations.actions.initializeDefaultWorkflows); } diff --git a/services/platform/app/features/products/hooks/mutations.ts b/services/platform/app/features/products/hooks/mutations.ts index 9880cf9fd..9143c7828 100644 --- a/services/platform/app/features/products/hooks/mutations.ts +++ b/services/platform/app/features/products/hooks/mutations.ts @@ -2,22 +2,13 @@ import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; import { api } from '@/convex/_generated/api'; export function useCreateProduct() { - const { mutateAsync } = useConvexMutation( - api.products.mutations.createProduct, - ); - return mutateAsync; + return useConvexMutation(api.products.mutations.createProduct); } export function useDeleteProduct() { - const { mutateAsync } = useConvexMutation( - api.products.mutations.deleteProduct, - ); - return mutateAsync; + return useConvexMutation(api.products.mutations.deleteProduct); } export function useUpdateProduct() { - const { mutateAsync } = useConvexMutation( - api.products.mutations.updateProduct, - ); - return mutateAsync; + return useConvexMutation(api.products.mutations.updateProduct); } diff --git a/services/platform/app/features/settings/account/components/account-form-client.tsx b/services/platform/app/features/settings/account/components/account-form-client.tsx index 055b237dc..cd1e94498 100644 --- a/services/platform/app/features/settings/account/components/account-form-client.tsx +++ b/services/platform/app/features/settings/account/components/account-form-client.tsx @@ -44,7 +44,7 @@ export function AccountFormClient({ const { t: tAuth } = useT('auth'); const { t: tCommon } = useT('common'); const { t: tToast } = useT('toast'); - const { mutateAsync: updatePassword } = useUpdatePassword(); + const updatePassword = useUpdatePassword(); const { toast } = useToast(); const { data: hasCredential, isLoading: isCredentialLoading } = @@ -101,7 +101,7 @@ function ChangePasswordForm({ tCommon, tToast, }: { - updatePassword: ReturnType['mutateAsync']; + updatePassword: ReturnType; toast: ReturnType['toast']; tAuth: ReturnType['t']; tCommon: ReturnType['t']; @@ -205,7 +205,7 @@ function SetPasswordForm({ tCommon, tToast, }: { - updatePassword: ReturnType['mutateAsync']; + updatePassword: ReturnType; toast: ReturnType['toast']; tAuth: ReturnType['t']; tCommon: ReturnType['t']; diff --git a/services/platform/app/features/settings/api-keys/components/api-key-create-dialog.tsx b/services/platform/app/features/settings/api-keys/components/api-key-create-dialog.tsx index e3a74cd56..acced5248 100644 --- a/services/platform/app/features/settings/api-keys/components/api-key-create-dialog.tsx +++ b/services/platform/app/features/settings/api-keys/components/api-key-create-dialog.tsx @@ -37,7 +37,7 @@ export function ApiKeyCreateDialog({ const { t: tSettings } = useT('settings'); const { t: tCommon } = useT('common'); const { toast } = useToast(); - const { mutateAsync: createKey } = useCreateApiKey(organizationId); + const createKey = useCreateApiKey(organizationId); const [createdKey, setCreatedKey] = useState(null); const [copied, setCopied] = useState(false); diff --git a/services/platform/app/features/settings/api-keys/components/api-key-revoke-dialog.tsx b/services/platform/app/features/settings/api-keys/components/api-key-revoke-dialog.tsx index 1fe7c774c..9b31ec72d 100644 --- a/services/platform/app/features/settings/api-keys/components/api-key-revoke-dialog.tsx +++ b/services/platform/app/features/settings/api-keys/components/api-key-revoke-dialog.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useState } from 'react'; + import { DeleteDialog } from '@/app/components/ui/dialog/delete-dialog'; import { toast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; @@ -24,29 +26,30 @@ export function ApiKeyRevokeDialog({ onSuccess, }: ApiKeyRevokeDialogProps) { const { t: tSettings } = useT('settings'); - const { mutate: revokeKey, isPending: isRevoking } = - useRevokeApiKey(organizationId); + const [isRevoking, setIsRevoking] = useState(false); + const revokeKey = useRevokeApiKey(organizationId); - const handleConfirm = () => { + const handleConfirm = async () => { if (isRevoking) return; - revokeKey(apiKey.id, { - onSuccess: () => { - toast({ - title: tSettings('apiKeys.keyRevoked'), - variant: 'success', - }); - onOpenChange(false); - onSuccess?.(); - }, - onError: (error) => { - console.error(error); - toast({ - title: tSettings('apiKeys.keyRevokeFailed'), - variant: 'destructive', - }); - }, - }); + setIsRevoking(true); + try { + await revokeKey(apiKey.id); + toast({ + title: tSettings('apiKeys.keyRevoked'), + variant: 'success', + }); + onOpenChange(false); + onSuccess?.(); + } catch (error) { + console.error(error); + toast({ + title: tSettings('apiKeys.keyRevokeFailed'), + variant: 'destructive', + }); + } finally { + setIsRevoking(false); + } }; return ( diff --git a/services/platform/app/features/settings/api-keys/hooks/use-api-keys.ts b/services/platform/app/features/settings/api-keys/hooks/use-api-keys.ts index bd719ca1e..2e8fd316e 100644 --- a/services/platform/app/features/settings/api-keys/hooks/use-api-keys.ts +++ b/services/platform/app/features/settings/api-keys/hooks/use-api-keys.ts @@ -1,4 +1,5 @@ -import { useReactMutation } from '@/app/hooks/use-react-mutation'; +import { useCallback } from 'react'; + import { useReactQuery } from '@/app/hooks/use-react-query'; import { useReactQueryClient } from '@/app/hooks/use-react-query-client'; import { authClient } from '@/lib/auth-client'; @@ -32,8 +33,8 @@ export function useApiKeys(organizationId: string) { export function useCreateApiKey(organizationId: string) { const queryClient = useReactQueryClient(); - return useReactMutation({ - mutationFn: async ({ + return useCallback( + async ({ name, expiresIn, }: CreateApiKeyParams): Promise => { @@ -50,24 +51,24 @@ export function useCreateApiKey(organizationId: string) { throw new Error('API key creation returned no key/id'); } + void queryClient.invalidateQueries({ + queryKey: ['api-keys', organizationId], + }); + return { key: result.data.key, id: result.data.id, }; }, - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: ['api-keys', organizationId], - }); - }, - }); + [queryClient, organizationId], + ); } export function useRevokeApiKey(organizationId: string) { const queryClient = useReactQueryClient(); - return useReactMutation({ - mutationFn: async (keyId: string) => { + return useCallback( + async (keyId: string) => { const result = await authClient.apiKey.delete({ keyId, }); @@ -76,12 +77,12 @@ export function useRevokeApiKey(organizationId: string) { throw new Error(result.error.message); } - return result.data; - }, - onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['api-keys', organizationId], }); + + return result.data; }, - }); + [queryClient, organizationId], + ); } diff --git a/services/platform/app/features/settings/integrations/components/integration-manage-dialog.tsx b/services/platform/app/features/settings/integrations/components/integration-manage-dialog.tsx index a71a51b00..a668acaa7 100644 --- a/services/platform/app/features/settings/integrations/components/integration-manage-dialog.tsx +++ b/services/platform/app/features/settings/integrations/components/integration-manage-dialog.tsx @@ -170,13 +170,13 @@ export function IntegrationManageDialog({ const isActive = optimisticActive ?? integration.isActive; const iconUrl = optimisticIconUrl ?? integration.iconUrl; - const { mutateAsync: updateIntegration } = useUpdateIntegration(); - const { mutateAsync: testConnection } = useTestIntegration(); + const updateIntegration = useUpdateIntegration(); + const testConnection = useTestIntegration(); const deleteIntegration = useDeleteIntegration(); - const { mutateAsync: generateUploadUrl } = useGenerateUploadUrl(); - const { mutateAsync: updateIcon } = useUpdateIntegrationIcon(); - const { mutateAsync: generateOAuth2Url } = useGenerateIntegrationOAuth2Url(); - const { mutateAsync: saveOAuth2Credentials } = useSaveOAuth2Credentials(); + const generateUploadUrl = useGenerateUploadUrl(); + const updateIcon = useUpdateIntegrationIcon(); + const generateOAuth2Url = useGenerateIntegrationOAuth2Url(); + const saveOAuth2Credentials = useSaveOAuth2Credentials(); const hasOAuth2Config = !!integration.oauth2Config; diff --git a/services/platform/app/features/settings/integrations/components/integration-upload/integration-upload-dialog.tsx b/services/platform/app/features/settings/integrations/components/integration-upload/integration-upload-dialog.tsx index c65bef2e1..c545ff66d 100644 --- a/services/platform/app/features/settings/integrations/components/integration-upload/integration-upload-dialog.tsx +++ b/services/platform/app/features/settings/integrations/components/integration-upload/integration-upload-dialog.tsx @@ -28,8 +28,8 @@ export function IntegrationUploadDialog({ }: IntegrationUploadDialogProps) { const { t } = useT('settings'); const { t: tCommon } = useT('common'); - const { mutateAsync: createIntegration } = useCreateIntegration(); - const { mutateAsync: generateUploadUrl } = useGenerateUploadUrl(); + const createIntegration = useCreateIntegration(); + const generateUploadUrl = useGenerateUploadUrl(); const state = useUploadIntegration(); diff --git a/services/platform/app/features/settings/integrations/components/sso-config-dialog.tsx b/services/platform/app/features/settings/integrations/components/sso-config-dialog.tsx index c5abde6ee..799b28dee 100644 --- a/services/platform/app/features/settings/integrations/components/sso-config-dialog.tsx +++ b/services/platform/app/features/settings/integrations/components/sso-config-dialog.tsx @@ -104,11 +104,11 @@ export function SSOConfigDialog({ enableOneDriveAccess: boolean; } | null>(null); - const { mutateAsync: upsertSSOProvider } = useUpsertSsoProvider(); - const { mutateAsync: removeSSOProvider } = useRemoveSsoProvider(); - const { mutateAsync: getFullConfig } = useSsoFullConfig(); - const { mutateAsync: testSSOConfig } = useTestSsoConfig(); - const { mutateAsync: testExistingSSOConfig } = useTestExistingSsoConfig(); + const upsertSSOProvider = useUpsertSsoProvider(); + const removeSSOProvider = useRemoveSsoProvider(); + const getFullConfig = useSsoFullConfig(); + const testSSOConfig = useTestSsoConfig(); + const testExistingSSOConfig = useTestExistingSsoConfig(); const isConnected = !!existingProvider; diff --git a/services/platform/app/features/settings/integrations/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/settings/integrations/hooks/__tests__/mutation-hooks.test.ts index 978fc0d4e..931fbab6a 100644 --- a/services/platform/app/features/settings/integrations/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/settings/integrations/hooks/__tests__/mutation-hooks.test.ts @@ -2,13 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { toId } from '@/convex/lib/type_cast_helpers'; -const mockMutateAsync = vi.fn(); +const mockMutationFn = vi.fn(); vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => ({ - mutateAsync: mockMutateAsync, - isPending: false, - }), + useConvexMutation: () => mockMutationFn, })); vi.mock('@/convex/_generated/api', () => ({ @@ -34,26 +31,26 @@ describe('useDeleteIntegration', () => { vi.clearAllMocks(); }); - it('returns mutateAsync from useConvexMutation', () => { + it('returns the mutation function from useConvexMutation', () => { const deleteIntegration = useDeleteIntegration(); - expect(deleteIntegration).toBe(mockMutateAsync); + expect(deleteIntegration).toBe(mockMutationFn); }); - it('calls mutateAsync with the correct args', async () => { - mockMutateAsync.mockResolvedValueOnce(null); + it('calls mutation with the correct args', async () => { + mockMutationFn.mockResolvedValueOnce(null); const deleteIntegration = useDeleteIntegration(); await deleteIntegration({ integrationId: toId<'integrations'>('int-123'), }); - expect(mockMutateAsync).toHaveBeenCalledWith({ + expect(mockMutationFn).toHaveBeenCalledWith({ integrationId: 'int-123', }); }); - it('propagates errors from mutateAsync', async () => { - mockMutateAsync.mockRejectedValueOnce(new Error('Delete failed')); + it('propagates errors from mutation', async () => { + mockMutationFn.mockRejectedValueOnce(new Error('Delete failed')); const deleteIntegration = useDeleteIntegration(); await expect( diff --git a/services/platform/app/features/settings/integrations/hooks/actions.ts b/services/platform/app/features/settings/integrations/hooks/actions.ts index 9f862cd94..2a39d6f2e 100644 --- a/services/platform/app/features/settings/integrations/hooks/actions.ts +++ b/services/platform/app/features/settings/integrations/hooks/actions.ts @@ -1,44 +1,42 @@ -import { useConvexActionMutation } from '@/app/hooks/use-convex-action-mutation'; +import { useConvexAction } from '@/app/hooks/use-convex-action'; import { api } from '@/convex/_generated/api'; export function useTestIntegration() { - return useConvexActionMutation(api.integrations.actions.testConnection); + return useConvexAction(api.integrations.actions.testConnection); } export function useTestSsoConfig() { - return useConvexActionMutation(api.sso_providers.actions.testConfig); + return useConvexAction(api.sso_providers.actions.testConfig); } export function useTestExistingSsoConfig() { - return useConvexActionMutation(api.sso_providers.actions.testExistingConfig); + return useConvexAction(api.sso_providers.actions.testExistingConfig); } export function useCreateIntegration() { - return useConvexActionMutation(api.integrations.actions.create); + return useConvexAction(api.integrations.actions.create); } export function useUpdateIntegration() { - return useConvexActionMutation(api.integrations.actions.update); + return useConvexAction(api.integrations.actions.update); } export function useUpsertSsoProvider() { - return useConvexActionMutation(api.sso_providers.actions.upsert); + return useConvexAction(api.sso_providers.actions.upsert); } export function useRemoveSsoProvider() { - return useConvexActionMutation(api.sso_providers.actions.remove); + return useConvexAction(api.sso_providers.actions.remove); } export function useSsoFullConfig() { - return useConvexActionMutation(api.sso_providers.actions.getWithClientId); + return useConvexAction(api.sso_providers.actions.getWithClientId); } export function useGenerateIntegrationOAuth2Url() { - return useConvexActionMutation(api.integrations.actions.generateOAuth2Url); + return useConvexAction(api.integrations.actions.generateOAuth2Url); } export function useSaveOAuth2Credentials() { - return useConvexActionMutation( - api.integrations.actions.saveOAuth2ClientCredentials, - ); + return useConvexAction(api.integrations.actions.saveOAuth2ClientCredentials); } diff --git a/services/platform/app/features/settings/integrations/hooks/mutations.ts b/services/platform/app/features/settings/integrations/hooks/mutations.ts index f72a89353..ced3871b1 100644 --- a/services/platform/app/features/settings/integrations/hooks/mutations.ts +++ b/services/platform/app/features/settings/integrations/hooks/mutations.ts @@ -10,8 +10,5 @@ export function useUpdateIntegrationIcon() { } export function useDeleteIntegration() { - const { mutateAsync } = useConvexMutation( - api.integrations.mutations.deleteIntegration, - ); - return mutateAsync; + return useConvexMutation(api.integrations.mutations.deleteIntegration); } diff --git a/services/platform/app/features/settings/integrations/hooks/use-generate-integration-oauth2-url.ts b/services/platform/app/features/settings/integrations/hooks/use-generate-integration-oauth2-url.ts index 2a8373aa3..d4bd4b78a 100644 --- a/services/platform/app/features/settings/integrations/hooks/use-generate-integration-oauth2-url.ts +++ b/services/platform/app/features/settings/integrations/hooks/use-generate-integration-oauth2-url.ts @@ -1,6 +1,6 @@ -import { useConvexActionMutation } from '@/app/hooks/use-convex-action-mutation'; +import { useConvexAction } from '@/app/hooks/use-convex-action'; import { api } from '@/convex/_generated/api'; export function useGenerateIntegrationOAuth2Url() { - return useConvexActionMutation(api.integrations.actions.generateOAuth2Url); + return useConvexAction(api.integrations.actions.generateOAuth2Url); } diff --git a/services/platform/app/features/settings/integrations/hooks/use-save-oauth2-credentials.ts b/services/platform/app/features/settings/integrations/hooks/use-save-oauth2-credentials.ts index 92039fb9f..ad1c7e768 100644 --- a/services/platform/app/features/settings/integrations/hooks/use-save-oauth2-credentials.ts +++ b/services/platform/app/features/settings/integrations/hooks/use-save-oauth2-credentials.ts @@ -1,8 +1,6 @@ -import { useConvexActionMutation } from '@/app/hooks/use-convex-action-mutation'; +import { useConvexAction } from '@/app/hooks/use-convex-action'; import { api } from '@/convex/_generated/api'; export function useSaveOAuth2Credentials() { - return useConvexActionMutation( - api.integrations.actions.saveOAuth2ClientCredentials, - ); + return useConvexAction(api.integrations.actions.saveOAuth2ClientCredentials); } diff --git a/services/platform/app/features/settings/organization/components/member-add-dialog.tsx b/services/platform/app/features/settings/organization/components/member-add-dialog.tsx index 49275178f..f9ffcbbe6 100644 --- a/services/platform/app/features/settings/organization/components/member-add-dialog.tsx +++ b/services/platform/app/features/settings/organization/components/member-add-dialog.tsx @@ -80,8 +80,8 @@ export function AddMemberDialog({ } | null>(null); const { toast } = useToast(); - const { mutateAsync: createMember, isPending: isSubmitting } = - useCreateMember(); + const [isSubmitting, setIsSubmitting] = useState(false); + const createMember = useCreateMember(); const form = useForm({ resolver: zodResolver(addMemberSchema), defaultValues: { @@ -120,6 +120,7 @@ export function AddMemberDialog({ ); const onSubmit = async (data: AddMemberFormData) => { + setIsSubmitting(true); try { const result = await createMember({ organizationId, @@ -153,6 +154,8 @@ export function AddMemberDialog({ title: tToast('error.addMemberFailed'), variant: 'destructive', }); + } finally { + setIsSubmitting(false); } }; diff --git a/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx b/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx index a6e98eb4e..4fad7db9c 100644 --- a/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx +++ b/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx @@ -91,7 +91,7 @@ export function EditMemberDialog({ const updateMemberRole = useUpdateMemberRole(); const updateMemberDisplayName = useUpdateMemberDisplayName(); - const { mutateAsync: setMemberPassword } = useSetMemberPassword(); + const setMemberPassword = useSetMemberPassword(); const handleUpdateMember = async ( memberId: string, diff --git a/services/platform/app/features/settings/organization/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/settings/organization/hooks/__tests__/mutation-hooks.test.ts index 9bae68c50..323541daf 100644 --- a/services/platform/app/features/settings/organization/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/settings/organization/hooks/__tests__/mutation-hooks.test.ts @@ -1,12 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const mockMutateAsync = vi.fn(); +const mockMutationFn = vi.fn(); vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => ({ - mutateAsync: mockMutateAsync, - isPending: false, - }), + useConvexMutation: () => mockMutationFn, })); vi.mock('@/convex/_generated/api', () => ({ @@ -38,22 +35,22 @@ describe('useRemoveMember', () => { vi.clearAllMocks(); }); - it('returns mutateAsync from useConvexMutation', () => { + it('returns the mutation function from useConvexMutation', () => { const removeMember = useRemoveMember(); - expect(removeMember).toBe(mockMutateAsync); + expect(removeMember).toBe(mockMutationFn); }); - it('calls mutateAsync with the correct args', async () => { - mockMutateAsync.mockResolvedValueOnce(null); + it('calls mutation with the correct args', async () => { + mockMutationFn.mockResolvedValueOnce(null); const removeMember = useRemoveMember(); await removeMember({ memberId: 'member-123' }); - expect(mockMutateAsync).toHaveBeenCalledWith({ memberId: 'member-123' }); + expect(mockMutationFn).toHaveBeenCalledWith({ memberId: 'member-123' }); }); - it('propagates errors from mutateAsync', async () => { - mockMutateAsync.mockRejectedValueOnce(new Error('Delete failed')); + it('propagates errors from mutation', async () => { + mockMutationFn.mockRejectedValueOnce(new Error('Delete failed')); const removeMember = useRemoveMember(); await expect(removeMember({ memberId: 'member-789' })).rejects.toThrow( @@ -67,25 +64,25 @@ describe('useUpdateMemberRole', () => { vi.clearAllMocks(); }); - it('returns mutateAsync from useConvexMutation', () => { + it('returns the mutation function from useConvexMutation', () => { const updateRole = useUpdateMemberRole(); - expect(updateRole).toBe(mockMutateAsync); + expect(updateRole).toBe(mockMutationFn); }); - it('calls mutateAsync with the correct args', async () => { - mockMutateAsync.mockResolvedValueOnce(null); + it('calls mutation with the correct args', async () => { + mockMutationFn.mockResolvedValueOnce(null); const updateRole = useUpdateMemberRole(); await updateRole({ memberId: 'member-123', role: 'admin' }); - expect(mockMutateAsync).toHaveBeenCalledWith({ + expect(mockMutationFn).toHaveBeenCalledWith({ memberId: 'member-123', role: 'admin', }); }); - it('propagates errors from mutateAsync', async () => { - mockMutateAsync.mockRejectedValueOnce(new Error('Update failed')); + it('propagates errors from mutation', async () => { + mockMutationFn.mockRejectedValueOnce(new Error('Update failed')); const updateRole = useUpdateMemberRole(); await expect( @@ -99,25 +96,25 @@ describe('useUpdateMemberDisplayName', () => { vi.clearAllMocks(); }); - it('returns mutateAsync from useConvexMutation', () => { + it('returns the mutation function from useConvexMutation', () => { const updateName = useUpdateMemberDisplayName(); - expect(updateName).toBe(mockMutateAsync); + expect(updateName).toBe(mockMutationFn); }); - it('calls mutateAsync with the correct args', async () => { - mockMutateAsync.mockResolvedValueOnce(null); + it('calls mutation with the correct args', async () => { + mockMutationFn.mockResolvedValueOnce(null); const updateName = useUpdateMemberDisplayName(); await updateName({ memberId: 'member-123', displayName: 'New Name' }); - expect(mockMutateAsync).toHaveBeenCalledWith({ + expect(mockMutationFn).toHaveBeenCalledWith({ memberId: 'member-123', displayName: 'New Name', }); }); - it('propagates errors from mutateAsync', async () => { - mockMutateAsync.mockRejectedValueOnce(new Error('Update failed')); + it('propagates errors from mutation', async () => { + mockMutationFn.mockRejectedValueOnce(new Error('Update failed')); const updateName = useUpdateMemberDisplayName(); await expect( diff --git a/services/platform/app/features/settings/organization/hooks/mutations.ts b/services/platform/app/features/settings/organization/hooks/mutations.ts index 12e3ff127..81fac5e91 100644 --- a/services/platform/app/features/settings/organization/hooks/mutations.ts +++ b/services/platform/app/features/settings/organization/hooks/mutations.ts @@ -10,20 +10,13 @@ export function useCreateMember() { } export function useRemoveMember() { - const { mutateAsync } = useConvexMutation(api.members.mutations.removeMember); - return mutateAsync; + return useConvexMutation(api.members.mutations.removeMember); } export function useUpdateMemberRole() { - const { mutateAsync } = useConvexMutation( - api.members.mutations.updateMemberRole, - ); - return mutateAsync; + return useConvexMutation(api.members.mutations.updateMemberRole); } export function useUpdateMemberDisplayName() { - const { mutateAsync } = useConvexMutation( - api.members.mutations.updateMemberDisplayName, - ); - return mutateAsync; + return useConvexMutation(api.members.mutations.updateMemberDisplayName); } diff --git a/services/platform/app/features/settings/teams/components/team-create-dialog.tsx b/services/platform/app/features/settings/teams/components/team-create-dialog.tsx index 174569e7c..f62ce55e3 100644 --- a/services/platform/app/features/settings/teams/components/team-create-dialog.tsx +++ b/services/platform/app/features/settings/teams/components/team-create-dialog.tsx @@ -33,7 +33,7 @@ export function TeamCreateDialog({ const { t: tSettings } = useT('settings'); const { t: tCommon } = useT('common'); const { toast } = useToast(); - const { mutateAsync: addMember } = useCreateTeamMember(); + const addMember = useCreateTeamMember(); const nameRequiredError = tSettings('teams.teamNameRequired'); const schema = useMemo( diff --git a/services/platform/app/features/settings/teams/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/settings/teams/hooks/__tests__/mutation-hooks.test.ts index 959e23daa..bb27a7e4a 100644 --- a/services/platform/app/features/settings/teams/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/settings/teams/hooks/__tests__/mutation-hooks.test.ts @@ -1,12 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const mockMutateAsync = vi.fn(); +const mockMutationFn = vi.fn(); vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => ({ - mutateAsync: mockMutateAsync, - isPending: false, - }), + useConvexMutation: () => mockMutationFn, })); vi.mock('@/convex/_generated/api', () => ({ @@ -31,13 +28,13 @@ describe('useAddTeamMember', () => { vi.clearAllMocks(); }); - it('returns mutateAsync from useConvexMutation', () => { + it('returns the mutation function from useConvexMutation', () => { const addTeamMember = useAddTeamMember(); - expect(addTeamMember).toBe(mockMutateAsync); + expect(addTeamMember).toBe(mockMutationFn); }); - it('calls mutateAsync with the correct args', async () => { - mockMutateAsync.mockResolvedValueOnce(undefined); + it('calls mutation with the correct args', async () => { + mockMutationFn.mockResolvedValueOnce(undefined); const addTeamMember = useAddTeamMember(); await addTeamMember({ @@ -46,15 +43,15 @@ describe('useAddTeamMember', () => { organizationId: 'org-789', }); - expect(mockMutateAsync).toHaveBeenCalledWith({ + expect(mockMutationFn).toHaveBeenCalledWith({ teamId: 'team-123', userId: 'user-456', organizationId: 'org-789', }); }); - it('propagates errors from mutateAsync', async () => { - mockMutateAsync.mockRejectedValueOnce(new Error('Add failed')); + it('propagates errors from mutation', async () => { + mockMutationFn.mockRejectedValueOnce(new Error('Add failed')); const addTeamMember = useAddTeamMember(); await expect( @@ -72,13 +69,13 @@ describe('useRemoveTeamMember', () => { vi.clearAllMocks(); }); - it('returns mutateAsync from useConvexMutation', () => { + it('returns the mutation function from useConvexMutation', () => { const removeTeamMember = useRemoveTeamMember(); - expect(removeTeamMember).toBe(mockMutateAsync); + expect(removeTeamMember).toBe(mockMutationFn); }); - it('calls mutateAsync with the correct args', async () => { - mockMutateAsync.mockResolvedValueOnce(undefined); + it('calls mutation with the correct args', async () => { + mockMutationFn.mockResolvedValueOnce(undefined); const removeTeamMember = useRemoveTeamMember(); await removeTeamMember({ @@ -86,14 +83,14 @@ describe('useRemoveTeamMember', () => { organizationId: 'org-456', }); - expect(mockMutateAsync).toHaveBeenCalledWith({ + expect(mockMutationFn).toHaveBeenCalledWith({ teamMemberId: 'tm-123', organizationId: 'org-456', }); }); - it('propagates errors from mutateAsync', async () => { - mockMutateAsync.mockRejectedValueOnce(new Error('Remove failed')); + it('propagates errors from mutation', async () => { + mockMutationFn.mockRejectedValueOnce(new Error('Remove failed')); const removeTeamMember = useRemoveTeamMember(); await expect( @@ -106,9 +103,8 @@ describe('useRemoveTeamMember', () => { }); describe('useCreateTeamMember', () => { - it('returns the full mutation result from useConvexMutation', () => { + it('returns the mutation function from useConvexMutation', () => { const result = useCreateTeamMember(); - expect(result).toHaveProperty('mutateAsync', mockMutateAsync); - expect(result).toHaveProperty('isPending', false); + expect(result).toBe(mockMutationFn); }); }); diff --git a/services/platform/app/features/settings/teams/hooks/mutations.ts b/services/platform/app/features/settings/teams/hooks/mutations.ts index a296864c5..ffe10543d 100644 --- a/services/platform/app/features/settings/teams/hooks/mutations.ts +++ b/services/platform/app/features/settings/teams/hooks/mutations.ts @@ -6,15 +6,9 @@ export function useCreateTeamMember() { } export function useAddTeamMember() { - const { mutateAsync } = useConvexMutation( - api.team_members.mutations.addMember, - ); - return mutateAsync; + return useConvexMutation(api.team_members.mutations.addMember); } export function useRemoveTeamMember() { - const { mutateAsync } = useConvexMutation( - api.team_members.mutations.removeMember, - ); - return mutateAsync; + return useConvexMutation(api.team_members.mutations.removeMember); } diff --git a/services/platform/app/features/tone-of-voice/components/tone-of-voice-form-client.tsx b/services/platform/app/features/tone-of-voice/components/tone-of-voice-form-client.tsx index 67f31c80c..62704fc4c 100644 --- a/services/platform/app/features/tone-of-voice/components/tone-of-voice-form-client.tsx +++ b/services/platform/app/features/tone-of-voice/components/tone-of-voice-form-client.tsx @@ -44,12 +44,12 @@ export function ToneOfVoiceFormClient({ const { t: tToast } = useT('toast'); const orgId = organizationId; - const { mutateAsync: addExample } = useAddExample(); - const { mutateAsync: updateExample } = useUpdateExample(); - const { mutateAsync: deleteExample } = useDeleteExample(); - const { mutateAsync: upsertTone } = useUpsertTone(); - const { mutateAsync: generateTone, isPending: isGenerating } = - useGenerateTone(); + const addExample = useAddExample(); + const updateExample = useUpdateExample(); + const deleteExample = useDeleteExample(); + const upsertTone = useUpsertTone(); + const generateTone = useGenerateTone(); + const [isGenerating, setIsGenerating] = useState(false); const form = useForm(); const { register, setValue, watch, formState, handleSubmit } = form; @@ -151,6 +151,7 @@ export function ToneOfVoiceFormClient({ return; } + setIsGenerating(true); try { const result = await generateTone({ organizationId: orgId, @@ -174,6 +175,8 @@ export function ToneOfVoiceFormClient({ title: tToast('error.toneGenerateFailed'), variant: 'destructive', }); + } finally { + setIsGenerating(false); } }; diff --git a/services/platform/app/features/tone-of-voice/hooks/actions.ts b/services/platform/app/features/tone-of-voice/hooks/actions.ts index 3a6f6097b..93c9ef0c9 100644 --- a/services/platform/app/features/tone-of-voice/hooks/actions.ts +++ b/services/platform/app/features/tone-of-voice/hooks/actions.ts @@ -1,6 +1,6 @@ -import { useConvexActionMutation } from '@/app/hooks/use-convex-action-mutation'; +import { useConvexAction } from '@/app/hooks/use-convex-action'; import { api } from '@/convex/_generated/api'; export function useGenerateTone() { - return useConvexActionMutation(api.tone_of_voice.actions.generateToneOfVoice); + return useConvexAction(api.tone_of_voice.actions.generateToneOfVoice); } diff --git a/services/platform/app/features/vendors/components/vendor-delete-dialog.tsx b/services/platform/app/features/vendors/components/vendor-delete-dialog.tsx index 3cb3fc1e4..1d4e0b69b 100644 --- a/services/platform/app/features/vendors/components/vendor-delete-dialog.tsx +++ b/services/platform/app/features/vendors/components/vendor-delete-dialog.tsx @@ -29,7 +29,7 @@ export function VendorDeleteDialog({ }: VendorDeleteDialogProps) { const { t: tVendors } = useT('vendors'); const { t: tToast } = useT('toast'); - const { mutateAsync: deleteVendor } = useDeleteVendor(); + const deleteVendor = useDeleteVendor(); const dialog = useDeleteDialog({ isOpen: controlledIsOpen, diff --git a/services/platform/app/features/vendors/components/vendor-edit-dialog.tsx b/services/platform/app/features/vendors/components/vendor-edit-dialog.tsx index 43470d447..a62c51eb3 100644 --- a/services/platform/app/features/vendors/components/vendor-edit-dialog.tsx +++ b/services/platform/app/features/vendors/components/vendor-edit-dialog.tsx @@ -35,7 +35,7 @@ export function VendorEditDialog({ const { t: tVendors } = useT('vendors'); const { t: tCommon } = useT('common'); const { t: tGlobal } = useT('global'); - const { mutateAsync: updateVendor } = useUpdateVendor(); + const updateVendor = useUpdateVendor(); const localeOptions = useMemo( () => [ diff --git a/services/platform/app/features/vendors/components/vendors-import-dialog.tsx b/services/platform/app/features/vendors/components/vendors-import-dialog.tsx index 0e20bfe01..b977ce083 100644 --- a/services/platform/app/features/vendors/components/vendors-import-dialog.tsx +++ b/services/platform/app/features/vendors/components/vendors-import-dialog.tsx @@ -101,7 +101,7 @@ export function ImportVendorsDialog({ formState: { isSubmitting }, } = formMethods; - const { mutateAsync: bulkCreateVendors } = useBulkCreateVendors(); + const bulkCreateVendors = useBulkCreateVendors(); // Reset form when mode changes to ensure defaultValues are current useEffect(() => { diff --git a/services/platform/app/features/vendors/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/vendors/hooks/__tests__/mutation-hooks.test.ts index 02eecb916..6b5f5ee46 100644 --- a/services/platform/app/features/vendors/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/vendors/hooks/__tests__/mutation-hooks.test.ts @@ -2,13 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { toId } from '@/convex/lib/type_cast_helpers'; -const mockMutateAsync = vi.fn(); +const mockMutationFn = vi.fn(); vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => ({ - mutateAsync: mockMutateAsync, - isPending: false, - }), + useConvexMutation: () => mockMutationFn, })); vi.mock('@/convex/_generated/api', () => ({ @@ -30,10 +27,9 @@ import { } from '../mutations'; describe('useBulkCreateVendors', () => { - it('returns the full mutation result from useConvexMutation', () => { + it('returns the mutation function from useConvexMutation', () => { const result = useBulkCreateVendors(); - expect(result).toHaveProperty('mutateAsync', mockMutateAsync); - expect(result).toHaveProperty('isPending', false); + expect(result).toBe(mockMutationFn); }); }); @@ -42,24 +38,23 @@ describe('useDeleteVendor', () => { vi.clearAllMocks(); }); - it('returns the full mutation result from useConvexMutation', () => { + it('returns the mutation function from useConvexMutation', () => { const result = useDeleteVendor(); - expect(result).toHaveProperty('mutateAsync', mockMutateAsync); - expect(result).toHaveProperty('isPending', false); + expect(result).toBe(mockMutationFn); }); - it('calls mutateAsync with the correct args', async () => { - mockMutateAsync.mockResolvedValueOnce(null); - const { mutateAsync: deleteVendor } = useDeleteVendor(); + it('calls mutation with the correct args', async () => { + mockMutationFn.mockResolvedValueOnce(null); + const deleteVendor = useDeleteVendor(); await deleteVendor({ vendorId: toId<'vendors'>('vendor-123') }); - expect(mockMutateAsync).toHaveBeenCalledWith({ vendorId: 'vendor-123' }); + expect(mockMutationFn).toHaveBeenCalledWith({ vendorId: 'vendor-123' }); }); - it('propagates errors from mutateAsync', async () => { - mockMutateAsync.mockRejectedValueOnce(new Error('Delete failed')); - const { mutateAsync: deleteVendor } = useDeleteVendor(); + it('propagates errors from mutation', async () => { + mockMutationFn.mockRejectedValueOnce(new Error('Delete failed')); + const deleteVendor = useDeleteVendor(); await expect( deleteVendor({ vendorId: toId<'vendors'>('vendor-789') }), @@ -72,15 +67,14 @@ describe('useUpdateVendor', () => { vi.clearAllMocks(); }); - it('returns the full mutation result from useConvexMutation', () => { + it('returns the mutation function from useConvexMutation', () => { const result = useUpdateVendor(); - expect(result).toHaveProperty('mutateAsync', mockMutateAsync); - expect(result).toHaveProperty('isPending', false); + expect(result).toBe(mockMutationFn); }); - it('calls mutateAsync with the correct args', async () => { - mockMutateAsync.mockResolvedValueOnce(undefined); - const { mutateAsync: updateVendor } = useUpdateVendor(); + it('calls mutation with the correct args', async () => { + mockMutationFn.mockResolvedValueOnce(undefined); + const updateVendor = useUpdateVendor(); await updateVendor({ vendorId: toId<'vendors'>('vendor-123'), @@ -88,25 +82,25 @@ describe('useUpdateVendor', () => { email: 'new@example.com', }); - expect(mockMutateAsync).toHaveBeenCalledWith({ + expect(mockMutationFn).toHaveBeenCalledWith({ vendorId: 'vendor-123', name: 'Updated Name', email: 'new@example.com', }); }); - it('calls mutateAsync with only vendorId when no fields updated', async () => { - mockMutateAsync.mockResolvedValueOnce(undefined); - const { mutateAsync: updateVendor } = useUpdateVendor(); + it('calls mutation with only vendorId when no fields updated', async () => { + mockMutationFn.mockResolvedValueOnce(undefined); + const updateVendor = useUpdateVendor(); await updateVendor({ vendorId: toId<'vendors'>('vendor-456') }); - expect(mockMutateAsync).toHaveBeenCalledWith({ vendorId: 'vendor-456' }); + expect(mockMutationFn).toHaveBeenCalledWith({ vendorId: 'vendor-456' }); }); - it('propagates errors from mutateAsync', async () => { - mockMutateAsync.mockRejectedValueOnce(new Error('Update failed')); - const { mutateAsync: updateVendor } = useUpdateVendor(); + it('propagates errors from mutation', async () => { + mockMutationFn.mockRejectedValueOnce(new Error('Update failed')); + const updateVendor = useUpdateVendor(); await expect( updateVendor({ vendorId: toId<'vendors'>('vendor-789'), name: 'Fail' }), diff --git a/services/platform/app/features/websites/components/website-add-dialog.tsx b/services/platform/app/features/websites/components/website-add-dialog.tsx index d6fe56920..fa0e7dae7 100644 --- a/services/platform/app/features/websites/components/website-add-dialog.tsx +++ b/services/platform/app/features/websites/components/website-add-dialog.tsx @@ -31,7 +31,7 @@ export function AddWebsiteDialog({ }: AddWebsiteDialogProps) { const { t: tWebsites } = useT('websites'); const [isLoading, setIsLoading] = useState(false); - const { mutateAsync: createWebsite } = useCreateWebsite(); + const createWebsite = useCreateWebsite(); const formSchema = useMemo( () => diff --git a/services/platform/app/features/websites/components/website-delete-dialog.tsx b/services/platform/app/features/websites/components/website-delete-dialog.tsx index c5f78301e..f467bd2d4 100644 --- a/services/platform/app/features/websites/components/website-delete-dialog.tsx +++ b/services/platform/app/features/websites/components/website-delete-dialog.tsx @@ -22,7 +22,7 @@ export function DeleteWebsiteDialog({ }: DeleteWebsiteDialogProps) { const { t: tWebsites } = useT('websites'); const { t: tToast } = useT('toast'); - const { mutateAsync: deleteWebsite } = useDeleteWebsite(); + const deleteWebsite = useDeleteWebsite(); const translations = useDeleteDialogTranslations({ tEntity: tWebsites, diff --git a/services/platform/app/features/websites/components/website-edit-dialog.tsx b/services/platform/app/features/websites/components/website-edit-dialog.tsx index 3fa6f18ad..fe428078c 100644 --- a/services/platform/app/features/websites/components/website-edit-dialog.tsx +++ b/services/platform/app/features/websites/components/website-edit-dialog.tsx @@ -32,7 +32,7 @@ export function EditWebsiteDialog({ }: EditWebsiteDialogProps) { const { t: tWebsites } = useT('websites'); const [isLoading, setIsLoading] = useState(false); - const { mutateAsync: updateWebsite } = useUpdateWebsite(); + const updateWebsite = useUpdateWebsite(); const formSchema = useMemo( () => diff --git a/services/platform/app/features/websites/components/website-row-actions.tsx b/services/platform/app/features/websites/components/website-row-actions.tsx index 0c0d82245..eb7879964 100644 --- a/services/platform/app/features/websites/components/website-row-actions.tsx +++ b/services/platform/app/features/websites/components/website-row-actions.tsx @@ -1,7 +1,7 @@ 'use client'; import { Eye, ScanText, RefreshCcw, Pencil, Trash2 } from 'lucide-react'; -import { useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import { EntityRowActions, @@ -25,10 +25,11 @@ export function WebsiteRowActions({ website }: WebsiteRowActionsProps) { const { t: tCommon } = useT('common'); const dialogs = useEntityRowDialogs(['view', 'edit', 'delete']); - const { mutateAsync: rescanWebsite, isPending: isRescanning } = - useRescanWebsite(); + const [isRescanning, setIsRescanning] = useState(false); + const rescanWebsite = useRescanWebsite(); const handleRescan = useCallback(async () => { + setIsRescanning(true); try { await rescanWebsite({ websiteId: website._id }); toast({ @@ -41,6 +42,8 @@ export function WebsiteRowActions({ website }: WebsiteRowActionsProps) { title: t('actions.rescanFailed'), variant: 'destructive', }); + } finally { + setIsRescanning(false); } }, [rescanWebsite, website._id, t]); diff --git a/services/platform/app/hooks/use-convex-action-mutation.ts b/services/platform/app/hooks/use-convex-action-mutation.ts deleted file mode 100644 index 9eacd2ac9..000000000 --- a/services/platform/app/hooks/use-convex-action-mutation.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { FunctionArgs, FunctionReference } from 'convex/server'; - -import { useMutation } from '@tanstack/react-query'; - -import { useConvexClient } from './use-convex-client'; - -export function useConvexActionMutation< - Func extends FunctionReference<'action'>, ->(func: Func) { - const convexClient = useConvexClient(); - - return useMutation({ - mutationFn: (args: FunctionArgs) => convexClient.action(func, args), - }); -} diff --git a/services/platform/app/hooks/use-convex-mutation.ts b/services/platform/app/hooks/use-convex-mutation.ts index 0a0494712..d449e57d8 100644 --- a/services/platform/app/hooks/use-convex-mutation.ts +++ b/services/platform/app/hooks/use-convex-mutation.ts @@ -1,15 +1 @@ -import type { FunctionArgs, FunctionReference } from 'convex/server'; - -import { useMutation } from '@tanstack/react-query'; - -import { useConvexClient } from './use-convex-client'; - -export function useConvexMutation>( - func: Func, -) { - const convexClient = useConvexClient(); - - return useMutation({ - mutationFn: (args: FunctionArgs) => convexClient.mutation(func, args), - }); -} +export { useMutation as useConvexMutation } from 'convex/react'; diff --git a/services/platform/app/routes/dashboard/$id/automations/$amId.tsx b/services/platform/app/routes/dashboard/$id/automations/$amId.tsx index 310e45fe5..c5291ec40 100644 --- a/services/platform/app/routes/dashboard/$id/automations/$amId.tsx +++ b/services/platform/app/routes/dashboard/$id/automations/$amId.tsx @@ -101,7 +101,7 @@ function AutomationDetailLayout() { const [editMode, setEditMode] = useState(false); const isSubmittingRef = useRef(false); const { register, getValues } = useForm<{ name: string }>(); - const { mutateAsync: updateWorkflow } = useUpdateAutomation(); + const updateWorkflow = useUpdateAutomation(); const { data: automation } = useWorkflow(automationId); const workflowStepCollection = useWorkflowStepCollection(amId); diff --git a/services/platform/app/routes/dashboard/$id/automations/$amId/configuration.tsx b/services/platform/app/routes/dashboard/$id/automations/$amId/configuration.tsx index d8d722739..e62eee633 100644 --- a/services/platform/app/routes/dashboard/$id/automations/$amId/configuration.tsx +++ b/services/platform/app/routes/dashboard/$id/automations/$amId/configuration.tsx @@ -58,7 +58,7 @@ function ConfigurationPage() { const { data: workflow, isLoading: isWorkflowLoading } = useWorkflow(automationId); - const { mutateAsync: updateWorkflow } = useUpdateAutomation(); + const updateWorkflow = useUpdateAutomation(); useEffect(() => { if (workflow) { diff --git a/services/platform/convex/README.md b/services/platform/convex/README.md index f0347380d..9040ad021 100644 --- a/services/platform/convex/README.md +++ b/services/platform/convex/README.md @@ -89,27 +89,38 @@ export const updateCustomer = mutationWithRLS({ ### Action ```ts -// convex/customers/actions.ts -import { action } from './_generated/server'; +// convex/documents/actions.ts import { v } from 'convex/values'; -export const bulkCreateCustomers = action({ +import { internal } from '../_generated/api'; +import { action } from '../_generated/server'; + +export const retryRagIndexing = action({ args: { - organizationId: v.string(), - customers: v.array( - v.object({ - /* fields */ - }), - ), + documentId: v.id('documents'), }, + returns: v.object({ + success: v.boolean(), + jobId: v.optional(v.string()), + error: v.optional(v.string()), + }), handler: async (ctx, args) => { - // Actions can call mutations and interact with external systems - for (const customer of args.customers) { - await ctx.runMutation(api.customers.mutations.createCustomer, { - organizationId: args.organizationId, - ...customer, - }); + // Actions can call queries/mutations and interact with external systems + const document = await ctx.runQuery( + internal.documents.internal_queries.getDocumentByIdRaw, + { documentId: args.documentId }, + ); + + if (!document) { + return { success: false, error: 'Document not found' }; } + + const result = await ragAction.execute(ctx, { + operation: 'upload_document', + recordId: args.documentId, + }); + + return { success: result.success, jobId: result.jobId }; }, }); ``` @@ -171,9 +182,9 @@ export function useUpdateCustomer() { ### Actions (for side effects) ```ts -// features/customers/hooks/actions.ts -export function useBulkCreateCustomers() { - return useConvexAction(api.customers.actions.bulkCreateCustomers); +// features/documents/hooks/actions.ts +export function useRetryRagIndexing() { + return useConvexAction(api.documents.actions.retryRagIndexing); } ``` From 3a657805c581e91232a22ae13c79929667eb22ec Mon Sep 17 00:00:00 2001 From: methosiea Date: Sat, 14 Feb 2026 10:49:12 +0100 Subject: [PATCH 2/8] chore: remove --- package-lock.json | 85 -------- .../components/approval-detail-dialog.tsx | 16 +- .../approvals/components/approvals-client.tsx | 5 +- .../hooks/__tests__/collection-hooks.test.ts | 45 ---- .../features/approvals/hooks/collections.ts | 6 - .../app/features/approvals/hooks/queries.ts | 20 +- .../automation-active-toggle.test.tsx | 44 +++- .../components/automation-active-toggle.tsx | 88 ++++---- .../components/automation-assistant.tsx | 10 +- .../components/automation-create-dialog.tsx | 9 +- .../components/automation-navigation.tsx | 108 +++++---- .../components/automation-row-actions.tsx | 158 ++++++++------ .../components/automation-sidepanel.tsx | 49 +++-- .../components/automation-steps.tsx | 2 +- .../components/automation-tester.tsx | 7 +- .../components/automations-client.tsx | 4 +- .../hooks/__tests__/collection-hooks.test.ts | 176 --------------- .../features/automations/hooks/collections.ts | 24 -- .../app/features/automations/hooks/queries.ts | 50 +++-- .../components/event-create-dialog.tsx | 6 +- .../triggers/components/events-section.tsx | 14 +- .../components/schedule-create-dialog.tsx | 21 +- .../triggers/components/schedules-section.tsx | 7 +- .../triggers/components/webhooks-section.tsx | 53 ++--- .../hooks/__tests__/collection-hooks.test.ts | 173 --------------- .../automations/triggers/hooks/collections.ts | 28 --- .../automations/triggers/hooks/queries.ts | 48 ++-- .../chat/components/agent-selector.tsx | 4 +- .../features/chat/components/chat-actions.tsx | 60 ++--- .../chat/components/chat-history-sidebar.tsx | 6 +- .../chat/components/chat-search-dialog.tsx | 4 +- .../components/human-input-request-card.tsx | 47 ++-- .../components/integration-approval-card.tsx | 5 +- .../workflow-creation-approval-card.tsx | 5 +- .../hooks/__tests__/collection-hooks.test.ts | 59 ----- .../chat/hooks/__tests__/query-hooks.test.ts | 132 ----------- .../app/features/chat/hooks/collections.ts | 6 - .../app/features/chat/hooks/queries.ts | 103 +++------ .../chat/hooks/use-convex-file-upload.ts | 2 +- .../features/chat/hooks/use-send-message.ts | 10 +- .../components/conversation-header.tsx | 162 +++++++------- .../components/conversation-panel.tsx | 48 ++-- .../components/conversations-client.tsx | 12 +- .../components/message-editor.tsx | 2 +- .../hooks/__tests__/collection-hooks.test.ts | 71 ------ .../hooks/__tests__/mutation-hooks.test.ts | 81 ++++--- .../conversations/hooks/collections.ts | 10 - .../features/conversations/hooks/queries.ts | 19 +- .../custom-agent-active-toggle.test.tsx | 43 ++-- .../components/custom-agent-active-toggle.tsx | 103 +++++---- .../components/custom-agent-create-dialog.tsx | 2 +- .../components/custom-agent-knowledge.tsx | 4 +- .../components/custom-agent-navigation.tsx | 135 ++++++------ .../components/custom-agent-row-actions.tsx | 170 ++++++++------- .../custom-agent-version-history-dialog.tsx | 65 +++--- .../custom-agent-webhook-section.tsx | 63 +++--- .../components/test-chat-panel.tsx | 6 +- .../components/tool-selector.tsx | 11 +- .../hooks/__tests__/collection-hooks.test.ts | 205 ------------------ .../hooks/__tests__/query-hooks.test.ts | 57 ----- .../custom-agents/hooks/collections.ts | 52 ----- .../features/custom-agents/hooks/queries.ts | 127 +++++------ .../components/customers-import-dialog.tsx | 2 +- .../hooks/__tests__/collection-hooks.test.ts | 71 ------ .../features/customers/hooks/collections.ts | 6 - .../app/features/customers/hooks/queries.ts | 57 ++--- .../components/document-preview-dialog.tsx | 4 +- .../components/document-row-actions.tsx | 72 +++--- .../components/document-team-tags-dialog.tsx | 6 +- .../components/document-upload-dialog.tsx | 4 +- .../documents/components/documents-client.tsx | 4 +- .../components/onedrive-import-dialog.tsx | 19 +- .../documents/components/rag-status-badge.tsx | 7 +- .../hooks/__tests__/collection-hooks.test.ts | 71 ------ .../hooks/__tests__/mutation-hooks.test.ts | 53 +++-- .../features/documents/hooks/collections.ts | 6 - .../app/features/documents/hooks/mutations.ts | 4 +- .../app/features/documents/hooks/queries.ts | 18 +- .../components/organization-form-client.tsx | 3 +- .../organization/hooks/collections.ts | 10 - .../features/organization/hooks/queries.ts | 18 +- .../components/product-edit-dialog.tsx | 48 ++-- .../hooks/__tests__/collection-hooks.test.ts | 71 ------ .../features/products/hooks/collections.ts | 6 - .../app/features/products/hooks/queries.ts | 18 +- .../components/account-form-client.tsx | 6 +- .../components/api-key-create-dialog.tsx | 10 +- .../components/api-key-revoke-dialog.tsx | 45 ++-- .../settings/api-keys/hooks/use-api-keys.ts | 31 ++- .../components/integration-manage-dialog.tsx | 29 +-- .../integration-upload-dialog.tsx | 4 +- .../components/sso-config-dialog.tsx | 34 ++- .../hooks/__tests__/collection-hooks.test.ts | 52 ----- .../hooks/__tests__/mutation-hooks.test.ts | 30 ++- .../integrations/hooks/collections.ts | 10 - .../settings/integrations/hooks/queries.ts | 16 +- .../components/member-add-dialog.tsx | 7 +- .../components/member-edit-dialog.tsx | 6 +- .../organization-settings-client.tsx | 4 +- .../hooks/__tests__/collection-hooks.test.ts | 44 ---- .../hooks/__tests__/mutation-hooks.test.ts | 64 +++--- .../organization/hooks/collections.ts | 6 - .../settings/organization/hooks/queries.ts | 18 +- .../teams/components/team-create-dialog.tsx | 2 +- .../teams/components/team-delete-dialog.tsx | 2 +- .../teams/components/team-edit-dialog.tsx | 2 +- .../teams/components/team-members-dialog.tsx | 15 +- .../teams/components/team-row-actions.tsx | 2 +- .../settings/teams/components/team-table.tsx | 2 +- .../teams/components/teams-settings.tsx | 6 +- .../hooks/__tests__/collection-hooks.test.ts | 75 ------- .../hooks/__tests__/mutation-hooks.test.ts | 52 +++-- .../settings/teams/hooks/collections.ts | 22 -- .../features/settings/teams/hooks/queries.ts | 23 +- .../components/tone-of-voice-form-client.tsx | 15 +- .../components/vendor-delete-dialog.tsx | 2 +- .../vendors/components/vendor-edit-dialog.tsx | 2 +- .../components/vendors-import-dialog.tsx | 2 +- .../components/vendors-page-wrapper.tsx | 4 +- .../hooks/__tests__/collection-hooks.test.ts | 71 ------ .../hooks/__tests__/mutation-hooks.test.ts | 54 +++-- .../app/features/vendors/hooks/collections.ts | 6 - .../app/features/vendors/hooks/queries.ts | 17 +- .../components/website-add-dialog.tsx | 49 ++--- .../components/website-delete-dialog.tsx | 2 +- .../components/website-edit-dialog.tsx | 47 ++-- .../components/website-row-actions.tsx | 41 ++-- .../websites/components/websites-table.tsx | 4 +- .../hooks/__tests__/collection-hooks.test.ts | 71 ------ .../features/websites/hooks/collections.ts | 6 - .../app/features/websites/hooks/queries.ts | 16 +- .../hooks/__tests__/use-convex-action.test.ts | 40 +++- .../platform/app/hooks/use-convex-action.ts | 26 ++- .../platform/app/hooks/use-convex-mutation.ts | 26 ++- .../platform/app/hooks/use-team-filter.tsx | 4 +- .../dashboard/$id/_knowledge/websites.tsx | 4 +- .../dashboard/$id/automations/$amId.tsx | 16 +- .../$id/automations/$amId/configuration.tsx | 7 +- .../dashboard/$id/automations/index.tsx | 7 +- .../dashboard/$id/custom-agents/$agentId.tsx | 13 +- .../dashboard/$id/custom-agents/index.tsx | 4 +- .../dashboard/$id/settings/integrations.tsx | 7 +- .../routes/dashboard/create-organization.tsx | 3 +- .../platform/app/routes/dashboard/index.tsx | 3 +- services/platform/convex/README.md | 69 ++---- .../__tests__/collection-registry.test.ts | 200 ----------------- .../convex-collection-options.test.ts | 137 ------------ .../lib/collections/collection-registry.ts | 65 ------ .../collections/convex-collection-options.ts | 94 -------- .../entities/__tests__/approvals.test.ts | 119 ---------- .../__tests__/automation-roots.test.ts | 123 ----------- .../__tests__/available-integrations.test.ts | 123 ----------- .../entities/__tests__/conversations.test.ts | 123 ----------- .../__tests__/custom-agent-versions.test.ts | 123 ----------- .../__tests__/custom-agent-webhooks.test.ts | 125 ----------- .../entities/__tests__/custom-agents.test.ts | 123 ----------- .../entities/__tests__/customers.test.ts | 119 ---------- .../entities/__tests__/documents.test.ts | 119 ---------- .../entities/__tests__/integrations.test.ts | 103 --------- .../entities/__tests__/members.test.ts | 119 ---------- .../entities/__tests__/products.test.ts | 119 ---------- .../entities/__tests__/team-members.test.ts | 117 ---------- .../entities/__tests__/teams.test.ts | 119 ---------- .../entities/__tests__/threads.test.ts | 115 ---------- .../entities/__tests__/vendors.test.ts | 119 ---------- .../entities/__tests__/websites.test.ts | 119 ---------- .../entities/__tests__/wf-automations.test.ts | 123 ----------- .../__tests__/wf-event-subscriptions.test.ts | 129 ----------- .../entities/__tests__/wf-schedules.test.ts | 127 ----------- .../entities/__tests__/wf-steps.test.ts | 119 ---------- .../entities/__tests__/wf-webhooks.test.ts | 125 ----------- .../lib/collections/entities/approvals.ts | 40 ---- .../collections/entities/automation-roots.ts | 25 --- .../entities/available-integrations.ts | 25 --- .../collections/entities/available-tools.ts | 25 --- .../lib/collections/entities/conversations.ts | 69 ------ .../entities/custom-agent-versions.ts | 26 --- .../entities/custom-agent-webhooks.ts | 54 ----- .../lib/collections/entities/custom-agents.ts | 63 ------ .../lib/collections/entities/customers.ts | 55 ----- .../lib/collections/entities/documents.ts | 58 ----- .../lib/collections/entities/integrations.ts | 33 --- .../lib/collections/entities/members.ts | 75 ------- .../lib/collections/entities/products.ts | 69 ------ .../lib/collections/entities/team-members.ts | 59 ----- .../lib/collections/entities/teams.ts | 24 -- .../lib/collections/entities/threads.ts | 44 ---- .../entities/user-organizations.ts | 25 --- .../lib/collections/entities/vendors.ts | 55 ----- .../lib/collections/entities/websites.ts | 67 ------ .../collections/entities/wf-automations.ts | 82 ------- .../entities/wf-event-subscriptions.ts | 77 ------- .../lib/collections/entities/wf-schedules.ts | 88 -------- .../lib/collections/entities/wf-steps.ts | 61 ------ .../lib/collections/entities/wf-webhooks.ts | 67 ------ .../lib/collections/use-collection.ts | 61 ------ services/platform/lib/types/convex-helpers.ts | 8 + services/platform/package.json | 3 - 198 files changed, 1656 insertions(+), 7810 deletions(-) delete mode 100644 services/platform/app/features/approvals/hooks/__tests__/collection-hooks.test.ts delete mode 100644 services/platform/app/features/approvals/hooks/collections.ts delete mode 100644 services/platform/app/features/automations/hooks/__tests__/collection-hooks.test.ts delete mode 100644 services/platform/app/features/automations/hooks/collections.ts delete mode 100644 services/platform/app/features/automations/triggers/hooks/__tests__/collection-hooks.test.ts delete mode 100644 services/platform/app/features/automations/triggers/hooks/collections.ts delete mode 100644 services/platform/app/features/chat/hooks/__tests__/collection-hooks.test.ts delete mode 100644 services/platform/app/features/chat/hooks/__tests__/query-hooks.test.ts delete mode 100644 services/platform/app/features/chat/hooks/collections.ts delete mode 100644 services/platform/app/features/conversations/hooks/__tests__/collection-hooks.test.ts delete mode 100644 services/platform/app/features/conversations/hooks/collections.ts delete mode 100644 services/platform/app/features/custom-agents/hooks/__tests__/collection-hooks.test.ts delete mode 100644 services/platform/app/features/custom-agents/hooks/__tests__/query-hooks.test.ts delete mode 100644 services/platform/app/features/custom-agents/hooks/collections.ts delete mode 100644 services/platform/app/features/customers/hooks/__tests__/collection-hooks.test.ts delete mode 100644 services/platform/app/features/customers/hooks/collections.ts delete mode 100644 services/platform/app/features/documents/hooks/__tests__/collection-hooks.test.ts delete mode 100644 services/platform/app/features/documents/hooks/collections.ts delete mode 100644 services/platform/app/features/organization/hooks/collections.ts delete mode 100644 services/platform/app/features/products/hooks/__tests__/collection-hooks.test.ts delete mode 100644 services/platform/app/features/products/hooks/collections.ts delete mode 100644 services/platform/app/features/settings/integrations/hooks/__tests__/collection-hooks.test.ts delete mode 100644 services/platform/app/features/settings/integrations/hooks/collections.ts delete mode 100644 services/platform/app/features/settings/organization/hooks/__tests__/collection-hooks.test.ts delete mode 100644 services/platform/app/features/settings/organization/hooks/collections.ts delete mode 100644 services/platform/app/features/settings/teams/hooks/__tests__/collection-hooks.test.ts delete mode 100644 services/platform/app/features/settings/teams/hooks/collections.ts delete mode 100644 services/platform/app/features/vendors/hooks/__tests__/collection-hooks.test.ts delete mode 100644 services/platform/app/features/vendors/hooks/collections.ts delete mode 100644 services/platform/app/features/websites/hooks/__tests__/collection-hooks.test.ts delete mode 100644 services/platform/app/features/websites/hooks/collections.ts delete mode 100644 services/platform/lib/collections/__tests__/collection-registry.test.ts delete mode 100644 services/platform/lib/collections/__tests__/convex-collection-options.test.ts delete mode 100644 services/platform/lib/collections/collection-registry.ts delete mode 100644 services/platform/lib/collections/convex-collection-options.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/approvals.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/automation-roots.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/available-integrations.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/conversations.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/custom-agent-versions.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/custom-agent-webhooks.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/custom-agents.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/customers.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/documents.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/integrations.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/members.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/products.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/team-members.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/teams.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/threads.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/vendors.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/websites.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/wf-automations.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/wf-event-subscriptions.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/wf-schedules.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/wf-steps.test.ts delete mode 100644 services/platform/lib/collections/entities/__tests__/wf-webhooks.test.ts delete mode 100644 services/platform/lib/collections/entities/approvals.ts delete mode 100644 services/platform/lib/collections/entities/automation-roots.ts delete mode 100644 services/platform/lib/collections/entities/available-integrations.ts delete mode 100644 services/platform/lib/collections/entities/available-tools.ts delete mode 100644 services/platform/lib/collections/entities/conversations.ts delete mode 100644 services/platform/lib/collections/entities/custom-agent-versions.ts delete mode 100644 services/platform/lib/collections/entities/custom-agent-webhooks.ts delete mode 100644 services/platform/lib/collections/entities/custom-agents.ts delete mode 100644 services/platform/lib/collections/entities/customers.ts delete mode 100644 services/platform/lib/collections/entities/documents.ts delete mode 100644 services/platform/lib/collections/entities/integrations.ts delete mode 100644 services/platform/lib/collections/entities/members.ts delete mode 100644 services/platform/lib/collections/entities/products.ts delete mode 100644 services/platform/lib/collections/entities/team-members.ts delete mode 100644 services/platform/lib/collections/entities/teams.ts delete mode 100644 services/platform/lib/collections/entities/threads.ts delete mode 100644 services/platform/lib/collections/entities/user-organizations.ts delete mode 100644 services/platform/lib/collections/entities/vendors.ts delete mode 100644 services/platform/lib/collections/entities/websites.ts delete mode 100644 services/platform/lib/collections/entities/wf-automations.ts delete mode 100644 services/platform/lib/collections/entities/wf-event-subscriptions.ts delete mode 100644 services/platform/lib/collections/entities/wf-schedules.ts delete mode 100644 services/platform/lib/collections/entities/wf-steps.ts delete mode 100644 services/platform/lib/collections/entities/wf-webhooks.ts delete mode 100644 services/platform/lib/collections/use-collection.ts create mode 100644 services/platform/lib/types/convex-helpers.ts diff --git a/package-lock.json b/package-lock.json index e02a7b284..71ad46ace 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10781,33 +10781,6 @@ "resolved": "services/rag", "link": true }, - "node_modules/@tanstack/db": { - "version": "0.5.25", - "resolved": "https://registry.npmjs.org/@tanstack/db/-/db-0.5.25.tgz", - "integrity": "sha512-VqVchs6Mm4rw2GyiOkaoD+PJw6lCJT8EI/TzPu8KWZy3QxyOlilpMvEuDTCl0LZdp1iLYlQT1NdgDg0gimV3kQ==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@tanstack/db-ivm": "0.1.17", - "@tanstack/pacer-lite": "^0.2.0" - }, - "peerDependencies": { - "typescript": ">=4.7" - } - }, - "node_modules/@tanstack/db-ivm": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/@tanstack/db-ivm/-/db-ivm-0.1.17.tgz", - "integrity": "sha512-DK7vm56CDxNuRAdsbiPs+gITJ+16tUtYgZg3BRTLYKGIDsy8sdIO7sQFq5zl7Y+aIKAPmMAbVp9UjJ75FTtwgQ==", - "license": "MIT", - "dependencies": { - "fractional-indexing": "^3.2.0", - "sorted-btree": "^1.8.1" - }, - "peerDependencies": { - "typescript": ">=4.7" - } - }, "node_modules/@tanstack/history": { "version": "1.154.14", "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.154.14.tgz", @@ -10821,19 +10794,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/pacer-lite": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.2.1.tgz", - "integrity": "sha512-3PouiFjR4B6x1c969/Pl4ZIJleof1M0n6fNX8NRiC9Sqv1g06CVDlEaXUR4212ycGFyfq4q+t8Gi37Xy+z34iQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@tanstack/query-core": { "version": "5.90.20", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", @@ -10844,33 +10804,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/query-db-collection": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/@tanstack/query-db-collection/-/query-db-collection-1.0.22.tgz", - "integrity": "sha512-feYfOIA/xgf3S/aWIhq7Oov/RE66M0wMOZUk1+oAHZ3W7x0br7JzRKFYwdTtIMtopFt8tDq3Pt2gtZVZu+S7rA==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@tanstack/db": "0.5.25" - }, - "peerDependencies": { - "@tanstack/query-core": "^5.0.0", - "typescript": ">=4.7" - } - }, - "node_modules/@tanstack/react-db": { - "version": "0.1.69", - "resolved": "https://registry.npmjs.org/@tanstack/react-db/-/react-db-0.1.69.tgz", - "integrity": "sha512-rqhajRK5InIEKT9RABE9zNbYZL5NGkySjGNVANyilu/ADFHV8rhtkMEnhHcbrzv0grIKpcSlx1AvTgJNbbzjkw==", - "license": "MIT", - "dependencies": { - "@tanstack/db": "0.5.25", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, "node_modules/@tanstack/react-query": { "version": "5.90.21", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", @@ -16092,15 +16025,6 @@ "node": ">=0.8" } }, - "node_modules/fractional-indexing": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.2.0.tgz", - "integrity": "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==", - "license": "CC0-1.0", - "engines": { - "node": "^14.13.1 || >=16.0.0" - } - }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -23421,12 +23345,6 @@ "url": "https://github.com/sponsors/cyyynthia" } }, - "node_modules/sorted-btree": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sorted-btree/-/sorted-btree-1.8.1.tgz", - "integrity": "sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ==", - "license": "MIT" - }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -26616,9 +26534,6 @@ "@radix-ui/react-visually-hidden": "1.2.4", "@sentry/tanstackstart-react": "^10.37.0", "@tailwindcss/postcss": "4.1.18", - "@tanstack/db": "^0.5.25", - "@tanstack/query-db-collection": "^1.0.22", - "@tanstack/react-db": "^0.1.69", "@tanstack/react-query": "5.90.21", "@tanstack/react-router": "1.159.10", "@tanstack/react-table": "8.21.3", diff --git a/services/platform/app/features/approvals/components/approval-detail-dialog.tsx b/services/platform/app/features/approvals/components/approval-detail-dialog.tsx index 9ce66ac2e..7b08e16fd 100644 --- a/services/platform/app/features/approvals/components/approval-detail-dialog.tsx +++ b/services/platform/app/features/approvals/components/approval-detail-dialog.tsx @@ -8,10 +8,11 @@ import { Badge } from '@/app/components/ui/feedback/badge'; import { Stack, HStack } from '@/app/components/ui/layout/layout'; import { Button } from '@/app/components/ui/primitives/button'; import { CustomerInfoDialog } from '@/app/features/customers/components/customer-info-dialog'; -import { useCustomerByEmail } from '@/app/features/customers/hooks/queries'; +import { + useCustomerByEmail, + useCustomers, +} from '@/app/features/customers/hooks/queries'; import { useFormatDate } from '@/app/hooks/use-format-date'; -import { createCustomersCollection } from '@/lib/collections/entities/customers'; -import { useCollection } from '@/lib/collections/use-collection'; import { useT } from '@/lib/i18n/client'; import { cn } from '@/lib/utils/cn'; @@ -49,14 +50,9 @@ export function ApprovalDetailDialog({ const { formatDate } = useFormatDate(); const [customerInfoOpen, setCustomerInfoOpen] = useState(false); - // Lookup customer by email from collection - const customersCollection = useCollection( - 'customers', - createCustomersCollection, - approvalDetail?.organizationId ?? '', - ); + const { customers } = useCustomers(approvalDetail?.organizationId ?? ''); const customerRecord = useCustomerByEmail( - customersCollection, + customers, approvalDetail?.customer.email, ); diff --git a/services/platform/app/features/approvals/components/approvals-client.tsx b/services/platform/app/features/approvals/components/approvals-client.tsx index d47ef4a5d..ebfcefbe6 100644 --- a/services/platform/app/features/approvals/components/approvals-client.tsx +++ b/services/platform/app/features/approvals/components/approvals-client.tsx @@ -143,8 +143,9 @@ export function ApprovalsClient({ const { data: memberContext } = useCurrentMemberContext(organizationId); - const updateApprovalStatus = useUpdateApprovalStatus(); - const removeRecommendedProduct = useRemoveRecommendedProduct(); + const { mutateAsync: updateApprovalStatus } = useUpdateApprovalStatus(); + const { mutateAsync: removeRecommendedProduct } = + useRemoveRecommendedProduct(); const handleApprove = useCallback( async (approvalId: string) => { diff --git a/services/platform/app/features/approvals/hooks/__tests__/collection-hooks.test.ts b/services/platform/app/features/approvals/hooks/__tests__/collection-hooks.test.ts deleted file mode 100644 index 9f2ce5789..000000000 --- a/services/platform/app/features/approvals/hooks/__tests__/collection-hooks.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((_builder: (q: unknown) => unknown) => { - return { data: [], isLoading: false }; - }), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useApprovals } from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); - -describe('useApprovals', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data from live query', () => { - const items = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: items, - isLoading: false, - } as ReturnType); - - const result = useApprovals(mockCollection as never); - expect(result.approvals).toBe(items); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useApprovals(mockCollection as never); - expect(result.approvals).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/approvals/hooks/collections.ts b/services/platform/app/features/approvals/hooks/collections.ts deleted file mode 100644 index a43bcf18a..000000000 --- a/services/platform/app/features/approvals/hooks/collections.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createApprovalsCollection } from '@/lib/collections/entities/approvals'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useApprovalCollection(organizationId: string) { - return useCollection('approvals', createApprovalsCollection, organizationId); -} diff --git a/services/platform/app/features/approvals/hooks/queries.ts b/services/platform/app/features/approvals/hooks/queries.ts index 903880584..c627e05c0 100644 --- a/services/platform/app/features/approvals/hooks/queries.ts +++ b/services/platform/app/features/approvals/hooks/queries.ts @@ -1,17 +1,21 @@ -import type { Collection } from '@tanstack/db'; - -import { useLiveQuery } from '@tanstack/react-db'; - -import type { Approval } from '@/lib/collections/entities/approvals'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; import { useCachedPaginatedQuery } from '@/app/hooks/use-cached-paginated-query'; +import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { api } from '@/convex/_generated/api'; -export function useApprovals(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export type Approval = ConvexItemOf< + typeof api.approvals.queries.listApprovalsByOrganization +>; + +export function useApprovals(organizationId: string) { + const { data, isLoading } = useConvexQuery( + api.approvals.queries.listApprovalsByOrganization, + { organizationId }, + ); return { - approvals: data, + approvals: data ?? [], isLoading, }; } diff --git a/services/platform/app/features/automations/components/automation-active-toggle.test.tsx b/services/platform/app/features/automations/components/automation-active-toggle.test.tsx index 5a09ef1e1..aee6100ab 100644 --- a/services/platform/app/features/automations/components/automation-active-toggle.test.tsx +++ b/services/platform/app/features/automations/components/automation-active-toggle.test.tsx @@ -12,8 +12,26 @@ const mockUnpublish = vi.fn(); vi.mock('../hooks/mutations', async (importOriginal) => ({ ...(await importOriginal()), - useRepublishAutomation: () => mockRepublish, - useUnpublishAutomation: () => mockUnpublish, + useRepublishAutomation: () => ({ + mutate: mockRepublish, + mutateAsync: mockRepublish, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), + }), + useUnpublishAutomation: () => ({ + mutate: mockUnpublish, + mutateAsync: mockUnpublish, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), + }), })); vi.mock('@/app/hooks/use-convex-auth', () => ({ @@ -105,10 +123,13 @@ describe('AutomationActiveToggle', () => { await user.click(screen.getByRole('switch')); await waitFor(() => { - expect(mockRepublish).toHaveBeenCalledWith({ - wfDefinitionId: 'wf-1', - publishedBy: 'test@example.com', - }); + expect(mockRepublish).toHaveBeenCalledWith( + { + wfDefinitionId: 'wf-1', + publishedBy: 'test@example.com', + }, + expect.any(Object), + ); }); }); @@ -140,10 +161,13 @@ describe('AutomationActiveToggle', () => { await user.click(confirmButton); await waitFor(() => { - expect(mockUnpublish).toHaveBeenCalledWith({ - wfDefinitionId: 'wf-1', - updatedBy: 'user-1', - }); + expect(mockUnpublish).toHaveBeenCalledWith( + { + wfDefinitionId: 'wf-1', + updatedBy: 'user-1', + }, + expect.any(Object), + ); }); }); diff --git a/services/platform/app/features/automations/components/automation-active-toggle.tsx b/services/platform/app/features/automations/components/automation-active-toggle.tsx index 8fe00b655..944fb111b 100644 --- a/services/platform/app/features/automations/components/automation-active-toggle.tsx +++ b/services/platform/app/features/automations/components/automation-active-toggle.tsx @@ -30,67 +30,71 @@ export function AutomationActiveToggle({ const [showDeactivateDialog, setShowDeactivateDialog] = useState(false); - const republishAutomation = useRepublishAutomation(); - const [isRepublishing, setIsRepublishing] = useState(false); - const unpublishAutomation = useUnpublishAutomation(); - const [isUnpublishing, setIsUnpublishing] = useState(false); + const { mutate: republishAutomation, isPending: isRepublishing } = + useRepublishAutomation(); + const { mutate: unpublishAutomation, isPending: isUnpublishing } = + useUnpublishAutomation(); const isToggling = isRepublishing || isUnpublishing; const isActive = automation.status === 'active'; const isDraft = automation.status === 'draft'; - const handleActivate = useCallback(async () => { + const handleActivate = useCallback(() => { if (!user) return; - setIsRepublishing(true); - try { - await republishAutomation({ + republishAutomation( + { wfDefinitionId: automation._id, publishedBy: user.email ?? user.userId, - }); - toast({ - title: tToast('success.automationPublished'), - variant: 'success', - }); - } catch (error) { - console.error('Failed to activate automation:', error); - toast({ - title: tToast('error.automationPublishFailed'), - variant: 'destructive', - }); - } finally { - setIsRepublishing(false); - } + }, + { + onSuccess: () => { + toast({ + title: tToast('success.automationPublished'), + variant: 'success', + }); + }, + onError: (error) => { + console.error('Failed to activate automation:', error); + toast({ + title: tToast('error.automationPublishFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [republishAutomation, automation._id, user, tToast]); - const handleDeactivateConfirm = useCallback(async () => { + const handleDeactivateConfirm = useCallback(() => { if (!user) return; - setIsUnpublishing(true); - try { - await unpublishAutomation({ + unpublishAutomation( + { wfDefinitionId: automation._id, updatedBy: user.userId, - }); - setShowDeactivateDialog(false); - toast({ - title: tToast('success.automationDeactivated'), - variant: 'success', - }); - } catch (error) { - console.error('Failed to deactivate automation:', error); - toast({ - title: tToast('error.automationDeactivateFailed'), - variant: 'destructive', - }); - } finally { - setIsUnpublishing(false); - } + }, + { + onSuccess: () => { + setShowDeactivateDialog(false); + toast({ + title: tToast('success.automationDeactivated'), + variant: 'success', + }); + }, + onError: (error) => { + console.error('Failed to deactivate automation:', error); + toast({ + title: tToast('error.automationDeactivateFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [unpublishAutomation, automation._id, user, tToast]); const handleToggle = useCallback( (checked: boolean) => { if (checked) { - void handleActivate(); + handleActivate(); } else { setShowDeactivateDialog(true); } diff --git a/services/platform/app/features/automations/components/automation-assistant.tsx b/services/platform/app/features/automations/components/automation-assistant.tsx index fd4d27b86..e17cea141 100644 --- a/services/platform/app/features/automations/components/automation-assistant.tsx +++ b/services/platform/app/features/automations/components/automation-assistant.tsx @@ -292,11 +292,11 @@ function AutomationAssistantContent({ delay: 16, }); - // Connect to workflow assistant agent - const chatWithWorkflowAssistant = useChatWithWorkflowAssistant(); - const createChatThread = useCreateThread(); - const deleteChatThread = useDeleteThread(); - const updateWorkflowMetadata = useUpdateAutomationMetadata(); + const { mutateAsync: chatWithWorkflowAssistant } = + useChatWithWorkflowAssistant(); + const { mutateAsync: createChatThread } = useCreateThread(); + const { mutateAsync: deleteChatThread } = useDeleteThread(); + const { mutateAsync: updateWorkflowMetadata } = useUpdateAutomationMetadata(); const { data: workflow } = useWorkflow(automationId); diff --git a/services/platform/app/features/automations/components/automation-create-dialog.tsx b/services/platform/app/features/automations/components/automation-create-dialog.tsx index dd144ac87..e48eadb68 100644 --- a/services/platform/app/features/automations/components/automation-create-dialog.tsx +++ b/services/platform/app/features/automations/components/automation-create-dialog.tsx @@ -40,9 +40,10 @@ export function CreateAutomationDialog({ const { t } = useT('automations'); const { t: tCommon } = useT('common'); const { user } = useAuth(); - const createChatThread = useCreateThread(); - const updateWorkflowMetadata = useUpdateAutomationMetadata(); - const chatWithWorkflowAssistant = useChatWithWorkflowAssistant(); + const { mutateAsync: createChatThread } = useCreateThread(); + const { mutateAsync: updateWorkflowMetadata } = useUpdateAutomationMetadata(); + const { mutateAsync: chatWithWorkflowAssistant } = + useChatWithWorkflowAssistant(); const formSchema = useMemo( () => @@ -66,7 +67,7 @@ export function CreateAutomationDialog({ }); const navigate = useNavigate(); - const createAutomation = useCreateAutomation(); + const { mutateAsync: createAutomation } = useCreateAutomation(); const onSubmit = async (data: FormData) => { try { diff --git a/services/platform/app/features/automations/components/automation-navigation.tsx b/services/platform/app/features/automations/components/automation-navigation.tsx index 5023e1e46..158e75205 100644 --- a/services/platform/app/features/automations/components/automation-navigation.tsx +++ b/services/platform/app/features/automations/components/automation-navigation.tsx @@ -8,7 +8,6 @@ import { Upload, Pencil, } from 'lucide-react'; -import { useState } from 'react'; import type { Doc } from '@/convex/_generated/dataModel'; @@ -60,12 +59,12 @@ export function AutomationNavigation({ ); const { user } = useAuth(); - const publishAutomation = usePublishAutomationDraft(); - const [isPublishing, setIsPublishing] = useState(false); - const createDraftFromActive = useCreateDraftFromActive(); - const [isCreatingDraft, setIsCreatingDraft] = useState(false); - const unpublishAutomation = useUnpublishAutomation(); - const [isUnpublishing, setIsUnpublishing] = useState(false); + const { mutate: publishAutomation, isPending: isPublishing } = + usePublishAutomationDraft(); + const { mutateAsync: createDraftFromActive, isPending: isCreatingDraft } = + useCreateDraftFromActive(); + const { mutate: unpublishAutomation, isPending: isUnpublishing } = + useUnpublishAutomation(); const { data: versions } = useListWorkflowVersions( organizationId, @@ -98,7 +97,7 @@ export function AutomationNavigation({ return null; } - const handlePublish = async () => { + const handlePublish = () => { if (!automationId || !user?.email) { toast({ title: t('navigation.toast.unableToPublish'), @@ -107,29 +106,30 @@ export function AutomationNavigation({ return; } - setIsPublishing(true); - try { - await publishAutomation({ + publishAutomation( + { wfDefinitionId: toId<'wfDefinitions'>(automationId), publishedBy: user.email, - }); - - toast({ - title: t('navigation.toast.published'), - variant: 'success', - }); - } catch (error) { - console.error('Failed to publish automation:', error); - toast({ - title: - error instanceof Error - ? error.message - : t('navigation.toast.publishFailed'), - variant: 'destructive', - }); - } finally { - setIsPublishing(false); - } + }, + { + onSuccess: () => { + toast({ + title: t('navigation.toast.published'), + variant: 'success', + }); + }, + onError: (error) => { + console.error('Failed to publish automation:', error); + toast({ + title: + error instanceof Error + ? error.message + : t('navigation.toast.publishFailed'), + variant: 'destructive', + }); + }, + }, + ); }; const handleCreateDraft = async () => { @@ -141,21 +141,18 @@ export function AutomationNavigation({ return; } - setIsCreatingDraft(true); try { const result = await createDraftFromActive({ wfDefinitionId: toId<'wfDefinitions'>(automationId), createdBy: user.email, }); - // Navigate to the draft void navigate({ to: '/dashboard/$id/automations/$amId', params: { id: organizationId, amId: result.draftId }, search: { panel: 'ai-chat' }, }); - // Show appropriate message based on whether it's new or existing if (result.isNewDraft) { toast({ title: t('navigation.toast.draftCreated'), @@ -175,12 +172,10 @@ export function AutomationNavigation({ : t('navigation.toast.draftFailed'), variant: 'destructive', }); - } finally { - setIsCreatingDraft(false); } }; - const handleUnpublish = async () => { + const handleUnpublish = () => { if (!automationId || !user?.userId) { toast({ title: t('navigation.toast.unableToDeactivate'), @@ -189,29 +184,30 @@ export function AutomationNavigation({ return; } - setIsUnpublishing(true); - try { - await unpublishAutomation({ + unpublishAutomation( + { wfDefinitionId: toId<'wfDefinitions'>(automationId), updatedBy: user.userId, - }); - - toast({ - title: t('navigation.toast.deactivated'), - variant: 'success', - }); - } catch (error) { - console.error('Failed to unpublish automation:', error); - toast({ - title: - error instanceof Error - ? error.message - : t('navigation.toast.deactivateFailed'), - variant: 'destructive', - }); - } finally { - setIsUnpublishing(false); - } + }, + { + onSuccess: () => { + toast({ + title: t('navigation.toast.deactivated'), + variant: 'success', + }); + }, + onError: (error) => { + console.error('Failed to unpublish automation:', error); + toast({ + title: + error instanceof Error + ? error.message + : t('navigation.toast.deactivateFailed'), + variant: 'destructive', + }); + }, + }, + ); }; return ( diff --git a/services/platform/app/features/automations/components/automation-row-actions.tsx b/services/platform/app/features/automations/components/automation-row-actions.tsx index 3a5592e74..0f980ea88 100644 --- a/services/platform/app/features/automations/components/automation-row-actions.tsx +++ b/services/platform/app/features/automations/components/automation-row-actions.tsx @@ -1,7 +1,7 @@ 'use client'; import { CircleStop, Copy, Pencil, Trash2, Upload } from 'lucide-react'; -import { useMemo, useCallback, useState } from 'react'; +import { useMemo, useCallback } from 'react'; import { ConfirmDialog } from '@/app/components/ui/dialog/confirm-dialog'; import { @@ -35,51 +35,61 @@ export function AutomationRowActions({ const { t: tToast } = useT('toast'); const { user } = useAuth(); const dialogs = useEntityRowDialogs(['delete', 'rename', 'unpublish']); - const [isDeleting, setIsDeleting] = useState(false); - const duplicateAutomation = useDuplicateAutomation(); - const deleteAutomation = useDeleteAutomation(); - const republishAutomation = useRepublishAutomation(); - const unpublishAutomation = useUnpublishAutomation(); - const [isUnpublishing, setIsUnpublishing] = useState(false); - const updateAutomation = useUpdateAutomation(); + const { mutate: duplicateAutomation } = useDuplicateAutomation(); + const { mutate: deleteAutomation, isPending: isDeleting } = + useDeleteAutomation(); + const { mutate: republishAutomation } = useRepublishAutomation(); + const { mutate: unpublishAutomation, isPending: isUnpublishing } = + useUnpublishAutomation(); + const { mutateAsync: updateAutomation } = useUpdateAutomation(); - const handlePublish = useCallback(async () => { + const handlePublish = useCallback(() => { if (!user) return; - try { - await republishAutomation({ + republishAutomation( + { wfDefinitionId: automation._id, publishedBy: user.email ?? user.userId, - }); - toast({ - title: tToast('success.automationPublished'), - variant: 'success', - }); - } catch (error) { - console.error('Failed to publish automation:', error); - toast({ - title: tToast('error.automationPublishFailed'), - variant: 'destructive', - }); - } + }, + { + onSuccess: () => { + toast({ + title: tToast('success.automationPublished'), + variant: 'success', + }); + }, + onError: (error) => { + console.error('Failed to publish automation:', error); + toast({ + title: tToast('error.automationPublishFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [republishAutomation, automation._id, user, tToast]); - const handleDuplicate = useCallback(async () => { - try { - await duplicateAutomation({ + const handleDuplicate = useCallback(() => { + duplicateAutomation( + { wfDefinitionId: automation._id, - }); - toast({ - title: tToast('success.automationDuplicated'), - variant: 'success', - }); - } catch (error) { - console.error('Failed to duplicate automation:', error); - toast({ - title: tToast('error.automationDuplicateFailed'), - variant: 'destructive', - }); - } + }, + { + onSuccess: () => { + toast({ + title: tToast('success.automationDuplicated'), + variant: 'success', + }); + }, + onError: (error) => { + console.error('Failed to duplicate automation:', error); + toast({ + title: tToast('error.automationDuplicateFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [duplicateAutomation, automation._id, tToast]); const handleRename = useCallback( @@ -107,46 +117,50 @@ export function AutomationRowActions({ [updateAutomation, automation._id, user, tToast], ); - const handleUnpublishConfirm = useCallback(async () => { + const handleUnpublishConfirm = useCallback(() => { if (!user) return; - setIsUnpublishing(true); - try { - await unpublishAutomation({ + unpublishAutomation( + { wfDefinitionId: automation._id, updatedBy: user.userId, - }); - dialogs.setOpen.unpublish(false); - toast({ - title: tToast('success.automationDeactivated'), - variant: 'success', - }); - } catch (error) { - console.error('Failed to unpublish automation:', error); - toast({ - title: tToast('error.automationDeactivateFailed'), - variant: 'destructive', - }); - } finally { - setIsUnpublishing(false); - } + }, + { + onSuccess: () => { + dialogs.setOpen.unpublish(false); + toast({ + title: tToast('success.automationDeactivated'), + variant: 'success', + }); + }, + onError: (error) => { + console.error('Failed to unpublish automation:', error); + toast({ + title: tToast('error.automationDeactivateFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [unpublishAutomation, automation._id, user, dialogs.setOpen, tToast]); - const handleDeleteConfirm = useCallback(async () => { - setIsDeleting(true); - try { - await deleteAutomation({ + const handleDeleteConfirm = useCallback(() => { + deleteAutomation( + { wfDefinitionId: automation._id, - }); - dialogs.setOpen.delete(false); - } catch (error) { - console.error('Failed to delete automation:', error); - toast({ - title: tToast('error.automationDeleteFailed'), - variant: 'destructive', - }); - } finally { - setIsDeleting(false); - } + }, + { + onSuccess: () => { + dialogs.setOpen.delete(false); + }, + onError: (error) => { + console.error('Failed to delete automation:', error); + toast({ + title: tToast('error.automationDeleteFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [deleteAutomation, automation._id, dialogs.setOpen, tToast]); const actions = useMemo( diff --git a/services/platform/app/features/automations/components/automation-sidepanel.tsx b/services/platform/app/features/automations/components/automation-sidepanel.tsx index 215e3c95b..0347d2ca0 100644 --- a/services/platform/app/features/automations/components/automation-sidepanel.tsx +++ b/services/platform/app/features/automations/components/automation-sidepanel.tsx @@ -86,8 +86,7 @@ export function AutomationSidePanel({ const [editedNextSteps, setEditedNextSteps] = useState< Record >({}); - const [isSaving, setIsSaving] = useState(false); - const updateStep = useUpdateStep(); + const { mutate: updateStep, isPending: isSaving } = useUpdateStep(); const originalConfigJson = useMemo( () => (step?.config ? JSON.stringify(step.config, null, 2) : '{}'), @@ -143,33 +142,35 @@ export function AutomationSidePanel({ setEditedConfig(value); }, []); - const handleSave = useCallback(async () => { + const handleSave = useCallback(() => { if (!step || !parsedEditedConfig || !isValid) return; - setIsSaving(true); - try { - const updates: Record = { config: parsedEditedConfig }; - if (isNextStepsDirty) { - updates.nextSteps = editedNextSteps; - } - await updateStep({ + const updates: Record = { config: parsedEditedConfig }; + if (isNextStepsDirty) { + updates.nextSteps = editedNextSteps; + } + updateStep( + { stepRecordId: step._id, updates, editMode: 'json', - }); - toast({ - title: t('sidePanel.stepSaved'), - variant: 'default', - }); - } catch (error) { - console.error('Failed to save step:', error); - toast({ - title: t('sidePanel.stepSaveFailed'), - variant: 'destructive', - }); - } finally { - setIsSaving(false); - } + }, + { + onSuccess: () => { + toast({ + title: t('sidePanel.stepSaved'), + variant: 'default', + }); + }, + onError: (error) => { + console.error('Failed to save step:', error); + toast({ + title: t('sidePanel.stepSaveFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [ step, parsedEditedConfig, diff --git a/services/platform/app/features/automations/components/automation-steps.tsx b/services/platform/app/features/automations/components/automation-steps.tsx index d915fcd15..c86109589 100644 --- a/services/platform/app/features/automations/components/automation-steps.tsx +++ b/services/platform/app/features/automations/components/automation-steps.tsx @@ -81,7 +81,7 @@ function AutomationStepsInner({ }: AutomationStepsProps) { const { t } = useT('automations'); const { user } = useAuth(); - const createStep = useCreateStep(); + const { mutateAsync: createStep } = useCreateStep(); const isDraft = status === 'draft'; const isActive = status === 'active'; const hasSteps = steps && steps.length > 0; diff --git a/services/platform/app/features/automations/components/automation-tester.tsx b/services/platform/app/features/automations/components/automation-tester.tsx index 32f815702..85605cc9e 100644 --- a/services/platform/app/features/automations/components/automation-tester.tsx +++ b/services/platform/app/features/automations/components/automation-tester.tsx @@ -56,8 +56,8 @@ export function AutomationTester({ const [isDryRunning, setIsDryRunning] = useState(false); const [dryRunResult, setDryRunResult] = useState(null); - const startWorkflow = useStartWorkflow(); - const [isExecuting, setIsExecuting] = useState(false); + const { mutateAsync: startWorkflow, isPending: isExecuting } = + useStartWorkflow(); const parsedInput = (() => { try { @@ -105,7 +105,6 @@ export function AutomationTester({ return; } - setIsExecuting(true); try { const executionId = await startWorkflow({ organizationId, @@ -137,8 +136,6 @@ export function AutomationTester({ : t('tester.toast.startFailed'), variant: 'destructive', }); - } finally { - setIsExecuting(false); } }; diff --git a/services/platform/app/features/automations/components/automations-client.tsx b/services/platform/app/features/automations/components/automations-client.tsx index 20db754ac..a4adde791 100644 --- a/services/platform/app/features/automations/components/automations-client.tsx +++ b/services/platform/app/features/automations/components/automations-client.tsx @@ -11,7 +11,6 @@ import { DataTable } from '@/app/components/ui/data-table/data-table'; import { useListPage } from '@/app/hooks/use-list-page'; import { useT } from '@/lib/i18n/client'; -import { useWfAutomationCollection } from '../hooks/collections'; import { useAutomations } from '../hooks/queries'; import { AutomationsActionMenu } from './automations-action-menu'; import { useAutomationsTableConfig } from './use-automations-table-config'; @@ -29,8 +28,7 @@ export function AutomationsClient({ organizationId }: AutomationsClientProps) { const { columns, searchPlaceholder, stickyLayout, pageSize } = useAutomationsTableConfig(); - const wfAutomationCollection = useWfAutomationCollection(organizationId); - const { automations, isLoading } = useAutomations(wfAutomationCollection); + const { automations, isLoading } = useAutomations(organizationId); const handleRowClick = useCallback( (row: Row>) => { diff --git a/services/platform/app/features/automations/hooks/__tests__/collection-hooks.test.ts b/services/platform/app/features/automations/hooks/__tests__/collection-hooks.test.ts deleted file mode 100644 index 766bed2fc..000000000 --- a/services/platform/app/features/automations/hooks/__tests__/collection-hooks.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((_builder: (q: unknown) => unknown) => { - return { data: [], isLoading: false }; - }), -})); - -vi.mock('@/lib/collections/entities/automation-roots', () => ({ - createAutomationRootsCollection: vi.fn(), -})); - -vi.mock('@/lib/collections/entities/wf-automations', () => ({ - createWfAutomationsCollection: vi.fn(), -})); - -vi.mock('@/lib/collections/entities/wf-steps', () => ({ - createWfStepsCollection: vi.fn(), -})); - -vi.mock('@/lib/collections/use-collection', () => ({ - useCollection: vi.fn(() => mockCollection), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useCollection } from '@/lib/collections/use-collection'; - -import { - useAutomationRootCollection, - useWfAutomationCollection, - useWorkflowStepCollection, -} from '../collections'; -import { - useAutomationRoots, - useAutomations, - useWorkflowSteps, -} from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); - -describe('useAutomationRootCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection with correct params', () => { - useAutomationRootCollection('org-123'); - expect(useCollection).toHaveBeenCalledWith( - 'automation-roots', - expect.any(Function), - 'org-123', - ); - }); -}); - -describe('useWfAutomationCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection with correct params', () => { - useWfAutomationCollection('org-123'); - expect(useCollection).toHaveBeenCalledWith( - 'wf-automations', - expect.any(Function), - 'org-123', - ); - }); -}); - -describe('useWorkflowStepCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection with correct params', () => { - useWorkflowStepCollection('wf-def-123'); - expect(useCollection).toHaveBeenCalledWith( - 'wf-steps', - expect.any(Function), - 'wf-def-123', - ); - }); -}); - -describe('useAutomationRoots', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data from live query', () => { - const items = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: items, - isLoading: false, - } as ReturnType); - - const result = useAutomationRoots(mockCollection as never); - expect(result.automationRoots).toBe(items); - expect(result.isLoading).toBe(false); - }); - - it('returns empty array when loading', () => { - mockUseLiveQuery.mockReturnValueOnce({ - data: [], - isLoading: true, - } as ReturnType); - - const result = useAutomationRoots(mockCollection as never); - expect(result.automationRoots).toEqual([]); - expect(result.isLoading).toBe(true); - }); -}); - -describe('useAutomations', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data from live query', () => { - const items = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: items, - isLoading: false, - } as ReturnType); - - const result = useAutomations(mockCollection as never); - expect(result.automations).toBe(items); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useAutomations(mockCollection as never); - expect(result.automations).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); - -describe('useWorkflowSteps', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data when loaded', () => { - const steps = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: steps, - isLoading: false, - } as ReturnType); - - const result = useWorkflowSteps(mockCollection as never); - expect(result.steps).toBe(steps); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useWorkflowSteps(mockCollection as never); - expect(result.steps).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/automations/hooks/collections.ts b/services/platform/app/features/automations/hooks/collections.ts deleted file mode 100644 index b876d4211..000000000 --- a/services/platform/app/features/automations/hooks/collections.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { createAutomationRootsCollection } from '@/lib/collections/entities/automation-roots'; -import { createWfAutomationsCollection } from '@/lib/collections/entities/wf-automations'; -import { createWfStepsCollection } from '@/lib/collections/entities/wf-steps'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useAutomationRootCollection(organizationId: string) { - return useCollection( - 'automation-roots', - createAutomationRootsCollection, - organizationId, - ); -} - -export function useWfAutomationCollection(organizationId: string) { - return useCollection( - 'wf-automations', - createWfAutomationsCollection, - organizationId, - ); -} - -export function useWorkflowStepCollection(wfDefinitionId: string) { - return useCollection('wf-steps', createWfStepsCollection, wfDefinitionId); -} diff --git a/services/platform/app/features/automations/hooks/queries.ts b/services/platform/app/features/automations/hooks/queries.ts index d11eb830e..6811e2741 100644 --- a/services/platform/app/features/automations/hooks/queries.ts +++ b/services/platform/app/features/automations/hooks/queries.ts @@ -1,49 +1,61 @@ -import type { Collection } from '@tanstack/db'; - -import { useLiveQuery } from '@tanstack/react-db'; import { useMemo } from 'react'; import type { Id } from '@/convex/_generated/dataModel'; -import type { AutomationRoot } from '@/lib/collections/entities/automation-roots'; -import type { WfAutomation } from '@/lib/collections/entities/wf-automations'; -import type { WfStep } from '@/lib/collections/entities/wf-steps'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; import { useCachedPaginatedQuery } from '@/app/hooks/use-cached-paginated-query'; import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { useDebounce } from '@/app/hooks/use-debounce'; import { api } from '@/convex/_generated/api'; -export function useAutomationRoots( - collection: Collection, -) { - const { data, isLoading } = useLiveQuery(() => collection); +export type AutomationRoot = ConvexItemOf< + typeof api.wf_definitions.queries.listAutomationRoots +>; + +export type WfAutomation = ConvexItemOf< + typeof api.wf_definitions.queries.listAutomations +>; + +export type WfStep = ConvexItemOf< + typeof api.wf_step_defs.queries.getWorkflowSteps +>; + +export function useAutomationRoots(organizationId: string) { + const { data, isLoading } = useConvexQuery( + api.wf_definitions.queries.listAutomationRoots, + { organizationId }, + ); return { - automationRoots: data, + automationRoots: data ?? [], isLoading, }; } -export function useAutomations(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export function useAutomations(organizationId: string) { + const { data, isLoading } = useConvexQuery( + api.wf_definitions.queries.listAutomations, + { organizationId }, + ); return { - automations: data, + automations: data ?? [], isLoading, }; } -export function useWorkflowSteps(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export function useWorkflowSteps(wfDefinitionId: string) { + const { data, isLoading } = useConvexQuery( + api.wf_step_defs.queries.getWorkflowSteps, + { wfDefinitionId }, + ); return { - steps: data, + steps: data ?? [], isLoading, }; } -export type { AutomationRoot }; - export function useListWorkflowVersions( organizationId: string | undefined, name: string | undefined, diff --git a/services/platform/app/features/automations/triggers/components/event-create-dialog.tsx b/services/platform/app/features/automations/triggers/components/event-create-dialog.tsx index 7ee9d694e..09dbc301d 100644 --- a/services/platform/app/features/automations/triggers/components/event-create-dialog.tsx +++ b/services/platform/app/features/automations/triggers/components/event-create-dialog.tsx @@ -17,7 +17,6 @@ import { } from '@/convex/workflows/triggers/event_types'; import { useT } from '@/lib/i18n/client'; -import { useAutomationRootCollection } from '../../hooks/collections'; import { useAutomationRoots } from '../../hooks/queries'; import { useCreateEventSubscription, @@ -76,10 +75,7 @@ export function EventCreateDialog({ [selectedEventType], ); - const automationRootCollection = useAutomationRootCollection(organizationId); - const { automationRoots: workflows } = useAutomationRoots( - automationRootCollection, - ); + const { automationRoots: workflows } = useAutomationRoots(organizationId); const options = useMemo(() => { const result: { value: string; label: string; disabled?: boolean }[] = []; diff --git a/services/platform/app/features/automations/triggers/components/events-section.tsx b/services/platform/app/features/automations/triggers/components/events-section.tsx index 79c863492..6d583b634 100644 --- a/services/platform/app/features/automations/triggers/components/events-section.tsx +++ b/services/platform/app/features/automations/triggers/components/events-section.tsx @@ -6,7 +6,6 @@ import { Plus, Zap, Trash2, Pencil } from 'lucide-react'; import { useState, useMemo, useCallback } from 'react'; import type { Id } from '@/convex/_generated/dataModel'; -import type { WfEventSubscription } from '@/lib/collections/entities/wf-event-subscriptions'; import { DataTable } from '@/app/components/ui/data-table/data-table'; import { DeleteDialog } from '@/app/components/ui/dialog/delete-dialog'; @@ -20,9 +19,9 @@ import { } from '@/convex/workflows/triggers/event_types'; import { useT } from '@/lib/i18n/client'; -import { useAutomationRootCollection } from '../../hooks/collections'; +import type { WfEventSubscription } from '../hooks/queries'; + import { useAutomationRoots } from '../../hooks/queries'; -import { useEventSubscriptionCollection } from '../hooks/collections'; import { useDeleteEventSubscription, useToggleEventSubscription, @@ -44,14 +43,9 @@ export function EventsSection({ }: EventsSectionProps) { const { t } = useT('automations'); const { toast } = useToast(); - const eventSubscriptionCollection = - useEventSubscriptionCollection(workflowRootId); - const { subscriptions } = useEventSubscriptions(eventSubscriptionCollection); + const { subscriptions } = useEventSubscriptions(workflowRootId); - const automationRootCollection = useAutomationRootCollection(organizationId); - const { automationRoots: workflows } = useAutomationRoots( - automationRootCollection, - ); + const { automationRoots: workflows } = useAutomationRoots(organizationId); const workflowNameMap = useMemo(() => { const map = new Map(); diff --git a/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx b/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx index 06c0dad34..b9a0ebe0a 100644 --- a/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx +++ b/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx @@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { CronExpressionParser } from 'cron-parser'; import { Sparkles } from 'lucide-react'; -import { useState, useMemo, useEffect, useCallback } from 'react'; +import { useMemo, useEffect, useCallback, useState } from 'react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; @@ -55,12 +55,13 @@ export function ScheduleCreateDialog({ const { t } = useT('automations'); const { t: tCommon } = useT('common'); const { toast } = useToast(); - const createSchedule = useCreateSchedule(); - const updateSchedule = useUpdateSchedule(); - const generateCron = useGenerateCron(); - const [isGenerating, setIsGenerating] = useState(false); - - const [isSubmitting, setIsSubmitting] = useState(false); + const { mutateAsync: createSchedule, isPending: isCreatingSchedule } = + useCreateSchedule(); + const { mutateAsync: updateSchedule, isPending: isUpdatingSchedule } = + useUpdateSchedule(); + const { mutateAsync: generateCron, isPending: isGenerating } = + useGenerateCron(); + const isSubmitting = isCreatingSchedule || isUpdatingSchedule; const [naturalLanguage, setNaturalLanguage] = useState(''); const [cronDescription, setCronDescription] = useState(''); const [generateError, setGenerateError] = useState(''); @@ -108,7 +109,6 @@ export function ScheduleCreateDialog({ const handleGenerate = useCallback(async () => { if (!naturalLanguage.trim() || isGenerating) return; - setIsGenerating(true); setGenerateError(''); setCronDescription(''); @@ -122,13 +122,10 @@ export function ScheduleCreateDialog({ setCronDescription(result.description); } catch { setGenerateError(t('triggers.schedules.form.ai.generateError')); - } finally { - setIsGenerating(false); } }, [naturalLanguage, isGenerating, generateCron, setValue, t]); const onSubmit = async (data: ScheduleFormData) => { - setIsSubmitting(true); try { if (isEdit && schedule) { await updateSchedule({ @@ -158,8 +155,6 @@ export function ScheduleCreateDialog({ title: tCommon('errors.generic'), variant: 'destructive', }); - } finally { - setIsSubmitting(false); } }; diff --git a/services/platform/app/features/automations/triggers/components/schedules-section.tsx b/services/platform/app/features/automations/triggers/components/schedules-section.tsx index 13d4381fe..b93c3a446 100644 --- a/services/platform/app/features/automations/triggers/components/schedules-section.tsx +++ b/services/platform/app/features/automations/triggers/components/schedules-section.tsx @@ -6,7 +6,6 @@ import { Plus, Calendar, Pencil, Trash2 } from 'lucide-react'; import { useState, useMemo, useCallback } from 'react'; import type { Id } from '@/convex/_generated/dataModel'; -import type { WfSchedule } from '@/lib/collections/entities/wf-schedules'; import { DataTable } from '@/app/components/ui/data-table/data-table'; import { DeleteDialog } from '@/app/components/ui/dialog/delete-dialog'; @@ -15,7 +14,8 @@ import { Button } from '@/app/components/ui/primitives/button'; import { useToast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; -import { useScheduleCollection } from '../hooks/collections'; +import type { WfSchedule } from '../hooks/queries'; + import { useDeleteSchedule, useToggleSchedule } from '../hooks/mutations'; import { useSchedules } from '../hooks/queries'; import { CollapsibleSection } from './collapsible-section'; @@ -34,8 +34,7 @@ export function SchedulesSection({ }: SchedulesSectionProps) { const { t } = useT('automations'); const { toast } = useToast(); - const scheduleCollection = useScheduleCollection(workflowRootId); - const { schedules } = useSchedules(scheduleCollection); + const { schedules } = useSchedules(workflowRootId); const toggleSchedule = useToggleSchedule(); const deleteScheduleMutation = useDeleteSchedule(); diff --git a/services/platform/app/features/automations/triggers/components/webhooks-section.tsx b/services/platform/app/features/automations/triggers/components/webhooks-section.tsx index 2d1d483e7..9a6bd2b3b 100644 --- a/services/platform/app/features/automations/triggers/components/webhooks-section.tsx +++ b/services/platform/app/features/automations/triggers/components/webhooks-section.tsx @@ -6,7 +6,6 @@ import { Plus, Webhook, Copy, Check, Trash2 } from 'lucide-react'; import { useState, useMemo, useCallback } from 'react'; import type { Id } from '@/convex/_generated/dataModel'; -import type { WfWebhook } from '@/lib/collections/entities/wf-webhooks'; import { DataTable } from '@/app/components/ui/data-table/data-table'; import { DeleteDialog } from '@/app/components/ui/dialog/delete-dialog'; @@ -16,7 +15,8 @@ import { useToast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; import { useSiteUrl } from '@/lib/site-url-context'; -import { useWebhookCollection } from '../hooks/collections'; +import type { WfWebhook } from '../hooks/queries'; + import { useCreateWebhook, useDeleteWebhook, @@ -40,16 +40,15 @@ export function WebhooksSection({ const { t } = useT('automations'); const { toast } = useToast(); - const webhookCollection = useWebhookCollection(workflowRootId); - const { webhooks } = useWebhooks(webhookCollection); + const { webhooks } = useWebhooks(workflowRootId); - const createWebhook = useCreateWebhook(); - const [isCreating, setIsCreating] = useState(false); - const toggleWebhook = useToggleWebhook(); - const deleteWebhookMutation = useDeleteWebhook(); + const { mutateAsync: createWebhook, isPending: isCreating } = + useCreateWebhook(); + const { mutateAsync: toggleWebhook } = useToggleWebhook(); + const { mutate: deleteWebhookMutation, isPending: isDeleting } = + useDeleteWebhook(); const [createdUrl, setCreatedUrl] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); - const [isDeleting, setIsDeleting] = useState(false); const [copiedUrl, setCopiedUrl] = useState(null); const siteUrl = useSiteUrl(); @@ -60,7 +59,6 @@ export function WebhooksSection({ ); const handleCreate = useCallback(async () => { - setIsCreating(true); try { const result = await createWebhook({ organizationId, @@ -73,8 +71,6 @@ export function WebhooksSection({ }); } catch { toast({ title: 'Failed to create webhook', variant: 'destructive' }); - } finally { - setIsCreating(false); } }, [createWebhook, organizationId, workflowRootId, toast, t, getWebhookUrl]); @@ -95,21 +91,26 @@ export function WebhooksSection({ [toggleWebhook, toast, t], ); - const handleDelete = useCallback(async () => { + const handleDelete = useCallback(() => { if (!deleteTarget) return; - setIsDeleting(true); - try { - await deleteWebhookMutation({ webhookId: deleteTarget._id }); - toast({ - title: t('triggers.webhooks.toast.deleted'), - variant: 'success', - }); - setDeleteTarget(null); - } catch { - toast({ title: 'Failed to delete webhook', variant: 'destructive' }); - } finally { - setIsDeleting(false); - } + deleteWebhookMutation( + { webhookId: deleteTarget._id }, + { + onSuccess: () => { + toast({ + title: t('triggers.webhooks.toast.deleted'), + variant: 'success', + }); + setDeleteTarget(null); + }, + onError: () => { + toast({ + title: 'Failed to delete webhook', + variant: 'destructive', + }); + }, + }, + ); }, [deleteTarget, deleteWebhookMutation, toast, t]); const handleCopyUrl = useCallback( diff --git a/services/platform/app/features/automations/triggers/hooks/__tests__/collection-hooks.test.ts b/services/platform/app/features/automations/triggers/hooks/__tests__/collection-hooks.test.ts deleted file mode 100644 index e0069e510..000000000 --- a/services/platform/app/features/automations/triggers/hooks/__tests__/collection-hooks.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((_builder: (q: unknown) => unknown) => { - return { data: [], isLoading: false }; - }), -})); - -vi.mock('@/lib/collections/entities/wf-schedules', () => ({ - createWfSchedulesCollection: vi.fn(), -})); - -vi.mock('@/lib/collections/entities/wf-webhooks', () => ({ - createWfWebhooksCollection: vi.fn(), -})); - -vi.mock('@/lib/collections/entities/wf-event-subscriptions', () => ({ - createWfEventSubscriptionsCollection: vi.fn(), -})); - -vi.mock('@/lib/collections/use-collection', () => ({ - useCollection: vi.fn(() => mockCollection), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useCollection } from '@/lib/collections/use-collection'; - -import { - useScheduleCollection, - useWebhookCollection, - useEventSubscriptionCollection, -} from '../collections'; -import { useSchedules, useWebhooks, useEventSubscriptions } from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); - -describe('useScheduleCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection with correct params', () => { - useScheduleCollection('wf-root-123'); - expect(useCollection).toHaveBeenCalledWith( - 'wf-schedules', - expect.any(Function), - 'wf-root-123', - ); - }); -}); - -describe('useWebhookCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection with correct params', () => { - useWebhookCollection('wf-root-123'); - expect(useCollection).toHaveBeenCalledWith( - 'wf-webhooks', - expect.any(Function), - 'wf-root-123', - ); - }); -}); - -describe('useEventSubscriptionCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection with correct params', () => { - useEventSubscriptionCollection('wf-root-123'); - expect(useCollection).toHaveBeenCalledWith( - 'wf-event-subscriptions', - expect.any(Function), - 'wf-root-123', - ); - }); -}); - -describe('useSchedules', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data when loaded', () => { - const schedules = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: schedules, - isLoading: false, - } as ReturnType); - - const result = useSchedules(mockCollection as never); - expect(result.schedules).toBe(schedules); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useSchedules(mockCollection as never); - expect(result.schedules).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); - -describe('useWebhooks', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data when loaded', () => { - const webhooks = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: webhooks, - isLoading: false, - } as ReturnType); - - const result = useWebhooks(mockCollection as never); - expect(result.webhooks).toBe(webhooks); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useWebhooks(mockCollection as never); - expect(result.webhooks).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); - -describe('useEventSubscriptions', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data when loaded', () => { - const subscriptions = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: subscriptions, - isLoading: false, - } as ReturnType); - - const result = useEventSubscriptions(mockCollection as never); - expect(result.subscriptions).toBe(subscriptions); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useEventSubscriptions(mockCollection as never); - expect(result.subscriptions).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/automations/triggers/hooks/collections.ts b/services/platform/app/features/automations/triggers/hooks/collections.ts deleted file mode 100644 index 972072dfd..000000000 --- a/services/platform/app/features/automations/triggers/hooks/collections.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { createWfEventSubscriptionsCollection } from '@/lib/collections/entities/wf-event-subscriptions'; -import { createWfSchedulesCollection } from '@/lib/collections/entities/wf-schedules'; -import { createWfWebhooksCollection } from '@/lib/collections/entities/wf-webhooks'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useScheduleCollection(workflowRootId: string) { - return useCollection( - 'wf-schedules', - createWfSchedulesCollection, - workflowRootId, - ); -} - -export function useWebhookCollection(workflowRootId: string) { - return useCollection( - 'wf-webhooks', - createWfWebhooksCollection, - workflowRootId, - ); -} - -export function useEventSubscriptionCollection(workflowRootId: string) { - return useCollection( - 'wf-event-subscriptions', - createWfEventSubscriptionsCollection, - workflowRootId, - ); -} diff --git a/services/platform/app/features/automations/triggers/hooks/queries.ts b/services/platform/app/features/automations/triggers/hooks/queries.ts index d3e212d91..e9cb49a7c 100644 --- a/services/platform/app/features/automations/triggers/hooks/queries.ts +++ b/services/platform/app/features/automations/triggers/hooks/queries.ts @@ -1,36 +1,52 @@ -import type { Collection } from '@tanstack/db'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; -import { useLiveQuery } from '@tanstack/react-db'; +import { useConvexQuery } from '@/app/hooks/use-convex-query'; +import { api } from '@/convex/_generated/api'; -import type { WfEventSubscription } from '@/lib/collections/entities/wf-event-subscriptions'; -import type { WfSchedule } from '@/lib/collections/entities/wf-schedules'; -import type { WfWebhook } from '@/lib/collections/entities/wf-webhooks'; +export type WfSchedule = ConvexItemOf< + typeof api.workflows.triggers.queries.getSchedules +>; -export function useSchedules(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export type WfWebhook = ConvexItemOf< + typeof api.workflows.triggers.queries.getWebhooks +>; + +export type WfEventSubscription = ConvexItemOf< + typeof api.workflows.triggers.queries.getEventSubscriptions +>; + +export function useSchedules(workflowRootId: string) { + const { data, isLoading } = useConvexQuery( + api.workflows.triggers.queries.getSchedules, + { workflowRootId }, + ); return { - schedules: data, + schedules: data ?? [], isLoading, }; } -export function useWebhooks(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export function useWebhooks(workflowRootId: string) { + const { data, isLoading } = useConvexQuery( + api.workflows.triggers.queries.getWebhooks, + { workflowRootId }, + ); return { - webhooks: data, + webhooks: data ?? [], isLoading, }; } -export function useEventSubscriptions( - collection: Collection, -) { - const { data, isLoading } = useLiveQuery(() => collection); +export function useEventSubscriptions(workflowRootId: string) { + const { data, isLoading } = useConvexQuery( + api.workflows.triggers.queries.getEventSubscriptions, + { workflowRootId }, + ); return { - subscriptions: data, + subscriptions: data ?? [], isLoading, }; } diff --git a/services/platform/app/features/chat/components/agent-selector.tsx b/services/platform/app/features/chat/components/agent-selector.tsx index b37f0c3aa..7183037d1 100644 --- a/services/platform/app/features/chat/components/agent-selector.tsx +++ b/services/platform/app/features/chat/components/agent-selector.tsx @@ -8,7 +8,6 @@ import { PopoverContent, PopoverTrigger, } from '@/app/components/ui/overlays/popover'; -import { useCustomAgentCollection } from '@/app/features/custom-agents/hooks/collections'; import { useT } from '@/lib/i18n/client'; import { useChatLayout } from '../context/chat-layout-context'; @@ -47,8 +46,7 @@ function filterOptions(options: AgentOption[], query: string) { export function AgentSelector({ organizationId }: AgentSelectorProps) { const { t } = useT('chat'); const { selectedAgent, setSelectedAgent } = useChatLayout(); - const customAgentCollection = useCustomAgentCollection(organizationId); - const { agents: customAgents } = useChatAgents(customAgentCollection); + const { agents: customAgents } = useChatAgents(organizationId); const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const searchRef = useRef(null); diff --git a/services/platform/app/features/chat/components/chat-actions.tsx b/services/platform/app/features/chat/components/chat-actions.tsx index 563674725..fa55f613a 100644 --- a/services/platform/app/features/chat/components/chat-actions.tsx +++ b/services/platform/app/features/chat/components/chat-actions.tsx @@ -2,7 +2,7 @@ import { useNavigate } from '@tanstack/react-router'; import { Pencil, Trash2 } from 'lucide-react'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { DeleteDialog } from '@/app/components/ui/dialog/delete-dialog'; import { HStack } from '@/app/components/ui/layout/layout'; @@ -36,39 +36,45 @@ export function ChatActions({ }: ChatActionsProps) { const navigate = useNavigate(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); const { toast } = useToast(); const { t: tCommon } = useT('common'); const { t: tChat } = useT('chat'); - const deleteThread = useDeleteThread(); + const { mutate: deleteThread, isPending: isLoading } = useDeleteThread(); - const handleDelete = async () => { - try { - setIsLoading(true); - await deleteThread({ - threadId: chat.id, - }); + const handleDelete = useCallback(() => { + deleteThread( + { threadId: chat.id }, + { + onSuccess: () => { + setIsDeleteDialogOpen(false); - setIsDeleteDialogOpen(false); - - if (currentChatId === chat.id) { - void navigate({ - to: '/dashboard/$id/chat', - params: { id: organizationId }, - }); - } - } catch (error) { - console.error('Failed to delete chat:', error); - toast({ - title: tChat('deleteFailed'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - }; + if (currentChatId === chat.id) { + void navigate({ + to: '/dashboard/$id/chat', + params: { id: organizationId }, + }); + } + }, + onError: (error) => { + console.error('Failed to delete chat:', error); + toast({ + title: tChat('deleteFailed'), + variant: 'destructive', + }); + }, + }, + ); + }, [ + chat.id, + currentChatId, + organizationId, + deleteThread, + navigate, + toast, + tChat, + ]); return ( <> diff --git a/services/platform/app/features/chat/components/chat-history-sidebar.tsx b/services/platform/app/features/chat/components/chat-history-sidebar.tsx index ba43f0f41..04c3de6e2 100644 --- a/services/platform/app/features/chat/components/chat-history-sidebar.tsx +++ b/services/platform/app/features/chat/components/chat-history-sidebar.tsx @@ -15,7 +15,6 @@ import { useToast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; import { cn } from '@/lib/utils/cn'; -import { useThreadCollection } from '../hooks/collections'; import { useUpdateThread } from '../hooks/mutations'; import { useThreads } from '../hooks/queries'; import { ChatActions } from './chat-actions'; @@ -58,10 +57,9 @@ export function ChatHistorySidebar({ const isMounted = useIsMounted(); const { toast } = useToast(); - const threadCollection = useThreadCollection(); - const { threads: threadsData } = useThreads(threadCollection); + const { threads: threadsData } = useThreads(); - const updateThread = useUpdateThread(); + const { mutateAsync: updateThread } = useUpdateThread(); const chats = useMemo( () => diff --git a/services/platform/app/features/chat/components/chat-search-dialog.tsx b/services/platform/app/features/chat/components/chat-search-dialog.tsx index 6d4f07aec..97c6cf89e 100644 --- a/services/platform/app/features/chat/components/chat-search-dialog.tsx +++ b/services/platform/app/features/chat/components/chat-search-dialog.tsx @@ -12,7 +12,6 @@ import { useT } from '@/lib/i18n/client'; import { cn } from '@/lib/utils/cn'; import { filterByTextSearch } from '@/lib/utils/filtering'; -import { useThreadCollection } from '../hooks/collections'; import { useThreads } from '../hooks/queries'; interface ChatSearchDialogProps { @@ -38,8 +37,7 @@ export function ChatSearchDialog({ const debouncedQuery = useDebounce(query, 300); - const threadCollection = useThreadCollection(); - const { threads: allThreads } = useThreads(threadCollection); + const { threads: allThreads } = useThreads(); const threadsData = useMemo(() => { if (!allThreads) return null; diff --git a/services/platform/app/features/chat/components/human-input-request-card.tsx b/services/platform/app/features/chat/components/human-input-request-card.tsx index 5fdebd644..7b520c193 100644 --- a/services/platform/app/features/chat/components/human-input-request-card.tsx +++ b/services/platform/app/features/chat/components/human-input-request-card.tsx @@ -1,7 +1,7 @@ 'use client'; import { XCircle, Loader2, MessageCircleQuestion, Send } from 'lucide-react'; -import { memo, useState } from 'react'; +import { memo, useCallback, useState } from 'react'; import type { Id } from '@/convex/_generated/dataModel'; import type { HumanInputRequestMetadata } from '@/lib/shared/schemas/approvals'; @@ -41,12 +41,12 @@ function HumanInputRequestCardComponent({ const [selectedValue, setSelectedValue] = useState(''); const [selectedValues, setSelectedValues] = useState([]); - const [isSubmitting, setIsSubmitting] = useState(false); - const submitResponse = useSubmitHumanInputResponse(); + const { mutate: submitResponse, isPending: isSubmitting } = + useSubmitHumanInputResponse(); const isPending = status === 'pending'; - const handleSubmit = async () => { + const handleSubmit = useCallback(() => { let response: string | string[]; switch (metadata.format) { @@ -77,23 +77,30 @@ function HumanInputRequestCardComponent({ } setError(null); - setIsSubmitting(true); - try { - await submitResponse({ - approvalId, - response, - }); - onResponseSubmitted?.(); - } catch (err) { - setError( - err instanceof Error ? err.message : 'Failed to submit response', - ); - console.error('Failed to submit response:', err); - } finally { - setIsSubmitting(false); - } - }; + submitResponse( + { approvalId, response }, + { + onSuccess: () => { + onResponseSubmitted?.(); + }, + onError: (err) => { + setError( + err instanceof Error ? err.message : 'Failed to submit response', + ); + console.error('Failed to submit response:', err); + }, + }, + ); + }, [ + metadata.format, + textValue, + selectedValue, + selectedValues, + approvalId, + submitResponse, + onResponseSubmitted, + ]); const handleMultiSelectToggle = (value: string) => { setSelectedValues((prev) => diff --git a/services/platform/app/features/chat/components/integration-approval-card.tsx b/services/platform/app/features/chat/components/integration-approval-card.tsx index ac2cee14c..b1bf682bc 100644 --- a/services/platform/app/features/chat/components/integration-approval-card.tsx +++ b/services/platform/app/features/chat/components/integration-approval-card.tsx @@ -53,8 +53,9 @@ function IntegrationApprovalCardComponent({ const [isRejecting, setIsRejecting] = useState(false); const [error, setError] = useState(null); - const updateApprovalStatus = useUpdateApprovalStatus(); - const executeApprovedOperation = useExecuteApprovedIntegrationOperation(); + const { mutateAsync: updateApprovalStatus } = useUpdateApprovalStatus(); + const { mutateAsync: executeApprovedOperation } = + useExecuteApprovedIntegrationOperation(); const isPending = status === 'pending'; const isProcessing = isApproving || isRejecting; diff --git a/services/platform/app/features/chat/components/workflow-creation-approval-card.tsx b/services/platform/app/features/chat/components/workflow-creation-approval-card.tsx index af1781dcf..907c0ec60 100644 --- a/services/platform/app/features/chat/components/workflow-creation-approval-card.tsx +++ b/services/platform/app/features/chat/components/workflow-creation-approval-card.tsx @@ -246,8 +246,9 @@ function WorkflowCreationApprovalCardComponent({ ); const { copied, onClick: handleCopy } = useCopyButton(configJson); - const updateApprovalStatus = useUpdateApprovalStatus(); - const executeApprovedWorkflow = useExecuteApprovedWorkflowCreation(); + const { mutateAsync: updateApprovalStatus } = useUpdateApprovalStatus(); + const { mutateAsync: executeApprovedWorkflow } = + useExecuteApprovedWorkflowCreation(); const isPending = status === 'pending'; const isProcessing = isApproving || isRejecting; diff --git a/services/platform/app/features/chat/hooks/__tests__/collection-hooks.test.ts b/services/platform/app/features/chat/hooks/__tests__/collection-hooks.test.ts deleted file mode 100644 index f0e733203..000000000 --- a/services/platform/app/features/chat/hooks/__tests__/collection-hooks.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((_builder: (q: unknown) => unknown) => { - return { data: [], isLoading: false }; - }), -})); - -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useMemo: (fn: () => unknown) => fn(), - }; -}); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useThreads } from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); - -describe('useThreads', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data sorted by creation time descending', () => { - const threads = [ - { _id: '1', _creationTime: 100 }, - { _id: '2', _creationTime: 200 }, - ]; - mockUseLiveQuery.mockReturnValueOnce({ - data: threads, - isLoading: false, - } as ReturnType); - - const result = useThreads(mockCollection as never); - expect(result.threads).toStrictEqual([ - { _id: '2', _creationTime: 200 }, - { _id: '1', _creationTime: 100 }, - ]); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', _creationTime: 100 }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useThreads(mockCollection as never); - expect(result.threads).toStrictEqual(mockData); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/chat/hooks/__tests__/query-hooks.test.ts b/services/platform/app/features/chat/hooks/__tests__/query-hooks.test.ts deleted file mode 100644 index 74add2c0a..000000000 --- a/services/platform/app/features/chat/hooks/__tests__/query-hooks.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); -let capturedQueryBuilder: ((q: unknown) => unknown) | null = null; - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((builder: (q: unknown) => unknown, _deps: unknown[]) => { - capturedQueryBuilder = builder; - return { data: [], isLoading: false }; - }), -})); - -vi.mock('@/app/hooks/use-team-filter', () => ({ - useTeamFilter: vi.fn(() => ({ selectedTeamId: null })), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useTeamFilter } from '@/app/hooks/use-team-filter'; - -import { useChatAgents } from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); -const mockUseTeamFilter = vi.mocked(useTeamFilter); - -type Agent = { - _id: string; - status: string; - teamId?: string; - sharedWithTeamIds?: string[]; -}; - -function extractWhereFilter(): (row: { agent: Agent }) => boolean { - if (!capturedQueryBuilder) throw new Error('No query builder captured'); - - let whereFilter: ((row: { agent: Agent }) => boolean) | null = null; - - const mockQ = { - from: () => ({ - fn: { - where: (fn: (row: { agent: Agent }) => boolean) => { - whereFilter = fn; - return { - select: ( - _fn: (row: { agent: Agent }) => Record, - ) => { - // select now returns an explicit field object, not the proxy reference - }, - }; - }, - }, - }), - }; - - capturedQueryBuilder(mockQ); - if (!whereFilter) throw new Error('No where filter captured'); - return whereFilter; -} - -describe('useChatAgents', () => { - beforeEach(() => { - vi.clearAllMocks(); - capturedQueryBuilder = null; - }); - - it('returns data even while loading', () => { - const agents = [{ _id: 'a1', name: 'Agent 1' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: agents, - isLoading: true, - } as ReturnType); - - const result = useChatAgents(mockCollection as never); - expect(result.agents).toBe(agents); - }); - - it('returns data when loaded', () => { - const agents = [{ _id: 'a1', name: 'Agent 1' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: agents, - isLoading: false, - } as ReturnType); - - const result = useChatAgents(mockCollection as never); - expect(result.agents).toBe(agents); - }); - - it('filters for active status only', () => { - mockUseTeamFilter.mockReturnValue({ selectedTeamId: null } as ReturnType< - typeof useTeamFilter - >); - useChatAgents(mockCollection as never); - - const filter = extractWhereFilter(); - - expect(filter({ agent: { _id: 'a1', status: 'active' } })).toBe(true); - expect(filter({ agent: { _id: 'a2', status: 'draft' } })).toBe(false); - expect(filter({ agent: { _id: 'a3', status: 'archived' } })).toBe(false); - }); - - it('filters by team when team filter is active', () => { - mockUseTeamFilter.mockReturnValue({ - selectedTeamId: 'team-1', - } as ReturnType); - useChatAgents(mockCollection as never); - - const filter = extractWhereFilter(); - - expect( - filter({ agent: { _id: 'a1', status: 'active', teamId: 'team-1' } }), - ).toBe(true); - - expect( - filter({ - agent: { - _id: 'a2', - status: 'active', - teamId: 'team-2', - sharedWithTeamIds: ['team-1'], - }, - }), - ).toBe(true); - - expect( - filter({ agent: { _id: 'a3', status: 'active', teamId: 'team-2' } }), - ).toBe(false); - - expect( - filter({ agent: { _id: 'a4', status: 'draft', teamId: 'team-1' } }), - ).toBe(false); - }); -}); diff --git a/services/platform/app/features/chat/hooks/collections.ts b/services/platform/app/features/chat/hooks/collections.ts deleted file mode 100644 index e202d88a3..000000000 --- a/services/platform/app/features/chat/hooks/collections.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createThreadsCollection } from '@/lib/collections/entities/threads'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useThreadCollection() { - return useCollection('threads', createThreadsCollection, 'user-threads'); -} diff --git a/services/platform/app/features/chat/hooks/queries.ts b/services/platform/app/features/chat/hooks/queries.ts index b28af78ca..cc5f23dd1 100644 --- a/services/platform/app/features/chat/hooks/queries.ts +++ b/services/platform/app/features/chat/hooks/queries.ts @@ -1,85 +1,56 @@ -import type { Collection } from '@tanstack/db'; - import { useUIMessages, type UIMessage } from '@convex-dev/agent/react'; -import { useLiveQuery } from '@tanstack/react-db'; import { useMemo } from 'react'; import type { Id } from '@/convex/_generated/dataModel'; import type { WorkflowCreationMetadata } from '@/convex/approvals/types'; -import type { CustomAgent } from '@/lib/collections/entities/custom-agents'; -import type { Thread } from '@/lib/collections/entities/threads'; import type { HumanInputRequestMetadata } from '@/lib/shared/schemas/approvals'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; -import { useApprovalCollection } from '@/app/features/approvals/hooks/collections'; import { useApprovals } from '@/app/features/approvals/hooks/queries'; import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { useTeamFilter } from '@/app/hooks/use-team-filter'; import { api } from '@/convex/_generated/api'; -export function useThreads(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export type Thread = ConvexItemOf; + +export function useThreads() { + const { data, isLoading } = useConvexQuery(api.threads.queries.listThreads); - const sorted = useMemo( + const threads = useMemo( () => data?.slice().sort((a, b) => b._creationTime - a._creationTime), [data], ); return { - threads: sorted, + threads, isLoading, }; } -export function useChatAgents(collection: Collection) { +export type CustomAgent = ConvexItemOf< + typeof api.custom_agents.queries.listCustomAgents +>; + +export function useChatAgents(organizationId: string) { const { selectedTeamId } = useTeamFilter(); + const { data } = useConvexQuery(api.custom_agents.queries.listCustomAgents, { + organizationId, + }); - const { data } = useLiveQuery( - (q) => - q - .from({ agent: collection }) - .fn.where((row) => { - const { agent } = row; - if (agent.status !== 'active') return false; - if (!selectedTeamId) return true; - return ( - agent.teamId === selectedTeamId || - (agent.sharedWithTeamIds?.includes(selectedTeamId) ?? false) - ); - }) - .select(({ agent }) => ({ - _id: agent._id, - _creationTime: agent._creationTime, - organizationId: agent.organizationId, - name: agent.name, - displayName: agent.displayName, - description: agent.description, - avatarUrl: agent.avatarUrl, - systemInstructions: agent.systemInstructions, - toolNames: agent.toolNames, - integrationBindings: agent.integrationBindings, - modelPreset: agent.modelPreset, - knowledgeEnabled: agent.knowledgeEnabled, - includeOrgKnowledge: agent.includeOrgKnowledge, - knowledgeTopK: agent.knowledgeTopK, - toneOfVoiceId: agent.toneOfVoiceId, - filePreprocessingEnabled: agent.filePreprocessingEnabled, - teamId: agent.teamId, - sharedWithTeamIds: agent.sharedWithTeamIds, - createdBy: agent.createdBy, - isActive: agent.isActive, - versionNumber: agent.versionNumber, - status: agent.status, - rootVersionId: agent.rootVersionId, - parentVersionId: agent.parentVersionId, - publishedAt: agent.publishedAt, - publishedBy: agent.publishedBy, - changeLog: agent.changeLog, - })), - [selectedTeamId], - ); + const agents = useMemo(() => { + if (!data) return undefined; + return data.filter((agent) => { + if (agent.status !== 'active') return false; + if (!selectedTeamId) return true; + return ( + agent.teamId === selectedTeamId || + (agent.sharedWithTeamIds?.includes(selectedTeamId) ?? false) + ); + }); + }, [data, selectedTeamId]); return { - agents: data, + agents, }; } @@ -114,8 +85,7 @@ export function useHumanInputRequests( organizationId: string, threadId: string | undefined, ) { - const approvalCollection = useApprovalCollection(organizationId); - const { approvals, isLoading } = useApprovals(approvalCollection); + const { approvals, isLoading } = useApprovals(organizationId); const humanInputRequests = useMemo((): HumanInputRequest[] => { if (!approvals || !threadId) return []; @@ -127,10 +97,9 @@ export function useHumanInputRequests( a.metadata !== undefined, ) .map((a) => ({ - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- collection returns string IDs; downstream expects Id<'approvals'> - _id: a._id as Id<'approvals'>, + _id: a._id, status: a.status, - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- narrowing from generic JSON record to specific schema type + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Convex metadata uses v.any(); cast to specific schema type metadata: a.metadata as unknown as HumanInputRequestMetadata, _creationTime: a._creationTime, messageId: a.messageId, @@ -172,8 +141,7 @@ export function useIntegrationApprovals( organizationId: string, threadId: string | undefined, ) { - const approvalCollection = useApprovalCollection(organizationId); - const { approvals, isLoading } = useApprovals(approvalCollection); + const { approvals, isLoading } = useApprovals(organizationId); const integrationApprovals = useMemo((): IntegrationApproval[] => { if (!approvals || !threadId) return []; @@ -185,8 +153,7 @@ export function useIntegrationApprovals( a.metadata !== undefined, ) .map((a) => ({ - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- collection returns string IDs; downstream expects Id<'approvals'> - _id: a._id as Id<'approvals'>, + _id: a._id, status: a.status, // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Convex metadata uses v.any(); cast to specific metadata shape metadata: a.metadata as IntegrationOperationMetadata, @@ -217,8 +184,7 @@ export function useWorkflowCreationApprovals( organizationId: string, threadId: string | undefined, ) { - const approvalCollection = useApprovalCollection(organizationId); - const { approvals, isLoading } = useApprovals(approvalCollection); + const { approvals, isLoading } = useApprovals(organizationId); const workflowCreationApprovals = useMemo((): WorkflowCreationApproval[] => { if (!approvals || !threadId) return []; @@ -230,8 +196,7 @@ export function useWorkflowCreationApprovals( a.metadata !== undefined, ) .map((a) => ({ - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- collection returns string IDs; downstream expects Id<'approvals'> - _id: a._id as Id<'approvals'>, + _id: a._id, status: a.status, // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Convex metadata uses v.any(); cast to specific metadata shape metadata: a.metadata as WorkflowCreationMetadata, diff --git a/services/platform/app/features/chat/hooks/use-convex-file-upload.ts b/services/platform/app/features/chat/hooks/use-convex-file-upload.ts index ba9bb5df7..b2ae4997e 100644 --- a/services/platform/app/features/chat/hooks/use-convex-file-upload.ts +++ b/services/platform/app/features/chat/hooks/use-convex-file-upload.ts @@ -37,7 +37,7 @@ export function useConvexFileUpload(config?: ConvexFileUploadConfig) { const { t } = useT('chat'); const [attachments, setAttachments] = useState([]); const [uploadingFiles, setUploadingFiles] = useState([]); - const generateUploadUrl = useGenerateUploadUrl(); + const { mutateAsync: generateUploadUrl } = useGenerateUploadUrl(); const mergedConfig = useMemo( () => ({ ...DEFAULT_CONFIG, ...config }), diff --git a/services/platform/app/features/chat/hooks/use-send-message.ts b/services/platform/app/features/chat/hooks/use-send-message.ts index 681d57b3a..137b38ce2 100644 --- a/services/platform/app/features/chat/hooks/use-send-message.ts +++ b/services/platform/app/features/chat/hooks/use-send-message.ts @@ -49,11 +49,11 @@ export function useSendMessage({ const { t } = useT('chat'); const navigate = useNavigate(); - const createThread = useCreateThread(); - const updateThread = useUpdateThread(); - const chatWithAgent = useChatWithAgent(); - const chatWithBuiltinAgent = useChatWithBuiltinAgent(); - const chatWithCustomAgent = useChatWithCustomAgent(); + const { mutateAsync: createThread } = useCreateThread(); + const { mutateAsync: updateThread } = useUpdateThread(); + const { mutateAsync: chatWithAgent } = useChatWithAgent(); + const { mutateAsync: chatWithBuiltinAgent } = useChatWithBuiltinAgent(); + const { mutateAsync: chatWithCustomAgent } = useChatWithCustomAgent(); const sendMessage = useCallback( async (message: string, attachments?: FileAttachment[]) => { diff --git a/services/platform/app/features/conversations/components/conversation-header.tsx b/services/platform/app/features/conversations/components/conversation-header.tsx index d037b8935..abbbf4c37 100644 --- a/services/platform/app/features/conversations/components/conversation-header.tsx +++ b/services/platform/app/features/conversations/components/conversation-header.tsx @@ -8,7 +8,7 @@ import { ShieldX, UserIcon, } from 'lucide-react'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { Stack, HStack } from '@/app/components/ui/layout/layout'; import { @@ -19,11 +19,12 @@ import { } from '@/app/components/ui/overlays/dropdown-menu'; import { Button } from '@/app/components/ui/primitives/button'; import { CustomerInfoDialog } from '@/app/features/customers/components/customer-info-dialog'; -import { useCustomerById } from '@/app/features/customers/hooks/queries'; +import { + useCustomerById, + useCustomers, +} from '@/app/features/customers/hooks/queries'; import { toast } from '@/app/hooks/use-toast'; import { toId } from '@/convex/lib/type_cast_helpers'; -import { createCustomersCollection } from '@/lib/collections/entities/customers'; -import { useCollection } from '@/lib/collections/use-collection'; import { useT } from '@/lib/i18n/client'; import type { ConversationWithMessages } from '../types'; @@ -55,93 +56,82 @@ export function ConversationHeader({ const { customer } = conversation; const [isCustomerInfoOpen, setIsCustomerInfoOpen] = useState(false); - const [isClosing, setIsClosing] = useState(false); - const [isReopening, setIsReopening] = useState(false); - const [isMarkingSpam, setIsMarkingSpam] = useState(false); - const closeConversation = useCloseConversation(); - const reopenConversation = useReopenConversation(); - const markAsSpamMutation = useMarkAsSpam(); + const { mutate: closeConversation, isPending: isClosing } = + useCloseConversation(); + const { mutate: reopenConversation, isPending: isReopening } = + useReopenConversation(); + const { mutate: markAsSpamMutation, isPending: isMarkingSpam } = + useMarkAsSpam(); const isLoading = isClosing || isReopening || isMarkingSpam; - // Lookup full customer document from collection - const customersCollection = useCollection( - 'customers', - createCustomersCollection, - organizationId, - ); - const customerDoc = useCustomerById( - customersCollection, - conversation.customerId, - ); - - const handleResolveConversation = async () => { - setIsClosing(true); - try { - await closeConversation({ - conversationId: toId<'conversations'>(conversation.id), - }); - - toast({ - title: t('header.toast.closed'), - variant: 'success', - }); - onResolve?.(); - } catch (error) { - console.error('Error closing conversation:', error); - toast({ - title: t('header.toast.closeFailed'), - variant: 'destructive', - }); - } finally { - setIsClosing(false); - } - }; - - const handleReopenConversation = async () => { - setIsReopening(true); - try { - await reopenConversation({ - conversationId: toId<'conversations'>(conversation.id), - }); + const { customers } = useCustomers(organizationId); + const customerDoc = useCustomerById(customers, conversation.customerId); - toast({ - title: t('header.toast.reopened'), - variant: 'success', - }); - onReopen?.(); - } catch (error) { - console.error('Error reopening conversation:', error); - toast({ - title: t('header.toast.reopenFailed'), - variant: 'destructive', - }); - } finally { - setIsReopening(false); - } - }; + const handleResolveConversation = useCallback(() => { + closeConversation( + { conversationId: toId<'conversations'>(conversation.id) }, + { + onSuccess: () => { + toast({ + title: t('header.toast.closed'), + variant: 'success', + }); + onResolve?.(); + }, + onError: (error) => { + console.error('Error closing conversation:', error); + toast({ + title: t('header.toast.closeFailed'), + variant: 'destructive', + }); + }, + }, + ); + }, [closeConversation, conversation.id, t, onResolve]); - const handleMarkAsSpam = async () => { - setIsMarkingSpam(true); - try { - await markAsSpamMutation({ - conversationId: toId<'conversations'>(conversation.id), - }); + const handleReopenConversation = useCallback(() => { + reopenConversation( + { conversationId: toId<'conversations'>(conversation.id) }, + { + onSuccess: () => { + toast({ + title: t('header.toast.reopened'), + variant: 'success', + }); + onReopen?.(); + }, + onError: (error) => { + console.error('Error reopening conversation:', error); + toast({ + title: t('header.toast.reopenFailed'), + variant: 'destructive', + }); + }, + }, + ); + }, [reopenConversation, conversation.id, t, onReopen]); - toast({ - title: t('header.toast.markedAsSpam'), - variant: 'success', - }); - onResolve?.(); - } catch (error) { - console.error('Error marking conversation as spam:', error); - toast({ - title: t('header.toast.markAsSpamFailed'), - variant: 'destructive', - }); - } finally { - setIsMarkingSpam(false); - } - }; + const handleMarkAsSpam = useCallback(() => { + markAsSpamMutation( + { conversationId: toId<'conversations'>(conversation.id) }, + { + onSuccess: () => { + toast({ + title: t('header.toast.markedAsSpam'), + variant: 'success', + }); + onResolve?.(); + }, + onError: (error) => { + console.error('Error marking conversation as spam:', error); + toast({ + title: t('header.toast.markAsSpamFailed'), + variant: 'destructive', + }); + }, + }, + ); + }, [markAsSpamMutation, conversation.id, t, onResolve]); return ( <> diff --git a/services/platform/app/features/conversations/components/conversation-panel.tsx b/services/platform/app/features/conversations/components/conversation-panel.tsx index 475b3cbcd..924198c9a 100644 --- a/services/platform/app/features/conversations/components/conversation-panel.tsx +++ b/services/platform/app/features/conversations/components/conversation-panel.tsx @@ -61,10 +61,11 @@ export function ConversationPanel({ selectedConversationId, ); - const markAsRead = useMarkAsRead(); - const sendMessageViaIntegration = useSendMessageViaIntegration(); - const generateUploadUrl = useGenerateUploadUrl(); - const downloadAttachments = useDownloadAttachments(); + const { mutate: markAsRead } = useMarkAsRead(); + const { mutateAsync: sendMessageViaIntegration } = + useSendMessageViaIntegration(); + const { mutateAsync: generateUploadUrl } = useGenerateUploadUrl(); + const { mutate: downloadAttachments } = useDownloadAttachments(); const containerRef = useRef(null); const messageComposerRef = useRef(null); @@ -107,11 +108,14 @@ export function ConversationPanel({ new Date(conversation.last_read_at)); if (hasUnreadMessages) { - markAsRead({ - conversationId: toId<'conversations'>(selectedConversationId), - }).catch((error: Error) => { - console.error('Failed to mark conversation as read:', error); - }); + markAsRead( + { conversationId: toId<'conversations'>(selectedConversationId) }, + { + onError: (error) => { + console.error('Failed to mark conversation as read:', error); + }, + }, + ); } } }, [conversation, selectedConversationId, markAsRead]); @@ -368,15 +372,23 @@ export function ConversationPanel({ key={message.id} message={message} onDownloadAttachments={(messageId) => { - downloadAttachments({ - messageId: toId<'conversationMessages'>(messageId), - }).catch((error: Error) => { - console.error('Failed to download attachments:', error); - toast({ - title: tConversations('panel.downloadFailed'), - variant: 'destructive', - }); - }); + downloadAttachments( + { + messageId: toId<'conversationMessages'>(messageId), + }, + { + onError: (error) => { + console.error( + 'Failed to download attachments:', + error, + ); + toast({ + title: tConversations('panel.downloadFailed'), + variant: 'destructive', + }); + }, + }, + ); }} /> ))} diff --git a/services/platform/app/features/conversations/components/conversations-client.tsx b/services/platform/app/features/conversations/components/conversations-client.tsx index df3ed2583..61252f1ab 100644 --- a/services/platform/app/features/conversations/components/conversations-client.tsx +++ b/services/platform/app/features/conversations/components/conversations-client.tsx @@ -170,9 +170,9 @@ export function ConversationsClient({ }, [paginatedResult.results, searchQuery, initialSearch]); // Convex mutations - const bulkResolve = useBulkCloseConversations(); - const bulkReopen = useBulkReopenConversations(); - const addMessage = useAddMessage(); + const { mutateAsync: bulkResolve } = useBulkCloseConversations(); + const { mutateAsync: bulkReopen } = useBulkReopenConversations(); + const { mutateAsync: addMessage } = useAddMessage(); const [selectionState, setSelectionState] = useState({ type: 'individual', @@ -237,9 +237,9 @@ interface ConversationsClientInnerProps { isOpen: boolean; isSending: boolean; }) => void; - bulkResolve: ReturnType; - bulkReopen: ReturnType; - addMessage: ReturnType; + bulkResolve: ReturnType['mutateAsync']; + bulkReopen: ReturnType['mutateAsync']; + addMessage: ReturnType['mutateAsync']; paginatedResult: UsePaginatedQueryResult; tChat: ReturnType['t']; tConversations: ReturnType['t']; diff --git a/services/platform/app/features/conversations/components/message-editor.tsx b/services/platform/app/features/conversations/components/message-editor.tsx index 9de2da927..484d30d15 100644 --- a/services/platform/app/features/conversations/components/message-editor.tsx +++ b/services/platform/app/features/conversations/components/message-editor.tsx @@ -109,7 +109,7 @@ function MilkdownEditorInner({ const { t: tConversations } = useT('conversations'); const { t: tCommon } = useT('common'); - const improveMessage = useImproveMessage(); + const { mutateAsync: improveMessage } = useImproveMessage(); // Helper function to format file size const formatFileSize = (bytes: number): string => { diff --git a/services/platform/app/features/conversations/hooks/__tests__/collection-hooks.test.ts b/services/platform/app/features/conversations/hooks/__tests__/collection-hooks.test.ts deleted file mode 100644 index 470e084c8..000000000 --- a/services/platform/app/features/conversations/hooks/__tests__/collection-hooks.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((_builder: (q: unknown) => unknown) => { - return { data: [], isLoading: false }; - }), -})); - -vi.mock('@/lib/collections/entities/conversations', () => ({ - createConversationsCollection: vi.fn(), -})); - -vi.mock('@/lib/collections/use-collection', () => ({ - useCollection: vi.fn(() => mockCollection), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useCollection } from '@/lib/collections/use-collection'; - -import { useConversationCollection } from '../collections'; -import { useConversations } from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); - -describe('useConversationCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection with correct params', () => { - useConversationCollection('org-123'); - expect(useCollection).toHaveBeenCalledWith( - 'conversations', - expect.any(Function), - 'org-123', - ); - }); -}); - -describe('useConversations', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data from live query', () => { - const items = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: items, - isLoading: false, - } as ReturnType); - - const result = useConversations(mockCollection as never); - expect(result.conversations).toBe(items); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useConversations(mockCollection as never); - expect(result.conversations).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/conversations/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/conversations/hooks/__tests__/mutation-hooks.test.ts index 94887ae8a..355c3fe38 100644 --- a/services/platform/app/features/conversations/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/conversations/hooks/__tests__/mutation-hooks.test.ts @@ -2,10 +2,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { toId } from '@/convex/lib/type_cast_helpers'; -const mockMutationFn = vi.fn(); +const mockMutateAsync = vi.fn(); vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => mockMutationFn, + useConvexMutation: () => ({ + mutate: mockMutateAsync, + mutateAsync: mockMutateAsync, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), + }), })); vi.mock('@/convex/_generated/api', () => ({ @@ -33,27 +42,28 @@ describe('useCloseConversation', () => { vi.clearAllMocks(); }); - it('returns the mutation function from useConvexMutation', () => { - const closeConversation = useCloseConversation(); - expect(closeConversation).toBe(mockMutationFn); + it('returns a mutation result object from useConvexMutation', () => { + const result = useCloseConversation(); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); it('calls mutation with the correct args', async () => { - mockMutationFn.mockResolvedValueOnce(null); - const closeConversation = useCloseConversation(); + mockMutateAsync.mockResolvedValueOnce(null); + const { mutateAsync: closeConversation } = useCloseConversation(); await closeConversation({ conversationId: toId<'conversations'>('conv-123'), }); - expect(mockMutationFn).toHaveBeenCalledWith({ + expect(mockMutateAsync).toHaveBeenCalledWith({ conversationId: toId<'conversations'>('conv-123'), }); }); it('propagates errors from mutation', async () => { - mockMutationFn.mockRejectedValueOnce(new Error('Close failed')); - const closeConversation = useCloseConversation(); + mockMutateAsync.mockRejectedValueOnce(new Error('Close failed')); + const { mutateAsync: closeConversation } = useCloseConversation(); await expect( closeConversation({ @@ -68,27 +78,28 @@ describe('useReopenConversation', () => { vi.clearAllMocks(); }); - it('returns the mutation function from useConvexMutation', () => { - const reopenConversation = useReopenConversation(); - expect(reopenConversation).toBe(mockMutationFn); + it('returns a mutation result object from useConvexMutation', () => { + const result = useReopenConversation(); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); it('calls mutation with the correct args', async () => { - mockMutationFn.mockResolvedValueOnce(null); - const reopenConversation = useReopenConversation(); + mockMutateAsync.mockResolvedValueOnce(null); + const { mutateAsync: reopenConversation } = useReopenConversation(); await reopenConversation({ conversationId: toId<'conversations'>('conv-123'), }); - expect(mockMutationFn).toHaveBeenCalledWith({ + expect(mockMutateAsync).toHaveBeenCalledWith({ conversationId: toId<'conversations'>('conv-123'), }); }); it('propagates errors from mutation', async () => { - mockMutationFn.mockRejectedValueOnce(new Error('Reopen failed')); - const reopenConversation = useReopenConversation(); + mockMutateAsync.mockRejectedValueOnce(new Error('Reopen failed')); + const { mutateAsync: reopenConversation } = useReopenConversation(); await expect( reopenConversation({ @@ -103,27 +114,28 @@ describe('useMarkAsRead', () => { vi.clearAllMocks(); }); - it('returns the mutation function from useConvexMutation', () => { - const markAsRead = useMarkAsRead(); - expect(markAsRead).toBe(mockMutationFn); + it('returns a mutation result object from useConvexMutation', () => { + const result = useMarkAsRead(); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); it('calls mutation with the correct args', async () => { - mockMutationFn.mockResolvedValueOnce(null); - const markAsRead = useMarkAsRead(); + mockMutateAsync.mockResolvedValueOnce(null); + const { mutateAsync: markAsRead } = useMarkAsRead(); await markAsRead({ conversationId: toId<'conversations'>('conv-123'), }); - expect(mockMutationFn).toHaveBeenCalledWith({ + expect(mockMutateAsync).toHaveBeenCalledWith({ conversationId: toId<'conversations'>('conv-123'), }); }); it('propagates errors from mutation', async () => { - mockMutationFn.mockRejectedValueOnce(new Error('MarkAsRead failed')); - const markAsRead = useMarkAsRead(); + mockMutateAsync.mockRejectedValueOnce(new Error('MarkAsRead failed')); + const { mutateAsync: markAsRead } = useMarkAsRead(); await expect( markAsRead({ @@ -138,27 +150,28 @@ describe('useMarkAsSpam', () => { vi.clearAllMocks(); }); - it('returns the mutation function from useConvexMutation', () => { - const markAsSpam = useMarkAsSpam(); - expect(markAsSpam).toBe(mockMutationFn); + it('returns a mutation result object from useConvexMutation', () => { + const result = useMarkAsSpam(); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); it('calls mutation with the correct args', async () => { - mockMutationFn.mockResolvedValueOnce(null); - const markAsSpam = useMarkAsSpam(); + mockMutateAsync.mockResolvedValueOnce(null); + const { mutateAsync: markAsSpam } = useMarkAsSpam(); await markAsSpam({ conversationId: toId<'conversations'>('conv-123'), }); - expect(mockMutationFn).toHaveBeenCalledWith({ + expect(mockMutateAsync).toHaveBeenCalledWith({ conversationId: toId<'conversations'>('conv-123'), }); }); it('propagates errors from mutation', async () => { - mockMutationFn.mockRejectedValueOnce(new Error('Spam failed')); - const markAsSpam = useMarkAsSpam(); + mockMutateAsync.mockRejectedValueOnce(new Error('Spam failed')); + const { mutateAsync: markAsSpam } = useMarkAsSpam(); await expect( markAsSpam({ diff --git a/services/platform/app/features/conversations/hooks/collections.ts b/services/platform/app/features/conversations/hooks/collections.ts deleted file mode 100644 index 87553ff73..000000000 --- a/services/platform/app/features/conversations/hooks/collections.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createConversationsCollection } from '@/lib/collections/entities/conversations'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useConversationCollection(organizationId: string) { - return useCollection( - 'conversations', - createConversationsCollection, - organizationId, - ); -} diff --git a/services/platform/app/features/conversations/hooks/queries.ts b/services/platform/app/features/conversations/hooks/queries.ts index dcb3f445f..60a02c8ef 100644 --- a/services/platform/app/features/conversations/hooks/queries.ts +++ b/services/platform/app/features/conversations/hooks/queries.ts @@ -1,19 +1,22 @@ -import type { Collection } from '@tanstack/db'; - -import { useLiveQuery } from '@tanstack/react-db'; - -import type { Conversation } from '@/lib/collections/entities/conversations'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; import { useCachedPaginatedQuery } from '@/app/hooks/use-cached-paginated-query'; import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { api } from '@/convex/_generated/api'; import { toId } from '@/lib/utils/type-guards'; -export function useConversations(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export type Conversation = ConvexItemOf< + typeof api.conversations.queries.listConversations +>; + +export function useConversations(organizationId: string) { + const { data, isLoading } = useConvexQuery( + api.conversations.queries.listConversations, + { organizationId }, + ); return { - conversations: data, + conversations: data ?? [], isLoading, }; } diff --git a/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.test.tsx b/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.test.tsx index 47f8d69a8..5aaf7f21c 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.test.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.test.tsx @@ -13,9 +13,21 @@ const mockUnpublish = vi.fn(); vi.mock('../hooks/mutations', async (importOriginal) => ({ ...(await importOriginal()), - useActivateCustomAgentVersion: () => mockActivateVersion, - usePublishCustomAgent: () => mockPublish, - useUnpublishCustomAgent: () => mockUnpublish, + useActivateCustomAgentVersion: () => ({ + mutate: mockActivateVersion, + mutateAsync: mockActivateVersion, + isPending: false, + }), + usePublishCustomAgent: () => ({ + mutate: mockPublish, + mutateAsync: mockPublish, + isPending: false, + }), + useUnpublishCustomAgent: () => ({ + mutate: mockUnpublish, + mutateAsync: mockUnpublish, + isPending: false, + }), })); vi.mock('@/app/hooks/use-toast', () => ({ @@ -104,10 +116,13 @@ describe('CustomAgentActiveToggle', () => { await user.click(screen.getByRole('switch')); await waitFor(() => { - expect(mockActivateVersion).toHaveBeenCalledWith({ - customAgentId: 'agent-root-1', - targetVersion: 2, - }); + expect(mockActivateVersion).toHaveBeenCalledWith( + { + customAgentId: 'agent-root-1', + targetVersion: 2, + }, + expect.any(Object), + ); }); }); @@ -121,9 +136,10 @@ describe('CustomAgentActiveToggle', () => { await user.click(screen.getByRole('switch')); await waitFor(() => { - expect(mockPublish).toHaveBeenCalledWith({ - customAgentId: 'agent-root-1', - }); + expect(mockPublish).toHaveBeenCalledWith( + { customAgentId: 'agent-root-1' }, + expect.any(Object), + ); expect(mockActivateVersion).not.toHaveBeenCalled(); }); }); @@ -156,9 +172,10 @@ describe('CustomAgentActiveToggle', () => { await user.click(confirmButton); await waitFor(() => { - expect(mockUnpublish).toHaveBeenCalledWith({ - customAgentId: 'agent-root-1', - }); + expect(mockUnpublish).toHaveBeenCalledWith( + { customAgentId: 'agent-root-1' }, + expect.any(Object), + ); }); }); diff --git a/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.tsx b/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.tsx index 9212fdfb9..2c2bc4ab0 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.tsx @@ -32,13 +32,13 @@ export function CustomAgentActiveToggle({ const { t: tCommon } = useT('common'); const [showDeactivateDialog, setShowDeactivateDialog] = useState(false); - const [isActivating, setIsActivating] = useState(false); - const [isPublishing, setIsPublishing] = useState(false); - const [isUnpublishing, setIsUnpublishing] = useState(false); - const activateVersion = useActivateCustomAgentVersion(); - const publishAgent = usePublishCustomAgent(); - const unpublishAgent = useUnpublishCustomAgent(); + const { mutate: activateVersion, isPending: isActivating } = + useActivateCustomAgentVersion(); + const { mutate: publishAgent, isPending: isPublishing } = + usePublishCustomAgent(); + const { mutate: unpublishAgent, isPending: isUnpublishing } = + useUnpublishCustomAgent(); const isToggling = isActivating || isPublishing || isUnpublishing; @@ -47,36 +47,33 @@ export function CustomAgentActiveToggle({ const isUnpublishedDraft = agent.status === 'draft' && agent.versionNumber === 1; - const handleActivate = useCallback(async () => { + const handleActivate = useCallback(() => { + const callbacks = { + onSuccess: () => { + toast({ + title: t('customAgents.agentPublished'), + variant: 'success', + }); + }, + onError: (error: Error) => { + console.error('Failed to activate agent:', error); + toast({ + title: t('customAgents.agentPublishFailed'), + variant: 'destructive', + }); + }, + }; + if (agent.status === 'draft') { - setIsPublishing(true); + publishAgent({ customAgentId: toId<'customAgents'>(rootId) }, callbacks); } else { - setIsActivating(true); - } - try { - if (agent.status === 'draft') { - await publishAgent({ - customAgentId: toId<'customAgents'>(rootId), - }); - } else { - await activateVersion({ + activateVersion( + { customAgentId: toId<'customAgents'>(rootId), targetVersion: agent.versionNumber, - }); - } - toast({ - title: t('customAgents.agentPublished'), - variant: 'success', - }); - } catch (error) { - console.error('Failed to activate agent:', error); - toast({ - title: t('customAgents.agentPublishFailed'), - variant: 'destructive', - }); - } finally { - setIsPublishing(false); - setIsActivating(false); + }, + callbacks, + ); } }, [ activateVersion, @@ -87,32 +84,32 @@ export function CustomAgentActiveToggle({ t, ]); - const handleDeactivateConfirm = useCallback(async () => { - setIsUnpublishing(true); - try { - await unpublishAgent({ - customAgentId: toId<'customAgents'>(rootId), - }); - setShowDeactivateDialog(false); - toast({ - title: t('customAgents.agentDeactivated'), - variant: 'success', - }); - } catch (error) { - console.error('Failed to deactivate agent:', error); - toast({ - title: t('customAgents.agentDeactivateFailed'), - variant: 'destructive', - }); - } finally { - setIsUnpublishing(false); - } + const handleDeactivateConfirm = useCallback(() => { + unpublishAgent( + { customAgentId: toId<'customAgents'>(rootId) }, + { + onSuccess: () => { + setShowDeactivateDialog(false); + toast({ + title: t('customAgents.agentDeactivated'), + variant: 'success', + }); + }, + onError: (error) => { + console.error('Failed to deactivate agent:', error); + toast({ + title: t('customAgents.agentDeactivateFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [unpublishAgent, rootId, t]); const handleToggle = useCallback( (checked: boolean) => { if (checked) { - void handleActivate(); + handleActivate(); } else { setShowDeactivateDialog(true); } diff --git a/services/platform/app/features/custom-agents/components/custom-agent-create-dialog.tsx b/services/platform/app/features/custom-agents/components/custom-agent-create-dialog.tsx index 3784957bf..f6caaca54 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-create-dialog.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-create-dialog.tsx @@ -34,7 +34,7 @@ export function CreateCustomAgentDialog({ const { t } = useT('settings'); const { t: tCommon } = useT('common'); const navigate = useNavigate(); - const createAgent = useCreateCustomAgent(); + const { mutateAsync: createAgent } = useCreateCustomAgent(); const formSchema = useMemo( () => diff --git a/services/platform/app/features/custom-agents/components/custom-agent-knowledge.tsx b/services/platform/app/features/custom-agents/components/custom-agent-knowledge.tsx index d4bb5f4d7..aa6942610 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-knowledge.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-knowledge.tsx @@ -8,7 +8,6 @@ import type { RagStatus } from '@/types/documents'; import { Switch } from '@/app/components/ui/forms/switch'; import { Stack, NarrowContainer } from '@/app/components/ui/layout/layout'; import { RagStatusBadge } from '@/app/features/documents/components/rag-status-badge'; -import { useDocumentCollection } from '@/app/features/documents/hooks/collections'; import { useDocuments } from '@/app/features/documents/hooks/queries'; import { useTeamFilter } from '@/app/hooks/use-team-filter'; import { useT } from '@/lib/i18n/client'; @@ -74,9 +73,8 @@ export function CustomAgentKnowledge({ return teams.find((team) => team.id === agent.teamId)?.name ?? null; }, [agent?.teamId, teams]); - const documentCollection = useDocumentCollection(organizationId); const { documents: allDocuments, isLoading: isDocumentsLoading } = - useDocuments(documentCollection); + useDocuments(organizationId); const documents = useMemo( // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Collection returns DocumentItemResponse; cast to DocumentEntry for display diff --git a/services/platform/app/features/custom-agents/components/custom-agent-navigation.tsx b/services/platform/app/features/custom-agents/components/custom-agent-navigation.tsx index a4d163cae..28e619e9b 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-navigation.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-navigation.tsx @@ -2,7 +2,7 @@ import { useNavigate } from '@tanstack/react-router'; import { ChevronDown, CircleStop, FlaskConical, Pencil } from 'lucide-react'; -import { useMemo, useCallback, useState } from 'react'; +import { useMemo, useCallback } from 'react'; import { Badge } from '@/app/components/ui/feedback/badge'; import { @@ -47,14 +47,14 @@ export function CustomAgentNavigation({ const { agent, versions, hasDraft, draftVersionNumber } = useCustomAgentVersion(); - const [isPublishing, setIsPublishing] = useState(false); - const [isUnpublishing, setIsUnpublishing] = useState(false); - const [isActivating, setIsActivating] = useState(false); - const [isCreatingDraft, setIsCreatingDraft] = useState(false); - const publishAgent = usePublishCustomAgent(); - const unpublishAgent = useUnpublishCustomAgent(); - const activateVersion = useActivateCustomAgentVersion(); - const createDraft = useCreateDraftFromVersion(); + const { mutate: publishAgent, isPending: isPublishing } = + usePublishCustomAgent(); + const { mutate: unpublishAgent, isPending: isUnpublishing } = + useUnpublishCustomAgent(); + const { mutate: activateVersion, isPending: isActivating } = + useActivateCustomAgentVersion(); + const { mutateAsync: createDraft, isPending: isCreatingDraft } = + useCreateDraftFromVersion(); const basePath = `/dashboard/${organizationId}/custom-agents/${agentId}`; const versionSearch = @@ -112,68 +112,70 @@ export function CustomAgentNavigation({ }); }, [navigate, organizationId, agentId]); - const handlePublish = async () => { - setIsPublishing(true); - try { - await publishAgent({ - customAgentId: toId<'customAgents'>(agentId), - }); - toast({ - title: t('customAgents.agentPublished'), - variant: 'success', - }); - } catch (error) { - console.error(error); - toast({ - title: t('customAgents.agentPublishFailed'), - variant: 'destructive', - }); - } finally { - setIsPublishing(false); - } + const handlePublish = () => { + publishAgent( + { customAgentId: toId<'customAgents'>(agentId) }, + { + onSuccess: () => { + toast({ + title: t('customAgents.agentPublished'), + variant: 'success', + }); + }, + onError: (error) => { + console.error(error); + toast({ + title: t('customAgents.agentPublishFailed'), + variant: 'destructive', + }); + }, + }, + ); }; - const handleUnpublish = async () => { - setIsUnpublishing(true); - try { - await unpublishAgent({ - customAgentId: toId<'customAgents'>(agentId), - }); - toast({ - title: t('customAgents.agentDeactivated'), - variant: 'success', - }); - } catch (error) { - console.error(error); - toast({ - title: t('customAgents.agentDeactivateFailed'), - variant: 'destructive', - }); - } finally { - setIsUnpublishing(false); - } + const handleUnpublish = () => { + unpublishAgent( + { customAgentId: toId<'customAgents'>(agentId) }, + { + onSuccess: () => { + toast({ + title: t('customAgents.agentDeactivated'), + variant: 'success', + }); + }, + onError: (error) => { + console.error(error); + toast({ + title: t('customAgents.agentDeactivateFailed'), + variant: 'destructive', + }); + }, + }, + ); }; - const handleActivate = async () => { - setIsActivating(true); - try { - await activateVersion({ + const handleActivate = () => { + activateVersion( + { customAgentId: toId<'customAgents'>(agentId), targetVersion: agent.versionNumber, - }); - toast({ - title: t('customAgents.agentPublished'), - variant: 'success', - }); - } catch (error) { - console.error(error); - toast({ - title: t('customAgents.agentPublishFailed'), - variant: 'destructive', - }); - } finally { - setIsActivating(false); - } + }, + { + onSuccess: () => { + toast({ + title: t('customAgents.agentPublished'), + variant: 'success', + }); + }, + onError: (error) => { + console.error(error); + toast({ + title: t('customAgents.agentPublishFailed'), + variant: 'destructive', + }); + }, + }, + ); }; const handleCreateDraft = async () => { @@ -182,7 +184,6 @@ export function CustomAgentNavigation({ return; } - setIsCreatingDraft(true); try { await createDraft({ customAgentId: toId<'customAgents'>(agentId), @@ -195,8 +196,6 @@ export function CustomAgentNavigation({ title: t('customAgents.agentUpdateFailed'), variant: 'destructive', }); - } finally { - setIsCreatingDraft(false); } }; diff --git a/services/platform/app/features/custom-agents/components/custom-agent-row-actions.tsx b/services/platform/app/features/custom-agents/components/custom-agent-row-actions.tsx index 1f4d526bd..a7e778609 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-row-actions.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-row-actions.tsx @@ -1,7 +1,7 @@ 'use client'; import { CircleStop, Copy, Play, Trash2, Upload } from 'lucide-react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import { ConfirmDialog } from '@/app/components/ui/dialog/confirm-dialog'; import { @@ -33,102 +33,106 @@ export function CustomAgentRowActions({ agent }: CustomAgentRowActionsProps) { const { t: tCommon } = useT('common'); const { t } = useT('settings'); const dialogs = useEntityRowDialogs(['delete', 'deactivate']); - const [isDuplicating, setIsDuplicating] = useState(false); - const [isPublishing, setIsPublishing] = useState(false); - const [isDeactivating, setIsDeactivating] = useState(false); - const [isActivating, setIsActivating] = useState(false); - const duplicateAgent = useDuplicateCustomAgent(); - const publishAgent = usePublishCustomAgent(); - const unpublishAgent = useUnpublishCustomAgent(); - const activateVersion = useActivateCustomAgentVersion(); + const { mutate: duplicateAgent, isPending: isDuplicating } = + useDuplicateCustomAgent(); + const { mutate: publishAgent, isPending: isPublishing } = + usePublishCustomAgent(); + const { mutate: unpublishAgent, isPending: isDeactivating } = + useUnpublishCustomAgent(); + const { mutate: activateVersion, isPending: isActivating } = + useActivateCustomAgentVersion(); const rootId = agent.rootVersionId ?? agent._id; - const handleDuplicate = useCallback(async () => { + const handleDuplicate = useCallback(() => { if (isDuplicating) return; - setIsDuplicating(true); - try { - await duplicateAgent({ customAgentId: toId<'customAgents'>(agent._id) }); - toast({ - title: t('customAgents.agentDuplicated'), - variant: 'success', - }); - } catch (error) { - console.error(error); - toast({ - title: t('customAgents.agentDuplicateFailed'), - variant: 'destructive', - }); - } finally { - setIsDuplicating(false); - } + duplicateAgent( + { customAgentId: toId<'customAgents'>(agent._id) }, + { + onSuccess: () => { + toast({ + title: t('customAgents.agentDuplicated'), + variant: 'success', + }); + }, + onError: (error) => { + console.error(error); + toast({ + title: t('customAgents.agentDuplicateFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [isDuplicating, duplicateAgent, agent._id, t]); - const handlePublish = useCallback(async () => { + const handlePublish = useCallback(() => { if (isPublishing) return; - setIsPublishing(true); - try { - await publishAgent({ - customAgentId: toId<'customAgents'>(rootId), - }); - toast({ - title: t('customAgents.agentPublished'), - variant: 'success', - }); - } catch (error) { - console.error(error); - toast({ - title: t('customAgents.agentPublishFailed'), - variant: 'destructive', - }); - } finally { - setIsPublishing(false); - } + publishAgent( + { customAgentId: toId<'customAgents'>(rootId) }, + { + onSuccess: () => { + toast({ + title: t('customAgents.agentPublished'), + variant: 'success', + }); + }, + onError: (error) => { + console.error(error); + toast({ + title: t('customAgents.agentPublishFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [isPublishing, publishAgent, rootId, t]); - const handleDeactivateConfirm = useCallback(async () => { - setIsDeactivating(true); - try { - await unpublishAgent({ - customAgentId: toId<'customAgents'>(rootId), - }); - dialogs.setOpen.deactivate(false); - toast({ - title: t('customAgents.agentDeactivated'), - variant: 'success', - }); - } catch (error) { - console.error(error); - toast({ - title: t('customAgents.agentDeactivateFailed'), - variant: 'destructive', - }); - } finally { - setIsDeactivating(false); - } + const handleDeactivateConfirm = useCallback(() => { + unpublishAgent( + { customAgentId: toId<'customAgents'>(rootId) }, + { + onSuccess: () => { + dialogs.setOpen.deactivate(false); + toast({ + title: t('customAgents.agentDeactivated'), + variant: 'success', + }); + }, + onError: (error) => { + console.error(error); + toast({ + title: t('customAgents.agentDeactivateFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [unpublishAgent, rootId, dialogs.setOpen, t]); - const handleActivate = useCallback(async () => { + const handleActivate = useCallback(() => { if (isActivating) return; - setIsActivating(true); - try { - await activateVersion({ + activateVersion( + { customAgentId: toId<'customAgents'>(rootId), targetVersion: agent.versionNumber, - }); - toast({ - title: t('customAgents.agentPublished'), - variant: 'success', - }); - } catch (error) { - console.error(error); - toast({ - title: t('customAgents.agentPublishFailed'), - variant: 'destructive', - }); - } finally { - setIsActivating(false); - } + }, + { + onSuccess: () => { + toast({ + title: t('customAgents.agentPublished'), + variant: 'success', + }); + }, + onError: (error) => { + console.error(error); + toast({ + title: t('customAgents.agentPublishFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [isActivating, activateVersion, rootId, agent.versionNumber, t]); const actions = useMemo( diff --git a/services/platform/app/features/custom-agents/components/custom-agent-version-history-dialog.tsx b/services/platform/app/features/custom-agents/components/custom-agent-version-history-dialog.tsx index 19f459692..2cacd5e2b 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-version-history-dialog.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-version-history-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useRef } from 'react'; import type { VersionStatus } from '@/lib/shared/schemas/custom_agents'; @@ -14,7 +14,6 @@ import { toast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; import { toId } from '@/lib/utils/type-guards'; -import { useCustomAgentVersionCollection } from '../hooks/collections'; import { useActivateCustomAgentVersion } from '../hooks/mutations'; import { useCustomAgentVersions } from '../hooks/queries'; @@ -40,41 +39,42 @@ export function CustomAgentVersionHistoryDialog({ }: CustomAgentVersionHistoryDialogProps) { const { t } = useT('settings'); const { formatDate } = useFormatDate(); - const activateVersion = useActivateCustomAgentVersion(); - const [activatingVersion, setActivatingVersion] = useState( - null, - ); + const { mutate: activateVersion, isPending: isActivating } = + useActivateCustomAgentVersion(); + const activatingVersionRef = useRef(null); - const customAgentVersionCollection = useCustomAgentVersionCollection( - open ? customAgentId : undefined, - ); - const { versions, isLoading: isLoadingVersions } = useCustomAgentVersions( - customAgentVersionCollection, - ); + const { versions, isLoading: isLoadingVersions } = + useCustomAgentVersions(customAgentId); const hasActiveVersion = versions?.some((v) => v.status === 'active') ?? false; - const handleActivate = async (targetVersion: number) => { - setActivatingVersion(targetVersion); - try { - await activateVersion({ + const handleActivate = (targetVersion: number) => { + activatingVersionRef.current = targetVersion; + activateVersion( + { customAgentId: toId<'customAgents'>(customAgentId), targetVersion, - }); - toast({ - title: t('customAgents.agentPublished'), - variant: 'success', - }); - } catch (error) { - console.error(error); - toast({ - title: t('customAgents.agentPublishFailed'), - variant: 'destructive', - }); - } finally { - setActivatingVersion(null); - } + }, + { + onSuccess: () => { + toast({ + title: t('customAgents.agentPublished'), + variant: 'success', + }); + }, + onError: (error) => { + console.error(error); + toast({ + title: t('customAgents.agentPublishFailed'), + variant: 'destructive', + }); + }, + onSettled: () => { + activatingVersionRef.current = null; + }, + }, + ); }; return ( @@ -132,9 +132,10 @@ export function CustomAgentVersionHistoryDialog({ size="sm" variant="outline" onClick={() => handleActivate(version.versionNumber)} - disabled={activatingVersion !== null || hasActiveVersion} + disabled={isActivating || hasActiveVersion} > - {activatingVersion === version.versionNumber + {isActivating && + activatingVersionRef.current === version.versionNumber ? t('customAgents.versions.activating') : t('customAgents.versions.activate')} diff --git a/services/platform/app/features/custom-agents/components/custom-agent-webhook-section.tsx b/services/platform/app/features/custom-agents/components/custom-agent-webhook-section.tsx index ff4812b78..d0f5a51c9 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-webhook-section.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-webhook-section.tsx @@ -19,10 +19,6 @@ import { useSiteUrl } from '@/lib/site-url-context'; import { toId } from '@/lib/utils/type-guards'; import { SecretRevealDialog } from '../../automations/triggers/components/secret-reveal-dialog'; -import { - useCustomAgentVersionCollection, - useCustomAgentWebhookCollection, -} from '../hooks/collections'; import { useCreateCustomAgentWebhook, useDeleteCustomAgentWebhook, @@ -48,19 +44,17 @@ export function CustomAgentWebhookSection({ const { t } = useT('settings'); const { toast } = useToast(); - const customAgentVersionCollection = useCustomAgentVersionCollection(agentId); - const { versions } = useCustomAgentVersions(customAgentVersionCollection); - const customAgentWebhookCollection = useCustomAgentWebhookCollection(agentId); - const { webhooks } = useCustomAgentWebhooks(customAgentWebhookCollection); + const { versions } = useCustomAgentVersions(agentId); + const { webhooks } = useCustomAgentWebhooks(agentId); - const [isCreating, setIsCreating] = useState(false); - const createWebhook = useCreateCustomAgentWebhook(); - const toggleWebhook = useToggleCustomAgentWebhook(); - const deleteWebhookMutation = useDeleteCustomAgentWebhook(); + const { mutateAsync: createWebhook, isPending: isCreating } = + useCreateCustomAgentWebhook(); + const { mutateAsync: toggleWebhook } = useToggleCustomAgentWebhook(); + const { mutate: deleteWebhookMutation, isPending: isDeleting } = + useDeleteCustomAgentWebhook(); const [createdUrl, setCreatedUrl] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); - const [isDeleting, setIsDeleting] = useState(false); const [copiedToken, setCopiedToken] = useState(null); const [usageTarget, setUsageTarget] = useState(null); const [copiedExample, setCopiedExample] = useState(null); @@ -74,7 +68,6 @@ export function CustomAgentWebhookSection({ ); const handleCreate = useCallback(async () => { - setIsCreating(true); try { const result = await createWebhook({ organizationId, @@ -90,8 +83,6 @@ export function CustomAgentWebhookSection({ title: t('customAgents.webhook.toast.createFailed'), variant: 'destructive', }); - } finally { - setIsCreating(false); } }, [createWebhook, organizationId, agentId, toast, t, getWebhookUrl]); @@ -112,29 +103,29 @@ export function CustomAgentWebhookSection({ }); } }, - [toggleWebhook, toast, t], + [toggleWebhook, t, toast], ); - const handleDelete = useCallback(async () => { + const handleDelete = useCallback(() => { if (!deleteTarget) return; - setIsDeleting(true); - try { - await deleteWebhookMutation({ - webhookId: toId<'customAgentWebhooks'>(deleteTarget._id), - }); - toast({ - title: t('customAgents.webhook.toast.deleted'), - variant: 'success', - }); - setDeleteTarget(null); - } catch { - toast({ - title: t('customAgents.webhook.toast.deleteFailed'), - variant: 'destructive', - }); - } finally { - setIsDeleting(false); - } + deleteWebhookMutation( + { webhookId: toId<'customAgentWebhooks'>(deleteTarget._id) }, + { + onSuccess: () => { + toast({ + title: t('customAgents.webhook.toast.deleted'), + variant: 'success', + }); + setDeleteTarget(null); + }, + onError: () => { + toast({ + title: t('customAgents.webhook.toast.deleteFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [deleteTarget, deleteWebhookMutation, toast, t]); const handleCopyUrl = useCallback( diff --git a/services/platform/app/features/custom-agents/components/test-chat-panel.tsx b/services/platform/app/features/custom-agents/components/test-chat-panel.tsx index 0ea452086..41483f23c 100644 --- a/services/platform/app/features/custom-agents/components/test-chat-panel.tsx +++ b/services/platform/app/features/custom-agents/components/test-chat-panel.tsx @@ -181,9 +181,9 @@ function TestChatPanelContent({ }); const { agent: currentAgent } = useCustomAgentVersion(); - const testAgent = useTestAgent(); - const createChatThread = useCreateThread(); - const deleteChatThread = useDeleteThread(); + const { mutateAsync: testAgent } = useTestAgent(); + const { mutateAsync: createChatThread } = useCreateThread(); + const { mutateAsync: deleteChatThread } = useDeleteThread(); const { approvals: integrationApprovals } = useIntegrationApprovals( organizationId, diff --git a/services/platform/app/features/custom-agents/components/tool-selector.tsx b/services/platform/app/features/custom-agents/components/tool-selector.tsx index ff0614764..b0283b68c 100644 --- a/services/platform/app/features/custom-agents/components/tool-selector.tsx +++ b/services/platform/app/features/custom-agents/components/tool-selector.tsx @@ -9,10 +9,6 @@ import { Skeleton } from '@/app/components/ui/feedback/skeleton'; import { Checkbox } from '@/app/components/ui/forms/checkbox'; import { useT } from '@/lib/i18n/client'; -import { - useAvailableIntegrationCollection, - useAvailableToolCollection, -} from '../hooks/collections'; import { useAvailableIntegrations, useAvailableTools } from '../hooks/queries'; interface ToolSelectorProps { @@ -78,12 +74,9 @@ export function ToolSelector({ lockedTools, }: ToolSelectorProps) { const { t } = useT('settings'); - const availableToolCollection = useAvailableToolCollection(); - const { tools, isLoading } = useAvailableTools(availableToolCollection); - const availableIntegrationCollection = - useAvailableIntegrationCollection(organizationId); + const { tools, isLoading } = useAvailableTools(); const { integrations, isLoading: integrationsLoading } = - useAvailableIntegrations(availableIntegrationCollection); + useAvailableIntegrations(organizationId); const selectedSet = useMemo(() => new Set(value), [value]); const selectedBindingsSet = useMemo( diff --git a/services/platform/app/features/custom-agents/hooks/__tests__/collection-hooks.test.ts b/services/platform/app/features/custom-agents/hooks/__tests__/collection-hooks.test.ts deleted file mode 100644 index 4a4bdd3f9..000000000 --- a/services/platform/app/features/custom-agents/hooks/__tests__/collection-hooks.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); -let capturedQueryBuilder: ((q: unknown) => unknown) | null = null; - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((builder: (q: unknown) => unknown, _deps: unknown[]) => { - capturedQueryBuilder = builder; - return { data: [], isLoading: false }; - }), -})); - -vi.mock('@/app/hooks/use-team-filter', () => ({ - useTeamFilter: vi.fn(() => ({ selectedTeamId: null })), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useTeamFilter } from '@/app/hooks/use-team-filter'; - -import { - useCustomAgents, - useCustomAgentVersions, - useCustomAgentWebhooks, -} from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); -const mockUseTeamFilter = vi.mocked(useTeamFilter); - -type Agent = { - _id: string; - status: string; - teamId?: string; - sharedWithTeamIds?: string[]; -}; - -function extractWhereFilter(): (row: { agent: Agent }) => boolean { - if (!capturedQueryBuilder) throw new Error('No query builder captured'); - - let whereFilter: ((row: { agent: Agent }) => boolean) | null = null; - - const mockQ = { - from: () => ({ - fn: { - where: (fn: (row: { agent: Agent }) => boolean) => { - whereFilter = fn; - return { - select: (fn: (row: { agent: Agent }) => unknown) => { - const agent = { _id: 'test', status: 'active' } as Agent; - const result = fn({ agent }); - expect(result).toHaveProperty('_id', 'test'); - }, - }; - }, - }, - }), - }; - - capturedQueryBuilder(mockQ); - if (!whereFilter) throw new Error('No where filter captured'); - return whereFilter; -} - -describe('useCustomAgents', () => { - beforeEach(() => { - vi.clearAllMocks(); - capturedQueryBuilder = null; - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useCustomAgents(mockCollection as never); - expect(result.agents).toBe(mockData); - expect(result.isLoading).toBe(true); - }); - - it('returns data when loaded', () => { - const agents = [{ _id: 'a1', name: 'Agent 1' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: agents, - isLoading: false, - } as ReturnType); - - const result = useCustomAgents(mockCollection as never); - expect(result.agents).toBe(agents); - expect(result.isLoading).toBe(false); - }); - - it('passes all agents when no team filter', () => { - mockUseTeamFilter.mockReturnValue({ selectedTeamId: null } as ReturnType< - typeof useTeamFilter - >); - useCustomAgents(mockCollection as never); - - const filter = extractWhereFilter(); - expect( - filter({ agent: { _id: 'a1', status: 'active', teamId: 'team-x' } }), - ).toBe(true); - }); - - it('filters by teamId when team filter is active', () => { - mockUseTeamFilter.mockReturnValue({ - selectedTeamId: 'team-1', - } as ReturnType); - useCustomAgents(mockCollection as never); - - const filter = extractWhereFilter(); - - expect( - filter({ agent: { _id: 'a1', status: 'active', teamId: 'team-1' } }), - ).toBe(true); - - expect( - filter({ - agent: { - _id: 'a2', - status: 'active', - teamId: 'team-2', - sharedWithTeamIds: ['team-1'], - }, - }), - ).toBe(true); - - expect( - filter({ agent: { _id: 'a3', status: 'active', teamId: 'team-2' } }), - ).toBe(false); - }); - - it('excludes agents with no teamId when team filter is active', () => { - mockUseTeamFilter.mockReturnValue({ - selectedTeamId: 'team-1', - } as ReturnType); - useCustomAgents(mockCollection as never); - - const filter = extractWhereFilter(); - expect( - filter({ agent: { _id: 'a1', status: 'active', teamId: undefined } }), - ).toBe(false); - }); -}); - -describe('useCustomAgentVersions', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data when loaded', () => { - const versions = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: versions, - isLoading: false, - } as ReturnType); - - const result = useCustomAgentVersions(mockCollection as never); - expect(result.versions).toBe(versions); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useCustomAgentVersions(mockCollection as never); - expect(result.versions).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); - -describe('useCustomAgentWebhooks', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data when loaded', () => { - const webhooks = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: webhooks, - isLoading: false, - } as ReturnType); - - const result = useCustomAgentWebhooks(mockCollection as never); - expect(result.webhooks).toBe(webhooks); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useCustomAgentWebhooks(mockCollection as never); - expect(result.webhooks).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/custom-agents/hooks/__tests__/query-hooks.test.ts b/services/platform/app/features/custom-agents/hooks/__tests__/query-hooks.test.ts deleted file mode 100644 index 56c2d1b1e..000000000 --- a/services/platform/app/features/custom-agents/hooks/__tests__/query-hooks.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { Collection } from '@tanstack/db'; - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import type { AvailableIntegration } from '@/lib/collections/entities/available-integrations'; - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((_builder: (q: unknown) => unknown, _deps: unknown[]) => { - return { data: [], isLoading: false }; - }), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useAvailableIntegrations } from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); -const mockCollection = Symbol('collection') as unknown as Collection< - AvailableIntegration, - string ->; - -describe('useAvailableIntegrations', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('calls useLiveQuery with the provided collection', () => { - useAvailableIntegrations(mockCollection); - - expect(mockUseLiveQuery).toHaveBeenCalledWith(expect.any(Function)); - }); - - it('returns data when loaded', () => { - const integrations = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: integrations, - isLoading: false, - } as ReturnType); - - const result = useAvailableIntegrations(mockCollection); - expect(result.integrations).toBe(integrations); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useAvailableIntegrations(mockCollection); - expect(result.integrations).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/custom-agents/hooks/collections.ts b/services/platform/app/features/custom-agents/hooks/collections.ts deleted file mode 100644 index 55fb96588..000000000 --- a/services/platform/app/features/custom-agents/hooks/collections.ts +++ /dev/null @@ -1,52 +0,0 @@ -'use client'; - -import { createAvailableIntegrationsCollection } from '@/lib/collections/entities/available-integrations'; -import { createAvailableToolsCollection } from '@/lib/collections/entities/available-tools'; -import { createCustomAgentVersionsCollection } from '@/lib/collections/entities/custom-agent-versions'; -import { createCustomAgentWebhooksCollection } from '@/lib/collections/entities/custom-agent-webhooks'; -import { createCustomAgentsCollection } from '@/lib/collections/entities/custom-agents'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useCustomAgentCollection(organizationId: string) { - return useCollection( - 'custom-agents', - createCustomAgentsCollection, - organizationId, - ); -} - -export function useCustomAgentVersionCollection( - customAgentId: string | undefined, -) { - return useCollection( - 'custom-agent-versions', - createCustomAgentVersionsCollection, - customAgentId ?? '', - ); -} - -export function useCustomAgentWebhookCollection( - customAgentId: string | undefined, -) { - return useCollection( - 'custom-agent-webhooks', - createCustomAgentWebhooksCollection, - customAgentId ?? '', - ); -} - -export function useAvailableIntegrationCollection(organizationId: string) { - return useCollection( - 'available-integrations', - createAvailableIntegrationsCollection, - organizationId, - ); -} - -export function useAvailableToolCollection() { - return useCollection( - 'available-tools', - createAvailableToolsCollection, - 'global', - ); -} diff --git a/services/platform/app/features/custom-agents/hooks/queries.ts b/services/platform/app/features/custom-agents/hooks/queries.ts index 2c7471dc2..57f9ce211 100644 --- a/services/platform/app/features/custom-agents/hooks/queries.ts +++ b/services/platform/app/features/custom-agents/hooks/queries.ts @@ -1,75 +1,50 @@ -import type { Collection } from '@tanstack/db'; - -import { useLiveQuery } from '@tanstack/react-db'; +import { useMemo } from 'react'; import type { Id } from '@/convex/_generated/dataModel'; -import type { AvailableIntegration } from '@/lib/collections/entities/available-integrations'; -import type { AvailableTool } from '@/lib/collections/entities/available-tools'; -import type { CustomAgentVersion } from '@/lib/collections/entities/custom-agent-versions'; -import type { CustomAgentWebhook } from '@/lib/collections/entities/custom-agent-webhooks'; -import type { CustomAgent } from '@/lib/collections/entities/custom-agents'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { useTeamFilter } from '@/app/hooks/use-team-filter'; import { api } from '@/convex/_generated/api'; -export function useCustomAgents(collection: Collection) { +export type CustomAgent = ConvexItemOf< + typeof api.custom_agents.queries.listCustomAgents +>; + +export function useCustomAgents(organizationId: string) { const { selectedTeamId } = useTeamFilter(); - const { data, isLoading } = useLiveQuery( - (q) => - q - .from({ agent: collection }) - .fn.where((row) => { - if (!selectedTeamId) return true; - const { agent } = row; - return ( - agent.teamId === selectedTeamId || - (agent.sharedWithTeamIds?.includes(selectedTeamId) ?? false) - ); - }) - .select(({ agent }) => ({ - _id: agent._id, - _creationTime: agent._creationTime, - organizationId: agent.organizationId, - name: agent.name, - displayName: agent.displayName, - description: agent.description, - avatarUrl: agent.avatarUrl, - systemInstructions: agent.systemInstructions, - toolNames: agent.toolNames, - integrationBindings: agent.integrationBindings, - modelPreset: agent.modelPreset, - knowledgeEnabled: agent.knowledgeEnabled, - includeOrgKnowledge: agent.includeOrgKnowledge, - knowledgeTopK: agent.knowledgeTopK, - toneOfVoiceId: agent.toneOfVoiceId, - filePreprocessingEnabled: agent.filePreprocessingEnabled, - teamId: agent.teamId, - sharedWithTeamIds: agent.sharedWithTeamIds, - createdBy: agent.createdBy, - isActive: agent.isActive, - versionNumber: agent.versionNumber, - status: agent.status, - rootVersionId: agent.rootVersionId, - parentVersionId: agent.parentVersionId, - publishedAt: agent.publishedAt, - publishedBy: agent.publishedBy, - changeLog: agent.changeLog, - })), - [selectedTeamId], + const { data, isLoading } = useConvexQuery( + api.custom_agents.queries.listCustomAgents, + { organizationId }, ); + const agents = useMemo(() => { + if (!data) return undefined; + return data.filter((agent) => { + if (!selectedTeamId) return true; + return ( + agent.teamId === selectedTeamId || + (agent.sharedWithTeamIds?.includes(selectedTeamId) ?? false) + ); + }); + }, [data, selectedTeamId]); + return { - agents: data, + agents, isLoading, }; } -export function useCustomAgentVersions( - collection: Collection, -) { - const { data, isLoading } = useLiveQuery(() => collection); +export type CustomAgentVersion = ConvexItemOf< + typeof api.custom_agents.queries.getCustomAgentVersions +>; + +export function useCustomAgentVersions(customAgentId: string) { + const { data, isLoading } = useConvexQuery( + api.custom_agents.queries.getCustomAgentVersions, + { customAgentId }, + ); return { versions: data, @@ -77,10 +52,15 @@ export function useCustomAgentVersions( }; } -export function useCustomAgentWebhooks( - collection: Collection, -) { - const { data, isLoading } = useLiveQuery(() => collection); +export type CustomAgentWebhook = ConvexItemOf< + typeof api.custom_agents.webhooks.queries.getWebhooks +>; + +export function useCustomAgentWebhooks(customAgentId: string) { + const { data, isLoading } = useConvexQuery( + api.custom_agents.webhooks.queries.getWebhooks, + { customAgentId }, + ); return { webhooks: data, @@ -88,10 +68,15 @@ export function useCustomAgentWebhooks( }; } -export function useAvailableIntegrations( - collection: Collection, -) { - const { data, isLoading } = useLiveQuery(() => collection); +export type AvailableIntegration = ConvexItemOf< + typeof api.custom_agents.queries.getAvailableIntegrations +>; + +export function useAvailableIntegrations(organizationId: string) { + const { data, isLoading } = useConvexQuery( + api.custom_agents.queries.getAvailableIntegrations, + { organizationId }, + ); return { integrations: data, @@ -99,10 +84,14 @@ export function useAvailableIntegrations( }; } -export function useAvailableTools( - collection: Collection, -) { - const { data, isLoading } = useLiveQuery(() => collection); +export type AvailableTool = ConvexItemOf< + typeof api.custom_agents.queries.getAvailableTools +>; + +export function useAvailableTools() { + const { data, isLoading } = useConvexQuery( + api.custom_agents.queries.getAvailableTools, + ); return { tools: data, @@ -123,5 +112,3 @@ export function useCustomAgentByVersion( export function useModelPresets() { return useConvexQuery(api.custom_agents.queries.getModelPresets); } - -export type { CustomAgentVersion, CustomAgentWebhook }; diff --git a/services/platform/app/features/customers/components/customers-import-dialog.tsx b/services/platform/app/features/customers/components/customers-import-dialog.tsx index 19c7e713b..f5ad50772 100644 --- a/services/platform/app/features/customers/components/customers-import-dialog.tsx +++ b/services/platform/app/features/customers/components/customers-import-dialog.tsx @@ -104,7 +104,7 @@ export function ImportCustomersDialog({ formState: { isSubmitting }, } = formMethods; - const bulkCreateCustomers = useBulkCreateCustomers(); + const { mutateAsync: bulkCreateCustomers } = useBulkCreateCustomers(); const handleClose = useCallback(() => { formMethods.reset(); diff --git a/services/platform/app/features/customers/hooks/__tests__/collection-hooks.test.ts b/services/platform/app/features/customers/hooks/__tests__/collection-hooks.test.ts deleted file mode 100644 index 6dbfbf11e..000000000 --- a/services/platform/app/features/customers/hooks/__tests__/collection-hooks.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((_builder: (q: unknown) => unknown) => { - return { data: [], isLoading: false }; - }), -})); - -vi.mock('@/lib/collections/entities/customers', () => ({ - createCustomersCollection: vi.fn(), -})); - -vi.mock('@/lib/collections/use-collection', () => ({ - useCollection: vi.fn(() => mockCollection), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useCollection } from '@/lib/collections/use-collection'; - -import { useCustomerCollection } from '../collections'; -import { useCustomers } from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); - -describe('useCustomerCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection with correct params', () => { - useCustomerCollection('org-123'); - expect(useCollection).toHaveBeenCalledWith( - 'customers', - expect.any(Function), - 'org-123', - ); - }); -}); - -describe('useCustomers', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data from live query', () => { - const items = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: items, - isLoading: false, - } as ReturnType); - - const result = useCustomers(mockCollection as never); - expect(result.customers).toBe(items); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useCustomers(mockCollection as never); - expect(result.customers).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/customers/hooks/collections.ts b/services/platform/app/features/customers/hooks/collections.ts deleted file mode 100644 index 959fb6414..000000000 --- a/services/platform/app/features/customers/hooks/collections.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createCustomersCollection } from '@/lib/collections/entities/customers'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useCustomerCollection(organizationId: string) { - return useCollection('customers', createCustomersCollection, organizationId); -} diff --git a/services/platform/app/features/customers/hooks/queries.ts b/services/platform/app/features/customers/hooks/queries.ts index 26059944a..2a7eb2eea 100644 --- a/services/platform/app/features/customers/hooks/queries.ts +++ b/services/platform/app/features/customers/hooks/queries.ts @@ -1,66 +1,43 @@ -import type { Collection, Ref } from '@tanstack/db'; - -import { useLiveQuery } from '@tanstack/react-db'; import { useMemo } from 'react'; -import type { Customer } from '@/lib/collections/entities/customers'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; import { useCachedPaginatedQuery } from '@/app/hooks/use-cached-paginated-query'; +import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { api } from '@/convex/_generated/api'; -const selectCustomerFields = ({ c }: { c: Ref }) => ({ - _id: c._id, - _creationTime: c._creationTime, - organizationId: c.organizationId, - name: c.name, - email: c.email, - externalId: c.externalId, - status: c.status, - source: c.source, - locale: c.locale, - address: c.address, - metadata: c.metadata, -}); +export type Customer = ConvexItemOf; -export function useCustomers(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export function useCustomers(organizationId: string) { + const { data, isLoading } = useConvexQuery( + api.customers.queries.listCustomers, + { organizationId }, + ); return { - customers: data, + customers: data ?? [], isLoading, }; } export function useCustomerByEmail( - collection: Collection, + customers: Customer[], email: string | undefined, ) { - const { data } = useLiveQuery( - (q) => - q - .from({ c: collection }) - .fn.where((row) => row.c.email === email) - .select(selectCustomerFields), - [email], + return useMemo( + () => customers.find((c) => c.email === email) ?? null, + [customers, email], ); - - return useMemo(() => data?.[0] ?? null, [data]); } export function useCustomerById( - collection: Collection, + customers: Customer[], customerId: string | undefined, ) { - const { data } = useLiveQuery( - (q) => - q - .from({ c: collection }) - .fn.where((row) => row.c._id === customerId) - .select(selectCustomerFields), - [customerId], + return useMemo( + () => customers.find((c) => c._id === customerId) ?? null, + [customers, customerId], ); - - return useMemo(() => data?.[0] ?? null, [data]); } interface ListCustomersPaginatedArgs { diff --git a/services/platform/app/features/documents/components/document-preview-dialog.tsx b/services/platform/app/features/documents/components/document-preview-dialog.tsx index 5aca4c2ac..8f009a95b 100644 --- a/services/platform/app/features/documents/components/document-preview-dialog.tsx +++ b/services/platform/app/features/documents/components/document-preview-dialog.tsx @@ -12,7 +12,6 @@ import { IconButton } from '@/app/components/ui/primitives/icon-button'; import { useToast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; -import { useDocumentCollection } from '../hooks/collections'; import { useDocuments } from '../hooks/queries'; import { DocumentPreview } from './document-preview'; @@ -42,8 +41,7 @@ export function DocumentPreviewDialog({ const [isDownloading, setIsDownloading] = useState(false); const { toast } = useToast(); - const documentCollection = useDocumentCollection(organizationId); - const { documents, isLoading } = useDocuments(documentCollection); + const { documents, isLoading } = useDocuments(organizationId); const doc = useMemo(() => { if (!documents || !open) return undefined; diff --git a/services/platform/app/features/documents/components/document-row-actions.tsx b/services/platform/app/features/documents/components/document-row-actions.tsx index f9bc5dffc..43e9f72d5 100644 --- a/services/platform/app/features/documents/components/document-row-actions.tsx +++ b/services/platform/app/features/documents/components/document-row-actions.tsx @@ -1,7 +1,7 @@ 'use client'; import { RefreshCw, Trash2, Users } from 'lucide-react'; -import { useMemo, useCallback, useState } from 'react'; +import { useMemo, useCallback } from 'react'; import { EntityRowActions, @@ -41,10 +41,9 @@ export function DocumentRowActions({ const { t: tDocuments } = useT('documents'); const { t: tCommon } = useT('common'); const dialogs = useEntityRowDialogs(['delete', 'deleteFolder', 'teamTags']); - const [isDeleting, setIsDeleting] = useState(false); - const deleteDocument = useDeleteDocument(); - const retryRagIndexing = useRetryRagIndexing(); - const [isReindexing, setIsReindexing] = useState(false); + const { mutate: deleteDocument, isPending: isDeleting } = useDeleteDocument(); + const { mutateAsync: retryRagIndexing, isPending: isReindexing } = + useRetryRagIndexing(); // Determine if delete action should be visible const canDelete = @@ -52,40 +51,36 @@ export function DocumentRowActions({ !!isDirectlySelected || (itemType === 'folder' && !!syncConfigId); - const handleDeleteConfirm = useCallback(async () => { - try { - setIsDeleting(true); - await deleteDocument({ - documentId: toId<'documents'>(documentId), - }); - dialogs.setOpen.delete(false); - } catch (error) { - console.error('Delete error:', error); - toast({ - title: tDocuments('actions.deleteFileFailed'), - variant: 'destructive', - }); - } finally { - setIsDeleting(false); - } + const handleDeleteConfirm = useCallback(() => { + deleteDocument( + { documentId: toId<'documents'>(documentId) }, + { + onSuccess: () => dialogs.setOpen.delete(false), + onError: (error) => { + console.error('Delete error:', error); + toast({ + title: tDocuments('actions.deleteFileFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [deleteDocument, documentId, dialogs.setOpen, tDocuments]); - const handleDeleteFolderConfirm = useCallback(async () => { - try { - setIsDeleting(true); - await deleteDocument({ - documentId: toId<'documents'>(documentId), - }); - dialogs.setOpen.deleteFolder(false); - } catch (error) { - console.error('Failed to delete folder:', error); - toast({ - title: tDocuments('actions.deleteFolderFailed'), - variant: 'destructive', - }); - } finally { - setIsDeleting(false); - } + const handleDeleteFolderConfirm = useCallback(() => { + deleteDocument( + { documentId: toId<'documents'>(documentId) }, + { + onSuccess: () => dialogs.setOpen.deleteFolder(false), + onError: (error) => { + console.error('Failed to delete folder:', error); + toast({ + title: tDocuments('actions.deleteFolderFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [deleteDocument, documentId, dialogs.setOpen, tDocuments]); const handleDeleteClick = useCallback(() => { @@ -98,7 +93,6 @@ export function DocumentRowActions({ const handleReindex = useCallback(async () => { if (isReindexing) return; - setIsReindexing(true); try { const result = await retryRagIndexing({ documentId: toId<'documents'>(documentId), @@ -121,8 +115,6 @@ export function DocumentRowActions({ title: tDocuments('rag.toast.unexpectedError'), variant: 'destructive', }); - } finally { - setIsReindexing(false); } }, [documentId, retryRagIndexing, tDocuments, isReindexing]); diff --git a/services/platform/app/features/documents/components/document-team-tags-dialog.tsx b/services/platform/app/features/documents/components/document-team-tags-dialog.tsx index 109ff46de..acbe46447 100644 --- a/services/platform/app/features/documents/components/document-team-tags-dialog.tsx +++ b/services/platform/app/features/documents/components/document-team-tags-dialog.tsx @@ -7,9 +7,7 @@ import { Dialog } from '@/app/components/ui/dialog/dialog'; import { Checkbox } from '@/app/components/ui/forms/checkbox'; import { Stack } from '@/app/components/ui/layout/layout'; import { Button } from '@/app/components/ui/primitives/button'; -import { useTeamCollection } from '@/app/features/settings/teams/hooks/collections'; import { useTeams } from '@/app/features/settings/teams/hooks/queries'; -import { useOrganizationId } from '@/app/hooks/use-organization-id'; import { toast } from '@/app/hooks/use-toast'; import { toId } from '@/convex/lib/type_cast_helpers'; import { useT } from '@/lib/i18n/client'; @@ -39,7 +37,6 @@ function DocumentTeamTagsDialogContent({ }: DocumentTeamTagsDialogProps) { const { t: tDocuments } = useT('documents'); const { t: tCommon } = useT('common'); - const organizationId = useOrganizationId(); const [selectedTeams, setSelectedTeams] = useState>( () => new Set(currentTeamTags), @@ -48,8 +45,7 @@ function DocumentTeamTagsDialogContent({ const updateDocument = useUpdateDocument(); - const teamCollection = useTeamCollection(organizationId ?? undefined); - const { teams, isLoading } = useTeams(teamCollection); + const { teams, isLoading } = useTeams(); const handleToggleTeam = useCallback((teamId: string) => { setSelectedTeams((prev) => { diff --git a/services/platform/app/features/documents/components/document-upload-dialog.tsx b/services/platform/app/features/documents/components/document-upload-dialog.tsx index 6c34b9638..98381fc6b 100644 --- a/services/platform/app/features/documents/components/document-upload-dialog.tsx +++ b/services/platform/app/features/documents/components/document-upload-dialog.tsx @@ -8,7 +8,6 @@ import { Checkbox } from '@/app/components/ui/forms/checkbox'; import { FileUpload } from '@/app/components/ui/forms/file-upload'; import { Stack } from '@/app/components/ui/layout/layout'; import { Button } from '@/app/components/ui/primitives/button'; -import { useTeamCollection } from '@/app/features/settings/teams/hooks/collections'; import { useTeams } from '@/app/features/settings/teams/hooks/queries'; import { useTeamFilter } from '@/app/hooks/use-team-filter'; import { toast } from '@/app/hooks/use-toast'; @@ -44,8 +43,7 @@ export function DocumentUploadDialog({ ); const [selectedFiles, setSelectedFiles] = useState([]); - const teamCollection = useTeamCollection(organizationId); - const { teams, isLoading: isLoadingTeams } = useTeams(teamCollection); + const { teams, isLoading: isLoadingTeams } = useTeams(); const { uploadFiles, isUploading } = useDocumentUpload({ organizationId, diff --git a/services/platform/app/features/documents/components/documents-client.tsx b/services/platform/app/features/documents/components/documents-client.tsx index 3378eec30..6bbcf71f8 100644 --- a/services/platform/app/features/documents/components/documents-client.tsx +++ b/services/platform/app/features/documents/components/documents-client.tsx @@ -16,7 +16,6 @@ import { DataTableSkeleton } from '@/app/components/ui/data-table/data-table-ske import { Badge } from '@/app/components/ui/feedback/badge'; import { Skeleton } from '@/app/components/ui/feedback/skeleton'; import { HStack } from '@/app/components/ui/layout/layout'; -import { useTeamCollection } from '@/app/features/settings/teams/hooks/collections'; import { useTeams } from '@/app/features/settings/teams/hooks/queries'; import { useDebounce } from '@/app/hooks/use-debounce'; import { useListPage } from '@/app/hooks/use-list-page'; @@ -81,8 +80,7 @@ export function DocumentsClient({ const [query, setQuery] = useState(searchQuery ?? ''); const debouncedQuery = useDebounce(query, 300); - const teamCollection = useTeamCollection(organizationId); - const { teams, isLoading: isLoadingTeams } = useTeams(teamCollection); + const { teams, isLoading: isLoadingTeams } = useTeams(); const teamMap = useMemo(() => { if (!teams) return new Map(); diff --git a/services/platform/app/features/documents/components/onedrive-import-dialog.tsx b/services/platform/app/features/documents/components/onedrive-import-dialog.tsx index c655300c7..b6e9121f0 100644 --- a/services/platform/app/features/documents/components/onedrive-import-dialog.tsx +++ b/services/platform/app/features/documents/components/onedrive-import-dialog.tsx @@ -3,7 +3,7 @@ import type { ColumnDef } from '@tanstack/react-table'; import { Home, Loader2, Database, Users } from 'lucide-react'; -import { useState, useMemo, useCallback } from 'react'; +import { useMemo, useCallback, useState } from 'react'; import { OneDriveIcon } from '@/app/components/icons/onedrive-icon'; import { SharePointIcon } from '@/app/components/icons/sharepoint-icon'; @@ -19,7 +19,6 @@ import { import { SearchInput } from '@/app/components/ui/forms/search-input'; import { Stack, HStack } from '@/app/components/ui/layout/layout'; import { Button } from '@/app/components/ui/primitives/button'; -import { useTeamCollection } from '@/app/features/settings/teams/hooks/collections'; import { useTeams } from '@/app/features/settings/teams/hooks/queries'; import { useConvexAction } from '@/app/hooks/use-convex-action'; import { useFormatDate } from '@/app/hooks/use-format-date'; @@ -460,10 +459,12 @@ export function OneDriveImportDialog({ const { t: tCommon } = useT('common'); const { selectedTeamId } = useTeamFilter(); - const importFilesAction = useImportOneDriveFiles(); - const [isImporting, setIsImporting] = useState(false); - const listOneDriveFiles = useConvexAction(api.onedrive.actions.listFiles); - const listSharePointFiles = useConvexAction( + const { mutateAsync: importFilesAction, isPending: isImporting } = + useImportOneDriveFiles(); + const { mutateAsync: listOneDriveFiles } = useConvexAction( + api.onedrive.actions.listFiles, + ); + const { mutateAsync: listSharePointFiles } = useConvexAction( api.onedrive.actions.listSharePointFiles, ); @@ -498,8 +499,7 @@ export function OneDriveImportDialog({ Array<{ id: string | undefined; name: string }> >([{ id: undefined, name: t('breadcrumb.oneDrive') }]); - const teamCollection = useTeamCollection(organizationId); - const { teams, isLoading: isLoadingTeams } = useTeams(teamCollection); + const { teams, isLoading: isLoadingTeams } = useTeams(); const handleToggleTeam = useCallback((teamId: string) => { setSelectedTeams((prev) => { @@ -756,7 +756,6 @@ export function OneDriveImportDialog({ }; const handleImport = async () => { - setIsImporting(true); try { const selectedItemsArray = Array.from(selectedItems.values()); @@ -875,8 +874,6 @@ export function OneDriveImportDialog({ error instanceof Error ? error.message : tCommon('errors.generic'), variant: 'destructive', }); - } finally { - setIsImporting(false); } }; diff --git a/services/platform/app/features/documents/components/rag-status-badge.tsx b/services/platform/app/features/documents/components/rag-status-badge.tsx index 5f55e98e2..5ec624b7f 100644 --- a/services/platform/app/features/documents/components/rag-status-badge.tsx +++ b/services/platform/app/features/documents/components/rag-status-badge.tsx @@ -45,8 +45,8 @@ export function RagStatusBadge({ }: RagStatusBadgeProps) { const { t } = useT('documents'); const { formatDate } = useFormatDate(); - const retryRagIndexing = useRetryRagIndexing(); - const [isRetrying, setIsRetrying] = useState(false); + const { mutateAsync: retryRagIndexing, isPending: isRetrying } = + useRetryRagIndexing(); const [isCompletedDialogOpen, setIsCompletedDialogOpen] = useState(false); const [isFailedDialogOpen, setIsFailedDialogOpen] = useState(false); @@ -77,7 +77,6 @@ export function RagStatusBadge({ return; } - setIsRetrying(true); try { const result = await retryRagIndexing({ documentId: toId<'documents'>(documentId), @@ -99,8 +98,6 @@ export function RagStatusBadge({ title: t('rag.toast.unexpectedError'), variant: 'destructive', }); - } finally { - setIsRetrying(false); } }; diff --git a/services/platform/app/features/documents/hooks/__tests__/collection-hooks.test.ts b/services/platform/app/features/documents/hooks/__tests__/collection-hooks.test.ts deleted file mode 100644 index c5800a92e..000000000 --- a/services/platform/app/features/documents/hooks/__tests__/collection-hooks.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((_builder: (q: unknown) => unknown) => { - return { data: [], isLoading: false }; - }), -})); - -vi.mock('@/lib/collections/entities/documents', () => ({ - createDocumentsCollection: vi.fn(), -})); - -vi.mock('@/lib/collections/use-collection', () => ({ - useCollection: vi.fn(() => mockCollection), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useCollection } from '@/lib/collections/use-collection'; - -import { useDocumentCollection } from '../collections'; -import { useDocuments } from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); - -describe('useDocumentCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection with correct params', () => { - useDocumentCollection('org-123'); - expect(useCollection).toHaveBeenCalledWith( - 'documents', - expect.any(Function), - 'org-123', - ); - }); -}); - -describe('useDocuments', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data from live query', () => { - const items = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: items, - isLoading: false, - } as ReturnType); - - const result = useDocuments(mockCollection as never); - expect(result.documents).toBe(items); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useDocuments(mockCollection as never); - expect(result.documents).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/documents/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/documents/hooks/__tests__/mutation-hooks.test.ts index d85c7c8a5..e133cf412 100644 --- a/services/platform/app/features/documents/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/documents/hooks/__tests__/mutation-hooks.test.ts @@ -2,10 +2,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { toId } from '@/convex/lib/type_cast_helpers'; -const mockMutationFn = vi.fn(); +const mockMutateAsync = vi.fn(); vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => mockMutationFn, + useConvexMutation: () => ({ + mutate: mockMutateAsync, + mutateAsync: mockMutateAsync, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), + }), })); vi.mock('@/convex/_generated/api', () => ({ @@ -26,25 +35,26 @@ describe('useDeleteDocument', () => { vi.clearAllMocks(); }); - it('returns the mutation function from useConvexMutation', () => { - const deleteDocument = useDeleteDocument(); - expect(deleteDocument).toBe(mockMutationFn); + it('returns a mutation result object from useConvexMutation', () => { + const result = useDeleteDocument(); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); it('calls mutation with the correct args', async () => { - mockMutationFn.mockResolvedValueOnce(null); - const deleteDocument = useDeleteDocument(); + mockMutateAsync.mockResolvedValueOnce(null); + const { mutateAsync: deleteDocument } = useDeleteDocument(); await deleteDocument({ documentId: toId<'documents'>('doc-123') }); - expect(mockMutationFn).toHaveBeenCalledWith({ + expect(mockMutateAsync).toHaveBeenCalledWith({ documentId: toId<'documents'>('doc-123'), }); }); it('propagates errors from mutation', async () => { - mockMutationFn.mockRejectedValueOnce(new Error('Delete failed')); - const deleteDocument = useDeleteDocument(); + mockMutateAsync.mockRejectedValueOnce(new Error('Delete failed')); + const { mutateAsync: deleteDocument } = useDeleteDocument(); await expect( deleteDocument({ documentId: toId<'documents'>('doc-789') }), @@ -57,40 +67,41 @@ describe('useUpdateDocument', () => { vi.clearAllMocks(); }); - it('returns the mutation function from useConvexMutation', () => { - const updateDocument = useUpdateDocument(); - expect(updateDocument).toBe(mockMutationFn); + it('returns a mutation result object from useConvexMutation', () => { + const result = useUpdateDocument(); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); it('calls mutation with documentId and teamTags', async () => { - mockMutationFn.mockResolvedValueOnce(undefined); - const updateDocument = useUpdateDocument(); + mockMutateAsync.mockResolvedValueOnce(undefined); + const { mutateAsync: updateDocument } = useUpdateDocument(); await updateDocument({ documentId: toId<'documents'>('doc-123'), teamTags: ['team-1', 'team-2'], }); - expect(mockMutationFn).toHaveBeenCalledWith({ + expect(mockMutateAsync).toHaveBeenCalledWith({ documentId: toId<'documents'>('doc-123'), teamTags: ['team-1', 'team-2'], }); }); it('calls mutation with documentId only', async () => { - mockMutationFn.mockResolvedValueOnce(undefined); - const updateDocument = useUpdateDocument(); + mockMutateAsync.mockResolvedValueOnce(undefined); + const { mutateAsync: updateDocument } = useUpdateDocument(); await updateDocument({ documentId: toId<'documents'>('doc-123') }); - expect(mockMutationFn).toHaveBeenCalledWith({ + expect(mockMutateAsync).toHaveBeenCalledWith({ documentId: toId<'documents'>('doc-123'), }); }); it('propagates errors from mutation', async () => { - mockMutationFn.mockRejectedValueOnce(new Error('Update failed')); - const updateDocument = useUpdateDocument(); + mockMutateAsync.mockRejectedValueOnce(new Error('Update failed')); + const { mutateAsync: updateDocument } = useUpdateDocument(); await expect( updateDocument({ diff --git a/services/platform/app/features/documents/hooks/collections.ts b/services/platform/app/features/documents/hooks/collections.ts deleted file mode 100644 index 3c7952d82..000000000 --- a/services/platform/app/features/documents/hooks/collections.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createDocumentsCollection } from '@/lib/collections/entities/documents'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useDocumentCollection(organizationId: string) { - return useCollection('documents', createDocumentsCollection, organizationId); -} diff --git a/services/platform/app/features/documents/hooks/mutations.ts b/services/platform/app/features/documents/hooks/mutations.ts index f348ed905..027cd3ea9 100644 --- a/services/platform/app/features/documents/hooks/mutations.ts +++ b/services/platform/app/features/documents/hooks/mutations.ts @@ -53,10 +53,10 @@ export function useDocumentUpload(options: UploadOptions) { const { t } = useT('documents'); const [isUploading, setIsUploading] = useState(false); const abortControllerRef = useRef(null); - const generateUploadUrl = useConvexMutation( + const { mutateAsync: generateUploadUrl } = useConvexMutation( api.files.mutations.generateUploadUrl, ); - const createDocumentFromUpload = useConvexMutation( + const { mutateAsync: createDocumentFromUpload } = useConvexMutation( api.documents.mutations.createDocumentFromUpload, ); diff --git a/services/platform/app/features/documents/hooks/queries.ts b/services/platform/app/features/documents/hooks/queries.ts index 3dac81b7a..c31289e41 100644 --- a/services/platform/app/features/documents/hooks/queries.ts +++ b/services/platform/app/features/documents/hooks/queries.ts @@ -1,19 +1,21 @@ -import type { Collection } from '@tanstack/db'; - -import { useLiveQuery } from '@tanstack/react-db'; - -import type { Document } from '@/lib/collections/entities/documents'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; import { useCachedPaginatedQuery } from '@/app/hooks/use-cached-paginated-query'; import { useConvexAction } from '@/app/hooks/use-convex-action'; +import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { useReactQuery } from '@/app/hooks/use-react-query'; import { api } from '@/convex/_generated/api'; -export function useDocuments(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export type Document = ConvexItemOf; + +export function useDocuments(organizationId: string) { + const { data, isLoading } = useConvexQuery( + api.documents.queries.listDocuments, + { organizationId }, + ); return { - documents: data, + documents: data ?? [], isLoading, }; } diff --git a/services/platform/app/features/organization/components/organization-form-client.tsx b/services/platform/app/features/organization/components/organization-form-client.tsx index e43862898..f2d998d9c 100644 --- a/services/platform/app/features/organization/components/organization-form-client.tsx +++ b/services/platform/app/features/organization/components/organization-form-client.tsx @@ -42,7 +42,8 @@ export function OrganizationFormClient() { }, }); - const initializeDefaultWorkflows = useInitializeDefaultWorkflows(); + const { mutateAsync: initializeDefaultWorkflows } = + useInitializeDefaultWorkflows(); const handleSubmit = form.handleSubmit(async (data) => { if (!user) { diff --git a/services/platform/app/features/organization/hooks/collections.ts b/services/platform/app/features/organization/hooks/collections.ts deleted file mode 100644 index 9db888d27..000000000 --- a/services/platform/app/features/organization/hooks/collections.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createUserOrganizationsCollection } from '@/lib/collections/entities/user-organizations'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useUserOrganizationCollection() { - return useCollection( - 'user-organizations', - createUserOrganizationsCollection, - 'current-user', - ); -} diff --git a/services/platform/app/features/organization/hooks/queries.ts b/services/platform/app/features/organization/hooks/queries.ts index 606ac2ea2..498e6439b 100644 --- a/services/platform/app/features/organization/hooks/queries.ts +++ b/services/platform/app/features/organization/hooks/queries.ts @@ -1,19 +1,19 @@ -import type { Collection } from '@tanstack/db'; - -import { useLiveQuery } from '@tanstack/react-db'; - -import type { UserOrganization } from '@/lib/collections/entities/user-organizations'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; import { useConvexAuth } from '@/app/hooks/use-convex-auth'; import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { api } from '@/convex/_generated/api'; -export function useUserOrganizations( - collection: Collection, -) { +export type UserOrganization = ConvexItemOf< + typeof api.members.queries.getUserOrganizationsList +>; + +export function useUserOrganizations() { const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth(); - const { data, isLoading } = useLiveQuery(() => collection); + const { data, isLoading } = useConvexQuery( + api.members.queries.getUserOrganizationsList, + ); return { organizations: data, diff --git a/services/platform/app/features/products/components/product-edit-dialog.tsx b/services/platform/app/features/products/components/product-edit-dialog.tsx index a5c6ce442..8c3259bae 100644 --- a/services/platform/app/features/products/components/product-edit-dialog.tsx +++ b/services/platform/app/features/products/components/product-edit-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Input } from '@/app/components/ui/forms/input'; @@ -35,8 +35,7 @@ export function ProductEditDialog({ product, }: EditProductDialogProps) { const { t: tProducts } = useT('products'); - const [isSubmitting, setIsSubmitting] = useState(false); - const updateProduct = useUpdateProduct(); + const { mutate: updateProduct, isPending: isSubmitting } = useUpdateProduct(); // Form state const [formData, setFormData] = useState({ @@ -62,7 +61,7 @@ export function ProductEditDialog({ }); }, [product]); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!formData.name.trim()) { @@ -73,10 +72,8 @@ export function ProductEditDialog({ return; } - try { - setIsSubmitting(true); - - await updateProduct({ + updateProduct( + { productId: product._id, name: formData.name.trim(), description: formData.description.trim() || undefined, @@ -85,23 +82,24 @@ export function ProductEditDialog({ price: formData.price ? parseFloat(formData.price) : undefined, currency: formData.currency || undefined, category: formData.category.trim() || undefined, - }); - - toast({ - title: tProducts('edit.toast.success'), - variant: 'success', - }); - - onClose(); - } catch (err) { - console.error('Update error:', err); - toast({ - title: tProducts('edit.toast.error'), - variant: 'destructive', - }); - } finally { - setIsSubmitting(false); - } + }, + { + onSuccess: () => { + toast({ + title: tProducts('edit.toast.success'), + variant: 'success', + }); + onClose(); + }, + onError: (err) => { + console.error('Update error:', err); + toast({ + title: tProducts('edit.toast.error'), + variant: 'destructive', + }); + }, + }, + ); }; return ( diff --git a/services/platform/app/features/products/hooks/__tests__/collection-hooks.test.ts b/services/platform/app/features/products/hooks/__tests__/collection-hooks.test.ts deleted file mode 100644 index 324f0e142..000000000 --- a/services/platform/app/features/products/hooks/__tests__/collection-hooks.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((_builder: (q: unknown) => unknown) => { - return { data: [], isLoading: false }; - }), -})); - -vi.mock('@/lib/collections/entities/products', () => ({ - createProductsCollection: vi.fn(), -})); - -vi.mock('@/lib/collections/use-collection', () => ({ - useCollection: vi.fn(() => mockCollection), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useCollection } from '@/lib/collections/use-collection'; - -import { useProductCollection } from '../collections'; -import { useProducts } from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); - -describe('useProductCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection with correct params', () => { - useProductCollection('org-123'); - expect(useCollection).toHaveBeenCalledWith( - 'products', - expect.any(Function), - 'org-123', - ); - }); -}); - -describe('useProducts', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data from live query', () => { - const items = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: items, - isLoading: false, - } as ReturnType); - - const result = useProducts(mockCollection as never); - expect(result.products).toBe(items); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useProducts(mockCollection as never); - expect(result.products).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/products/hooks/collections.ts b/services/platform/app/features/products/hooks/collections.ts deleted file mode 100644 index 9ccf10383..000000000 --- a/services/platform/app/features/products/hooks/collections.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createProductsCollection } from '@/lib/collections/entities/products'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useProductCollection(organizationId: string) { - return useCollection('products', createProductsCollection, organizationId); -} diff --git a/services/platform/app/features/products/hooks/queries.ts b/services/platform/app/features/products/hooks/queries.ts index 294f629e5..68c7d44b9 100644 --- a/services/platform/app/features/products/hooks/queries.ts +++ b/services/platform/app/features/products/hooks/queries.ts @@ -1,17 +1,19 @@ -import type { Collection } from '@tanstack/db'; - -import { useLiveQuery } from '@tanstack/react-db'; - -import type { Product } from '@/lib/collections/entities/products'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; import { useCachedPaginatedQuery } from '@/app/hooks/use-cached-paginated-query'; +import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { api } from '@/convex/_generated/api'; -export function useProducts(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export type Product = ConvexItemOf; + +export function useProducts(organizationId: string) { + const { data, isLoading } = useConvexQuery( + api.products.queries.listProducts, + { organizationId }, + ); return { - products: data, + products: data ?? [], isLoading, }; } diff --git a/services/platform/app/features/settings/account/components/account-form-client.tsx b/services/platform/app/features/settings/account/components/account-form-client.tsx index cd1e94498..055b237dc 100644 --- a/services/platform/app/features/settings/account/components/account-form-client.tsx +++ b/services/platform/app/features/settings/account/components/account-form-client.tsx @@ -44,7 +44,7 @@ export function AccountFormClient({ const { t: tAuth } = useT('auth'); const { t: tCommon } = useT('common'); const { t: tToast } = useT('toast'); - const updatePassword = useUpdatePassword(); + const { mutateAsync: updatePassword } = useUpdatePassword(); const { toast } = useToast(); const { data: hasCredential, isLoading: isCredentialLoading } = @@ -101,7 +101,7 @@ function ChangePasswordForm({ tCommon, tToast, }: { - updatePassword: ReturnType; + updatePassword: ReturnType['mutateAsync']; toast: ReturnType['toast']; tAuth: ReturnType['t']; tCommon: ReturnType['t']; @@ -205,7 +205,7 @@ function SetPasswordForm({ tCommon, tToast, }: { - updatePassword: ReturnType; + updatePassword: ReturnType['mutateAsync']; toast: ReturnType['toast']; tAuth: ReturnType['t']; tCommon: ReturnType['t']; diff --git a/services/platform/app/features/settings/api-keys/components/api-key-create-dialog.tsx b/services/platform/app/features/settings/api-keys/components/api-key-create-dialog.tsx index acced5248..043bb0539 100644 --- a/services/platform/app/features/settings/api-keys/components/api-key-create-dialog.tsx +++ b/services/platform/app/features/settings/api-keys/components/api-key-create-dialog.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Copy, Check } from 'lucide-react'; -import { useState, useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; @@ -37,7 +37,8 @@ export function ApiKeyCreateDialog({ const { t: tSettings } = useT('settings'); const { t: tCommon } = useT('common'); const { toast } = useToast(); - const createKey = useCreateApiKey(organizationId); + const { mutateAsync: createKey, isPending: isSubmitting } = + useCreateApiKey(organizationId); const [createdKey, setCreatedKey] = useState(null); const [copied, setCopied] = useState(false); @@ -75,8 +76,6 @@ export function ApiKeyCreateDialog({ [nameRequiredError], ); - const [isSubmitting, setIsSubmitting] = useState(false); - const form = useForm({ resolver: zodResolver(schema), defaultValues: { @@ -89,7 +88,6 @@ export function ApiKeyCreateDialog({ const expiresInValue = watch('expiresIn'); const onSubmit = async (data: ApiKeyFormData) => { - setIsSubmitting(true); try { const expiresIn = data.expiresIn === '0' ? undefined : parseInt(data.expiresIn, 10); @@ -113,8 +111,6 @@ export function ApiKeyCreateDialog({ title: tCommon('errors.generic'), variant: 'destructive', }); - } finally { - setIsSubmitting(false); } }; diff --git a/services/platform/app/features/settings/api-keys/components/api-key-revoke-dialog.tsx b/services/platform/app/features/settings/api-keys/components/api-key-revoke-dialog.tsx index 9b31ec72d..b7312490f 100644 --- a/services/platform/app/features/settings/api-keys/components/api-key-revoke-dialog.tsx +++ b/services/platform/app/features/settings/api-keys/components/api-key-revoke-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useCallback } from 'react'; import { DeleteDialog } from '@/app/components/ui/dialog/delete-dialog'; import { toast } from '@/app/hooks/use-toast'; @@ -26,31 +26,30 @@ export function ApiKeyRevokeDialog({ onSuccess, }: ApiKeyRevokeDialogProps) { const { t: tSettings } = useT('settings'); - const [isRevoking, setIsRevoking] = useState(false); - const revokeKey = useRevokeApiKey(organizationId); + const { mutate: revokeKey, isPending: isRevoking } = + useRevokeApiKey(organizationId); - const handleConfirm = async () => { + const handleConfirm = useCallback(() => { if (isRevoking) return; - setIsRevoking(true); - try { - await revokeKey(apiKey.id); - toast({ - title: tSettings('apiKeys.keyRevoked'), - variant: 'success', - }); - onOpenChange(false); - onSuccess?.(); - } catch (error) { - console.error(error); - toast({ - title: tSettings('apiKeys.keyRevokeFailed'), - variant: 'destructive', - }); - } finally { - setIsRevoking(false); - } - }; + revokeKey(apiKey.id, { + onSuccess: () => { + toast({ + title: tSettings('apiKeys.keyRevoked'), + variant: 'success', + }); + onOpenChange(false); + onSuccess?.(); + }, + onError: (error) => { + console.error(error); + toast({ + title: tSettings('apiKeys.keyRevokeFailed'), + variant: 'destructive', + }); + }, + }); + }, [isRevoking, revokeKey, apiKey.id, tSettings, onOpenChange, onSuccess]); return ( => { @@ -51,24 +50,24 @@ export function useCreateApiKey(organizationId: string) { throw new Error('API key creation returned no key/id'); } - void queryClient.invalidateQueries({ - queryKey: ['api-keys', organizationId], - }); - return { key: result.data.key, id: result.data.id, }; }, - [queryClient, organizationId], - ); + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ['api-keys', organizationId], + }); + }, + }); } export function useRevokeApiKey(organizationId: string) { const queryClient = useReactQueryClient(); - return useCallback( - async (keyId: string) => { + return useReactMutation({ + mutationFn: async (keyId: string) => { const result = await authClient.apiKey.delete({ keyId, }); @@ -77,12 +76,12 @@ export function useRevokeApiKey(organizationId: string) { throw new Error(result.error.message); } + return result.data; + }, + onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['api-keys', organizationId], }); - - return result.data; }, - [queryClient, organizationId], - ); + }); } diff --git a/services/platform/app/features/settings/integrations/components/integration-manage-dialog.tsx b/services/platform/app/features/settings/integrations/components/integration-manage-dialog.tsx index a668acaa7..d44ee1801 100644 --- a/services/platform/app/features/settings/integrations/components/integration-manage-dialog.tsx +++ b/services/platform/app/features/settings/integrations/components/integration-manage-dialog.tsx @@ -129,8 +129,6 @@ export function IntegrationManageDialog({ }: IntegrationManageDialogProps) { const { t } = useT('settings'); const { t: tCommon } = useT('common'); - const [isSubmitting, setIsSubmitting] = useState(false); - const [isTesting, setIsTesting] = useState(false); const [isUploadingIcon, setIsUploadingIcon] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; @@ -170,13 +168,18 @@ export function IntegrationManageDialog({ const isActive = optimisticActive ?? integration.isActive; const iconUrl = optimisticIconUrl ?? integration.iconUrl; - const updateIntegration = useUpdateIntegration(); - const testConnection = useTestIntegration(); - const deleteIntegration = useDeleteIntegration(); - const generateUploadUrl = useGenerateUploadUrl(); - const updateIcon = useUpdateIntegrationIcon(); - const generateOAuth2Url = useGenerateIntegrationOAuth2Url(); - const saveOAuth2Credentials = useSaveOAuth2Credentials(); + const { mutateAsync: updateIntegration, isPending: isUpdating } = + useUpdateIntegration(); + const { mutateAsync: testConnection, isPending: isTesting } = + useTestIntegration(); + const { mutateAsync: deleteIntegration, isPending: isDeleting } = + useDeleteIntegration(); + const { mutateAsync: generateUploadUrl } = useGenerateUploadUrl(); + const { mutateAsync: updateIcon } = useUpdateIntegrationIcon(); + const { mutateAsync: generateOAuth2Url } = useGenerateIntegrationOAuth2Url(); + const { mutateAsync: saveOAuth2Credentials } = useSaveOAuth2Credentials(); + + const isSubmitting = isUpdating || isDeleting; const hasOAuth2Config = !!integration.oauth2Config; @@ -414,7 +417,6 @@ export function IntegrationManageDialog({ ]); const handleTestConnection = useCallback(async () => { - setIsTesting(true); setTestResult(null); try { @@ -461,8 +463,6 @@ export function IntegrationManageDialog({ ? error.message : t('integrations.failedToTestConnection'), }); - } finally { - setIsTesting(false); } }, [ isSql, @@ -478,7 +478,6 @@ export function IntegrationManageDialog({ ]); const handleDisconnect = useCallback(async () => { - setIsSubmitting(true); try { await updateIntegration({ integrationId: integration._id, @@ -506,8 +505,6 @@ export function IntegrationManageDialog({ }), variant: 'destructive', }); - } finally { - setIsSubmitting(false); } }, [updateIntegration, integration, t]); @@ -581,7 +578,6 @@ export function IntegrationManageDialog({ oauth2Fields.clientSecret.trim().length > 0; const handleDelete = useCallback(async () => { - setIsSubmitting(true); try { await deleteIntegration({ integrationId: integration._id }); toast({ @@ -598,7 +594,6 @@ export function IntegrationManageDialog({ variant: 'destructive', }); } finally { - setIsSubmitting(false); setConfirmDelete(false); } }, [deleteIntegration, integration, onOpenChange, t]); diff --git a/services/platform/app/features/settings/integrations/components/integration-upload/integration-upload-dialog.tsx b/services/platform/app/features/settings/integrations/components/integration-upload/integration-upload-dialog.tsx index c545ff66d..c65bef2e1 100644 --- a/services/platform/app/features/settings/integrations/components/integration-upload/integration-upload-dialog.tsx +++ b/services/platform/app/features/settings/integrations/components/integration-upload/integration-upload-dialog.tsx @@ -28,8 +28,8 @@ export function IntegrationUploadDialog({ }: IntegrationUploadDialogProps) { const { t } = useT('settings'); const { t: tCommon } = useT('common'); - const createIntegration = useCreateIntegration(); - const generateUploadUrl = useGenerateUploadUrl(); + const { mutateAsync: createIntegration } = useCreateIntegration(); + const { mutateAsync: generateUploadUrl } = useGenerateUploadUrl(); const state = useUploadIntegration(); diff --git a/services/platform/app/features/settings/integrations/components/sso-config-dialog.tsx b/services/platform/app/features/settings/integrations/components/sso-config-dialog.tsx index 799b28dee..9073a5e9d 100644 --- a/services/platform/app/features/settings/integrations/components/sso-config-dialog.tsx +++ b/services/platform/app/features/settings/integrations/components/sso-config-dialog.tsx @@ -85,9 +85,6 @@ export function SSOConfigDialog({ ); const [defaultRole, setDefaultRole] = useState('member'); const [enableOneDriveAccess, setEnableOneDriveAccess] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [isLoadingConfig, setIsLoadingConfig] = useState(false); - const [isTesting, setIsTesting] = useState(false); const [testResult, setTestResult] = useState<{ valid: boolean; error?: string; @@ -104,11 +101,19 @@ export function SSOConfigDialog({ enableOneDriveAccess: boolean; } | null>(null); - const upsertSSOProvider = useUpsertSsoProvider(); - const removeSSOProvider = useRemoveSsoProvider(); - const getFullConfig = useSsoFullConfig(); - const testSSOConfig = useTestSsoConfig(); - const testExistingSSOConfig = useTestExistingSsoConfig(); + const { mutateAsync: upsertSSOProvider, isPending: isUpserting } = + useUpsertSsoProvider(); + const { mutateAsync: removeSSOProvider, isPending: isRemoving } = + useRemoveSsoProvider(); + const { mutateAsync: getFullConfig, isPending: isLoadingConfig } = + useSsoFullConfig(); + const { mutateAsync: testSSOConfig, isPending: isTestingNew } = + useTestSsoConfig(); + const { mutateAsync: testExistingSSOConfig, isPending: isTestingExisting } = + useTestExistingSsoConfig(); + + const isSubmitting = isUpserting || isRemoving; + const isTesting = isTestingNew || isTestingExisting; const isConnected = !!existingProvider; @@ -151,7 +156,6 @@ export function SSOConfigDialog({ useEffect(() => { if (open && existingProvider) { - setIsLoadingConfig(true); getFullConfig({}) .then((config) => { if (config) { @@ -197,9 +201,6 @@ export function SSOConfigDialog({ description: t('integrations.sso.configureError'), variant: 'destructive', }); - }) - .finally(() => { - setIsLoadingConfig(false); }); } else if (!existingProvider) { setIssuer(''); @@ -226,7 +227,6 @@ export function SSOConfigDialog({ return; } - setIsSubmitting(true); try { await upsertSSOProvider({ organizationId, @@ -268,13 +268,10 @@ export function SSOConfigDialog({ : t('integrations.sso.configureError'), variant: 'destructive', }); - } finally { - setIsSubmitting(false); } }; const handleDisconnect = async () => { - setIsSubmitting(true); try { await removeSSOProvider({ organizationId }); @@ -293,8 +290,6 @@ export function SSOConfigDialog({ : t('integrations.sso.disconnectError'), variant: 'destructive', }); - } finally { - setIsSubmitting(false); } }; @@ -310,7 +305,6 @@ export function SSOConfigDialog({ return; } - setIsTesting(true); setTestResult(null); try { @@ -354,8 +348,6 @@ export function SSOConfigDialog({ : t('integrations.sso.testError'), variant: 'destructive', }); - } finally { - setIsTesting(false); } }; diff --git a/services/platform/app/features/settings/integrations/hooks/__tests__/collection-hooks.test.ts b/services/platform/app/features/settings/integrations/hooks/__tests__/collection-hooks.test.ts deleted file mode 100644 index dbdb2d5c8..000000000 --- a/services/platform/app/features/settings/integrations/hooks/__tests__/collection-hooks.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((_builder: (q: unknown) => unknown) => { - return { data: [], isLoading: false }; - }), -})); - -vi.mock('@/lib/collections/entities/integrations', () => ({ - createIntegrationsCollection: vi.fn(), -})); - -vi.mock('@/lib/collections/use-collection', () => ({ - useCollection: vi.fn(() => mockCollection), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useIntegrations } from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); - -describe('useIntegrations', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data from live query', () => { - const items = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: items, - isLoading: false, - } as ReturnType); - - const result = useIntegrations(mockCollection as never); - expect(result.integrations).toBe(items); - expect(result.isLoading).toBe(false); - }); - - it('returns empty array when loading', () => { - mockUseLiveQuery.mockReturnValueOnce({ - data: [], - isLoading: true, - } as ReturnType); - - const result = useIntegrations(mockCollection as never); - expect(result.integrations).toEqual([]); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/settings/integrations/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/settings/integrations/hooks/__tests__/mutation-hooks.test.ts index 931fbab6a..f87cd2a58 100644 --- a/services/platform/app/features/settings/integrations/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/settings/integrations/hooks/__tests__/mutation-hooks.test.ts @@ -2,10 +2,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { toId } from '@/convex/lib/type_cast_helpers'; -const mockMutationFn = vi.fn(); +const mockMutateAsync = vi.fn(); vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => mockMutationFn, + useConvexMutation: () => ({ + mutate: mockMutateAsync, + mutateAsync: mockMutateAsync, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), + }), })); vi.mock('@/convex/_generated/api', () => ({ @@ -31,27 +40,28 @@ describe('useDeleteIntegration', () => { vi.clearAllMocks(); }); - it('returns the mutation function from useConvexMutation', () => { - const deleteIntegration = useDeleteIntegration(); - expect(deleteIntegration).toBe(mockMutationFn); + it('returns a mutation result object from useConvexMutation', () => { + const result = useDeleteIntegration(); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); it('calls mutation with the correct args', async () => { - mockMutationFn.mockResolvedValueOnce(null); - const deleteIntegration = useDeleteIntegration(); + mockMutateAsync.mockResolvedValueOnce(null); + const { mutateAsync: deleteIntegration } = useDeleteIntegration(); await deleteIntegration({ integrationId: toId<'integrations'>('int-123'), }); - expect(mockMutationFn).toHaveBeenCalledWith({ + expect(mockMutateAsync).toHaveBeenCalledWith({ integrationId: 'int-123', }); }); it('propagates errors from mutation', async () => { - mockMutationFn.mockRejectedValueOnce(new Error('Delete failed')); - const deleteIntegration = useDeleteIntegration(); + mockMutateAsync.mockRejectedValueOnce(new Error('Delete failed')); + const { mutateAsync: deleteIntegration } = useDeleteIntegration(); await expect( deleteIntegration({ diff --git a/services/platform/app/features/settings/integrations/hooks/collections.ts b/services/platform/app/features/settings/integrations/hooks/collections.ts deleted file mode 100644 index d34356807..000000000 --- a/services/platform/app/features/settings/integrations/hooks/collections.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createIntegrationsCollection } from '@/lib/collections/entities/integrations'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useIntegrationCollection(organizationId: string) { - return useCollection( - 'integrations', - createIntegrationsCollection, - organizationId, - ); -} diff --git a/services/platform/app/features/settings/integrations/hooks/queries.ts b/services/platform/app/features/settings/integrations/hooks/queries.ts index 93047fd9f..7ad07ac1f 100644 --- a/services/platform/app/features/settings/integrations/hooks/queries.ts +++ b/services/platform/app/features/settings/integrations/hooks/queries.ts @@ -1,14 +1,14 @@ -import type { Collection } from '@tanstack/db'; - -import { useLiveQuery } from '@tanstack/react-db'; - -import type { Integration } from '@/lib/collections/entities/integrations'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { api } from '@/convex/_generated/api'; -export function useIntegrations(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export type Integration = ConvexItemOf; + +export function useIntegrations(organizationId: string) { + const { data, isLoading } = useConvexQuery(api.integrations.queries.list, { + organizationId, + }); return { integrations: data ?? [], @@ -16,8 +16,6 @@ export function useIntegrations(collection: Collection) { }; } -export type { Integration }; - export function useSsoProvider() { return useConvexQuery(api.sso_providers.queries.get, {}); } diff --git a/services/platform/app/features/settings/organization/components/member-add-dialog.tsx b/services/platform/app/features/settings/organization/components/member-add-dialog.tsx index f9ffcbbe6..49275178f 100644 --- a/services/platform/app/features/settings/organization/components/member-add-dialog.tsx +++ b/services/platform/app/features/settings/organization/components/member-add-dialog.tsx @@ -80,8 +80,8 @@ export function AddMemberDialog({ } | null>(null); const { toast } = useToast(); - const [isSubmitting, setIsSubmitting] = useState(false); - const createMember = useCreateMember(); + const { mutateAsync: createMember, isPending: isSubmitting } = + useCreateMember(); const form = useForm({ resolver: zodResolver(addMemberSchema), defaultValues: { @@ -120,7 +120,6 @@ export function AddMemberDialog({ ); const onSubmit = async (data: AddMemberFormData) => { - setIsSubmitting(true); try { const result = await createMember({ organizationId, @@ -154,8 +153,6 @@ export function AddMemberDialog({ title: tToast('error.addMemberFailed'), variant: 'destructive', }); - } finally { - setIsSubmitting(false); } }; diff --git a/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx b/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx index 4fad7db9c..c24b6e01c 100644 --- a/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx +++ b/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx @@ -89,9 +89,9 @@ export function EditMemberDialog({ }, }); - const updateMemberRole = useUpdateMemberRole(); - const updateMemberDisplayName = useUpdateMemberDisplayName(); - const setMemberPassword = useSetMemberPassword(); + const { mutateAsync: updateMemberRole } = useUpdateMemberRole(); + const { mutateAsync: updateMemberDisplayName } = useUpdateMemberDisplayName(); + const { mutateAsync: setMemberPassword } = useSetMemberPassword(); const handleUpdateMember = async ( memberId: string, diff --git a/services/platform/app/features/settings/organization/components/organization-settings-client.tsx b/services/platform/app/features/settings/organization/components/organization-settings-client.tsx index b06afc60d..593c8b55b 100644 --- a/services/platform/app/features/settings/organization/components/organization-settings-client.tsx +++ b/services/platform/app/features/settings/organization/components/organization-settings-client.tsx @@ -15,7 +15,6 @@ import { useToast } from '@/app/hooks/use-toast'; import { authClient } from '@/lib/auth-client'; import { useT } from '@/lib/i18n/client'; -import { useMemberCollection } from '../hooks/collections'; import { useMembers } from '../hooks/queries'; import { AddMemberDialog } from './member-add-dialog'; import { MemberTable } from './member-table'; @@ -64,8 +63,7 @@ export function OrganizationSettingsClient({ const { formState, handleSubmit, register, reset } = form; const { isDirty, isSubmitting } = formState; - const memberCollection = useMemberCollection(organization?._id ?? ''); - const { members: allMembers } = useMembers(memberCollection); + const { members: allMembers } = useMembers(organization?._id ?? ''); const members = useMemo(() => { if (!allMembers) return null; diff --git a/services/platform/app/features/settings/organization/hooks/__tests__/collection-hooks.test.ts b/services/platform/app/features/settings/organization/hooks/__tests__/collection-hooks.test.ts deleted file mode 100644 index 31b5deeaa..000000000 --- a/services/platform/app/features/settings/organization/hooks/__tests__/collection-hooks.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((_builder: (q: unknown) => unknown) => { - return { data: [], isLoading: false }; - }), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useMembers } from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); - -describe('useMembers', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data from live query', () => { - const items = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: items, - isLoading: false, - } as ReturnType); - - const result = useMembers(mockCollection as never); - expect(result.members).toBe(items); - expect(result.isLoading).toBe(false); - }); - - it('returns empty array when loading', () => { - mockUseLiveQuery.mockReturnValueOnce({ - data: [], - isLoading: true, - } as ReturnType); - - const result = useMembers(mockCollection as never); - expect(result.members).toEqual([]); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/settings/organization/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/settings/organization/hooks/__tests__/mutation-hooks.test.ts index 323541daf..e63e666cd 100644 --- a/services/platform/app/features/settings/organization/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/settings/organization/hooks/__tests__/mutation-hooks.test.ts @@ -1,9 +1,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const mockMutationFn = vi.fn(); +const mockMutateAsync = vi.fn(); vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => mockMutationFn, + useConvexMutation: () => ({ + mutate: mockMutateAsync, + mutateAsync: mockMutateAsync, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), + }), })); vi.mock('@/convex/_generated/api', () => ({ @@ -35,23 +44,24 @@ describe('useRemoveMember', () => { vi.clearAllMocks(); }); - it('returns the mutation function from useConvexMutation', () => { - const removeMember = useRemoveMember(); - expect(removeMember).toBe(mockMutationFn); + it('returns a mutation result object from useConvexMutation', () => { + const result = useRemoveMember(); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); it('calls mutation with the correct args', async () => { - mockMutationFn.mockResolvedValueOnce(null); - const removeMember = useRemoveMember(); + mockMutateAsync.mockResolvedValueOnce(null); + const { mutateAsync: removeMember } = useRemoveMember(); await removeMember({ memberId: 'member-123' }); - expect(mockMutationFn).toHaveBeenCalledWith({ memberId: 'member-123' }); + expect(mockMutateAsync).toHaveBeenCalledWith({ memberId: 'member-123' }); }); it('propagates errors from mutation', async () => { - mockMutationFn.mockRejectedValueOnce(new Error('Delete failed')); - const removeMember = useRemoveMember(); + mockMutateAsync.mockRejectedValueOnce(new Error('Delete failed')); + const { mutateAsync: removeMember } = useRemoveMember(); await expect(removeMember({ memberId: 'member-789' })).rejects.toThrow( 'Delete failed', @@ -64,26 +74,27 @@ describe('useUpdateMemberRole', () => { vi.clearAllMocks(); }); - it('returns the mutation function from useConvexMutation', () => { - const updateRole = useUpdateMemberRole(); - expect(updateRole).toBe(mockMutationFn); + it('returns a mutation result object from useConvexMutation', () => { + const result = useUpdateMemberRole(); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); it('calls mutation with the correct args', async () => { - mockMutationFn.mockResolvedValueOnce(null); - const updateRole = useUpdateMemberRole(); + mockMutateAsync.mockResolvedValueOnce(null); + const { mutateAsync: updateRole } = useUpdateMemberRole(); await updateRole({ memberId: 'member-123', role: 'admin' }); - expect(mockMutationFn).toHaveBeenCalledWith({ + expect(mockMutateAsync).toHaveBeenCalledWith({ memberId: 'member-123', role: 'admin', }); }); it('propagates errors from mutation', async () => { - mockMutationFn.mockRejectedValueOnce(new Error('Update failed')); - const updateRole = useUpdateMemberRole(); + mockMutateAsync.mockRejectedValueOnce(new Error('Update failed')); + const { mutateAsync: updateRole } = useUpdateMemberRole(); await expect( updateRole({ memberId: 'member-789', role: 'admin' }), @@ -96,26 +107,27 @@ describe('useUpdateMemberDisplayName', () => { vi.clearAllMocks(); }); - it('returns the mutation function from useConvexMutation', () => { - const updateName = useUpdateMemberDisplayName(); - expect(updateName).toBe(mockMutationFn); + it('returns a mutation result object from useConvexMutation', () => { + const result = useUpdateMemberDisplayName(); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); it('calls mutation with the correct args', async () => { - mockMutationFn.mockResolvedValueOnce(null); - const updateName = useUpdateMemberDisplayName(); + mockMutateAsync.mockResolvedValueOnce(null); + const { mutateAsync: updateName } = useUpdateMemberDisplayName(); await updateName({ memberId: 'member-123', displayName: 'New Name' }); - expect(mockMutationFn).toHaveBeenCalledWith({ + expect(mockMutateAsync).toHaveBeenCalledWith({ memberId: 'member-123', displayName: 'New Name', }); }); it('propagates errors from mutation', async () => { - mockMutationFn.mockRejectedValueOnce(new Error('Update failed')); - const updateName = useUpdateMemberDisplayName(); + mockMutateAsync.mockRejectedValueOnce(new Error('Update failed')); + const { mutateAsync: updateName } = useUpdateMemberDisplayName(); await expect( updateName({ memberId: 'member-789', displayName: 'Fail' }), diff --git a/services/platform/app/features/settings/organization/hooks/collections.ts b/services/platform/app/features/settings/organization/hooks/collections.ts deleted file mode 100644 index c47dcc0fb..000000000 --- a/services/platform/app/features/settings/organization/hooks/collections.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createMembersCollection } from '@/lib/collections/entities/members'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useMemberCollection(organizationId: string) { - return useCollection('members', createMembersCollection, organizationId); -} diff --git a/services/platform/app/features/settings/organization/hooks/queries.ts b/services/platform/app/features/settings/organization/hooks/queries.ts index a2792765d..5682612d3 100644 --- a/services/platform/app/features/settings/organization/hooks/queries.ts +++ b/services/platform/app/features/settings/organization/hooks/queries.ts @@ -1,16 +1,20 @@ -import type { Collection } from '@tanstack/db'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; -import { useLiveQuery } from '@tanstack/react-db'; +import { useConvexQuery } from '@/app/hooks/use-convex-query'; +import { api } from '@/convex/_generated/api'; -import type { Member } from '@/lib/collections/entities/members'; +export type Member = ConvexItemOf< + typeof api.members.queries.listByOrganization +>; -export function useMembers(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export function useMembers(organizationId: string) { + const { data, isLoading } = useConvexQuery( + api.members.queries.listByOrganization, + { organizationId }, + ); return { members: data, isLoading, }; } - -export type { Member }; diff --git a/services/platform/app/features/settings/teams/components/team-create-dialog.tsx b/services/platform/app/features/settings/teams/components/team-create-dialog.tsx index f62ce55e3..174569e7c 100644 --- a/services/platform/app/features/settings/teams/components/team-create-dialog.tsx +++ b/services/platform/app/features/settings/teams/components/team-create-dialog.tsx @@ -33,7 +33,7 @@ export function TeamCreateDialog({ const { t: tSettings } = useT('settings'); const { t: tCommon } = useT('common'); const { toast } = useToast(); - const addMember = useCreateTeamMember(); + const { mutateAsync: addMember } = useCreateTeamMember(); const nameRequiredError = tSettings('teams.teamNameRequired'); const schema = useMemo( diff --git a/services/platform/app/features/settings/teams/components/team-delete-dialog.tsx b/services/platform/app/features/settings/teams/components/team-delete-dialog.tsx index 15586fc63..42a5cce27 100644 --- a/services/platform/app/features/settings/teams/components/team-delete-dialog.tsx +++ b/services/platform/app/features/settings/teams/components/team-delete-dialog.tsx @@ -7,7 +7,7 @@ import { toast } from '@/app/hooks/use-toast'; import { authClient } from '@/lib/auth-client'; import { useT } from '@/lib/i18n/client'; -import type { Team } from '../hooks/collections'; +import type { Team } from '../hooks/queries'; interface TeamDeleteDialogProps { open: boolean; diff --git a/services/platform/app/features/settings/teams/components/team-edit-dialog.tsx b/services/platform/app/features/settings/teams/components/team-edit-dialog.tsx index cdfa999f5..9b8d6a514 100644 --- a/services/platform/app/features/settings/teams/components/team-edit-dialog.tsx +++ b/services/platform/app/features/settings/teams/components/team-edit-dialog.tsx @@ -11,7 +11,7 @@ import { useToast } from '@/app/hooks/use-toast'; import { authClient } from '@/lib/auth-client'; import { useT } from '@/lib/i18n/client'; -import type { Team } from '../hooks/collections'; +import type { Team } from '../hooks/queries'; interface TeamEditDialogProps { team: Team; diff --git a/services/platform/app/features/settings/teams/components/team-members-dialog.tsx b/services/platform/app/features/settings/teams/components/team-members-dialog.tsx index 590db0390..23ac33c89 100644 --- a/services/platform/app/features/settings/teams/components/team-members-dialog.tsx +++ b/services/platform/app/features/settings/teams/components/team-members-dialog.tsx @@ -10,13 +10,9 @@ import { Button } from '@/app/components/ui/primitives/button'; import { toast } from '@/app/hooks/use-toast'; import { useT } from '@/lib/i18n/client'; -import type { Team } from '../hooks/collections'; - -import { useMemberCollection } from '../../organization/hooks/collections'; import { useMembers } from '../../organization/hooks/queries'; -import { useTeamMemberCollection } from '../hooks/collections'; import { useAddTeamMember, useRemoveTeamMember } from '../hooks/mutations'; -import { useTeamMembers } from '../hooks/queries'; +import { useTeamMembers, type Team } from '../hooks/queries'; interface TeamMembersDialogProps { open: boolean; @@ -38,14 +34,11 @@ export function TeamMembersDialog({ const [isAdding, setIsAdding] = useState(false); const [removingMemberId, setRemovingMemberId] = useState(null); - const memberCollection = useMemberCollection(organizationId); - const { members: orgMembers } = useMembers(memberCollection); + const { members: orgMembers } = useMembers(organizationId); - const teamMemberCollection = useTeamMemberCollection( - open ? team.id : undefined, + const { teamMembers, isLoading: isLoadingTeamMembers } = useTeamMembers( + team.id, ); - const { teamMembers, isLoading: isLoadingTeamMembers } = - useTeamMembers(teamMemberCollection); const addTeamMember = useAddTeamMember(); const removeTeamMember = useRemoveTeamMember(); diff --git a/services/platform/app/features/settings/teams/components/team-row-actions.tsx b/services/platform/app/features/settings/teams/components/team-row-actions.tsx index 67a91a320..ce195ca98 100644 --- a/services/platform/app/features/settings/teams/components/team-row-actions.tsx +++ b/services/platform/app/features/settings/teams/components/team-row-actions.tsx @@ -9,7 +9,7 @@ import { } from '@/app/components/ui/entity/entity-row-actions'; import { useT } from '@/lib/i18n/client'; -import type { Team } from '../hooks/collections'; +import type { Team } from '../hooks/queries'; import { TeamDeleteDialog } from './team-delete-dialog'; import { TeamEditDialog } from './team-edit-dialog'; diff --git a/services/platform/app/features/settings/teams/components/team-table.tsx b/services/platform/app/features/settings/teams/components/team-table.tsx index 727e86a58..aabfc87d4 100644 --- a/services/platform/app/features/settings/teams/components/team-table.tsx +++ b/services/platform/app/features/settings/teams/components/team-table.tsx @@ -9,7 +9,7 @@ import { DataTable } from '@/app/components/ui/data-table/data-table'; import { HStack } from '@/app/components/ui/layout/layout'; import { useT } from '@/lib/i18n/client'; -import type { Team } from '../hooks/collections'; +import type { Team } from '../hooks/queries'; import { TeamRowActions } from './team-row-actions'; diff --git a/services/platform/app/features/settings/teams/components/teams-settings.tsx b/services/platform/app/features/settings/teams/components/teams-settings.tsx index e6e9c8945..52d4adc87 100644 --- a/services/platform/app/features/settings/teams/components/teams-settings.tsx +++ b/services/platform/app/features/settings/teams/components/teams-settings.tsx @@ -9,7 +9,6 @@ import { Button } from '@/app/components/ui/primitives/button'; import { useDebounce } from '@/app/hooks/use-debounce'; import { useT } from '@/lib/i18n/client'; -import { useTeamCollection } from '../hooks/collections'; import { useTeams } from '../hooks/queries'; import { TeamCreateDialog } from './team-create-dialog'; import { TeamTable } from './team-table'; @@ -26,10 +25,7 @@ export function TeamsSettings({ organizationId }: TeamsSettingsProps) { // Debounce search query for filtering const debouncedSearch = useDebounce(searchQuery, 300); - // Fetch teams - in trusted headers mode, teams come from JWT claims - // In normal auth mode, teams come from the teamMember database table - const teamCollection = useTeamCollection(organizationId); - const { teams, isLoading } = useTeams(teamCollection); + const { teams, isLoading } = useTeams(); // Filter teams by search query const filteredTeams = teams?.filter((team: { id: string; name: string }) => diff --git a/services/platform/app/features/settings/teams/hooks/__tests__/collection-hooks.test.ts b/services/platform/app/features/settings/teams/hooks/__tests__/collection-hooks.test.ts deleted file mode 100644 index fa6c5cf06..000000000 --- a/services/platform/app/features/settings/teams/hooks/__tests__/collection-hooks.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((_builder: (q: unknown) => unknown, _deps: unknown[]) => { - return { data: [], isLoading: false }; - }), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useTeams, useTeamMembers } from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); - -describe('useTeams', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data when loaded', () => { - const teams = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: teams, - isLoading: false, - } as ReturnType); - - const result = useTeams(mockCollection as never); - expect(result.teams).toBe(teams); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useTeams(mockCollection as never); - expect(result.teams).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); - -describe('useTeamMembers', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data when loaded', () => { - const members = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: members, - isLoading: false, - } as ReturnType); - - const result = useTeamMembers(mockCollection as never); - expect(result.teamMembers).toBe(members); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useTeamMembers(mockCollection as never); - expect(result.teamMembers).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/settings/teams/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/settings/teams/hooks/__tests__/mutation-hooks.test.ts index bb27a7e4a..c2e4ead9d 100644 --- a/services/platform/app/features/settings/teams/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/settings/teams/hooks/__tests__/mutation-hooks.test.ts @@ -1,9 +1,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const mockMutationFn = vi.fn(); +const mockMutateAsync = vi.fn(); vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => mockMutationFn, + useConvexMutation: () => ({ + mutate: mockMutateAsync, + mutateAsync: mockMutateAsync, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), + }), })); vi.mock('@/convex/_generated/api', () => ({ @@ -28,14 +37,15 @@ describe('useAddTeamMember', () => { vi.clearAllMocks(); }); - it('returns the mutation function from useConvexMutation', () => { - const addTeamMember = useAddTeamMember(); - expect(addTeamMember).toBe(mockMutationFn); + it('returns a mutation result object from useConvexMutation', () => { + const result = useAddTeamMember(); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); it('calls mutation with the correct args', async () => { - mockMutationFn.mockResolvedValueOnce(undefined); - const addTeamMember = useAddTeamMember(); + mockMutateAsync.mockResolvedValueOnce(undefined); + const { mutateAsync: addTeamMember } = useAddTeamMember(); await addTeamMember({ teamId: 'team-123', @@ -43,7 +53,7 @@ describe('useAddTeamMember', () => { organizationId: 'org-789', }); - expect(mockMutationFn).toHaveBeenCalledWith({ + expect(mockMutateAsync).toHaveBeenCalledWith({ teamId: 'team-123', userId: 'user-456', organizationId: 'org-789', @@ -51,8 +61,8 @@ describe('useAddTeamMember', () => { }); it('propagates errors from mutation', async () => { - mockMutationFn.mockRejectedValueOnce(new Error('Add failed')); - const addTeamMember = useAddTeamMember(); + mockMutateAsync.mockRejectedValueOnce(new Error('Add failed')); + const { mutateAsync: addTeamMember } = useAddTeamMember(); await expect( addTeamMember({ @@ -69,29 +79,30 @@ describe('useRemoveTeamMember', () => { vi.clearAllMocks(); }); - it('returns the mutation function from useConvexMutation', () => { - const removeTeamMember = useRemoveTeamMember(); - expect(removeTeamMember).toBe(mockMutationFn); + it('returns a mutation result object from useConvexMutation', () => { + const result = useRemoveTeamMember(); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); it('calls mutation with the correct args', async () => { - mockMutationFn.mockResolvedValueOnce(undefined); - const removeTeamMember = useRemoveTeamMember(); + mockMutateAsync.mockResolvedValueOnce(undefined); + const { mutateAsync: removeTeamMember } = useRemoveTeamMember(); await removeTeamMember({ teamMemberId: 'tm-123', organizationId: 'org-456', }); - expect(mockMutationFn).toHaveBeenCalledWith({ + expect(mockMutateAsync).toHaveBeenCalledWith({ teamMemberId: 'tm-123', organizationId: 'org-456', }); }); it('propagates errors from mutation', async () => { - mockMutationFn.mockRejectedValueOnce(new Error('Remove failed')); - const removeTeamMember = useRemoveTeamMember(); + mockMutateAsync.mockRejectedValueOnce(new Error('Remove failed')); + const { mutateAsync: removeTeamMember } = useRemoveTeamMember(); await expect( removeTeamMember({ @@ -103,8 +114,9 @@ describe('useRemoveTeamMember', () => { }); describe('useCreateTeamMember', () => { - it('returns the mutation function from useConvexMutation', () => { + it('returns a mutation result object from useConvexMutation', () => { const result = useCreateTeamMember(); - expect(result).toBe(mockMutationFn); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); }); diff --git a/services/platform/app/features/settings/teams/hooks/collections.ts b/services/platform/app/features/settings/teams/hooks/collections.ts deleted file mode 100644 index 3f8917720..000000000 --- a/services/platform/app/features/settings/teams/hooks/collections.ts +++ /dev/null @@ -1,22 +0,0 @@ -'use client'; - -import type { TeamMember } from '@/lib/collections/entities/team-members'; -import type { Team } from '@/lib/collections/entities/teams'; - -import { createTeamMembersCollection } from '@/lib/collections/entities/team-members'; -import { createTeamsCollection } from '@/lib/collections/entities/teams'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useTeamCollection(organizationId: string | undefined) { - return useCollection('teams', createTeamsCollection, organizationId ?? ''); -} - -export function useTeamMemberCollection(teamId: string | undefined) { - return useCollection( - 'team-members', - createTeamMembersCollection, - teamId ?? '', - ); -} - -export type { Team, TeamMember }; diff --git a/services/platform/app/features/settings/teams/hooks/queries.ts b/services/platform/app/features/settings/teams/hooks/queries.ts index fe60e863a..1598bcd81 100644 --- a/services/platform/app/features/settings/teams/hooks/queries.ts +++ b/services/platform/app/features/settings/teams/hooks/queries.ts @@ -1,12 +1,12 @@ -import type { Collection } from '@tanstack/db'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; -import { useLiveQuery } from '@tanstack/react-db'; +import { useConvexQuery } from '@/app/hooks/use-convex-query'; +import { api } from '@/convex/_generated/api'; -import type { TeamMember } from '@/lib/collections/entities/team-members'; -import type { Team } from '@/lib/collections/entities/teams'; +export type Team = ConvexItemOf; -export function useTeams(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export function useTeams() { + const { data, isLoading } = useConvexQuery(api.members.queries.getMyTeams); return { teams: data ?? null, @@ -14,8 +14,15 @@ export function useTeams(collection: Collection) { }; } -export function useTeamMembers(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export type TeamMember = ConvexItemOf< + typeof api.team_members.queries.listByTeam +>; + +export function useTeamMembers(teamId: string) { + const { data, isLoading } = useConvexQuery( + api.team_members.queries.listByTeam, + { teamId }, + ); return { teamMembers: data, diff --git a/services/platform/app/features/tone-of-voice/components/tone-of-voice-form-client.tsx b/services/platform/app/features/tone-of-voice/components/tone-of-voice-form-client.tsx index 62704fc4c..67f31c80c 100644 --- a/services/platform/app/features/tone-of-voice/components/tone-of-voice-form-client.tsx +++ b/services/platform/app/features/tone-of-voice/components/tone-of-voice-form-client.tsx @@ -44,12 +44,12 @@ export function ToneOfVoiceFormClient({ const { t: tToast } = useT('toast'); const orgId = organizationId; - const addExample = useAddExample(); - const updateExample = useUpdateExample(); - const deleteExample = useDeleteExample(); - const upsertTone = useUpsertTone(); - const generateTone = useGenerateTone(); - const [isGenerating, setIsGenerating] = useState(false); + const { mutateAsync: addExample } = useAddExample(); + const { mutateAsync: updateExample } = useUpdateExample(); + const { mutateAsync: deleteExample } = useDeleteExample(); + const { mutateAsync: upsertTone } = useUpsertTone(); + const { mutateAsync: generateTone, isPending: isGenerating } = + useGenerateTone(); const form = useForm(); const { register, setValue, watch, formState, handleSubmit } = form; @@ -151,7 +151,6 @@ export function ToneOfVoiceFormClient({ return; } - setIsGenerating(true); try { const result = await generateTone({ organizationId: orgId, @@ -175,8 +174,6 @@ export function ToneOfVoiceFormClient({ title: tToast('error.toneGenerateFailed'), variant: 'destructive', }); - } finally { - setIsGenerating(false); } }; diff --git a/services/platform/app/features/vendors/components/vendor-delete-dialog.tsx b/services/platform/app/features/vendors/components/vendor-delete-dialog.tsx index 1d4e0b69b..3cb3fc1e4 100644 --- a/services/platform/app/features/vendors/components/vendor-delete-dialog.tsx +++ b/services/platform/app/features/vendors/components/vendor-delete-dialog.tsx @@ -29,7 +29,7 @@ export function VendorDeleteDialog({ }: VendorDeleteDialogProps) { const { t: tVendors } = useT('vendors'); const { t: tToast } = useT('toast'); - const deleteVendor = useDeleteVendor(); + const { mutateAsync: deleteVendor } = useDeleteVendor(); const dialog = useDeleteDialog({ isOpen: controlledIsOpen, diff --git a/services/platform/app/features/vendors/components/vendor-edit-dialog.tsx b/services/platform/app/features/vendors/components/vendor-edit-dialog.tsx index a62c51eb3..43470d447 100644 --- a/services/platform/app/features/vendors/components/vendor-edit-dialog.tsx +++ b/services/platform/app/features/vendors/components/vendor-edit-dialog.tsx @@ -35,7 +35,7 @@ export function VendorEditDialog({ const { t: tVendors } = useT('vendors'); const { t: tCommon } = useT('common'); const { t: tGlobal } = useT('global'); - const updateVendor = useUpdateVendor(); + const { mutateAsync: updateVendor } = useUpdateVendor(); const localeOptions = useMemo( () => [ diff --git a/services/platform/app/features/vendors/components/vendors-import-dialog.tsx b/services/platform/app/features/vendors/components/vendors-import-dialog.tsx index b977ce083..0e20bfe01 100644 --- a/services/platform/app/features/vendors/components/vendors-import-dialog.tsx +++ b/services/platform/app/features/vendors/components/vendors-import-dialog.tsx @@ -101,7 +101,7 @@ export function ImportVendorsDialog({ formState: { isSubmitting }, } = formMethods; - const bulkCreateVendors = useBulkCreateVendors(); + const { mutateAsync: bulkCreateVendors } = useBulkCreateVendors(); // Reset form when mode changes to ensure defaultValues are current useEffect(() => { diff --git a/services/platform/app/features/vendors/components/vendors-page-wrapper.tsx b/services/platform/app/features/vendors/components/vendors-page-wrapper.tsx index 25c01fea8..686c8d8af 100644 --- a/services/platform/app/features/vendors/components/vendors-page-wrapper.tsx +++ b/services/platform/app/features/vendors/components/vendors-page-wrapper.tsx @@ -2,7 +2,6 @@ import type { ReactNode } from 'react'; -import { useVendorCollection } from '../hooks/collections'; import { useVendors } from '../hooks/queries'; import { VendorsEmptyState } from './vendors-empty-state'; @@ -15,8 +14,7 @@ export function VendorsPageWrapper({ organizationId, children, }: VendorsPageWrapperProps) { - const vendorCollection = useVendorCollection(organizationId); - const { vendors } = useVendors(vendorCollection); + const { vendors } = useVendors(organizationId); const hasVendors = (vendors?.length ?? 0) > 0; if (!hasVendors) { diff --git a/services/platform/app/features/vendors/hooks/__tests__/collection-hooks.test.ts b/services/platform/app/features/vendors/hooks/__tests__/collection-hooks.test.ts deleted file mode 100644 index b70258aee..000000000 --- a/services/platform/app/features/vendors/hooks/__tests__/collection-hooks.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((_builder: (q: unknown) => unknown) => { - return { data: [], isLoading: false }; - }), -})); - -vi.mock('@/lib/collections/entities/vendors', () => ({ - createVendorsCollection: vi.fn(), -})); - -vi.mock('@/lib/collections/use-collection', () => ({ - useCollection: vi.fn(() => mockCollection), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useCollection } from '@/lib/collections/use-collection'; - -import { useVendorCollection } from '../collections'; -import { useVendors } from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); - -describe('useVendorCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection with correct params', () => { - useVendorCollection('org-123'); - expect(useCollection).toHaveBeenCalledWith( - 'vendors', - expect.any(Function), - 'org-123', - ); - }); -}); - -describe('useVendors', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data from live query', () => { - const items = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: items, - isLoading: false, - } as ReturnType); - - const result = useVendors(mockCollection as never); - expect(result.vendors).toBe(items); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useVendors(mockCollection as never); - expect(result.vendors).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/vendors/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/vendors/hooks/__tests__/mutation-hooks.test.ts index 6b5f5ee46..1057809f0 100644 --- a/services/platform/app/features/vendors/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/vendors/hooks/__tests__/mutation-hooks.test.ts @@ -2,10 +2,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { toId } from '@/convex/lib/type_cast_helpers'; -const mockMutationFn = vi.fn(); +const mockMutateAsync = vi.fn(); vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => mockMutationFn, + useConvexMutation: () => ({ + mutate: mockMutateAsync, + mutateAsync: mockMutateAsync, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), + }), })); vi.mock('@/convex/_generated/api', () => ({ @@ -27,9 +36,10 @@ import { } from '../mutations'; describe('useBulkCreateVendors', () => { - it('returns the mutation function from useConvexMutation', () => { + it('returns a mutation result object from useConvexMutation', () => { const result = useBulkCreateVendors(); - expect(result).toBe(mockMutationFn); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); }); @@ -38,23 +48,24 @@ describe('useDeleteVendor', () => { vi.clearAllMocks(); }); - it('returns the mutation function from useConvexMutation', () => { + it('returns a mutation result object from useConvexMutation', () => { const result = useDeleteVendor(); - expect(result).toBe(mockMutationFn); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); it('calls mutation with the correct args', async () => { - mockMutationFn.mockResolvedValueOnce(null); - const deleteVendor = useDeleteVendor(); + mockMutateAsync.mockResolvedValueOnce(null); + const { mutateAsync: deleteVendor } = useDeleteVendor(); await deleteVendor({ vendorId: toId<'vendors'>('vendor-123') }); - expect(mockMutationFn).toHaveBeenCalledWith({ vendorId: 'vendor-123' }); + expect(mockMutateAsync).toHaveBeenCalledWith({ vendorId: 'vendor-123' }); }); it('propagates errors from mutation', async () => { - mockMutationFn.mockRejectedValueOnce(new Error('Delete failed')); - const deleteVendor = useDeleteVendor(); + mockMutateAsync.mockRejectedValueOnce(new Error('Delete failed')); + const { mutateAsync: deleteVendor } = useDeleteVendor(); await expect( deleteVendor({ vendorId: toId<'vendors'>('vendor-789') }), @@ -67,14 +78,15 @@ describe('useUpdateVendor', () => { vi.clearAllMocks(); }); - it('returns the mutation function from useConvexMutation', () => { + it('returns a mutation result object from useConvexMutation', () => { const result = useUpdateVendor(); - expect(result).toBe(mockMutationFn); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); }); it('calls mutation with the correct args', async () => { - mockMutationFn.mockResolvedValueOnce(undefined); - const updateVendor = useUpdateVendor(); + mockMutateAsync.mockResolvedValueOnce(undefined); + const { mutateAsync: updateVendor } = useUpdateVendor(); await updateVendor({ vendorId: toId<'vendors'>('vendor-123'), @@ -82,7 +94,7 @@ describe('useUpdateVendor', () => { email: 'new@example.com', }); - expect(mockMutationFn).toHaveBeenCalledWith({ + expect(mockMutateAsync).toHaveBeenCalledWith({ vendorId: 'vendor-123', name: 'Updated Name', email: 'new@example.com', @@ -90,17 +102,17 @@ describe('useUpdateVendor', () => { }); it('calls mutation with only vendorId when no fields updated', async () => { - mockMutationFn.mockResolvedValueOnce(undefined); - const updateVendor = useUpdateVendor(); + mockMutateAsync.mockResolvedValueOnce(undefined); + const { mutateAsync: updateVendor } = useUpdateVendor(); await updateVendor({ vendorId: toId<'vendors'>('vendor-456') }); - expect(mockMutationFn).toHaveBeenCalledWith({ vendorId: 'vendor-456' }); + expect(mockMutateAsync).toHaveBeenCalledWith({ vendorId: 'vendor-456' }); }); it('propagates errors from mutation', async () => { - mockMutationFn.mockRejectedValueOnce(new Error('Update failed')); - const updateVendor = useUpdateVendor(); + mockMutateAsync.mockRejectedValueOnce(new Error('Update failed')); + const { mutateAsync: updateVendor } = useUpdateVendor(); await expect( updateVendor({ vendorId: toId<'vendors'>('vendor-789'), name: 'Fail' }), diff --git a/services/platform/app/features/vendors/hooks/collections.ts b/services/platform/app/features/vendors/hooks/collections.ts deleted file mode 100644 index 6c492ed46..000000000 --- a/services/platform/app/features/vendors/hooks/collections.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createVendorsCollection } from '@/lib/collections/entities/vendors'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useVendorCollection(organizationId: string) { - return useCollection('vendors', createVendorsCollection, organizationId); -} diff --git a/services/platform/app/features/vendors/hooks/queries.ts b/services/platform/app/features/vendors/hooks/queries.ts index aff94e956..3a4015a09 100644 --- a/services/platform/app/features/vendors/hooks/queries.ts +++ b/services/platform/app/features/vendors/hooks/queries.ts @@ -1,17 +1,18 @@ -import type { Collection } from '@tanstack/db'; - -import { useLiveQuery } from '@tanstack/react-db'; - -import type { Vendor } from '@/lib/collections/entities/vendors'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; import { useCachedPaginatedQuery } from '@/app/hooks/use-cached-paginated-query'; +import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { api } from '@/convex/_generated/api'; -export function useVendors(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export type Vendor = ConvexItemOf; + +export function useVendors(organizationId: string) { + const { data, isLoading } = useConvexQuery(api.vendors.queries.listVendors, { + organizationId, + }); return { - vendors: data, + vendors: data ?? [], isLoading, }; } diff --git a/services/platform/app/features/websites/components/website-add-dialog.tsx b/services/platform/app/features/websites/components/website-add-dialog.tsx index fa0e7dae7..f1a918655 100644 --- a/services/platform/app/features/websites/components/website-add-dialog.tsx +++ b/services/platform/app/features/websites/components/website-add-dialog.tsx @@ -1,7 +1,7 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useState, useMemo } from 'react'; +import { useMemo } from 'react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; @@ -30,8 +30,7 @@ export function AddWebsiteDialog({ organizationId, }: AddWebsiteDialogProps) { const { t: tWebsites } = useT('websites'); - const [isLoading, setIsLoading] = useState(false); - const createWebsite = useCreateWebsite(); + const { mutate: createWebsite, isPending: isLoading } = useCreateWebsite(); const formSchema = useMemo( () => @@ -74,31 +73,31 @@ export function AddWebsiteDialog({ const scanInterval = watch('scanInterval'); - const onSubmit = async (data: FormData) => { - setIsLoading(true); - try { - await createWebsite({ + const onSubmit = (data: FormData) => { + createWebsite( + { organizationId, domain: data.domain, scanInterval: data.scanInterval, - }); - - toast({ - title: tWebsites('toast.addSuccess'), - variant: 'success', - }); - - reset(); - onClose(); - } catch (error) { - console.error('Failed to add website:', error); - toast({ - title: tWebsites('toast.addError'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } + }, + { + onSuccess: () => { + toast({ + title: tWebsites('toast.addSuccess'), + variant: 'success', + }); + reset(); + onClose(); + }, + onError: (error) => { + console.error('Failed to add website:', error); + toast({ + title: tWebsites('toast.addError'), + variant: 'destructive', + }); + }, + }, + ); }; const handleClose = () => { diff --git a/services/platform/app/features/websites/components/website-delete-dialog.tsx b/services/platform/app/features/websites/components/website-delete-dialog.tsx index f467bd2d4..c5f78301e 100644 --- a/services/platform/app/features/websites/components/website-delete-dialog.tsx +++ b/services/platform/app/features/websites/components/website-delete-dialog.tsx @@ -22,7 +22,7 @@ export function DeleteWebsiteDialog({ }: DeleteWebsiteDialogProps) { const { t: tWebsites } = useT('websites'); const { t: tToast } = useT('toast'); - const deleteWebsite = useDeleteWebsite(); + const { mutateAsync: deleteWebsite } = useDeleteWebsite(); const translations = useDeleteDialogTranslations({ tEntity: tWebsites, diff --git a/services/platform/app/features/websites/components/website-edit-dialog.tsx b/services/platform/app/features/websites/components/website-edit-dialog.tsx index fe428078c..4eb1a638e 100644 --- a/services/platform/app/features/websites/components/website-edit-dialog.tsx +++ b/services/platform/app/features/websites/components/website-edit-dialog.tsx @@ -1,7 +1,7 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useState, useEffect, useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; @@ -31,8 +31,7 @@ export function EditWebsiteDialog({ website, }: EditWebsiteDialogProps) { const { t: tWebsites } = useT('websites'); - const [isLoading, setIsLoading] = useState(false); - const updateWebsite = useUpdateWebsite(); + const { mutate: updateWebsite, isPending: isLoading } = useUpdateWebsite(); const formSchema = useMemo( () => @@ -84,30 +83,30 @@ export function EditWebsiteDialog({ } }, [website, reset]); - const onSubmit = async (data: FormData) => { - setIsLoading(true); - try { - await updateWebsite({ + const onSubmit = (data: FormData) => { + updateWebsite( + { websiteId: website._id, domain: data.domain, scanInterval: data.scanInterval, - }); - - toast({ - title: tWebsites('toast.updateSuccess'), - variant: 'success', - }); - - onClose(); - } catch (error) { - console.error('Failed to update website:', error); - toast({ - title: tWebsites('toast.updateError'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } + }, + { + onSuccess: () => { + toast({ + title: tWebsites('toast.updateSuccess'), + variant: 'success', + }); + onClose(); + }, + onError: (error) => { + console.error('Failed to update website:', error); + toast({ + title: tWebsites('toast.updateError'), + variant: 'destructive', + }); + }, + }, + ); }; return ( diff --git a/services/platform/app/features/websites/components/website-row-actions.tsx b/services/platform/app/features/websites/components/website-row-actions.tsx index eb7879964..1066ff0df 100644 --- a/services/platform/app/features/websites/components/website-row-actions.tsx +++ b/services/platform/app/features/websites/components/website-row-actions.tsx @@ -1,7 +1,7 @@ 'use client'; import { Eye, ScanText, RefreshCcw, Pencil, Trash2 } from 'lucide-react'; -import { useState, useMemo, useCallback } from 'react'; +import { useMemo, useCallback } from 'react'; import { EntityRowActions, @@ -25,26 +25,27 @@ export function WebsiteRowActions({ website }: WebsiteRowActionsProps) { const { t: tCommon } = useT('common'); const dialogs = useEntityRowDialogs(['view', 'edit', 'delete']); - const [isRescanning, setIsRescanning] = useState(false); - const rescanWebsite = useRescanWebsite(); + const { mutate: rescanWebsite, isPending: isRescanning } = useRescanWebsite(); - const handleRescan = useCallback(async () => { - setIsRescanning(true); - try { - await rescanWebsite({ websiteId: website._id }); - toast({ - title: t('actions.rescanTriggered'), - variant: 'success', - }); - } catch (error) { - console.error('Failed to rescan website:', error); - toast({ - title: t('actions.rescanFailed'), - variant: 'destructive', - }); - } finally { - setIsRescanning(false); - } + const handleRescan = useCallback(() => { + rescanWebsite( + { websiteId: website._id }, + { + onSuccess: () => { + toast({ + title: t('actions.rescanTriggered'), + variant: 'success', + }); + }, + onError: (error) => { + console.error('Failed to rescan website:', error); + toast({ + title: t('actions.rescanFailed'), + variant: 'destructive', + }); + }, + }, + ); }, [rescanWebsite, website._id, t]); const actions = useMemo( diff --git a/services/platform/app/features/websites/components/websites-table.tsx b/services/platform/app/features/websites/components/websites-table.tsx index ee82edaf9..1b4e9a081 100644 --- a/services/platform/app/features/websites/components/websites-table.tsx +++ b/services/platform/app/features/websites/components/websites-table.tsx @@ -2,12 +2,12 @@ import { Globe } from 'lucide-react'; -import type { Website } from '@/lib/collections/entities/websites'; - import { DataTable } from '@/app/components/ui/data-table/data-table'; import { useListPage } from '@/app/hooks/use-list-page'; import { useT } from '@/lib/i18n/client'; +import type { Website } from '../hooks/queries'; + import { useWebsitesTableConfig } from '../hooks/use-websites-table-config'; import { WebsitesActionMenu } from './websites-action-menu'; diff --git a/services/platform/app/features/websites/hooks/__tests__/collection-hooks.test.ts b/services/platform/app/features/websites/hooks/__tests__/collection-hooks.test.ts deleted file mode 100644 index 6e07affcc..000000000 --- a/services/platform/app/features/websites/hooks/__tests__/collection-hooks.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockCollection = Symbol('collection'); - -vi.mock('@tanstack/react-db', () => ({ - useLiveQuery: vi.fn((_builder: (q: unknown) => unknown) => { - return { data: [], isLoading: false }; - }), -})); - -vi.mock('@/lib/collections/entities/websites', () => ({ - createWebsitesCollection: vi.fn(), -})); - -vi.mock('@/lib/collections/use-collection', () => ({ - useCollection: vi.fn(() => mockCollection), -})); - -import { useLiveQuery } from '@tanstack/react-db'; - -import { useCollection } from '@/lib/collections/use-collection'; - -import { useWebsiteCollection } from '../collections'; -import { useWebsites } from '../queries'; - -const mockUseLiveQuery = vi.mocked(useLiveQuery); - -describe('useWebsiteCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection with correct params', () => { - useWebsiteCollection('org-123'); - expect(useCollection).toHaveBeenCalledWith( - 'websites', - expect.any(Function), - 'org-123', - ); - }); -}); - -describe('useWebsites', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns data from live query', () => { - const items = [{ _id: '1' }, { _id: '2' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: items, - isLoading: false, - } as ReturnType); - - const result = useWebsites(mockCollection as never); - expect(result.websites).toBe(items); - expect(result.isLoading).toBe(false); - }); - - it('returns data even while loading', () => { - const mockData = [{ _id: '1', name: 'Test' }]; - mockUseLiveQuery.mockReturnValueOnce({ - data: mockData, - isLoading: true, - } as ReturnType); - - const result = useWebsites(mockCollection as never); - expect(result.websites).toBe(mockData); - expect(result.isLoading).toBe(true); - }); -}); diff --git a/services/platform/app/features/websites/hooks/collections.ts b/services/platform/app/features/websites/hooks/collections.ts deleted file mode 100644 index 6be1fff87..000000000 --- a/services/platform/app/features/websites/hooks/collections.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createWebsitesCollection } from '@/lib/collections/entities/websites'; -import { useCollection } from '@/lib/collections/use-collection'; - -export function useWebsiteCollection(organizationId: string) { - return useCollection('websites', createWebsitesCollection, organizationId); -} diff --git a/services/platform/app/features/websites/hooks/queries.ts b/services/platform/app/features/websites/hooks/queries.ts index 73de3f53a..c62afb798 100644 --- a/services/platform/app/features/websites/hooks/queries.ts +++ b/services/platform/app/features/websites/hooks/queries.ts @@ -1,14 +1,18 @@ -import type { Collection } from '@tanstack/db'; +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; -import { useLiveQuery } from '@tanstack/react-db'; +import { useConvexQuery } from '@/app/hooks/use-convex-query'; +import { api } from '@/convex/_generated/api'; -import type { Website } from '@/lib/collections/entities/websites'; +export type Website = ConvexItemOf; -export function useWebsites(collection: Collection) { - const { data, isLoading } = useLiveQuery(() => collection); +export function useWebsites(organizationId: string) { + const { data, isLoading } = useConvexQuery( + api.websites.queries.listWebsites, + { organizationId }, + ); return { - websites: data, + websites: data ?? [], isLoading, }; } diff --git a/services/platform/app/hooks/__tests__/use-convex-action.test.ts b/services/platform/app/hooks/__tests__/use-convex-action.test.ts index b8bcccb47..91391d5ec 100644 --- a/services/platform/app/hooks/__tests__/use-convex-action.test.ts +++ b/services/platform/app/hooks/__tests__/use-convex-action.test.ts @@ -1,10 +1,42 @@ -import { useAction } from 'convex/react'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@tanstack/react-query', () => ({ + useMutation: vi.fn((options: { mutationFn: unknown }) => ({ + mutate: options.mutationFn, + mutateAsync: options.mutationFn, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), + })), +})); + +vi.mock('../use-convex-client', () => ({ + useConvexClient: () => ({ + action: vi.fn(), + }), +})); import { useConvexAction } from '../use-convex-action'; +const mockActionRef = {} as Parameters[0]; + describe('useConvexAction', () => { - it('is the same function as useAction from convex/react', () => { - expect(useConvexAction).toBe(useAction); + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns a mutation result object with mutateAsync', () => { + const result = useConvexAction(mockActionRef); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('mutate'); + expect(result).toHaveProperty('isPending'); + }); + + it('returns isPending as false initially', () => { + const result = useConvexAction(mockActionRef); + expect(result.isPending).toBe(false); }); }); diff --git a/services/platform/app/hooks/use-convex-action.ts b/services/platform/app/hooks/use-convex-action.ts index 68f07d3ca..b6dd3c0c9 100644 --- a/services/platform/app/hooks/use-convex-action.ts +++ b/services/platform/app/hooks/use-convex-action.ts @@ -1 +1,25 @@ -export { useAction as useConvexAction } from 'convex/react'; +import type { UseMutationOptions } from '@tanstack/react-query'; +import type { + FunctionArgs, + FunctionReference, + FunctionReturnType, +} from 'convex/server'; + +import { useMutation } from '@tanstack/react-query'; + +import { useConvexClient } from './use-convex-client'; + +export function useConvexAction>( + func: Func, + options?: Omit< + UseMutationOptions, Error, FunctionArgs>, + 'mutationFn' + >, +) { + const convexClient = useConvexClient(); + + return useMutation({ + mutationFn: (args: FunctionArgs) => convexClient.action(func, args), + ...options, + }); +} diff --git a/services/platform/app/hooks/use-convex-mutation.ts b/services/platform/app/hooks/use-convex-mutation.ts index d449e57d8..295221824 100644 --- a/services/platform/app/hooks/use-convex-mutation.ts +++ b/services/platform/app/hooks/use-convex-mutation.ts @@ -1 +1,25 @@ -export { useMutation as useConvexMutation } from 'convex/react'; +import type { UseMutationOptions } from '@tanstack/react-query'; +import type { + FunctionArgs, + FunctionReference, + FunctionReturnType, +} from 'convex/server'; + +import { useMutation } from '@tanstack/react-query'; + +import { useConvexClient } from './use-convex-client'; + +export function useConvexMutation>( + func: Func, + options?: Omit< + UseMutationOptions, Error, FunctionArgs>, + 'mutationFn' + >, +) { + const convexClient = useConvexClient(); + + return useMutation({ + mutationFn: (args: FunctionArgs) => convexClient.mutation(func, args), + ...options, + }); +} diff --git a/services/platform/app/hooks/use-team-filter.tsx b/services/platform/app/hooks/use-team-filter.tsx index 9156a47e7..89cd2ab85 100644 --- a/services/platform/app/hooks/use-team-filter.tsx +++ b/services/platform/app/hooks/use-team-filter.tsx @@ -10,7 +10,6 @@ import { type ReactNode, } from 'react'; -import { useTeamCollection } from '@/app/features/settings/teams/hooks/collections'; import { useTeams } from '@/app/features/settings/teams/hooks/queries'; function getStorageKey(organizationId: string) { @@ -60,8 +59,7 @@ export function TeamFilterProvider({ return localStorage.getItem(storageKey); }); - const teamCollection = useTeamCollection(organizationId); - const { teams, isLoading: isLoadingTeams } = useTeams(teamCollection); + const { teams, isLoading: isLoadingTeams } = useTeams(); // Validate: clear selection if stored team no longer exists const validatedTeamId = diff --git a/services/platform/app/routes/dashboard/$id/_knowledge/websites.tsx b/services/platform/app/routes/dashboard/$id/_knowledge/websites.tsx index 0a6b1ed88..fdc6e7f33 100644 --- a/services/platform/app/routes/dashboard/$id/_knowledge/websites.tsx +++ b/services/platform/app/routes/dashboard/$id/_knowledge/websites.tsx @@ -4,7 +4,6 @@ import { z } from 'zod'; import { WebsitesEmptyState } from '@/app/features/websites/components/websites-empty-state'; import { WebsitesTable } from '@/app/features/websites/components/websites-table'; import { WebsitesTableSkeleton } from '@/app/features/websites/components/websites-table-skeleton'; -import { useWebsiteCollection } from '@/app/features/websites/hooks/collections'; import { useWebsites } from '@/app/features/websites/hooks/queries'; const searchSchema = z.object({ @@ -19,8 +18,7 @@ export const Route = createFileRoute('/dashboard/$id/_knowledge/websites')({ function WebsitesPage() { const { id: organizationId } = Route.useParams(); - const websiteCollection = useWebsiteCollection(organizationId); - const { websites, isLoading } = useWebsites(websiteCollection); + const { websites, isLoading } = useWebsites(organizationId); if (isLoading) { return ; diff --git a/services/platform/app/routes/dashboard/$id/automations/$amId.tsx b/services/platform/app/routes/dashboard/$id/automations/$amId.tsx index c5291ec40..a0ee323d7 100644 --- a/services/platform/app/routes/dashboard/$id/automations/$amId.tsx +++ b/services/platform/app/routes/dashboard/$id/automations/$amId.tsx @@ -5,7 +5,7 @@ import { Link, } from '@tanstack/react-router'; import { ChevronDown } from 'lucide-react'; -import { lazy, Suspense, useState, useRef } from 'react'; +import { lazy, Suspense, useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -26,7 +26,6 @@ import { } from '@/app/components/ui/overlays/dropdown-menu'; import { Button } from '@/app/components/ui/primitives/button'; import { AutomationNavigation } from '@/app/features/automations/components/automation-navigation'; -import { useWorkflowStepCollection } from '@/app/features/automations/hooks/collections'; import { useUpdateAutomation } from '@/app/features/automations/hooks/mutations'; import { useWorkflow, @@ -99,13 +98,12 @@ function AutomationDetailLayout() { ); const [editMode, setEditMode] = useState(false); - const isSubmittingRef = useRef(false); const { register, getValues } = useForm<{ name: string }>(); - const updateWorkflow = useUpdateAutomation(); + const { mutateAsync: updateWorkflow, isPending: isUpdating } = + useUpdateAutomation(); const { data: automation } = useWorkflow(automationId); - const workflowStepCollection = useWorkflowStepCollection(amId); - const { steps } = useWorkflowSteps(workflowStepCollection); + const { steps } = useWorkflowSteps(amId); const { data: memberContext } = useCurrentMemberContext(organizationId); const { data: versions } = useListWorkflowVersions( organizationId, @@ -113,16 +111,13 @@ function AutomationDetailLayout() { ); const handleSubmitAutomationName = async () => { - if (isSubmittingRef.current) return; - isSubmittingRef.current = true; + if (isUpdating) return; if (automation?.name === getValues().name || !getValues().name) { setEditMode(false); - isSubmittingRef.current = false; return; } if (!user?.userId) { setEditMode(false); - isSubmittingRef.current = false; return; } const values = getValues(); @@ -132,7 +127,6 @@ function AutomationDetailLayout() { updatedBy: user.userId, }); setEditMode(false); - isSubmittingRef.current = false; }; const validStatuses = ['draft', 'active', 'inactive', 'archived'] as const; diff --git a/services/platform/app/routes/dashboard/$id/automations/$amId/configuration.tsx b/services/platform/app/routes/dashboard/$id/automations/$amId/configuration.tsx index e62eee633..7d30d6774 100644 --- a/services/platform/app/routes/dashboard/$id/automations/$amId/configuration.tsx +++ b/services/platform/app/routes/dashboard/$id/automations/$amId/configuration.tsx @@ -53,12 +53,12 @@ function ConfigurationPage() { '{\n "environment": "production"\n}', ); const [hasChanges, setHasChanges] = useState(false); - const [isSaving, setIsSaving] = useState(false); const { data: workflow, isLoading: isWorkflowLoading } = useWorkflow(automationId); - const updateWorkflow = useUpdateAutomation(); + const { mutateAsync: updateWorkflow, isPending: isSaving } = + useUpdateAutomation(); useEffect(() => { if (workflow) { @@ -134,7 +134,6 @@ function ConfigurationPage() { } } - setIsSaving(true); try { let parsedVariables: Record | undefined; if (variables.trim()) { @@ -168,8 +167,6 @@ function ConfigurationPage() { title: tToast('error.saveFailed'), variant: 'destructive', }); - } finally { - setIsSaving(false); } }; diff --git a/services/platform/app/routes/dashboard/$id/automations/index.tsx b/services/platform/app/routes/dashboard/$id/automations/index.tsx index 913d6c215..c5de9c3de 100644 --- a/services/platform/app/routes/dashboard/$id/automations/index.tsx +++ b/services/platform/app/routes/dashboard/$id/automations/index.tsx @@ -7,7 +7,6 @@ import { DataTableActionMenu } from '@/app/components/ui/data-table/data-table-a import { DataTableEmptyState } from '@/app/components/ui/data-table/data-table-empty-state'; import { AutomationsClient } from '@/app/features/automations/components/automations-client'; import { AutomationsTableSkeleton } from '@/app/features/automations/components/automations-table-skeleton'; -import { useWfAutomationCollection } from '@/app/features/automations/hooks/collections'; import { useAutomations } from '@/app/features/automations/hooks/queries'; import { useCurrentMemberContext } from '@/app/hooks/use-current-member-context'; import { useT } from '@/lib/i18n/client'; @@ -44,10 +43,8 @@ function AutomationsPage() { const { data: memberContext, isLoading: isMemberLoading } = useCurrentMemberContext(organizationId); - const wfAutomationCollection = useWfAutomationCollection(organizationId); - const { automations, isLoading: isAutomationsLoading } = useAutomations( - wfAutomationCollection, - ); + const { automations, isLoading: isAutomationsLoading } = + useAutomations(organizationId); if (isMemberLoading || isAutomationsLoading) { return ( diff --git a/services/platform/app/routes/dashboard/$id/custom-agents/$agentId.tsx b/services/platform/app/routes/dashboard/$id/custom-agents/$agentId.tsx index 705cf28c3..b4cb1e2a4 100644 --- a/services/platform/app/routes/dashboard/$id/custom-agents/$agentId.tsx +++ b/services/platform/app/routes/dashboard/$id/custom-agents/$agentId.tsx @@ -15,9 +15,10 @@ import { } from '@/app/components/ui/overlays/sheet'; import { CustomAgentNavigation } from '@/app/features/custom-agents/components/custom-agent-navigation'; import { TestChatPanel } from '@/app/features/custom-agents/components/test-chat-panel'; -import { useCustomAgentVersionCollection } from '@/app/features/custom-agents/hooks/collections'; -import { useCustomAgentVersions } from '@/app/features/custom-agents/hooks/queries'; -import { useCustomAgentByVersion } from '@/app/features/custom-agents/hooks/queries'; +import { + useCustomAgentVersions, + useCustomAgentByVersion, +} from '@/app/features/custom-agents/hooks/queries'; import { CustomAgentVersionProvider } from '@/app/features/custom-agents/hooks/use-custom-agent-version-context'; import { useT } from '@/lib/i18n/client'; import { cn } from '@/lib/utils/cn'; @@ -51,10 +52,8 @@ function CustomAgentDetailLayout() { versionNumber, ); - const customAgentVersionCollection = useCustomAgentVersionCollection(agentId); - const { versions, isLoading: isLoadingVersions } = useCustomAgentVersions( - customAgentVersionCollection, - ); + const { versions, isLoading: isLoadingVersions } = + useCustomAgentVersions(agentId); if (isLoadingAgent || isLoadingVersions) { return ( diff --git a/services/platform/app/routes/dashboard/$id/custom-agents/index.tsx b/services/platform/app/routes/dashboard/$id/custom-agents/index.tsx index 864d6e7e8..6dd6cc0cf 100644 --- a/services/platform/app/routes/dashboard/$id/custom-agents/index.tsx +++ b/services/platform/app/routes/dashboard/$id/custom-agents/index.tsx @@ -4,7 +4,6 @@ import { ContentWrapper } from '@/app/components/layout/content-wrapper'; import { CustomAgentTable } from '@/app/features/custom-agents/components/custom-agent-table'; import { CustomAgentsEmptyState } from '@/app/features/custom-agents/components/custom-agents-empty-state'; import { CustomAgentsTableSkeleton } from '@/app/features/custom-agents/components/custom-agents-table-skeleton'; -import { useCustomAgentCollection } from '@/app/features/custom-agents/hooks/collections'; import { useCustomAgents } from '@/app/features/custom-agents/hooks/queries'; export const Route = createFileRoute('/dashboard/$id/custom-agents/')({ @@ -13,8 +12,7 @@ export const Route = createFileRoute('/dashboard/$id/custom-agents/')({ function CustomAgentsIndexPage() { const { id: organizationId } = Route.useParams(); - const customAgentCollection = useCustomAgentCollection(organizationId); - const { agents, isLoading } = useCustomAgents(customAgentCollection); + const { agents, isLoading } = useCustomAgents(organizationId); if (isLoading) { return ( diff --git a/services/platform/app/routes/dashboard/$id/settings/integrations.tsx b/services/platform/app/routes/dashboard/$id/settings/integrations.tsx index a5e349791..3e9624f7e 100644 --- a/services/platform/app/routes/dashboard/$id/settings/integrations.tsx +++ b/services/platform/app/routes/dashboard/$id/settings/integrations.tsx @@ -6,7 +6,6 @@ import { Skeleton } from '@/app/components/ui/feedback/skeleton'; import { Card, CardContent, CardFooter } from '@/app/components/ui/layout/card'; import { Stack, Grid, HStack } from '@/app/components/ui/layout/layout'; import { IntegrationsClient } from '@/app/features/settings/integrations/components/integrations-client'; -import { useIntegrationCollection } from '@/app/features/settings/integrations/hooks/collections'; import { useIntegrations, useSsoProvider, @@ -70,10 +69,8 @@ function IntegrationsPage() { const { data: memberContext, isLoading: isMemberLoading } = useCurrentMemberContext(organizationId); - const integrationCollection = useIntegrationCollection(organizationId); - const { integrations, isLoading: isIntegrationsLoading } = useIntegrations( - integrationCollection, - ); + const { integrations, isLoading: isIntegrationsLoading } = + useIntegrations(organizationId); const { data: ssoProvider, isLoading: isSsoLoading } = useSsoProvider(); if ( diff --git a/services/platform/app/routes/dashboard/create-organization.tsx b/services/platform/app/routes/dashboard/create-organization.tsx index 96efdc922..1f2a71817 100644 --- a/services/platform/app/routes/dashboard/create-organization.tsx +++ b/services/platform/app/routes/dashboard/create-organization.tsx @@ -2,7 +2,6 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { useEffect } from 'react'; import { OrganizationFormClient } from '@/app/features/organization/components/organization-form-client'; -import { useUserOrganizationCollection } from '@/app/features/organization/hooks/collections'; import { useUserOrganizations } from '@/app/features/organization/hooks/queries'; export const Route = createFileRoute('/dashboard/create-organization')({ @@ -16,7 +15,7 @@ function CreateOrganizationPage() { isLoading: isOrgsLoading, isAuthLoading, isAuthenticated, - } = useUserOrganizations(useUserOrganizationCollection()); + } = useUserOrganizations(); useEffect(() => { if (isAuthLoading || !isAuthenticated || isOrgsLoading || !organizations) { diff --git a/services/platform/app/routes/dashboard/index.tsx b/services/platform/app/routes/dashboard/index.tsx index 93276e792..f9c1f1d31 100644 --- a/services/platform/app/routes/dashboard/index.tsx +++ b/services/platform/app/routes/dashboard/index.tsx @@ -1,7 +1,6 @@ import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'; import { useEffect } from 'react'; -import { useUserOrganizationCollection } from '@/app/features/organization/hooks/collections'; import { useUserOrganizations } from '@/app/features/organization/hooks/queries'; import { authClient } from '@/lib/auth-client'; @@ -23,7 +22,7 @@ function DashboardIndex() { isLoading: isOrgsLoading, isAuthLoading, isAuthenticated, - } = useUserOrganizations(useUserOrganizationCollection()); + } = useUserOrganizations(); useEffect(() => { if (isAuthLoading || !isAuthenticated || isOrgsLoading || !organizations) { diff --git a/services/platform/convex/README.md b/services/platform/convex/README.md index 9040ad021..a19c61471 100644 --- a/services/platform/convex/README.md +++ b/services/platform/convex/README.md @@ -5,15 +5,13 @@ Backend functions for the Tale platform. All queries and mutations are protected ## Architecture ``` -Convex WebSocket → ConvexQueryClient → TanStack Query cache - → QueryCollection → Collection sync store → Live Queries → UI +Convex WebSocket → @convex-dev/react-query → TanStack Query cache → UI ``` ### Data Layer -- **Collections** (`lib/collections/`) provide real-time reactive data via TanStack DB, backed by Convex WebSocket subscriptions -- **Queries** return raw data without pagination, filtering, or sorting — all handled client-side via live queries -- **Mutations** are called via `useConvexMutation` — Convex WebSocket auto-syncs collections when mutations complete +- **Queries** are consumed via `useConvexQuery` which bridges Convex real-time subscriptions to TanStack Query. Queries return raw data without pagination, filtering, or sorting — all handled client-side +- **Mutations** are called via `useConvexMutation` (wraps TanStack Query's `useMutation`) — Convex WebSocket auto-syncs queries when mutations complete - **Actions** are called via `useConvexAction` for operations with side effects (external APIs, bulk operations) ### Validation @@ -127,50 +125,39 @@ export const retryRagIndexing = action({ ## Client-Side Patterns -### Collection (real-time list data) +### Query Hooks -Collections are the primary way to consume list data. They provide live, reactive queries powered by TanStack DB. +All data fetching uses `useConvexQuery` which bridges Convex real-time subscriptions to TanStack Query. Types are extracted with `ConvexItemOf`. ```ts -// features/customers/hooks/collections.ts -export function createCustomersCollection(queryClient, convexQueryFn, scopeId) { - return convexCollectionOptions({ - id: 'customers', - queryFn: api.customers.queries.listCustomers, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - }); -} +// features/customers/hooks/queries.ts +import type { ConvexItemOf } from '@/lib/types/convex-helpers'; +import { useConvexQuery } from '@/app/hooks/use-convex-query'; +import { api } from '@/convex/_generated/api'; -// features/customers/hooks/use-customer-collection.ts -export function useCustomerCollection(organizationId: string) { - return useCollection('customers', createCustomersCollection, organizationId); +export type Customer = ConvexItemOf; + +export function useCustomers(organizationId: string) { + const { data, isLoading } = useConvexQuery( + api.customers.queries.listCustomers, + { organizationId }, + ); + return { customers: data ?? [], isLoading }; } ``` -### Live Queries (filtering, sorting, joining) +Conditional/skippable queries pass `'skip'` as args: ```ts -// features/customers/hooks/queries.ts -export function useCustomers(collection: Collection) { - const { data, isLoading } = useLiveQuery((q) => - q.from({ customer: collection }).select(({ customer }) => customer), - ); - return { customers: data, isLoading }; -} - -export function useCustomerByEmail(collection, email: string | undefined) { - const { data } = useLiveQuery( - (q) => q.from({ c: collection }).fn.where((row) => row.c.email === email), - [email], +export function useWorkflow(wfDefinitionId: string | undefined) { + return useConvexQuery( + api.wf_definitions.queries.getWorkflow, + wfDefinitionId ? { wfDefinitionId } : 'skip', ); - return useMemo(() => data?.[0] ?? null, [data]); } ``` -### Mutations (via Convex WebSocket) +### Mutations (via TanStack Query + Convex WebSocket) ```ts // features/customers/hooks/mutations.ts @@ -188,16 +175,6 @@ export function useRetryRagIndexing() { } ``` -### Standalone Queries (non-collection) - -For singleton data or queries that don't fit the collection pattern: - -```ts -export function useCurrentUser() { - return useConvexQuery(api.auth.queries.getCurrentUser); -} -``` - ## Key Rules - **Never use `.collect()`** — use `for await (const item of query)` instead diff --git a/services/platform/lib/collections/__tests__/collection-registry.test.ts b/services/platform/lib/collections/__tests__/collection-registry.test.ts deleted file mode 100644 index 927145b3f..000000000 --- a/services/platform/lib/collections/__tests__/collection-registry.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@tanstack/react-db', () => ({ - createCollection: vi.fn((options: unknown) => ({ - _options: options, - _id: Math.random().toString(36), - })), -})); - -import { createCollection } from '@tanstack/react-db'; - -import { - getOrCreateCollection, - clearCollections, -} from '../collection-registry'; - -const mockCreateCollection = vi.mocked(createCollection); - -const mockQueryClient = {} as Parameters[3]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters[5]; -// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Test mock: simplified factory return type; createCollection is also mocked to accept any options -const mockFactory = vi.fn((orgId: string) => ({ - id: `test-${orgId}`, - _factory: true, -})) as unknown as Parameters[2]; - -describe('collection-registry', () => { - beforeEach(() => { - vi.clearAllMocks(); - clearCollections(); - }); - - describe('getOrCreateCollection', () => { - it('creates a new collection on first call', () => { - const result = getOrCreateCollection( - 'products', - 'org-123', - mockFactory, - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockFactory).toHaveBeenCalledWith( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - expect(mockCreateCollection).toHaveBeenCalledTimes(1); - expect(result).toBeDefined(); - }); - - it('returns cached collection on subsequent calls with same key', () => { - const first = getOrCreateCollection( - 'products', - 'org-123', - mockFactory, - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - const second = getOrCreateCollection( - 'products', - 'org-123', - mockFactory, - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockCreateCollection).toHaveBeenCalledTimes(1); - expect(first).toBe(second); - }); - - it('creates separate collections for different organizations', () => { - const first = getOrCreateCollection( - 'products', - 'org-123', - mockFactory, - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - const second = getOrCreateCollection( - 'products', - 'org-456', - mockFactory, - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockCreateCollection).toHaveBeenCalledTimes(2); - expect(first).not.toBe(second); - }); - - it('creates separate collections for different names', () => { - const first = getOrCreateCollection( - 'products', - 'org-123', - mockFactory, - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - const second = getOrCreateCollection( - 'customers', - 'org-123', - mockFactory, - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockCreateCollection).toHaveBeenCalledTimes(2); - expect(first).not.toBe(second); - }); - }); - - describe('clearCollections', () => { - it('clears all collections when no organizationId provided', () => { - getOrCreateCollection( - 'products', - 'org-123', - mockFactory, - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - getOrCreateCollection( - 'customers', - 'org-456', - mockFactory, - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - clearCollections(); - - // After clearing, new calls should create fresh collections - getOrCreateCollection( - 'products', - 'org-123', - mockFactory, - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - // 2 initial + 1 after clear - expect(mockCreateCollection).toHaveBeenCalledTimes(3); - }); - - it('clears only collections for specified organization', () => { - getOrCreateCollection( - 'products', - 'org-123', - mockFactory, - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - getOrCreateCollection( - 'products', - 'org-456', - mockFactory, - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - clearCollections('org-123'); - - // org-123 should be recreated - getOrCreateCollection( - 'products', - 'org-123', - mockFactory, - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - // org-456 should be cached still - getOrCreateCollection( - 'products', - 'org-456', - mockFactory, - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - // 2 initial + 1 recreated for org-123 - expect(mockCreateCollection).toHaveBeenCalledTimes(3); - }); - }); -}); diff --git a/services/platform/lib/collections/__tests__/convex-collection-options.test.ts b/services/platform/lib/collections/__tests__/convex-collection-options.test.ts deleted file mode 100644 index 07f4a99b4..000000000 --- a/services/platform/lib/collections/__tests__/convex-collection-options.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -import { convexQuery } from '@convex-dev/react-query'; -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -const mockConvexQuery = vi.mocked(convexQuery); -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); - -const mockFuncRef = {} as Parameters< - typeof convexCollectionOptions ->[0]['queryFn']; -const mockQueryClient = {} as Parameters< - typeof convexCollectionOptions ->[0]['queryClient']; -const mockConvexQueryFn = vi.fn(); - -describe('convexCollectionOptions', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('passes convexQuery queryKey to queryCollectionOptions', () => { - const args = { organizationId: 'org-123' }; - - convexCollectionOptions({ - id: 'test', - queryFn: mockFuncRef, - args, - queryClient: mockQueryClient, - convexQueryFn: mockConvexQueryFn, - getKey: (item: { _id: string }) => item._id, - }); - - expect(mockConvexQuery).toHaveBeenCalledWith(mockFuncRef, args); - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - - const passedConfig = mockQueryCollectionOptions.mock.calls[0][0]; - expect(passedConfig).toMatchObject({ - id: 'test', - queryKey: ['convexQuery', mockFuncRef, args], - queryClient: mockQueryClient, - staleTime: Infinity, - }); - }); - - it('wraps convexQueryFn in an async queryFn that delegates and casts', async () => { - convexCollectionOptions({ - id: 'test', - queryFn: mockFuncRef, - args: { organizationId: 'org-123' }, - queryClient: mockQueryClient, - convexQueryFn: mockConvexQueryFn, - getKey: (item: { _id: string }) => item._id, - }); - - const passedConfig = mockQueryCollectionOptions.mock.calls[0][0]; - expect(passedConfig.queryFn).toBeTypeOf('function'); - - const mockData = [{ _id: '1', name: 'Test' }]; - mockConvexQueryFn.mockResolvedValueOnce(mockData); - const mockCtx = { - queryKey: ['test'], - signal: new AbortController().signal, - meta: undefined, - }; - const queryFn = passedConfig.queryFn as (ctx: unknown) => Promise; - const result = await queryFn(mockCtx); - - expect(mockConvexQueryFn).toHaveBeenCalledWith(mockCtx); - expect(result).toEqual(mockData); - }); - - it('wraps mutation handlers to return { refetch: false }', async () => { - const onInsert = vi.fn().mockResolvedValue(undefined); - const onUpdate = vi.fn().mockResolvedValue(undefined); - const onDelete = vi.fn().mockResolvedValue(undefined); - - convexCollectionOptions({ - id: 'test', - queryFn: mockFuncRef, - args: { organizationId: 'org-123' }, - queryClient: mockQueryClient, - convexQueryFn: mockConvexQueryFn, - getKey: (item: { _id: string }) => item._id, - onInsert, - onUpdate, - onDelete, - }); - - const passedConfig = mockQueryCollectionOptions.mock.calls[0][0]; - - const mockParams = { transaction: { mutations: [] }, collection: {} }; - - const insertResult = await passedConfig.onInsert?.(mockParams as never); - expect(onInsert).toHaveBeenCalledWith(mockParams); - expect(insertResult).toEqual({ refetch: false }); - - const updateResult = await passedConfig.onUpdate?.(mockParams as never); - expect(onUpdate).toHaveBeenCalledWith(mockParams); - expect(updateResult).toEqual({ refetch: false }); - - const deleteResult = await passedConfig.onDelete?.(mockParams as never); - expect(onDelete).toHaveBeenCalledWith(mockParams); - expect(deleteResult).toEqual({ refetch: false }); - }); - - it('omits mutation handlers when not provided', () => { - convexCollectionOptions({ - id: 'test', - queryFn: mockFuncRef, - args: { organizationId: 'org-123' }, - queryClient: mockQueryClient, - convexQueryFn: mockConvexQueryFn, - getKey: (item: { _id: string }) => item._id, - }); - - const passedConfig = mockQueryCollectionOptions.mock.calls[0][0]; - expect(passedConfig.onInsert).toBeUndefined(); - expect(passedConfig.onUpdate).toBeUndefined(); - expect(passedConfig.onDelete).toBeUndefined(); - }); -}); diff --git a/services/platform/lib/collections/collection-registry.ts b/services/platform/lib/collections/collection-registry.ts deleted file mode 100644 index 2e9569706..000000000 --- a/services/platform/lib/collections/collection-registry.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Collection } from '@tanstack/db'; -import type { QueryClient, QueryFunction } from '@tanstack/react-query'; -import type { ConvexReactClient } from 'convex/react'; - -import { createCollection } from '@tanstack/react-db'; - -type CollectionFactory = ( - scopeId: string, - queryClient: QueryClient, - convexQueryFn: QueryFunction, - convexClient: ConvexReactClient, -) => Parameters>[0]; - -// Cache stores heterogeneous Collection instances keyed by `${name}:${scopeId}`. -// We use `unknown` since each entry may have a different T/TKey; callers narrow -// via the generic return type of `getOrCreateCollection`. -const collectionCache = new Map(); - -/** - * Get an existing collection from the cache or create a new one. - * Collections are keyed by `${name}:${scopeId}` to ensure - * each scope (organization, entity, user) has its own isolated collection instance. - */ -export function getOrCreateCollection< - T extends object, - TKey extends string | number = string, ->( - name: string, - scopeId: string, - factory: CollectionFactory, - queryClient: QueryClient, - convexQueryFn: QueryFunction, - convexClient: ConvexReactClient, -): Collection { - const cacheKey = `${name}:${scopeId}`; - let collection = collectionCache.get(cacheKey); - - if (!collection) { - const options = factory(scopeId, queryClient, convexQueryFn, convexClient); - collection = createCollection(options); - collectionCache.set(cacheKey, collection); - } - - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Heterogeneous cache stores different Collection types; the factory for a given name always produces Collection - return collection as Collection; -} - -/** - * Clear cached collections. If scopeId is provided, only collections - * for that scope are cleared. Otherwise all collections are cleared. - * Call this when a user switches organizations or signs out. - */ -export function clearCollections(scopeId?: string) { - if (scopeId) { - for (const key of collectionCache.keys()) { - if (key.endsWith(`:${scopeId}`)) { - collectionCache.delete(key); - } - } - } else { - collectionCache.clear(); - } -} - -export type { CollectionFactory }; diff --git a/services/platform/lib/collections/convex-collection-options.ts b/services/platform/lib/collections/convex-collection-options.ts deleted file mode 100644 index 727c0642c..000000000 --- a/services/platform/lib/collections/convex-collection-options.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { - DeleteMutationFnParams, - InsertMutationFnParams, - UpdateMutationFnParams, -} from '@tanstack/db'; -import type { QueryClient, QueryFunction } from '@tanstack/react-query'; -import type { - FunctionArgs, - FunctionReference, - FunctionReturnType, -} from 'convex/server'; - -import { convexQuery } from '@convex-dev/react-query'; -import { queryCollectionOptions } from '@tanstack/query-db-collection'; -import { createCollection } from '@tanstack/react-db'; - -type ConvexQueryRef = FunctionReference<'query'>; - -type ConvexItemOf = - FunctionReturnType extends Array - ? TItem - : never; - -interface ConvexCollectionConfig< - TQuery extends ConvexQueryRef, - TItem extends ConvexItemOf, -> { - id: string; - queryFn: TQuery; - args: FunctionArgs; - queryClient: QueryClient; - convexQueryFn: QueryFunction; - getKey: (item: TItem) => string; - onInsert?: (params: InsertMutationFnParams) => Promise; - onUpdate?: (params: UpdateMutationFnParams) => Promise; - onDelete?: (params: DeleteMutationFnParams) => Promise; -} - -function wrapHandler(handler: (params: TParams) => Promise) { - return async (params: TParams) => { - await handler(params); - // Convex WebSocket subscription pushes updates automatically - // via ConvexQueryClient → TanStack Query cache → QueryObserver, - // so we skip the automatic refetch after mutation handlers. - return { refetch: false }; - }; -} - -/** - * Creates collection options for a Convex query, bridging Convex's real-time - * WebSocket subscriptions into TanStack DB's QueryCollection. - * - * Data flow: - * Convex WebSocket → ConvexQueryClient → TanStack Query cache - * → QueryCollection QueryObserver → Collection sync store → Live Queries → UI - * - * Mutation flow: - * UI → convexClient.mutation() → Convex backend → WebSocket update → Collection syncs automatically - */ -export function convexCollectionOptions< - TQuery extends ConvexQueryRef, - TItem extends ConvexItemOf = ConvexItemOf, ->( - config: ConvexCollectionConfig, -): Parameters>[0] { - const convexOpts = convexQuery(config.queryFn, config.args); - - const options = queryCollectionOptions({ - id: config.id, - queryKey: convexOpts.queryKey, - // ConvexQueryClient's default queryFn sets up WebSocket subscriptions for - // queries with "convexQuery" key prefix and returns TItem[] at runtime. - // It's typed as returning `unknown` since it serves all query types on the - // shared QueryClient, while queryCollectionOptions expects `Promise`. - queryFn: async (ctx) => { - const data = await config.convexQueryFn(ctx); - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- ConvexQueryClient queryFn returns TItem[] at runtime for convexQuery-prefixed keys - return data as TItem[]; - }, - queryClient: config.queryClient, - getKey: config.getKey, - staleTime: Infinity, - onInsert: config.onInsert ? wrapHandler(config.onInsert) : undefined, - onUpdate: config.onUpdate ? wrapHandler(config.onUpdate) : undefined, - onDelete: config.onDelete ? wrapHandler(config.onDelete) : undefined, - }); - - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- @tanstack/query-db-collection resolves TKey to string|number and omits the SingleResult marker required by createCollection; both are third-party type gaps, safe since all Convex document IDs are strings - return options as unknown as Parameters< - typeof createCollection - >[0]; -} - -export type { ConvexCollectionConfig, ConvexItemOf }; diff --git a/services/platform/lib/collections/entities/__tests__/approvals.test.ts b/services/platform/lib/collections/entities/__tests__/approvals.test.ts deleted file mode 100644 index 80576da4d..000000000 --- a/services/platform/lib/collections/entities/__tests__/approvals.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - approvals: { - queries: { - listApprovalsByOrganization: 'listApprovalsByOrganization-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createApprovalsCollection } from '../approvals'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters[3]; - -describe('createApprovalsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createApprovalsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'approvals', - queryKey: [ - 'convexQuery', - 'listApprovalsByOrganization-ref', - { organizationId: 'org-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createApprovalsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createApprovalsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'approval-abc' })).toBe('approval-abc'); - }); - - it('defines mutation handlers', () => { - createApprovalsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeUndefined(); - }); - - it('passes different args per organization', () => { - createApprovalsCollection( - 'org-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createApprovalsCollection( - 'org-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ organizationId: 'org-1' }); - expect(config2.queryKey).toContainEqual({ organizationId: 'org-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/automation-roots.test.ts b/services/platform/lib/collections/entities/__tests__/automation-roots.test.ts deleted file mode 100644 index ddc4b15d8..000000000 --- a/services/platform/lib/collections/entities/__tests__/automation-roots.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - wf_definitions: { - queries: { - listAutomationRoots: 'listAutomationRoots-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createAutomationRootsCollection } from '../automation-roots'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters< - typeof createAutomationRootsCollection ->[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters< - typeof createAutomationRootsCollection ->[3]; - -describe('createAutomationRootsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createAutomationRootsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'automation-roots', - queryKey: [ - 'convexQuery', - 'listAutomationRoots-ref', - { organizationId: 'org-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createAutomationRootsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createAutomationRootsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'root-abc' })).toBe('root-abc'); - }); - - it('does not define mutation handlers', () => { - createAutomationRootsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeUndefined(); - expect(config.onDelete).toBeUndefined(); - }); - - it('passes different args per organization', () => { - createAutomationRootsCollection( - 'org-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createAutomationRootsCollection( - 'org-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ organizationId: 'org-1' }); - expect(config2.queryKey).toContainEqual({ organizationId: 'org-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/available-integrations.test.ts b/services/platform/lib/collections/entities/__tests__/available-integrations.test.ts deleted file mode 100644 index 91983ac7b..000000000 --- a/services/platform/lib/collections/entities/__tests__/available-integrations.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - custom_agents: { - queries: { - getAvailableIntegrations: 'getAvailableIntegrations-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createAvailableIntegrationsCollection } from '../available-integrations'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters< - typeof createAvailableIntegrationsCollection ->[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters< - typeof createAvailableIntegrationsCollection ->[3]; - -describe('createAvailableIntegrationsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createAvailableIntegrationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'available-integrations', - queryKey: [ - 'convexQuery', - 'getAvailableIntegrations-ref', - { organizationId: 'org-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createAvailableIntegrationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses name as the collection key', () => { - createAvailableIntegrationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { name: string }) => string; - expect(getKey({ name: 'slack' })).toBe('slack'); - }); - - it('does not define mutation handlers', () => { - createAvailableIntegrationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeUndefined(); - expect(config.onDelete).toBeUndefined(); - }); - - it('passes different args per organization', () => { - createAvailableIntegrationsCollection( - 'org-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createAvailableIntegrationsCollection( - 'org-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ organizationId: 'org-1' }); - expect(config2.queryKey).toContainEqual({ organizationId: 'org-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/conversations.test.ts b/services/platform/lib/collections/entities/__tests__/conversations.test.ts deleted file mode 100644 index c37c0722c..000000000 --- a/services/platform/lib/collections/entities/__tests__/conversations.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - conversations: { - queries: { - listConversations: 'listConversations-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createConversationsCollection } from '../conversations'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters< - typeof createConversationsCollection ->[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters< - typeof createConversationsCollection ->[3]; - -describe('createConversationsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createConversationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'conversations', - queryKey: [ - 'convexQuery', - 'listConversations-ref', - { organizationId: 'org-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createConversationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createConversationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'conv-abc' })).toBe('conv-abc'); - }); - - it('defines onUpdate handler but not onInsert or onDelete', () => { - createConversationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeUndefined(); - }); - - it('passes different args per organization', () => { - createConversationsCollection( - 'org-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createConversationsCollection( - 'org-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ organizationId: 'org-1' }); - expect(config2.queryKey).toContainEqual({ organizationId: 'org-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/custom-agent-versions.test.ts b/services/platform/lib/collections/entities/__tests__/custom-agent-versions.test.ts deleted file mode 100644 index af36d30b6..000000000 --- a/services/platform/lib/collections/entities/__tests__/custom-agent-versions.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - custom_agents: { - queries: { - getCustomAgentVersions: 'getCustomAgentVersions-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createCustomAgentVersionsCollection } from '../custom-agent-versions'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters< - typeof createCustomAgentVersionsCollection ->[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters< - typeof createCustomAgentVersionsCollection ->[3]; - -describe('createCustomAgentVersionsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createCustomAgentVersionsCollection( - 'agent-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'custom-agent-versions', - queryKey: [ - 'convexQuery', - 'getCustomAgentVersions-ref', - { customAgentId: 'agent-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createCustomAgentVersionsCollection( - 'agent-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createCustomAgentVersionsCollection( - 'agent-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'ver-abc' })).toBe('ver-abc'); - }); - - it('does not define mutation handlers', () => { - createCustomAgentVersionsCollection( - 'agent-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeUndefined(); - expect(config.onDelete).toBeUndefined(); - }); - - it('scopes by customAgentId', () => { - createCustomAgentVersionsCollection( - 'agent-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createCustomAgentVersionsCollection( - 'agent-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ customAgentId: 'agent-1' }); - expect(config2.queryKey).toContainEqual({ customAgentId: 'agent-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/custom-agent-webhooks.test.ts b/services/platform/lib/collections/entities/__tests__/custom-agent-webhooks.test.ts deleted file mode 100644 index 23344aa5f..000000000 --- a/services/platform/lib/collections/entities/__tests__/custom-agent-webhooks.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - custom_agents: { - webhooks: { - queries: { - getWebhooks: 'getWebhooks-ref', - }, - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createCustomAgentWebhooksCollection } from '../custom-agent-webhooks'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters< - typeof createCustomAgentWebhooksCollection ->[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters< - typeof createCustomAgentWebhooksCollection ->[3]; - -describe('createCustomAgentWebhooksCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createCustomAgentWebhooksCollection( - 'agent-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'custom-agent-webhooks', - queryKey: [ - 'convexQuery', - 'getWebhooks-ref', - { customAgentId: 'agent-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createCustomAgentWebhooksCollection( - 'agent-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createCustomAgentWebhooksCollection( - 'agent-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'wh-abc' })).toBe('wh-abc'); - }); - - it('defines mutation handlers', () => { - createCustomAgentWebhooksCollection( - 'agent-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeDefined(); - }); - - it('scopes by customAgentId', () => { - createCustomAgentWebhooksCollection( - 'agent-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createCustomAgentWebhooksCollection( - 'agent-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ customAgentId: 'agent-1' }); - expect(config2.queryKey).toContainEqual({ customAgentId: 'agent-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/custom-agents.test.ts b/services/platform/lib/collections/entities/__tests__/custom-agents.test.ts deleted file mode 100644 index 784c2a27c..000000000 --- a/services/platform/lib/collections/entities/__tests__/custom-agents.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - custom_agents: { - queries: { - listCustomAgents: 'listCustomAgents-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createCustomAgentsCollection } from '../custom-agents'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters< - typeof createCustomAgentsCollection ->[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters< - typeof createCustomAgentsCollection ->[3]; - -describe('createCustomAgentsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createCustomAgentsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'custom-agents', - queryKey: [ - 'convexQuery', - 'listCustomAgents-ref', - { organizationId: 'org-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createCustomAgentsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createCustomAgentsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'agent-abc' })).toBe('agent-abc'); - }); - - it('defines mutation handlers', () => { - createCustomAgentsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeDefined(); - }); - - it('passes different args per organization', () => { - createCustomAgentsCollection( - 'org-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createCustomAgentsCollection( - 'org-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ organizationId: 'org-1' }); - expect(config2.queryKey).toContainEqual({ organizationId: 'org-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/customers.test.ts b/services/platform/lib/collections/entities/__tests__/customers.test.ts deleted file mode 100644 index bd7dbfc53..000000000 --- a/services/platform/lib/collections/entities/__tests__/customers.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - customers: { - queries: { - listCustomers: 'listCustomers-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createCustomersCollection } from '../customers'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters[3]; - -describe('createCustomersCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createCustomersCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'customers', - queryKey: [ - 'convexQuery', - 'listCustomers-ref', - { organizationId: 'org-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createCustomersCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createCustomersCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'customer-abc' })).toBe('customer-abc'); - }); - - it('defines mutation handlers', () => { - createCustomersCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeDefined(); - }); - - it('passes different args per organization', () => { - createCustomersCollection( - 'org-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createCustomersCollection( - 'org-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ organizationId: 'org-1' }); - expect(config2.queryKey).toContainEqual({ organizationId: 'org-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/documents.test.ts b/services/platform/lib/collections/entities/__tests__/documents.test.ts deleted file mode 100644 index 51cc3f104..000000000 --- a/services/platform/lib/collections/entities/__tests__/documents.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - documents: { - queries: { - listDocuments: 'listDocuments-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createDocumentsCollection } from '../documents'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters[3]; - -describe('createDocumentsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createDocumentsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'documents', - queryKey: [ - 'convexQuery', - 'listDocuments-ref', - { organizationId: 'org-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createDocumentsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses id as the collection key', () => { - createDocumentsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { id: string }) => string; - expect(getKey({ id: 'doc-abc' })).toBe('doc-abc'); - }); - - it('defines mutation handlers', () => { - createDocumentsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeDefined(); - }); - - it('passes different args per organization', () => { - createDocumentsCollection( - 'org-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createDocumentsCollection( - 'org-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ organizationId: 'org-1' }); - expect(config2.queryKey).toContainEqual({ organizationId: 'org-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/integrations.test.ts b/services/platform/lib/collections/entities/__tests__/integrations.test.ts deleted file mode 100644 index 9f5d3f473..000000000 --- a/services/platform/lib/collections/entities/__tests__/integrations.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - integrations: { - queries: { - list: 'integrations-list-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createIntegrationsCollection } from '../integrations'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters< - typeof createIntegrationsCollection ->[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters< - typeof createIntegrationsCollection ->[3]; - -describe('createIntegrationsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createIntegrationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'integrations', - queryKey: [ - 'convexQuery', - 'integrations-list-ref', - { organizationId: 'org-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createIntegrationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createIntegrationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'integration-abc' })).toBe('integration-abc'); - }); - - it('defines mutation handlers', () => { - createIntegrationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeUndefined(); - expect(config.onDelete).toBeDefined(); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/members.test.ts b/services/platform/lib/collections/entities/__tests__/members.test.ts deleted file mode 100644 index 5a0e53639..000000000 --- a/services/platform/lib/collections/entities/__tests__/members.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - members: { - queries: { - listByOrganization: 'listByOrganization-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createMembersCollection } from '../members'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters[3]; - -describe('createMembersCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createMembersCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'members', - queryKey: [ - 'convexQuery', - 'listByOrganization-ref', - { organizationId: 'org-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createMembersCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createMembersCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'member-abc' })).toBe('member-abc'); - }); - - it('defines mutation handlers', () => { - createMembersCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeDefined(); - }); - - it('passes different args per organization', () => { - createMembersCollection( - 'org-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createMembersCollection( - 'org-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ organizationId: 'org-1' }); - expect(config2.queryKey).toContainEqual({ organizationId: 'org-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/products.test.ts b/services/platform/lib/collections/entities/__tests__/products.test.ts deleted file mode 100644 index b8908ea48..000000000 --- a/services/platform/lib/collections/entities/__tests__/products.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - products: { - queries: { - listProducts: 'listProducts-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createProductsCollection } from '../products'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters[3]; - -describe('createProductsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createProductsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'products', - queryKey: [ - 'convexQuery', - 'listProducts-ref', - { organizationId: 'org-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createProductsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createProductsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'product-abc' })).toBe('product-abc'); - }); - - it('defines mutation handlers', () => { - createProductsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeDefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeDefined(); - }); - - it('passes different args per organization', () => { - createProductsCollection( - 'org-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createProductsCollection( - 'org-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ organizationId: 'org-1' }); - expect(config2.queryKey).toContainEqual({ organizationId: 'org-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/team-members.test.ts b/services/platform/lib/collections/entities/__tests__/team-members.test.ts deleted file mode 100644 index 99142d6bc..000000000 --- a/services/platform/lib/collections/entities/__tests__/team-members.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - team_members: { - queries: { - listByTeam: 'listByTeam-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createTeamMembersCollection } from '../team-members'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters< - typeof createTeamMembersCollection ->[3]; - -describe('createTeamMembersCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createTeamMembersCollection( - 'team-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'team-members', - queryKey: ['convexQuery', 'listByTeam-ref', { teamId: 'team-123' }], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createTeamMembersCollection( - 'team-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createTeamMembersCollection( - 'team-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'member-abc' })).toBe('member-abc'); - }); - - it('defines mutation handlers', () => { - createTeamMembersCollection( - 'team-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeDefined(); - expect(config.onUpdate).toBeUndefined(); - expect(config.onDelete).toBeDefined(); - }); - - it('scopes by teamId', () => { - createTeamMembersCollection( - 'team-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createTeamMembersCollection( - 'team-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ teamId: 'team-1' }); - expect(config2.queryKey).toContainEqual({ teamId: 'team-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/teams.test.ts b/services/platform/lib/collections/entities/__tests__/teams.test.ts deleted file mode 100644 index 8291e46c9..000000000 --- a/services/platform/lib/collections/entities/__tests__/teams.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - members: { - queries: { - getMyTeams: 'getMyTeams-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createTeamsCollection } from '../teams'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters[3]; - -describe('createTeamsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createTeamsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'teams', - queryKey: [ - 'convexQuery', - 'getMyTeams-ref', - { organizationId: 'org-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createTeamsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses id as the collection key', () => { - createTeamsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { id: string }) => string; - expect(getKey({ id: 'team-abc' })).toBe('team-abc'); - }); - - it('does not define mutation handlers', () => { - createTeamsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeUndefined(); - expect(config.onDelete).toBeUndefined(); - }); - - it('passes different args per organization', () => { - createTeamsCollection( - 'org-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createTeamsCollection( - 'org-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ organizationId: 'org-1' }); - expect(config2.queryKey).toContainEqual({ organizationId: 'org-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/threads.test.ts b/services/platform/lib/collections/entities/__tests__/threads.test.ts deleted file mode 100644 index c7c8ea19a..000000000 --- a/services/platform/lib/collections/entities/__tests__/threads.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - threads: { - queries: { - listThreads: 'listThreads-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createThreadsCollection } from '../threads'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters[3]; - -describe('createThreadsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createThreadsCollection( - 'user-threads', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'threads', - queryKey: ['convexQuery', 'listThreads-ref', {}], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createThreadsCollection( - 'user-threads', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createThreadsCollection( - 'user-threads', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'thread-abc' })).toBe('thread-abc'); - }); - - it('defines mutation handlers', () => { - createThreadsCollection( - 'user-threads', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeDefined(); - }); - - it('passes empty args regardless of scope key', () => { - createThreadsCollection( - 'user-a', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createThreadsCollection( - 'user-b', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({}); - expect(config2.queryKey).toContainEqual({}); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/vendors.test.ts b/services/platform/lib/collections/entities/__tests__/vendors.test.ts deleted file mode 100644 index 877c3b597..000000000 --- a/services/platform/lib/collections/entities/__tests__/vendors.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - vendors: { - queries: { - listVendors: 'listVendors-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createVendorsCollection } from '../vendors'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters[3]; - -describe('createVendorsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createVendorsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'vendors', - queryKey: [ - 'convexQuery', - 'listVendors-ref', - { organizationId: 'org-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createVendorsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createVendorsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'vendor-abc' })).toBe('vendor-abc'); - }); - - it('defines mutation handlers', () => { - createVendorsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeDefined(); - }); - - it('passes different args per organization', () => { - createVendorsCollection( - 'org-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createVendorsCollection( - 'org-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ organizationId: 'org-1' }); - expect(config2.queryKey).toContainEqual({ organizationId: 'org-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/websites.test.ts b/services/platform/lib/collections/entities/__tests__/websites.test.ts deleted file mode 100644 index d1b057b4c..000000000 --- a/services/platform/lib/collections/entities/__tests__/websites.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - websites: { - queries: { - listWebsites: 'listWebsites-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createWebsitesCollection } from '../websites'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters[3]; - -describe('createWebsitesCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createWebsitesCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'websites', - queryKey: [ - 'convexQuery', - 'listWebsites-ref', - { organizationId: 'org-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createWebsitesCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createWebsitesCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'website-abc' })).toBe('website-abc'); - }); - - it('defines mutation handlers', () => { - createWebsitesCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeDefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeDefined(); - }); - - it('passes different args per organization', () => { - createWebsitesCollection( - 'org-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createWebsitesCollection( - 'org-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ organizationId: 'org-1' }); - expect(config2.queryKey).toContainEqual({ organizationId: 'org-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/wf-automations.test.ts b/services/platform/lib/collections/entities/__tests__/wf-automations.test.ts deleted file mode 100644 index 6695f6d99..000000000 --- a/services/platform/lib/collections/entities/__tests__/wf-automations.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - wf_definitions: { - queries: { - listAutomations: 'listAutomations-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createWfAutomationsCollection } from '../wf-automations'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters< - typeof createWfAutomationsCollection ->[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters< - typeof createWfAutomationsCollection ->[3]; - -describe('createWfAutomationsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createWfAutomationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'wf-automations', - queryKey: [ - 'convexQuery', - 'listAutomations-ref', - { organizationId: 'org-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createWfAutomationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createWfAutomationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'wf-abc' })).toBe('wf-abc'); - }); - - it('defines mutation handlers', () => { - createWfAutomationsCollection( - 'org-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeUndefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeDefined(); - }); - - it('passes different args per organization', () => { - createWfAutomationsCollection( - 'org-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createWfAutomationsCollection( - 'org-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ organizationId: 'org-1' }); - expect(config2.queryKey).toContainEqual({ organizationId: 'org-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/wf-event-subscriptions.test.ts b/services/platform/lib/collections/entities/__tests__/wf-event-subscriptions.test.ts deleted file mode 100644 index 9ea602ee7..000000000 --- a/services/platform/lib/collections/entities/__tests__/wf-event-subscriptions.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/lib/utils/type-guards', () => ({ - toId: vi.fn((id: string) => id), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - workflows: { - triggers: { - queries: { - getEventSubscriptions: 'getEventSubscriptions-ref', - }, - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createWfEventSubscriptionsCollection } from '../wf-event-subscriptions'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters< - typeof createWfEventSubscriptionsCollection ->[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters< - typeof createWfEventSubscriptionsCollection ->[3]; - -describe('createWfEventSubscriptionsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createWfEventSubscriptionsCollection( - 'root-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'wf-event-subscriptions', - queryKey: [ - 'convexQuery', - 'getEventSubscriptions-ref', - { workflowRootId: 'root-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createWfEventSubscriptionsCollection( - 'root-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createWfEventSubscriptionsCollection( - 'root-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'sub-abc' })).toBe('sub-abc'); - }); - - it('defines mutation handlers', () => { - createWfEventSubscriptionsCollection( - 'root-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeDefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeDefined(); - }); - - it('scopes by workflowRootId', () => { - createWfEventSubscriptionsCollection( - 'root-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createWfEventSubscriptionsCollection( - 'root-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ workflowRootId: 'root-1' }); - expect(config2.queryKey).toContainEqual({ workflowRootId: 'root-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/wf-schedules.test.ts b/services/platform/lib/collections/entities/__tests__/wf-schedules.test.ts deleted file mode 100644 index 9ae3b460c..000000000 --- a/services/platform/lib/collections/entities/__tests__/wf-schedules.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/lib/utils/type-guards', () => ({ - toId: vi.fn((id: string) => id), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - workflows: { - triggers: { - queries: { - getSchedules: 'getSchedules-ref', - }, - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createWfSchedulesCollection } from '../wf-schedules'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters< - typeof createWfSchedulesCollection ->[3]; - -describe('createWfSchedulesCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createWfSchedulesCollection( - 'root-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'wf-schedules', - queryKey: [ - 'convexQuery', - 'getSchedules-ref', - { workflowRootId: 'root-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createWfSchedulesCollection( - 'root-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createWfSchedulesCollection( - 'root-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'schedule-abc' })).toBe('schedule-abc'); - }); - - it('defines mutation handlers', () => { - createWfSchedulesCollection( - 'root-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeDefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeDefined(); - }); - - it('scopes by workflowRootId', () => { - createWfSchedulesCollection( - 'root-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createWfSchedulesCollection( - 'root-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ workflowRootId: 'root-1' }); - expect(config2.queryKey).toContainEqual({ workflowRootId: 'root-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/wf-steps.test.ts b/services/platform/lib/collections/entities/__tests__/wf-steps.test.ts deleted file mode 100644 index 10220a8a3..000000000 --- a/services/platform/lib/collections/entities/__tests__/wf-steps.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - wf_step_defs: { - queries: { - getWorkflowSteps: 'getWorkflowSteps-ref', - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createWfStepsCollection } from '../wf-steps'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters[3]; - -describe('createWfStepsCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createWfStepsCollection( - 'def-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'wf-steps', - queryKey: [ - 'convexQuery', - 'getWorkflowSteps-ref', - { wfDefinitionId: 'def-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createWfStepsCollection( - 'def-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createWfStepsCollection( - 'def-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'step-abc' })).toBe('step-abc'); - }); - - it('defines onInsert and onUpdate handlers', () => { - createWfStepsCollection( - 'def-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeDefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeUndefined(); - }); - - it('scopes by wfDefinitionId', () => { - createWfStepsCollection( - 'def-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createWfStepsCollection( - 'def-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ wfDefinitionId: 'def-1' }); - expect(config2.queryKey).toContainEqual({ wfDefinitionId: 'def-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/__tests__/wf-webhooks.test.ts b/services/platform/lib/collections/entities/__tests__/wf-webhooks.test.ts deleted file mode 100644 index 6565dc0c7..000000000 --- a/services/platform/lib/collections/entities/__tests__/wf-webhooks.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('@convex-dev/react-query', () => ({ - convexQuery: vi.fn((...args: unknown[]) => ({ - queryKey: ['convexQuery', ...args], - })), -})); - -vi.mock('@tanstack/query-db-collection', () => ({ - queryCollectionOptions: vi.fn((config: Record) => ({ - ...config, - _type: 'queryCollectionOptions', - })), -})); - -vi.mock('@/lib/utils/type-guards', () => ({ - toId: vi.fn((id: string) => id), -})); - -vi.mock('@/convex/_generated/api', () => ({ - api: { - workflows: { - triggers: { - queries: { - getWebhooks: 'getWebhooks-ref', - }, - }, - }, - }, -})); - -import { queryCollectionOptions } from '@tanstack/query-db-collection'; - -import { createWfWebhooksCollection } from '../wf-webhooks'; - -const mockQueryCollectionOptions = vi.mocked(queryCollectionOptions); -const mockQueryClient = {} as Parameters[1]; -const mockConvexQueryFn = vi.fn(); -const mockConvexClient = {} as Parameters[3]; - -describe('createWfWebhooksCollection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates collection options with correct id and query', () => { - createWfWebhooksCollection( - 'root-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - expect(mockQueryCollectionOptions).toHaveBeenCalledTimes(1); - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config).toMatchObject({ - id: 'wf-webhooks', - queryKey: [ - 'convexQuery', - 'getWebhooks-ref', - { workflowRootId: 'root-123' }, - ], - staleTime: Infinity, - }); - }); - - it('provides a queryFn wrapper', () => { - createWfWebhooksCollection( - 'root-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.queryFn).toBeTypeOf('function'); - }); - - it('uses _id as the collection key', () => { - createWfWebhooksCollection( - 'root-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - const getKey = config.getKey as (item: { _id: string }) => string; - expect(getKey({ _id: 'wh-abc' })).toBe('wh-abc'); - }); - - it('defines mutation handlers', () => { - createWfWebhooksCollection( - 'root-123', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config = mockQueryCollectionOptions.mock.calls[0][0]; - expect(config.onInsert).toBeDefined(); - expect(config.onUpdate).toBeDefined(); - expect(config.onDelete).toBeDefined(); - }); - - it('scopes by workflowRootId', () => { - createWfWebhooksCollection( - 'root-1', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - createWfWebhooksCollection( - 'root-2', - mockQueryClient, - mockConvexQueryFn, - mockConvexClient, - ); - - const config1 = mockQueryCollectionOptions.mock.calls[0][0]; - const config2 = mockQueryCollectionOptions.mock.calls[1][0]; - expect(config1.queryKey).toContainEqual({ workflowRootId: 'root-1' }); - expect(config2.queryKey).toContainEqual({ workflowRootId: 'root-2' }); - }); -}); diff --git a/services/platform/lib/collections/entities/approvals.ts b/services/platform/lib/collections/entities/approvals.ts deleted file mode 100644 index fc0023d54..000000000 --- a/services/platform/lib/collections/entities/approvals.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type Approval = ConvexItemOf< - typeof api.approvals.queries.listApprovalsByOrganization ->; - -export const createApprovalsCollection: CollectionFactory = ( - scopeId, - queryClient, - convexQueryFn, - convexClient, -) => - convexCollectionOptions({ - id: 'approvals', - queryFn: api.approvals.queries.listApprovalsByOrganization, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation(api.approvals.mutations.updateApprovalStatus, { - approvalId: toId<'approvals'>(m.key), - status: m.modified.status, - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- comments field present at runtime via collection.update(); not part of Approval query return type - comments: (m.modified as { comments?: string }).comments, - }), - ), - ); - }, - }); - -export type { Approval }; diff --git a/services/platform/lib/collections/entities/automation-roots.ts b/services/platform/lib/collections/entities/automation-roots.ts deleted file mode 100644 index 166e2967d..000000000 --- a/services/platform/lib/collections/entities/automation-roots.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { api } from '@/convex/_generated/api'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type AutomationRoot = ConvexItemOf< - typeof api.wf_definitions.queries.listAutomationRoots ->; - -export const createAutomationRootsCollection: CollectionFactory< - AutomationRoot, - string -> = (scopeId, queryClient, convexQueryFn) => - convexCollectionOptions({ - id: 'automation-roots', - queryFn: api.wf_definitions.queries.listAutomationRoots, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - }); - -export type { AutomationRoot }; diff --git a/services/platform/lib/collections/entities/available-integrations.ts b/services/platform/lib/collections/entities/available-integrations.ts deleted file mode 100644 index d697e6157..000000000 --- a/services/platform/lib/collections/entities/available-integrations.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { api } from '@/convex/_generated/api'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type AvailableIntegration = ConvexItemOf< - typeof api.custom_agents.queries.getAvailableIntegrations ->; - -export const createAvailableIntegrationsCollection: CollectionFactory< - AvailableIntegration, - string -> = (scopeId, queryClient, convexQueryFn) => - convexCollectionOptions({ - id: 'available-integrations', - queryFn: api.custom_agents.queries.getAvailableIntegrations, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item.name, - }); - -export type { AvailableIntegration }; diff --git a/services/platform/lib/collections/entities/available-tools.ts b/services/platform/lib/collections/entities/available-tools.ts deleted file mode 100644 index 9cd643853..000000000 --- a/services/platform/lib/collections/entities/available-tools.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { api } from '@/convex/_generated/api'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type AvailableTool = ConvexItemOf< - typeof api.custom_agents.queries.getAvailableTools ->; - -export const createAvailableToolsCollection: CollectionFactory< - AvailableTool, - string -> = (_scopeId, queryClient, convexQueryFn) => - convexCollectionOptions({ - id: 'available-tools', - queryFn: api.custom_agents.queries.getAvailableTools, - args: {}, - queryClient, - convexQueryFn, - getKey: (item) => item.name, - }); - -export type { AvailableTool }; diff --git a/services/platform/lib/collections/entities/conversations.ts b/services/platform/lib/collections/entities/conversations.ts deleted file mode 100644 index 97613f00a..000000000 --- a/services/platform/lib/collections/entities/conversations.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type Conversation = ConvexItemOf< - typeof api.conversations.queries.listConversations ->; - -export const createConversationsCollection: CollectionFactory< - Conversation, - string -> = (scopeId, queryClient, convexQueryFn, convexClient) => - convexCollectionOptions({ - id: 'conversations', - queryFn: api.conversations.queries.listConversations, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.flatMap((m) => { - const promises: Promise[] = []; - const { changes } = m; - const conversationId = toId<'conversations'>(m.key); - - if (changes.status === 'closed') { - promises.push( - convexClient.mutation( - api.conversations.mutations.closeConversation, - { conversationId }, - ), - ); - } else if (changes.status === 'open') { - promises.push( - convexClient.mutation( - api.conversations.mutations.reopenConversation, - { conversationId }, - ), - ); - } else if (changes.status === 'spam') { - promises.push( - convexClient.mutation( - api.conversations.mutations.markConversationAsSpam, - { conversationId }, - ), - ); - } - - if (changes.unread_count === 0) { - promises.push( - convexClient.mutation( - api.conversations.mutations.markConversationAsRead, - { conversationId }, - ), - ); - } - - return promises; - }), - ); - }, - }); - -export type { Conversation }; diff --git a/services/platform/lib/collections/entities/custom-agent-versions.ts b/services/platform/lib/collections/entities/custom-agent-versions.ts deleted file mode 100644 index edc615dca..000000000 --- a/services/platform/lib/collections/entities/custom-agent-versions.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type CustomAgentVersion = ConvexItemOf< - typeof api.custom_agents.queries.getCustomAgentVersions ->; - -export const createCustomAgentVersionsCollection: CollectionFactory< - CustomAgentVersion, - string -> = (scopeId, queryClient, convexQueryFn) => - convexCollectionOptions({ - id: 'custom-agent-versions', - queryFn: api.custom_agents.queries.getCustomAgentVersions, - args: { customAgentId: toId<'customAgents'>(scopeId) }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - }); - -export type { CustomAgentVersion }; diff --git a/services/platform/lib/collections/entities/custom-agent-webhooks.ts b/services/platform/lib/collections/entities/custom-agent-webhooks.ts deleted file mode 100644 index 87e4076f9..000000000 --- a/services/platform/lib/collections/entities/custom-agent-webhooks.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type CustomAgentWebhook = ConvexItemOf< - typeof api.custom_agents.webhooks.queries.getWebhooks ->; - -export const createCustomAgentWebhooksCollection: CollectionFactory< - CustomAgentWebhook, - string -> = (scopeId, queryClient, convexQueryFn, convexClient) => - convexCollectionOptions({ - id: 'custom-agent-webhooks', - queryFn: api.custom_agents.webhooks.queries.getWebhooks, - args: { customAgentId: toId<'customAgents'>(scopeId) }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - if ('isActive' in m.changes && m.changes.isActive !== undefined) { - return convexClient.mutation( - api.custom_agents.webhooks.mutations.toggleWebhook, - { - webhookId: toId<'customAgentWebhooks'>(m.key), - isActive: m.changes.isActive, - }, - ); - } - return Promise.resolve(); - }), - ); - }, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation( - api.custom_agents.webhooks.mutations.deleteWebhook, - { - webhookId: toId<'customAgentWebhooks'>(m.key), - }, - ), - ), - ); - }, - }); - -export type { CustomAgentWebhook }; diff --git a/services/platform/lib/collections/entities/custom-agents.ts b/services/platform/lib/collections/entities/custom-agents.ts deleted file mode 100644 index a8e803c87..000000000 --- a/services/platform/lib/collections/entities/custom-agents.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type CustomAgent = ConvexItemOf< - typeof api.custom_agents.queries.listCustomAgents ->; - -export const createCustomAgentsCollection: CollectionFactory< - CustomAgent, - string -> = (scopeId, queryClient, convexQueryFn, convexClient) => - convexCollectionOptions({ - id: 'custom-agents', - queryFn: api.custom_agents.queries.listCustomAgents, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - const { - _id, - _creationTime, - organizationId: _org, - rootVersionId: _root, - versionNumber: _ver, - status: _status, - isActive: _active, - createdBy: _created, - publishedAt: _pubAt, - publishedBy: _pubBy, - parentVersionId: _parent, - changeLog: _cl, - ...fields - } = m.changes; - return convexClient.mutation( - api.custom_agents.mutations.updateCustomAgent, - { - customAgentId: toId<'customAgents'>(m.key), - ...fields, - }, - ); - }), - ); - }, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation(api.custom_agents.mutations.deleteCustomAgent, { - customAgentId: toId<'customAgents'>(m.key), - }), - ), - ); - }, - }); - -export type { CustomAgent }; diff --git a/services/platform/lib/collections/entities/customers.ts b/services/platform/lib/collections/entities/customers.ts deleted file mode 100644 index 16c97ac13..000000000 --- a/services/platform/lib/collections/entities/customers.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type Customer = ConvexItemOf; - -export const createCustomersCollection: CollectionFactory = ( - scopeId, - queryClient, - convexQueryFn, - convexClient, -) => - convexCollectionOptions({ - id: 'customers', - queryFn: api.customers.queries.listCustomers, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - const { - _id, - _creationTime, - organizationId: _org, - externalId, - ...fields - } = m.changes; - return convexClient.mutation(api.customers.mutations.updateCustomer, { - customerId: toId<'customers'>(m.key), - ...fields, - ...(externalId !== undefined && { - externalId: String(externalId), - }), - }); - }), - ); - }, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation(api.customers.mutations.deleteCustomer, { - customerId: toId<'customers'>(m.key), - }), - ), - ); - }, - }); - -export type { Customer }; diff --git a/services/platform/lib/collections/entities/documents.ts b/services/platform/lib/collections/entities/documents.ts deleted file mode 100644 index c26efaf72..000000000 --- a/services/platform/lib/collections/entities/documents.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type Document = ConvexItemOf; - -export const createDocumentsCollection: CollectionFactory = ( - scopeId, - queryClient, - convexQueryFn, - convexClient, -) => - convexCollectionOptions({ - id: 'documents', - queryFn: api.documents.queries.listDocuments, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item.id, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - const { changes } = m; - return convexClient.mutation(api.documents.mutations.updateDocument, { - documentId: toId<'documents'>(m.key), - ...(changes.name !== undefined && { title: changes.name }), - ...(changes.teamTags !== undefined && { - teamTags: changes.teamTags, - }), - ...(changes.mimeType !== undefined && { - mimeType: changes.mimeType, - }), - ...(changes.extension !== undefined && { - extension: changes.extension, - }), - ...(changes.sourceProvider !== undefined && { - sourceProvider: changes.sourceProvider, - }), - }); - }), - ); - }, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation(api.documents.mutations.deleteDocument, { - documentId: toId<'documents'>(m.key), - }), - ), - ); - }, - }); - -export type { Document }; diff --git a/services/platform/lib/collections/entities/integrations.ts b/services/platform/lib/collections/entities/integrations.ts deleted file mode 100644 index 1a1c3b81d..000000000 --- a/services/platform/lib/collections/entities/integrations.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type Integration = ConvexItemOf; - -export const createIntegrationsCollection: CollectionFactory< - Integration, - string -> = (scopeId, queryClient, convexQueryFn, convexClient) => - convexCollectionOptions({ - id: 'integrations', - queryFn: api.integrations.queries.list, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation(api.integrations.mutations.deleteIntegration, { - integrationId: toId<'integrations'>(m.key), - }), - ), - ); - }, - }); - -export type { Integration }; diff --git a/services/platform/lib/collections/entities/members.ts b/services/platform/lib/collections/entities/members.ts deleted file mode 100644 index e4ff761b5..000000000 --- a/services/platform/lib/collections/entities/members.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type Member = { - _id: string; - organizationId: string; - userId: string; - role: string; - createdAt: number; - displayName: string | undefined; - email: string | undefined; -}; - -export const createMembersCollection: CollectionFactory = ( - scopeId, - queryClient, - convexQueryFn, - convexClient, -) => - convexCollectionOptions({ - id: 'members', - queryFn: api.members.queries.listByOrganization, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.flatMap((m) => { - const promises: Promise[] = []; - if (m.changes.role !== undefined) { - promises.push( - convexClient.mutation(api.members.mutations.updateMemberRole, { - memberId: toId<'members'>(m.key), - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Convex query types role as string; mutation validator uses narrow union; values are always valid member roles - role: m.changes.role as - | 'member' - | 'admin' - | 'developer' - | 'editor' - | 'disabled', - }), - ); - } - if (m.changes.displayName !== undefined) { - promises.push( - convexClient.mutation( - api.members.mutations.updateMemberDisplayName, - { - memberId: toId<'members'>(m.key), - displayName: m.changes.displayName, - }, - ), - ); - } - return promises; - }), - ); - }, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation(api.members.mutations.removeMember, { - memberId: toId<'members'>(m.key), - }), - ), - ); - }, - }); - -export type { Member }; diff --git a/services/platform/lib/collections/entities/products.ts b/services/platform/lib/collections/entities/products.ts deleted file mode 100644 index 71b556c19..000000000 --- a/services/platform/lib/collections/entities/products.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type Product = ConvexItemOf; - -export const createProductsCollection: CollectionFactory = ( - scopeId, - queryClient, - convexQueryFn, - convexClient, -) => - convexCollectionOptions({ - id: 'products', - queryFn: api.products.queries.listProducts, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onInsert: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - const { - _id, - _creationTime, - lastUpdated: _lu, - externalId: _ext, - ...fields - } = m.modified; - return convexClient.mutation(api.products.mutations.createProduct, { - ...fields, - }); - }), - ); - }, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - const { - _id, - _creationTime, - organizationId: _org, - lastUpdated: _lu, - externalId: _ext, - ...fields - } = m.changes; - return convexClient.mutation(api.products.mutations.updateProduct, { - productId: toId<'products'>(m.key), - ...fields, - }); - }), - ); - }, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation(api.products.mutations.deleteProduct, { - productId: toId<'products'>(m.key), - }), - ), - ); - }, - }); - -export type { Product }; diff --git a/services/platform/lib/collections/entities/team-members.ts b/services/platform/lib/collections/entities/team-members.ts deleted file mode 100644 index 9aff69acc..000000000 --- a/services/platform/lib/collections/entities/team-members.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type TeamMember = { - _id: string; - teamId: string; - userId: string; - role: string; - joinedAt: number; - displayName: string | undefined; - email: string | undefined; -}; - -export const createTeamMembersCollection: CollectionFactory< - TeamMember, - string -> = (scopeId, queryClient, convexQueryFn, convexClient) => - convexCollectionOptions({ - id: 'team-members', - queryFn: api.team_members.queries.listByTeam, - args: { teamId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onInsert: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- metadata typed as unknown; consumer passes organizationId - const meta = m.metadata as { organizationId: string } | undefined; - return convexClient.mutation(api.team_members.mutations.addMember, { - teamId: m.modified.teamId, - userId: m.modified.userId, - organizationId: meta?.organizationId ?? '', - }); - }), - ); - }, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- metadata typed as unknown; consumer passes organizationId - const meta = m.metadata as { organizationId: string } | undefined; - return convexClient.mutation( - api.team_members.mutations.removeMember, - { - teamMemberId: toId<'teamMembers'>(m.key), - organizationId: meta?.organizationId ?? '', - }, - ); - }), - ); - }, - }); - -export type { TeamMember }; diff --git a/services/platform/lib/collections/entities/teams.ts b/services/platform/lib/collections/entities/teams.ts deleted file mode 100644 index 4c7e1ec1b..000000000 --- a/services/platform/lib/collections/entities/teams.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { api } from '@/convex/_generated/api'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type Team = ConvexItemOf; - -export const createTeamsCollection: CollectionFactory = ( - scopeId, - queryClient, - convexQueryFn, -) => - convexCollectionOptions({ - id: 'teams', - queryFn: api.members.queries.getMyTeams, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item.id, - }); - -export type { Team }; diff --git a/services/platform/lib/collections/entities/threads.ts b/services/platform/lib/collections/entities/threads.ts deleted file mode 100644 index 14edd4870..000000000 --- a/services/platform/lib/collections/entities/threads.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { api } from '@/convex/_generated/api'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type Thread = ConvexItemOf; - -export const createThreadsCollection: CollectionFactory = ( - scopeId, - queryClient, - convexQueryFn, - convexClient, -) => - convexCollectionOptions({ - id: 'threads', - queryFn: api.threads.queries.listThreads, - args: {}, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation(api.threads.mutations.updateChatThread, { - threadId: m.key, - title: m.modified.title ?? '', - }), - ), - ); - }, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation(api.threads.mutations.deleteChatThread, { - threadId: m.key, - }), - ), - ); - }, - }); - -export type { Thread }; diff --git a/services/platform/lib/collections/entities/user-organizations.ts b/services/platform/lib/collections/entities/user-organizations.ts deleted file mode 100644 index 35fd06f1f..000000000 --- a/services/platform/lib/collections/entities/user-organizations.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { api } from '@/convex/_generated/api'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type UserOrganization = ConvexItemOf< - typeof api.members.queries.getUserOrganizationsList ->; - -export const createUserOrganizationsCollection: CollectionFactory< - UserOrganization, - string -> = (_scopeId, queryClient, convexQueryFn) => - convexCollectionOptions({ - id: 'user-organizations', - queryFn: api.members.queries.getUserOrganizationsList, - args: {}, - queryClient, - convexQueryFn, - getKey: (item) => item.organizationId, - }); - -export type { UserOrganization }; diff --git a/services/platform/lib/collections/entities/vendors.ts b/services/platform/lib/collections/entities/vendors.ts deleted file mode 100644 index de725f5af..000000000 --- a/services/platform/lib/collections/entities/vendors.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type Vendor = ConvexItemOf; - -export const createVendorsCollection: CollectionFactory = ( - scopeId, - queryClient, - convexQueryFn, - convexClient, -) => - convexCollectionOptions({ - id: 'vendors', - queryFn: api.vendors.queries.listVendors, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - const { - _id, - _creationTime, - organizationId: _org, - externalId, - ...fields - } = m.changes; - return convexClient.mutation(api.vendors.mutations.updateVendor, { - vendorId: toId<'vendors'>(m.key), - ...fields, - ...(externalId !== undefined && { - externalId: String(externalId), - }), - }); - }), - ); - }, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation(api.vendors.mutations.deleteVendor, { - vendorId: toId<'vendors'>(m.key), - }), - ), - ); - }, - }); - -export type { Vendor }; diff --git a/services/platform/lib/collections/entities/websites.ts b/services/platform/lib/collections/entities/websites.ts deleted file mode 100644 index 41730e5d5..000000000 --- a/services/platform/lib/collections/entities/websites.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type Website = ConvexItemOf; - -export const createWebsitesCollection: CollectionFactory = ( - scopeId, - queryClient, - convexQueryFn, - convexClient, -) => - convexCollectionOptions({ - id: 'websites', - queryFn: api.websites.queries.listWebsites, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onInsert: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - const { _id, _creationTime, ...fields } = m.modified; - return convexClient.mutation(api.websites.mutations.createWebsite, { - organizationId: scopeId, - domain: fields.domain, - title: fields.title, - description: fields.description, - scanInterval: fields.scanInterval, - }); - }), - ); - }, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - const { - _id, - _creationTime, - organizationId: _org, - lastScannedAt: _ls, - metadata: _meta, - ...fields - } = m.changes; - return convexClient.mutation(api.websites.mutations.updateWebsite, { - websiteId: toId<'websites'>(m.key), - ...fields, - }); - }), - ); - }, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation(api.websites.mutations.deleteWebsite, { - websiteId: toId<'websites'>(m.key), - }), - ), - ); - }, - }); - -export type { Website }; diff --git a/services/platform/lib/collections/entities/wf-automations.ts b/services/platform/lib/collections/entities/wf-automations.ts deleted file mode 100644 index cd46eeaef..000000000 --- a/services/platform/lib/collections/entities/wf-automations.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type WfAutomation = ConvexItemOf< - typeof api.wf_definitions.queries.listAutomations ->; - -export const createWfAutomationsCollection: CollectionFactory< - WfAutomation, - string -> = (scopeId, queryClient, convexQueryFn, convexClient) => - convexCollectionOptions({ - id: 'wf-automations', - queryFn: api.wf_definitions.queries.listAutomations, - args: { organizationId: scopeId }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- TanStack DB types metadata as unknown; we control the shape via collection.update() calls - const meta = m.metadata as - | { updatedBy: string; metadataOnly?: boolean } - | undefined; - const updatedBy = meta?.updatedBy ?? ''; - if (meta?.metadataOnly) { - return convexClient.mutation( - api.wf_definitions.mutations.updateWorkflowMetadata, - { - wfDefinitionId: toId<'wfDefinitions'>(m.key), - metadata: m.modified.metadata ?? {}, - updatedBy, - }, - ); - } - const { - name, - description, - version, - status, - workflowType, - config, - metadata, - } = m.changes; - return convexClient.mutation( - api.wf_definitions.mutations.updateWorkflow, - { - wfDefinitionId: toId<'wfDefinitions'>(m.key), - updates: { - name, - description, - version, - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Convex query types status as string; mutation validator uses narrow union; values are always valid workflow statuses - status: status as 'draft' | 'active' | 'archived' | undefined, - workflowType, - config, - metadata, - }, - updatedBy, - }, - ); - }), - ); - }, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation(api.wf_definitions.mutations.deleteWorkflow, { - wfDefinitionId: toId<'wfDefinitions'>(m.key), - }), - ), - ); - }, - }); - -export type { WfAutomation }; diff --git a/services/platform/lib/collections/entities/wf-event-subscriptions.ts b/services/platform/lib/collections/entities/wf-event-subscriptions.ts deleted file mode 100644 index ccdfb5756..000000000 --- a/services/platform/lib/collections/entities/wf-event-subscriptions.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type WfEventSubscription = ConvexItemOf< - typeof api.workflows.triggers.queries.getEventSubscriptions ->; - -export const createWfEventSubscriptionsCollection: CollectionFactory< - WfEventSubscription, - string -> = (scopeId, queryClient, convexQueryFn, convexClient) => - convexCollectionOptions({ - id: 'wf-event-subscriptions', - queryFn: api.workflows.triggers.queries.getEventSubscriptions, - args: { workflowRootId: toId<'wfDefinitions'>(scopeId) }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onInsert: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation( - api.workflows.triggers.mutations.createEventSubscription, - { - organizationId: m.modified.organizationId, - workflowRootId: toId<'wfDefinitions'>(m.modified.workflowRootId), - eventType: m.modified.eventType, - eventFilter: m.modified.eventFilter, - }, - ), - ), - ); - }, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map(async (m) => { - if ('isActive' in m.changes && m.changes.isActive !== undefined) { - await convexClient.mutation( - api.workflows.triggers.mutations.toggleEventSubscription, - { - subscriptionId: toId<'wfEventSubscriptions'>(m.key), - isActive: m.changes.isActive, - }, - ); - } - if ('eventFilter' in m.changes) { - await convexClient.mutation( - api.workflows.triggers.mutations.updateEventSubscription, - { - subscriptionId: toId<'wfEventSubscriptions'>(m.key), - eventFilter: m.changes.eventFilter, - }, - ); - } - }), - ); - }, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation( - api.workflows.triggers.mutations.deleteEventSubscription, - { - subscriptionId: toId<'wfEventSubscriptions'>(m.key), - }, - ), - ), - ); - }, - }); - -export type { WfEventSubscription }; diff --git a/services/platform/lib/collections/entities/wf-schedules.ts b/services/platform/lib/collections/entities/wf-schedules.ts deleted file mode 100644 index e837bc97d..000000000 --- a/services/platform/lib/collections/entities/wf-schedules.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type WfSchedule = ConvexItemOf< - typeof api.workflows.triggers.queries.getSchedules ->; - -export const createWfSchedulesCollection: CollectionFactory< - WfSchedule, - string -> = (scopeId, queryClient, convexQueryFn, convexClient) => - convexCollectionOptions({ - id: 'wf-schedules', - queryFn: api.workflows.triggers.queries.getSchedules, - args: { workflowRootId: toId<'wfDefinitions'>(scopeId) }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onInsert: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation( - api.workflows.triggers.mutations.createSchedule, - { - organizationId: m.modified.organizationId, - workflowRootId: toId<'wfDefinitions'>(m.modified.workflowRootId), - cronExpression: m.modified.cronExpression, - timezone: m.modified.timezone, - }, - ), - ), - ); - }, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map(async (m) => { - if ('isActive' in m.changes && m.changes.isActive !== undefined) { - await convexClient.mutation( - api.workflows.triggers.mutations.toggleSchedule, - { - scheduleId: toId<'wfSchedules'>(m.key), - isActive: m.changes.isActive, - }, - ); - } - const { - _id, - _creationTime, - organizationId: _org, - workflowRootId: _root, - isActive: _active, - createdAt: _at, - createdBy: _by, - lastTriggeredAt: _lt, - ...updateFields - } = m.changes; - if (Object.keys(updateFields).length > 0) { - await convexClient.mutation( - api.workflows.triggers.mutations.updateSchedule, - { - scheduleId: toId<'wfSchedules'>(m.key), - ...updateFields, - }, - ); - } - }), - ); - }, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation( - api.workflows.triggers.mutations.deleteSchedule, - { - scheduleId: toId<'wfSchedules'>(m.key), - }, - ), - ), - ); - }, - }); - -export type { WfSchedule }; diff --git a/services/platform/lib/collections/entities/wf-steps.ts b/services/platform/lib/collections/entities/wf-steps.ts deleted file mode 100644 index b96f11fdd..000000000 --- a/services/platform/lib/collections/entities/wf-steps.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type WfStep = ConvexItemOf; - -export const createWfStepsCollection: CollectionFactory = ( - scopeId, - queryClient, - convexQueryFn, - convexClient, -) => - convexCollectionOptions({ - id: 'wf-steps', - queryFn: api.wf_step_defs.queries.getWorkflowSteps, - args: { wfDefinitionId: toId<'wfDefinitions'>(scopeId) }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onInsert: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- metadata typed as unknown; consumer passes editMode - const meta = m.metadata as - | { editMode: 'visual' | 'json' | 'ai' } - | undefined; - return convexClient.mutation(api.wf_step_defs.mutations.createStep, { - wfDefinitionId: toId<'wfDefinitions'>(scopeId), - stepSlug: m.modified.stepSlug, - name: m.modified.name, - stepType: m.modified.stepType, - order: m.modified.order, - config: m.modified.config, - nextSteps: m.modified.nextSteps, - editMode: meta?.editMode ?? 'visual', - }); - }), - ); - }, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- TanStack DB types metadata as unknown; we control the shape via collection.update() calls - const meta = m.metadata as - | { editMode: 'visual' | 'json' | 'ai' } - | undefined; - return convexClient.mutation(api.wf_step_defs.mutations.updateStep, { - stepRecordId: toId<'wfStepDefs'>(m.key), - updates: m.changes, - editMode: meta?.editMode ?? 'visual', - }); - }), - ); - }, - }); - -export type { WfStep }; diff --git a/services/platform/lib/collections/entities/wf-webhooks.ts b/services/platform/lib/collections/entities/wf-webhooks.ts deleted file mode 100644 index 842eb3ff0..000000000 --- a/services/platform/lib/collections/entities/wf-webhooks.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { api } from '@/convex/_generated/api'; -import { toId } from '@/lib/utils/type-guards'; - -import type { CollectionFactory } from '../collection-registry'; -import type { ConvexItemOf } from '../convex-collection-options'; - -import { convexCollectionOptions } from '../convex-collection-options'; - -type WfWebhook = ConvexItemOf< - typeof api.workflows.triggers.queries.getWebhooks ->; - -export const createWfWebhooksCollection: CollectionFactory< - WfWebhook, - string -> = (scopeId, queryClient, convexQueryFn, convexClient) => - convexCollectionOptions({ - id: 'wf-webhooks', - queryFn: api.workflows.triggers.queries.getWebhooks, - args: { workflowRootId: toId<'wfDefinitions'>(scopeId) }, - queryClient, - convexQueryFn, - getKey: (item) => item._id, - onInsert: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation( - api.workflows.triggers.mutations.createWebhook, - { - organizationId: m.modified.organizationId, - workflowRootId: toId<'wfDefinitions'>(m.modified.workflowRootId), - }, - ), - ), - ); - }, - onUpdate: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => { - if ('isActive' in m.changes && m.changes.isActive !== undefined) { - return convexClient.mutation( - api.workflows.triggers.mutations.toggleWebhook, - { - webhookId: toId<'wfWebhooks'>(m.key), - isActive: m.changes.isActive, - }, - ); - } - return Promise.resolve(); - }), - ); - }, - onDelete: async ({ transaction }) => { - await Promise.all( - transaction.mutations.map((m) => - convexClient.mutation( - api.workflows.triggers.mutations.deleteWebhook, - { - webhookId: toId<'wfWebhooks'>(m.key), - }, - ), - ), - ); - }, - }); - -export type { WfWebhook }; diff --git a/services/platform/lib/collections/use-collection.ts b/services/platform/lib/collections/use-collection.ts deleted file mode 100644 index 04ca7191a..000000000 --- a/services/platform/lib/collections/use-collection.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { Collection } from '@tanstack/db'; - -import { useQueryClient } from '@tanstack/react-query'; -import { useMemo } from 'react'; - -import { useConvexClient } from '@/app/hooks/use-convex-client'; - -import type { CollectionFactory } from './collection-registry'; - -import { getOrCreateCollection } from './collection-registry'; - -/** - * React hook that provides access to a TanStack DB collection backed by a Convex query. - * Collections are lazily created and cached per scope. - * - * @param name - Unique name for this collection type (e.g. 'products', 'customers') - * @param factory - Factory function that creates the collection options - * @param scopeId - Scope identifier for the collection (organizationId, entityId, userId, etc.) - * @returns A TanStack DB Collection instance - * - * @example - * ```tsx - * const collection = useCollection('products', createProductsCollection, organizationId); - * const { data } = useLiveQuery((q) => q.from({ p: collection })); - * ``` - */ -export function useCollection< - T extends object, - TKey extends string | number = string, ->( - name: string, - factory: CollectionFactory, - scopeId: string, -): Collection { - const queryClient = useQueryClient(); - const convexClient = useConvexClient(); - - // ConvexQueryClient sets a default queryFn on the QueryClient that establishes - // WebSocket subscriptions for queries with "convexQuery" key prefix. Extract it - // so we can pass it to queryCollectionOptions for its internal QueryObserver. - const defaultQueryFn = queryClient.getDefaultOptions().queries?.queryFn; - if (!defaultQueryFn || typeof defaultQueryFn === 'symbol') { - throw new Error( - 'useCollection requires a default queryFn on QueryClient (set by ConvexQueryClient)', - ); - } - const convexQueryFn = defaultQueryFn; - - return useMemo( - () => - getOrCreateCollection( - name, - scopeId, - factory, - queryClient, - convexQueryFn, - convexClient, - ), - [name, scopeId, factory, queryClient, convexQueryFn, convexClient], - ); -} diff --git a/services/platform/lib/types/convex-helpers.ts b/services/platform/lib/types/convex-helpers.ts new file mode 100644 index 000000000..4aa6b42ba --- /dev/null +++ b/services/platform/lib/types/convex-helpers.ts @@ -0,0 +1,8 @@ +import type { FunctionReference, FunctionReturnType } from 'convex/server'; + +type ConvexQueryRef = FunctionReference<'query'>; + +export type ConvexItemOf = + FunctionReturnType extends Array + ? TItem + : never; diff --git a/services/platform/package.json b/services/platform/package.json index 6055f5492..999737b63 100644 --- a/services/platform/package.json +++ b/services/platform/package.json @@ -65,9 +65,6 @@ "@radix-ui/react-visually-hidden": "1.2.4", "@sentry/tanstackstart-react": "^10.37.0", "@tailwindcss/postcss": "4.1.18", - "@tanstack/db": "^0.5.25", - "@tanstack/query-db-collection": "^1.0.22", - "@tanstack/react-db": "^0.1.69", "@tanstack/react-query": "5.90.21", "@tanstack/react-router": "1.159.10", "@tanstack/react-table": "8.21.3", From df29ff4907fc821512d0f961e7fcb4b855792b06 Mon Sep 17 00:00:00 2001 From: methosiea Date: Sat, 14 Feb 2026 12:40:06 +0100 Subject: [PATCH 3/8] fix: issues --- .vscode/extensions.json | 6 +- .vscode/settings.json | 3 + services/graph-db/package.json | 8 +- .../app/features/approvals/hooks/mutations.ts | 11 +- .../features/automations/hooks/mutations.ts | 50 ++- .../components/event-create-dialog.tsx | 2 +- .../components/schedule-create-dialog.tsx | 2 +- .../automations/triggers/hooks/mutations.ts | 141 +++++-- .../app/features/chat/hooks/mutations.ts | 34 +- .../hooks/__tests__/mutation-hooks.test.ts | 37 +- .../features/conversations/hooks/mutations.ts | 51 ++- .../features/custom-agents/hooks/mutations.ts | 104 ++++- .../app/features/customers/hooks/mutations.ts | 20 +- .../hooks/__tests__/mutation-hooks.test.ts | 33 +- .../app/features/documents/hooks/mutations.ts | 20 +- .../app/features/products/hooks/mutations.ts | 55 ++- .../hooks/__tests__/mutation-hooks.test.ts | 31 +- .../settings/integrations/hooks/mutations.ts | 10 +- .../hooks/__tests__/mutation-hooks.test.ts | 81 +++- .../settings/organization/hooks/mutations.ts | 47 ++- .../hooks/__tests__/mutation-hooks.test.ts | 13 +- .../settings/teams/hooks/mutations.ts | 47 ++- .../hooks/__tests__/mutation-hooks.test.ts | 35 +- .../app/features/vendors/hooks/mutations.ts | 20 +- .../app/features/websites/hooks/mutations.ts | 37 +- .../app/hooks/__tests__/build-helpers.test.ts | 367 ++++++++++++++++++ .../hooks/__tests__/use-convex-query.test.ts | 20 + .../hooks/use-convex-optimistic-mutation.ts | 180 +++++++++ .../platform/app/hooks/use-convex-query.ts | 20 +- services/platform/app/router.tsx | 1 + services/platform/convex/README.md | 210 +++------- services/platform/convex/tsconfig.json | 15 +- turbo.json | 2 + 33 files changed, 1405 insertions(+), 308 deletions(-) create mode 100644 services/platform/app/hooks/__tests__/build-helpers.test.ts create mode 100644 services/platform/app/hooks/use-convex-optimistic-mutation.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json index f5dcfa3ad..d91d40d54 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,7 @@ { - "recommendations": ["oxc.oxc-vscode", "foxundermoon.shell-format"] + "recommendations": [ + "oxc.oxc-vscode", + "foxundermoon.shell-format", + "charliermarsh.ruff" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index f65419779..2235066d8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,8 @@ }, "[ignore]": { "editor.defaultFormatter": "foxundermoon.shell-format" + }, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" } } diff --git a/services/graph-db/package.json b/services/graph-db/package.json index fe7e618e8..6bda4b891 100644 --- a/services/graph-db/package.json +++ b/services/graph-db/package.json @@ -9,10 +9,10 @@ "docker:build": "docker compose build graph-db", "logs": "docker compose logs -f graph-db", "shell": "docker exec -it tale-graph-db sh", - "lint": "uv run ruff check .", - "lint:fix": "uv run ruff check --fix .", - "format": "uv run ruff format .", - "format:check": "uv run ruff format --check .", + "lint": "uv run ruff check tests", + "lint:fix": "uv run ruff check --fix tests", + "format": "uv run ruff format tests", + "format:check": "uv run ruff format --check tests", "test": "uv run pytest" } } diff --git a/services/platform/app/features/approvals/hooks/mutations.ts b/services/platform/app/features/approvals/hooks/mutations.ts index f34667d35..ee20d4ddf 100644 --- a/services/platform/app/features/approvals/hooks/mutations.ts +++ b/services/platform/app/features/approvals/hooks/mutations.ts @@ -1,4 +1,5 @@ import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; +import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-mutation'; import { api } from '@/convex/_generated/api'; export function useRemoveRecommendedProduct() { @@ -6,5 +7,13 @@ export function useRemoveRecommendedProduct() { } export function useUpdateApprovalStatus() { - return useConvexMutation(api.approvals.mutations.updateApprovalStatus); + return useConvexOptimisticMutation( + api.approvals.mutations.updateApprovalStatus, + api.approvals.queries.listApprovalsByOrganization, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ approvalId, status }, { update }) => + update(approvalId, { status }), + }, + ); } diff --git a/services/platform/app/features/automations/hooks/mutations.ts b/services/platform/app/features/automations/hooks/mutations.ts index 6f8a8be0f..29106ec2c 100644 --- a/services/platform/app/features/automations/hooks/mutations.ts +++ b/services/platform/app/features/automations/hooks/mutations.ts @@ -1,4 +1,5 @@ import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; +import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-mutation'; import { api } from '@/convex/_generated/api'; export function useStartWorkflow() { @@ -16,15 +17,39 @@ export function useDuplicateAutomation() { } export function usePublishAutomationDraft() { - return useConvexMutation(api.wf_definitions.mutations.publishDraft); + return useConvexOptimisticMutation( + api.wf_definitions.mutations.publishDraft, + api.wf_definitions.queries.listAutomations, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ wfDefinitionId }, { update }) => + update(wfDefinitionId, { status: 'active' }), + }, + ); } export function useUnpublishAutomation() { - return useConvexMutation(api.wf_definitions.mutations.unpublishWorkflow); + return useConvexOptimisticMutation( + api.wf_definitions.mutations.unpublishWorkflow, + api.wf_definitions.queries.listAutomations, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ wfDefinitionId }, { update }) => + update(wfDefinitionId, { status: 'archived' }), + }, + ); } export function useRepublishAutomation() { - return useConvexMutation(api.wf_definitions.mutations.republishWorkflow); + return useConvexOptimisticMutation( + api.wf_definitions.mutations.republishWorkflow, + api.wf_definitions.queries.listAutomations, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ wfDefinitionId }, { update }) => + update(wfDefinitionId, { status: 'active' }), + }, + ); } export function useCreateDraftFromActive() { @@ -44,9 +69,24 @@ export function useUpdateAutomation() { } export function useDeleteAutomation() { - return useConvexMutation(api.wf_definitions.mutations.deleteWorkflow); + return useConvexOptimisticMutation( + api.wf_definitions.mutations.deleteWorkflow, + api.wf_definitions.queries.listAutomations, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ wfDefinitionId }, { remove }) => remove(wfDefinitionId), + }, + ); } export function useUpdateAutomationMetadata() { - return useConvexMutation(api.wf_definitions.mutations.updateWorkflowMetadata); + return useConvexOptimisticMutation( + api.wf_definitions.mutations.updateWorkflowMetadata, + api.wf_definitions.queries.listAutomations, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ wfDefinitionId, ...changes }, { update }) => + update(wfDefinitionId, changes), + }, + ); } diff --git a/services/platform/app/features/automations/triggers/components/event-create-dialog.tsx b/services/platform/app/features/automations/triggers/components/event-create-dialog.tsx index 09dbc301d..8d4b354e8 100644 --- a/services/platform/app/features/automations/triggers/components/event-create-dialog.tsx +++ b/services/platform/app/features/automations/triggers/components/event-create-dialog.tsx @@ -50,7 +50,7 @@ export function EventCreateDialog({ const { t: tCommon } = useT('common'); const { toast } = useToast(); const createEventSubscription = useCreateEventSubscription(); - const updateEventSubscription = useUpdateEventSubscription(); + const updateEventSubscription = useUpdateEventSubscription(workflowRootId); const isEditMode = !!editing; diff --git a/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx b/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx index b9a0ebe0a..356681f89 100644 --- a/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx +++ b/services/platform/app/features/automations/triggers/components/schedule-create-dialog.tsx @@ -58,7 +58,7 @@ export function ScheduleCreateDialog({ const { mutateAsync: createSchedule, isPending: isCreatingSchedule } = useCreateSchedule(); const { mutateAsync: updateSchedule, isPending: isUpdatingSchedule } = - useUpdateSchedule(); + useUpdateSchedule(workflowRootId); const { mutateAsync: generateCron, isPending: isGenerating } = useGenerateCron(); const isSubmitting = isCreatingSchedule || isUpdatingSchedule; diff --git a/services/platform/app/features/automations/triggers/hooks/mutations.ts b/services/platform/app/features/automations/triggers/hooks/mutations.ts index 615900a62..fc4538850 100644 --- a/services/platform/app/features/automations/triggers/hooks/mutations.ts +++ b/services/platform/app/features/automations/triggers/hooks/mutations.ts @@ -1,54 +1,149 @@ -import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; +import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-mutation'; import { api } from '@/convex/_generated/api'; -export function useCreateSchedule() { - return useConvexMutation(api.workflows.triggers.mutations.createSchedule); +export function useCreateSchedule(workflowRootId?: string) { + return useConvexOptimisticMutation( + api.workflows.triggers.mutations.createSchedule, + api.workflows.triggers.queries.getSchedules, + { + queryArgs: workflowRootId ? { workflowRootId } : undefined, + onMutate: ({ cronExpression, timezone, organizationId }, { insert }) => + insert({ + organizationId, + workflowRootId: workflowRootId ?? '', + cronExpression, + timezone, + isActive: true, + createdAt: Date.now(), + }), + }, + ); } -export function useUpdateSchedule() { - return useConvexMutation(api.workflows.triggers.mutations.updateSchedule); +export function useUpdateSchedule(workflowRootId?: string) { + return useConvexOptimisticMutation( + api.workflows.triggers.mutations.updateSchedule, + api.workflows.triggers.queries.getSchedules, + { + queryArgs: workflowRootId ? { workflowRootId } : undefined, + onMutate: ({ scheduleId, ...changes }, { update }) => + update(scheduleId, changes), + }, + ); } -export function useToggleSchedule() { - return useConvexMutation(api.workflows.triggers.mutations.toggleSchedule); +export function useToggleSchedule(workflowRootId?: string) { + return useConvexOptimisticMutation( + api.workflows.triggers.mutations.toggleSchedule, + api.workflows.triggers.queries.getSchedules, + { + queryArgs: workflowRootId ? { workflowRootId } : undefined, + onMutate: ({ scheduleId }, { toggle }) => toggle(scheduleId, 'isActive'), + }, + ); } -export function useDeleteSchedule() { - return useConvexMutation(api.workflows.triggers.mutations.deleteSchedule); +export function useDeleteSchedule(workflowRootId?: string) { + return useConvexOptimisticMutation( + api.workflows.triggers.mutations.deleteSchedule, + api.workflows.triggers.queries.getSchedules, + { + queryArgs: workflowRootId ? { workflowRootId } : undefined, + onMutate: ({ scheduleId }, { remove }) => remove(scheduleId), + }, + ); } -export function useCreateWebhook() { - return useConvexMutation(api.workflows.triggers.mutations.createWebhook); +export function useCreateWebhook(workflowRootId?: string) { + return useConvexOptimisticMutation( + api.workflows.triggers.mutations.createWebhook, + api.workflows.triggers.queries.getWebhooks, + { + queryArgs: workflowRootId ? { workflowRootId } : undefined, + onMutate: ({ organizationId }, { insert }) => + insert({ + organizationId, + workflowRootId: workflowRootId ?? '', + token: '', + isActive: true, + createdAt: Date.now(), + }), + }, + ); } -export function useToggleWebhook() { - return useConvexMutation(api.workflows.triggers.mutations.toggleWebhook); +export function useToggleWebhook(workflowRootId?: string) { + return useConvexOptimisticMutation( + api.workflows.triggers.mutations.toggleWebhook, + api.workflows.triggers.queries.getWebhooks, + { + queryArgs: workflowRootId ? { workflowRootId } : undefined, + onMutate: ({ webhookId }, { toggle }) => toggle(webhookId, 'isActive'), + }, + ); } -export function useDeleteWebhook() { - return useConvexMutation(api.workflows.triggers.mutations.deleteWebhook); +export function useDeleteWebhook(workflowRootId?: string) { + return useConvexOptimisticMutation( + api.workflows.triggers.mutations.deleteWebhook, + api.workflows.triggers.queries.getWebhooks, + { + queryArgs: workflowRootId ? { workflowRootId } : undefined, + onMutate: ({ webhookId }, { remove }) => remove(webhookId), + }, + ); } -export function useCreateEventSubscription() { - return useConvexMutation( +export function useCreateEventSubscription(workflowRootId?: string) { + return useConvexOptimisticMutation( api.workflows.triggers.mutations.createEventSubscription, + api.workflows.triggers.queries.getEventSubscriptions, + { + queryArgs: workflowRootId ? { workflowRootId } : undefined, + onMutate: ({ eventType, eventFilter, organizationId }, { insert }) => + insert({ + organizationId, + workflowRootId: workflowRootId ?? '', + eventType, + eventFilter, + isActive: true, + createdAt: Date.now(), + }), + }, ); } -export function useUpdateEventSubscription() { - return useConvexMutation( +export function useUpdateEventSubscription(workflowRootId?: string) { + return useConvexOptimisticMutation( api.workflows.triggers.mutations.updateEventSubscription, + api.workflows.triggers.queries.getEventSubscriptions, + { + queryArgs: workflowRootId ? { workflowRootId } : undefined, + onMutate: ({ subscriptionId, ...changes }, { update }) => + update(subscriptionId, changes), + }, ); } -export function useToggleEventSubscription() { - return useConvexMutation( +export function useToggleEventSubscription(workflowRootId?: string) { + return useConvexOptimisticMutation( api.workflows.triggers.mutations.toggleEventSubscription, + api.workflows.triggers.queries.getEventSubscriptions, + { + queryArgs: workflowRootId ? { workflowRootId } : undefined, + onMutate: ({ subscriptionId }, { toggle }) => + toggle(subscriptionId, 'isActive'), + }, ); } -export function useDeleteEventSubscription() { - return useConvexMutation( +export function useDeleteEventSubscription(workflowRootId?: string) { + return useConvexOptimisticMutation( api.workflows.triggers.mutations.deleteEventSubscription, + api.workflows.triggers.queries.getEventSubscriptions, + { + queryArgs: workflowRootId ? { workflowRootId } : undefined, + onMutate: ({ subscriptionId }, { remove }) => remove(subscriptionId), + }, ); } diff --git a/services/platform/app/features/chat/hooks/mutations.ts b/services/platform/app/features/chat/hooks/mutations.ts index 37aef7fc9..ed1d34521 100644 --- a/services/platform/app/features/chat/hooks/mutations.ts +++ b/services/platform/app/features/chat/hooks/mutations.ts @@ -1,4 +1,5 @@ import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; +import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-mutation'; import { api } from '@/convex/_generated/api'; export function useChatWithAgent() { @@ -20,7 +21,19 @@ export function useSubmitHumanInputResponse() { } export function useCreateThread() { - return useConvexMutation(api.threads.mutations.createChatThread); + return useConvexOptimisticMutation( + api.threads.mutations.createChatThread, + api.threads.queries.listThreads, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ title }, { insert }) => + insert({ + _creationTime: Date.now(), + title, + status: 'active', + }), + }, + ); } export function useGenerateUploadUrl() { @@ -28,9 +41,24 @@ export function useGenerateUploadUrl() { } export function useDeleteThread() { - return useConvexMutation(api.threads.mutations.deleteChatThread); + return useConvexOptimisticMutation( + api.threads.mutations.deleteChatThread, + api.threads.queries.listThreads, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ threadId }, { remove }) => remove(threadId), + }, + ); } export function useUpdateThread() { - return useConvexMutation(api.threads.mutations.updateChatThread); + return useConvexOptimisticMutation( + api.threads.mutations.updateChatThread, + api.threads.queries.listThreads, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ threadId, ...changes }, { update }) => + update(threadId, changes), + }, + ); } diff --git a/services/platform/app/features/conversations/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/conversations/hooks/__tests__/mutation-hooks.test.ts index 355c3fe38..f9762b96c 100644 --- a/services/platform/app/features/conversations/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/conversations/hooks/__tests__/mutation-hooks.test.ts @@ -4,17 +4,23 @@ import { toId } from '@/convex/lib/type_cast_helpers'; const mockMutateAsync = vi.fn(); +const mockMutationResult = { + mutate: mockMutateAsync, + mutateAsync: mockMutateAsync, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), +}; + vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => ({ - mutate: mockMutateAsync, - mutateAsync: mockMutateAsync, - isPending: false, - isError: false, - isSuccess: false, - error: null, - data: undefined, - reset: vi.fn(), - }), + useConvexMutation: () => mockMutationResult, +})); + +vi.mock('@/app/hooks/use-convex-optimistic-mutation', () => ({ + useConvexOptimisticMutation: () => mockMutationResult, })); vi.mock('@/convex/_generated/api', () => ({ @@ -26,6 +32,9 @@ vi.mock('@/convex/_generated/api', () => ({ markConversationAsRead: 'markConversationAsRead', markConversationAsSpam: 'markConversationAsSpam', }, + queries: { + listConversations: 'listConversations', + }, }, }, })); @@ -42,7 +51,7 @@ describe('useCloseConversation', () => { vi.clearAllMocks(); }); - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useCloseConversation(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); @@ -78,7 +87,7 @@ describe('useReopenConversation', () => { vi.clearAllMocks(); }); - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useReopenConversation(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); @@ -114,7 +123,7 @@ describe('useMarkAsRead', () => { vi.clearAllMocks(); }); - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useMarkAsRead(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); @@ -150,7 +159,7 @@ describe('useMarkAsSpam', () => { vi.clearAllMocks(); }); - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useMarkAsSpam(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); diff --git a/services/platform/app/features/conversations/hooks/mutations.ts b/services/platform/app/features/conversations/hooks/mutations.ts index a36aa6449..5d7065fac 100644 --- a/services/platform/app/features/conversations/hooks/mutations.ts +++ b/services/platform/app/features/conversations/hooks/mutations.ts @@ -1,4 +1,5 @@ import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; +import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-mutation'; import { api } from '@/convex/_generated/api'; export function useGenerateUploadUrl() { @@ -12,11 +13,27 @@ export function useAddMessage() { } export function useBulkCloseConversations() { - return useConvexMutation(api.conversations.mutations.bulkCloseConversations); + return useConvexOptimisticMutation( + api.conversations.mutations.bulkCloseConversations, + api.conversations.queries.listConversations, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ conversationIds }, { bulkUpdate }) => + bulkUpdate(conversationIds, { status: 'closed' }), + }, + ); } export function useBulkReopenConversations() { - return useConvexMutation(api.conversations.mutations.bulkReopenConversations); + return useConvexOptimisticMutation( + api.conversations.mutations.bulkReopenConversations, + api.conversations.queries.listConversations, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ conversationIds }, { bulkUpdate }) => + bulkUpdate(conversationIds, { status: 'open' }), + }, + ); } export function useSendMessageViaIntegration() { @@ -26,11 +43,27 @@ export function useSendMessageViaIntegration() { } export function useCloseConversation() { - return useConvexMutation(api.conversations.mutations.closeConversation); + return useConvexOptimisticMutation( + api.conversations.mutations.closeConversation, + api.conversations.queries.listConversations, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ conversationId }, { update }) => + update(conversationId, { status: 'closed' }), + }, + ); } export function useReopenConversation() { - return useConvexMutation(api.conversations.mutations.reopenConversation); + return useConvexOptimisticMutation( + api.conversations.mutations.reopenConversation, + api.conversations.queries.listConversations, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ conversationId }, { update }) => + update(conversationId, { status: 'open' }), + }, + ); } export function useMarkAsRead() { @@ -38,7 +71,15 @@ export function useMarkAsRead() { } export function useMarkAsSpam() { - return useConvexMutation(api.conversations.mutations.markConversationAsSpam); + return useConvexOptimisticMutation( + api.conversations.mutations.markConversationAsSpam, + api.conversations.queries.listConversations, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ conversationId }, { update }) => + update(conversationId, { status: 'spam' }), + }, + ); } export function useDownloadAttachments() { diff --git a/services/platform/app/features/custom-agents/hooks/mutations.ts b/services/platform/app/features/custom-agents/hooks/mutations.ts index 7a853789d..38964a413 100644 --- a/services/platform/app/features/custom-agents/hooks/mutations.ts +++ b/services/platform/app/features/custom-agents/hooks/mutations.ts @@ -1,4 +1,5 @@ import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; +import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-mutation'; import { api } from '@/convex/_generated/api'; export function useTestAgent() { @@ -6,7 +7,21 @@ export function useTestAgent() { } export function useCreateCustomAgent() { - return useConvexMutation(api.custom_agents.mutations.createCustomAgent); + return useConvexOptimisticMutation( + api.custom_agents.mutations.createCustomAgent, + api.custom_agents.queries.listCustomAgents, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ name, displayName, description }, { insert }) => + insert({ + name, + displayName, + description, + status: 'draft', + _creationTime: Date.now(), + }), + }, + ); } export function useDuplicateCustomAgent() { @@ -24,35 +39,100 @@ export function useCreateDraftFromVersion() { } export function usePublishCustomAgent() { - return useConvexMutation(api.custom_agents.mutations.publishCustomAgent); + return useConvexOptimisticMutation( + api.custom_agents.mutations.publishCustomAgent, + api.custom_agents.queries.listCustomAgents, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ customAgentId }, { update }) => + update(customAgentId, { status: 'active' }), + }, + ); } export function useUnpublishCustomAgent() { - return useConvexMutation(api.custom_agents.mutations.unpublishCustomAgent); + return useConvexOptimisticMutation( + api.custom_agents.mutations.unpublishCustomAgent, + api.custom_agents.queries.listCustomAgents, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ customAgentId }, { update }) => + update(customAgentId, { status: 'archived' }), + }, + ); } -export function useCreateCustomAgentWebhook() { - return useConvexMutation(api.custom_agents.webhooks.mutations.createWebhook); +export function useCreateCustomAgentWebhook(customAgentId?: string) { + return useConvexOptimisticMutation( + api.custom_agents.webhooks.mutations.createWebhook, + api.custom_agents.webhooks.queries.getWebhooks, + { + queryArgs: customAgentId ? { customAgentId } : undefined, + onMutate: ({ organizationId }, { insert }) => + insert({ + organizationId, + customAgentId: customAgentId ?? '', + token: '', + isActive: true, + createdAt: Date.now(), + }), + }, + ); } export function useUpdateCustomAgent() { - return useConvexMutation(api.custom_agents.mutations.updateCustomAgent); + return useConvexOptimisticMutation( + api.custom_agents.mutations.updateCustomAgent, + api.custom_agents.queries.listCustomAgents, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ customAgentId, ...changes }, { update }) => + update(customAgentId, changes), + }, + ); } export function useUpdateCustomAgentMetadata() { - return useConvexMutation( + return useConvexOptimisticMutation( api.custom_agents.mutations.updateCustomAgentMetadata, + api.custom_agents.queries.listCustomAgents, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ customAgentId, ...changes }, { update }) => + update(customAgentId, changes), + }, ); } export function useDeleteCustomAgent() { - return useConvexMutation(api.custom_agents.mutations.deleteCustomAgent); + return useConvexOptimisticMutation( + api.custom_agents.mutations.deleteCustomAgent, + api.custom_agents.queries.listCustomAgents, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ customAgentId }, { remove }) => remove(customAgentId), + }, + ); } -export function useToggleCustomAgentWebhook() { - return useConvexMutation(api.custom_agents.webhooks.mutations.toggleWebhook); +export function useToggleCustomAgentWebhook(customAgentId?: string) { + return useConvexOptimisticMutation( + api.custom_agents.webhooks.mutations.toggleWebhook, + api.custom_agents.webhooks.queries.getWebhooks, + { + queryArgs: customAgentId ? { customAgentId } : undefined, + onMutate: ({ webhookId }, { toggle }) => toggle(webhookId, 'isActive'), + }, + ); } -export function useDeleteCustomAgentWebhook() { - return useConvexMutation(api.custom_agents.webhooks.mutations.deleteWebhook); +export function useDeleteCustomAgentWebhook(customAgentId?: string) { + return useConvexOptimisticMutation( + api.custom_agents.webhooks.mutations.deleteWebhook, + api.custom_agents.webhooks.queries.getWebhooks, + { + queryArgs: customAgentId ? { customAgentId } : undefined, + onMutate: ({ webhookId }, { remove }) => remove(webhookId), + }, + ); } diff --git a/services/platform/app/features/customers/hooks/mutations.ts b/services/platform/app/features/customers/hooks/mutations.ts index d59457edf..8b280498f 100644 --- a/services/platform/app/features/customers/hooks/mutations.ts +++ b/services/platform/app/features/customers/hooks/mutations.ts @@ -1,4 +1,5 @@ import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; +import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-mutation'; import { api } from '@/convex/_generated/api'; export function useBulkCreateCustomers() { @@ -6,9 +7,24 @@ export function useBulkCreateCustomers() { } export function useDeleteCustomer() { - return useConvexMutation(api.customers.mutations.deleteCustomer); + return useConvexOptimisticMutation( + api.customers.mutations.deleteCustomer, + api.customers.queries.listCustomers, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ customerId }, { remove }) => remove(customerId), + }, + ); } export function useUpdateCustomer() { - return useConvexMutation(api.customers.mutations.updateCustomer); + return useConvexOptimisticMutation( + api.customers.mutations.updateCustomer, + api.customers.queries.listCustomers, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ customerId, ...changes }, { update }) => + update(customerId, changes), + }, + ); } diff --git a/services/platform/app/features/documents/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/documents/hooks/__tests__/mutation-hooks.test.ts index e133cf412..dccc83cfa 100644 --- a/services/platform/app/features/documents/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/documents/hooks/__tests__/mutation-hooks.test.ts @@ -4,17 +4,23 @@ import { toId } from '@/convex/lib/type_cast_helpers'; const mockMutateAsync = vi.fn(); +const mockMutationResult = { + mutate: mockMutateAsync, + mutateAsync: mockMutateAsync, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), +}; + vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => ({ - mutate: mockMutateAsync, - mutateAsync: mockMutateAsync, - isPending: false, - isError: false, - isSuccess: false, - error: null, - data: undefined, - reset: vi.fn(), - }), + useConvexMutation: () => mockMutationResult, +})); + +vi.mock('@/app/hooks/use-convex-optimistic-mutation', () => ({ + useConvexOptimisticMutation: () => mockMutationResult, })); vi.mock('@/convex/_generated/api', () => ({ @@ -24,6 +30,9 @@ vi.mock('@/convex/_generated/api', () => ({ deleteDocument: 'deleteDocument', updateDocument: 'updateDocument', }, + queries: { + listDocuments: 'listDocuments', + }, }, }, })); @@ -35,7 +44,7 @@ describe('useDeleteDocument', () => { vi.clearAllMocks(); }); - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useDeleteDocument(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); @@ -67,7 +76,7 @@ describe('useUpdateDocument', () => { vi.clearAllMocks(); }); - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useUpdateDocument(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); diff --git a/services/platform/app/features/documents/hooks/mutations.ts b/services/platform/app/features/documents/hooks/mutations.ts index 027cd3ea9..7f8cad790 100644 --- a/services/platform/app/features/documents/hooks/mutations.ts +++ b/services/platform/app/features/documents/hooks/mutations.ts @@ -5,6 +5,7 @@ import { useState, useRef } from 'react'; import type { Id } from '@/convex/_generated/dataModel'; import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; +import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-mutation'; import { toast } from '@/app/hooks/use-toast'; import { api } from '@/convex/_generated/api'; import { toId } from '@/convex/lib/type_cast_helpers'; @@ -265,9 +266,24 @@ export function useDocumentUpload(options: UploadOptions) { } export function useDeleteDocument() { - return useConvexMutation(api.documents.mutations.deleteDocument); + return useConvexOptimisticMutation( + api.documents.mutations.deleteDocument, + api.documents.queries.listDocuments, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ documentId }, { remove }) => remove(documentId), + }, + ); } export function useUpdateDocument() { - return useConvexMutation(api.documents.mutations.updateDocument); + return useConvexOptimisticMutation( + api.documents.mutations.updateDocument, + api.documents.queries.listDocuments, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ documentId, ...changes }, { update }) => + update(documentId, changes), + }, + ); } diff --git a/services/platform/app/features/products/hooks/mutations.ts b/services/platform/app/features/products/hooks/mutations.ts index 9143c7828..091dd7599 100644 --- a/services/platform/app/features/products/hooks/mutations.ts +++ b/services/platform/app/features/products/hooks/mutations.ts @@ -1,14 +1,61 @@ -import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; +import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-mutation'; import { api } from '@/convex/_generated/api'; export function useCreateProduct() { - return useConvexMutation(api.products.mutations.createProduct); + return useConvexOptimisticMutation( + api.products.mutations.createProduct, + api.products.queries.listProducts, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ( + { + name, + description, + imageUrl, + stock, + price, + currency, + category, + tags, + status, + }, + { insert }, + ) => + insert({ + _creationTime: Date.now(), + name, + description, + imageUrl, + stock, + price, + currency, + category, + tags, + status: status ?? 'draft', + }), + }, + ); } export function useDeleteProduct() { - return useConvexMutation(api.products.mutations.deleteProduct); + return useConvexOptimisticMutation( + api.products.mutations.deleteProduct, + api.products.queries.listProducts, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ productId }, { remove }) => remove(productId), + }, + ); } export function useUpdateProduct() { - return useConvexMutation(api.products.mutations.updateProduct); + return useConvexOptimisticMutation( + api.products.mutations.updateProduct, + api.products.queries.listProducts, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ productId, ...changes }, { update }) => + update(productId, changes), + }, + ); } diff --git a/services/platform/app/features/settings/integrations/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/settings/integrations/hooks/__tests__/mutation-hooks.test.ts index f87cd2a58..030038a74 100644 --- a/services/platform/app/features/settings/integrations/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/settings/integrations/hooks/__tests__/mutation-hooks.test.ts @@ -4,17 +4,23 @@ import { toId } from '@/convex/lib/type_cast_helpers'; const mockMutateAsync = vi.fn(); +const mockMutationResult = { + mutate: mockMutateAsync, + mutateAsync: mockMutateAsync, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), +}; + vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => ({ - mutate: mockMutateAsync, - mutateAsync: mockMutateAsync, - isPending: false, - isError: false, - isSuccess: false, - error: null, - data: undefined, - reset: vi.fn(), - }), + useConvexMutation: () => mockMutationResult, +})); + +vi.mock('@/app/hooks/use-convex-optimistic-mutation', () => ({ + useConvexOptimisticMutation: () => mockMutationResult, })); vi.mock('@/convex/_generated/api', () => ({ @@ -29,6 +35,9 @@ vi.mock('@/convex/_generated/api', () => ({ updateIcon: 'updateIcon', deleteIntegration: 'deleteIntegration', }, + queries: { + list: 'list', + }, }, }, })); @@ -40,7 +49,7 @@ describe('useDeleteIntegration', () => { vi.clearAllMocks(); }); - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useDeleteIntegration(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); diff --git a/services/platform/app/features/settings/integrations/hooks/mutations.ts b/services/platform/app/features/settings/integrations/hooks/mutations.ts index ced3871b1..9dc82d80d 100644 --- a/services/platform/app/features/settings/integrations/hooks/mutations.ts +++ b/services/platform/app/features/settings/integrations/hooks/mutations.ts @@ -1,4 +1,5 @@ import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; +import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-mutation'; import { api } from '@/convex/_generated/api'; export function useGenerateUploadUrl() { @@ -10,5 +11,12 @@ export function useUpdateIntegrationIcon() { } export function useDeleteIntegration() { - return useConvexMutation(api.integrations.mutations.deleteIntegration); + return useConvexOptimisticMutation( + api.integrations.mutations.deleteIntegration, + api.integrations.queries.list, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ integrationId }, { remove }) => remove(integrationId), + }, + ); } diff --git a/services/platform/app/features/settings/organization/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/settings/organization/hooks/__tests__/mutation-hooks.test.ts index e63e666cd..6ee2c88f8 100644 --- a/services/platform/app/features/settings/organization/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/settings/organization/hooks/__tests__/mutation-hooks.test.ts @@ -2,17 +2,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockMutateAsync = vi.fn(); +const mockMutationResult = { + mutate: mockMutateAsync, + mutateAsync: mockMutateAsync, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), +}; + vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => ({ - mutate: mockMutateAsync, - mutateAsync: mockMutateAsync, - isPending: false, - isError: false, - isSuccess: false, - error: null, - data: undefined, - reset: vi.fn(), - }), + useConvexMutation: () => mockMutationResult, +})); + +vi.mock('@/app/hooks/use-convex-optimistic-mutation', () => ({ + useConvexOptimisticMutation: () => mockMutationResult, })); vi.mock('@/convex/_generated/api', () => ({ @@ -29,22 +35,71 @@ vi.mock('@/convex/_generated/api', () => ({ updateMemberRole: 'updateMemberRole', updateMemberDisplayName: 'updateMemberDisplayName', }, + queries: { + listByOrganization: 'listByOrganization', + }, }, }, })); import { + useCreateMember, useRemoveMember, useUpdateMemberRole, useUpdateMemberDisplayName, } from '../mutations'; +describe('useCreateMember', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns a mutation result object from useConvexOptimisticMutation', () => { + const result = useCreateMember(); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('isPending'); + }); + + it('calls mutation with the correct args', async () => { + mockMutateAsync.mockResolvedValueOnce({ + userId: 'user-1', + memberId: 'member-1', + isExistingUser: false, + }); + const { mutateAsync: createMember } = useCreateMember(); + + await createMember({ + organizationId: 'org-123', + email: 'test@example.com', + role: 'member', + }); + + expect(mockMutateAsync).toHaveBeenCalledWith({ + organizationId: 'org-123', + email: 'test@example.com', + role: 'member', + }); + }); + + it('propagates errors from mutation', async () => { + mockMutateAsync.mockRejectedValueOnce(new Error('Create failed')); + const { mutateAsync: createMember } = useCreateMember(); + + await expect( + createMember({ + organizationId: 'org-123', + email: 'test@example.com', + }), + ).rejects.toThrow('Create failed'); + }); +}); + describe('useRemoveMember', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useRemoveMember(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); @@ -74,7 +129,7 @@ describe('useUpdateMemberRole', () => { vi.clearAllMocks(); }); - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useUpdateMemberRole(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); @@ -107,7 +162,7 @@ describe('useUpdateMemberDisplayName', () => { vi.clearAllMocks(); }); - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useUpdateMemberDisplayName(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); diff --git a/services/platform/app/features/settings/organization/hooks/mutations.ts b/services/platform/app/features/settings/organization/hooks/mutations.ts index 81fac5e91..3349c9730 100644 --- a/services/platform/app/features/settings/organization/hooks/mutations.ts +++ b/services/platform/app/features/settings/organization/hooks/mutations.ts @@ -1,4 +1,5 @@ import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; +import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-mutation'; import { api } from '@/convex/_generated/api'; export function useSetMemberPassword() { @@ -6,17 +7,55 @@ export function useSetMemberPassword() { } export function useCreateMember() { - return useConvexMutation(api.users.mutations.createMember); + return useConvexOptimisticMutation( + api.users.mutations.createMember, + api.members.queries.listByOrganization, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ organizationId, email, displayName, role }, { insert }) => + insert({ + organizationId, + userId: '', + role: role ?? 'member', + createdAt: Date.now(), + displayName, + email, + }), + }, + ); } export function useRemoveMember() { - return useConvexMutation(api.members.mutations.removeMember); + return useConvexOptimisticMutation( + api.members.mutations.removeMember, + api.members.queries.listByOrganization, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ memberId }, { remove }) => remove(memberId), + }, + ); } export function useUpdateMemberRole() { - return useConvexMutation(api.members.mutations.updateMemberRole); + return useConvexOptimisticMutation( + api.members.mutations.updateMemberRole, + api.members.queries.listByOrganization, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ memberId, ...changes }, { update }) => + update(memberId, changes), + }, + ); } export function useUpdateMemberDisplayName() { - return useConvexMutation(api.members.mutations.updateMemberDisplayName); + return useConvexOptimisticMutation( + api.members.mutations.updateMemberDisplayName, + api.members.queries.listByOrganization, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ memberId, ...changes }, { update }) => + update(memberId, changes), + }, + ); } diff --git a/services/platform/app/features/settings/teams/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/settings/teams/hooks/__tests__/mutation-hooks.test.ts index c2e4ead9d..96ed7ba9a 100644 --- a/services/platform/app/features/settings/teams/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/settings/teams/hooks/__tests__/mutation-hooks.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockMutateAsync = vi.fn(); -vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => ({ +vi.mock('@/app/hooks/use-convex-optimistic-mutation', () => ({ + useConvexOptimisticMutation: () => ({ mutate: mockMutateAsync, mutateAsync: mockMutateAsync, isPending: false, @@ -22,6 +22,9 @@ vi.mock('@/convex/_generated/api', () => ({ addMember: 'addMember', removeMember: 'removeMember', }, + queries: { + listByTeam: 'listByTeam', + }, }, }, })); @@ -37,7 +40,7 @@ describe('useAddTeamMember', () => { vi.clearAllMocks(); }); - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useAddTeamMember(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); @@ -79,7 +82,7 @@ describe('useRemoveTeamMember', () => { vi.clearAllMocks(); }); - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useRemoveTeamMember(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); @@ -114,7 +117,7 @@ describe('useRemoveTeamMember', () => { }); describe('useCreateTeamMember', () => { - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useCreateTeamMember(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); diff --git a/services/platform/app/features/settings/teams/hooks/mutations.ts b/services/platform/app/features/settings/teams/hooks/mutations.ts index ffe10543d..b0515f292 100644 --- a/services/platform/app/features/settings/teams/hooks/mutations.ts +++ b/services/platform/app/features/settings/teams/hooks/mutations.ts @@ -1,14 +1,47 @@ -import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; +import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-mutation'; import { api } from '@/convex/_generated/api'; -export function useCreateTeamMember() { - return useConvexMutation(api.team_members.mutations.addMember); +export function useCreateTeamMember(teamId?: string) { + return useConvexOptimisticMutation( + api.team_members.mutations.addMember, + api.team_members.queries.listByTeam, + { + queryArgs: teamId ? { teamId } : undefined, + onMutate: ({ userId }, { insert }) => + insert({ + teamId: teamId ?? '', + userId, + role: 'member', + joinedAt: Date.now(), + }), + }, + ); } -export function useAddTeamMember() { - return useConvexMutation(api.team_members.mutations.addMember); +export function useAddTeamMember(teamId?: string) { + return useConvexOptimisticMutation( + api.team_members.mutations.addMember, + api.team_members.queries.listByTeam, + { + queryArgs: teamId ? { teamId } : undefined, + onMutate: ({ userId }, { insert }) => + insert({ + teamId: teamId ?? '', + userId, + role: 'member', + joinedAt: Date.now(), + }), + }, + ); } -export function useRemoveTeamMember() { - return useConvexMutation(api.team_members.mutations.removeMember); +export function useRemoveTeamMember(teamId?: string) { + return useConvexOptimisticMutation( + api.team_members.mutations.removeMember, + api.team_members.queries.listByTeam, + { + queryArgs: teamId ? { teamId } : undefined, + onMutate: ({ teamMemberId }, { remove }) => remove(teamMemberId), + }, + ); } diff --git a/services/platform/app/features/vendors/hooks/__tests__/mutation-hooks.test.ts b/services/platform/app/features/vendors/hooks/__tests__/mutation-hooks.test.ts index 1057809f0..75d636792 100644 --- a/services/platform/app/features/vendors/hooks/__tests__/mutation-hooks.test.ts +++ b/services/platform/app/features/vendors/hooks/__tests__/mutation-hooks.test.ts @@ -4,17 +4,23 @@ import { toId } from '@/convex/lib/type_cast_helpers'; const mockMutateAsync = vi.fn(); +const mockMutationResult = { + mutate: mockMutateAsync, + mutateAsync: mockMutateAsync, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), +}; + vi.mock('@/app/hooks/use-convex-mutation', () => ({ - useConvexMutation: () => ({ - mutate: mockMutateAsync, - mutateAsync: mockMutateAsync, - isPending: false, - isError: false, - isSuccess: false, - error: null, - data: undefined, - reset: vi.fn(), - }), + useConvexMutation: () => mockMutationResult, +})); + +vi.mock('@/app/hooks/use-convex-optimistic-mutation', () => ({ + useConvexOptimisticMutation: () => mockMutationResult, })); vi.mock('@/convex/_generated/api', () => ({ @@ -25,6 +31,9 @@ vi.mock('@/convex/_generated/api', () => ({ deleteVendor: 'deleteVendor', updateVendor: 'updateVendor', }, + queries: { + listVendors: 'listVendors', + }, }, }, })); @@ -36,7 +45,7 @@ import { } from '../mutations'; describe('useBulkCreateVendors', () => { - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useBulkCreateVendors(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); @@ -48,7 +57,7 @@ describe('useDeleteVendor', () => { vi.clearAllMocks(); }); - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useDeleteVendor(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); @@ -78,7 +87,7 @@ describe('useUpdateVendor', () => { vi.clearAllMocks(); }); - it('returns a mutation result object from useConvexMutation', () => { + it('returns a mutation result object from useConvexOptimisticMutation', () => { const result = useUpdateVendor(); expect(result).toHaveProperty('mutateAsync'); expect(result).toHaveProperty('isPending'); diff --git a/services/platform/app/features/vendors/hooks/mutations.ts b/services/platform/app/features/vendors/hooks/mutations.ts index 55e5769c2..0837ad8ce 100644 --- a/services/platform/app/features/vendors/hooks/mutations.ts +++ b/services/platform/app/features/vendors/hooks/mutations.ts @@ -1,4 +1,5 @@ import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; +import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-mutation'; import { api } from '@/convex/_generated/api'; export function useBulkCreateVendors() { @@ -6,9 +7,24 @@ export function useBulkCreateVendors() { } export function useDeleteVendor() { - return useConvexMutation(api.vendors.mutations.deleteVendor); + return useConvexOptimisticMutation( + api.vendors.mutations.deleteVendor, + api.vendors.queries.listVendors, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ vendorId }, { remove }) => remove(vendorId), + }, + ); } export function useUpdateVendor() { - return useConvexMutation(api.vendors.mutations.updateVendor); + return useConvexOptimisticMutation( + api.vendors.mutations.updateVendor, + api.vendors.queries.listVendors, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ vendorId, ...changes }, { update }) => + update(vendorId, changes), + }, + ); } diff --git a/services/platform/app/features/websites/hooks/mutations.ts b/services/platform/app/features/websites/hooks/mutations.ts index 1834b7a00..2fcb228fc 100644 --- a/services/platform/app/features/websites/hooks/mutations.ts +++ b/services/platform/app/features/websites/hooks/mutations.ts @@ -1,4 +1,5 @@ import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; +import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-mutation'; import { api } from '@/convex/_generated/api'; export function useRescanWebsite() { @@ -6,13 +7,43 @@ export function useRescanWebsite() { } export function useCreateWebsite() { - return useConvexMutation(api.websites.mutations.createWebsite); + return useConvexOptimisticMutation( + api.websites.mutations.createWebsite, + api.websites.queries.listWebsites, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ domain, title, description, scanInterval }, { insert }) => + insert({ + _creationTime: Date.now(), + domain, + title, + description, + scanInterval, + status: 'pending', + }), + }, + ); } export function useDeleteWebsite() { - return useConvexMutation(api.websites.mutations.deleteWebsite); + return useConvexOptimisticMutation( + api.websites.mutations.deleteWebsite, + api.websites.queries.listWebsites, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ websiteId }, { remove }) => remove(websiteId), + }, + ); } export function useUpdateWebsite() { - return useConvexMutation(api.websites.mutations.updateWebsite); + return useConvexOptimisticMutation( + api.websites.mutations.updateWebsite, + api.websites.queries.listWebsites, + { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: ({ websiteId, ...changes }, { update }) => + update(websiteId, changes), + }, + ); } diff --git a/services/platform/app/hooks/__tests__/build-helpers.test.ts b/services/platform/app/hooks/__tests__/build-helpers.test.ts new file mode 100644 index 000000000..2b68e25cd --- /dev/null +++ b/services/platform/app/hooks/__tests__/build-helpers.test.ts @@ -0,0 +1,367 @@ +import { QueryClient } from '@tanstack/react-query'; +import { describe, it, expect, beforeEach } from 'vitest'; + +import { buildHelpers } from '../use-convex-optimistic-mutation'; + +type CacheItem = Record & { _id: string }; + +const QUERY_KEY = ['test', 'items']; + +function assertDefined(val: T | undefined | null): asserts val is T { + expect(val).toBeDefined(); +} + +function createQueryClient(initial?: CacheItem[]) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + if (initial) { + qc.setQueryData(QUERY_KEY, initial); + } + return qc; +} + +function getCache(qc: QueryClient): CacheItem[] | undefined { + return qc.getQueryData(QUERY_KEY); +} + +describe('buildHelpers', () => { + let qc: QueryClient; + + beforeEach(() => { + qc = createQueryClient(); + }); + + describe('with undefined queryKey', () => { + it('returns undefined for all helpers', async () => { + const helpers = buildHelpers(qc, undefined); + + expect(await helpers.insert({ name: 'x' })).toBeUndefined(); + expect(await helpers.remove('id-1')).toBeUndefined(); + expect(await helpers.update('id-1', { name: 'y' })).toBeUndefined(); + expect(await helpers.bulkUpdate(['id-1'], { name: 'y' })).toBeUndefined(); + expect(await helpers.toggle('id-1', 'active')).toBeUndefined(); + }); + }); + + describe('insert', () => { + it('appends item with a temp id to the cache', async () => { + qc = createQueryClient([]); + const helpers = buildHelpers(qc, QUERY_KEY); + + await helpers.insert({ name: 'New item' }); + + const cache = getCache(qc); + assertDefined(cache); + expect(cache).toHaveLength(1); + expect(cache[0].name).toBe('New item'); + expect(cache[0]._id).toMatch(/^__optimistic_/); + }); + + it('returns context with previous state and tempId', async () => { + const initial = [{ _id: 'existing', name: 'Old' }]; + qc = createQueryClient(initial); + const helpers = buildHelpers(qc, QUERY_KEY); + + const ctx = await helpers.insert({ name: 'New' }); + + assertDefined(ctx); + expect(ctx.previous).toEqual(initial); + expect(ctx.queryKey).toEqual(QUERY_KEY); + expect(ctx.tempId).toMatch(/^__optimistic_/); + }); + + it('assigns unique temp ids across multiple inserts', async () => { + qc = createQueryClient([]); + const helpers = buildHelpers(qc, QUERY_KEY); + + const ctx1 = await helpers.insert({ name: 'First' }); + const ctx2 = await helpers.insert({ name: 'Second' }); + + assertDefined(ctx1); + assertDefined(ctx2); + expect(ctx1.tempId).not.toBe(ctx2.tempId); + expect(getCache(qc)).toHaveLength(2); + }); + + it('tempId is not overwritten when item contains _id', async () => { + qc = createQueryClient([]); + const helpers = buildHelpers(qc, QUERY_KEY); + + const ctx = await helpers.insert({ + _id: 'user-provided-id', + name: 'Test', + }); + + assertDefined(ctx); + const cache = getCache(qc); + assertDefined(cache); + expect(cache[0]._id).toBe(ctx.tempId); + expect(cache[0]._id).not.toBe('user-provided-id'); + }); + + it('initializes cache when no prior data exists', async () => { + const helpers = buildHelpers(qc, QUERY_KEY); + + await helpers.insert({ name: 'First ever' }); + + const cache = getCache(qc); + assertDefined(cache); + expect(cache).toHaveLength(1); + expect(cache[0].name).toBe('First ever'); + }); + }); + + describe('remove', () => { + it('removes item by id', async () => { + qc = createQueryClient([ + { _id: 'a', name: 'Alice' }, + { _id: 'b', name: 'Bob' }, + ]); + const helpers = buildHelpers(qc, QUERY_KEY); + + await helpers.remove('a'); + + const cache = getCache(qc); + assertDefined(cache); + expect(cache).toHaveLength(1); + expect(cache[0]._id).toBe('b'); + }); + + it('returns context with previous state', async () => { + const initial = [{ _id: 'a', name: 'Alice' }]; + qc = createQueryClient(initial); + const helpers = buildHelpers(qc, QUERY_KEY); + + const ctx = await helpers.remove('a'); + + assertDefined(ctx); + expect(ctx.previous).toEqual(initial); + }); + + it('does nothing when id is not found', async () => { + const initial = [{ _id: 'a', name: 'Alice' }]; + qc = createQueryClient(initial); + const helpers = buildHelpers(qc, QUERY_KEY); + + await helpers.remove('nonexistent'); + + expect(getCache(qc)).toEqual(initial); + }); + }); + + describe('update', () => { + it('updates item by id with changes', async () => { + qc = createQueryClient([{ _id: 'a', name: 'Alice', role: 'admin' }]); + const helpers = buildHelpers(qc, QUERY_KEY); + + await helpers.update('a', { role: 'member' }); + + const cache = getCache(qc); + assertDefined(cache); + expect(cache[0]).toEqual({ _id: 'a', name: 'Alice', role: 'member' }); + }); + + it('returns context with previous state', async () => { + const initial = [{ _id: 'a', name: 'Alice', role: 'admin' }]; + qc = createQueryClient(initial); + const helpers = buildHelpers(qc, QUERY_KEY); + + const ctx = await helpers.update('a', { role: 'member' }); + + assertDefined(ctx); + expect(ctx.previous).toEqual(initial); + }); + + it('does not modify other items', async () => { + qc = createQueryClient([ + { _id: 'a', name: 'Alice' }, + { _id: 'b', name: 'Bob' }, + ]); + const helpers = buildHelpers(qc, QUERY_KEY); + + await helpers.update('a', { name: 'Alicia' }); + + const cache = getCache(qc); + assertDefined(cache); + expect(cache[1]).toEqual({ _id: 'b', name: 'Bob' }); + }); + }); + + describe('bulkUpdate', () => { + it('updates multiple items by ids', async () => { + qc = createQueryClient([ + { _id: 'a', status: 'open' }, + { _id: 'b', status: 'open' }, + { _id: 'c', status: 'open' }, + ]); + const helpers = buildHelpers(qc, QUERY_KEY); + + await helpers.bulkUpdate(['a', 'c'], { status: 'closed' }); + + const cache = getCache(qc); + assertDefined(cache); + expect(cache[0].status).toBe('closed'); + expect(cache[1].status).toBe('open'); + expect(cache[2].status).toBe('closed'); + }); + + it('returns context with previous state', async () => { + const initial = [ + { _id: 'a', status: 'open' }, + { _id: 'b', status: 'open' }, + ]; + qc = createQueryClient(initial); + const helpers = buildHelpers(qc, QUERY_KEY); + + const ctx = await helpers.bulkUpdate(['a', 'b'], { status: 'closed' }); + + assertDefined(ctx); + expect(ctx.previous).toEqual(initial); + }); + }); + + describe('toggle', () => { + it('toggles a boolean field', async () => { + qc = createQueryClient([{ _id: 'a', isActive: true }]); + const helpers = buildHelpers(qc, QUERY_KEY); + + await helpers.toggle('a', 'isActive'); + + const cache = getCache(qc); + assertDefined(cache); + expect(cache[0].isActive).toBe(false); + }); + + it('toggles false to true', async () => { + qc = createQueryClient([{ _id: 'a', isActive: false }]); + const helpers = buildHelpers(qc, QUERY_KEY); + + await helpers.toggle('a', 'isActive'); + + const cache = getCache(qc); + assertDefined(cache); + expect(cache[0].isActive).toBe(true); + }); + + it('returns context with previous state', async () => { + const initial = [{ _id: 'a', isActive: true }]; + qc = createQueryClient(initial); + const helpers = buildHelpers(qc, QUERY_KEY); + + const ctx = await helpers.toggle('a', 'isActive'); + + assertDefined(ctx); + expect(ctx.previous).toEqual(initial); + }); + }); + + describe('insert then update (chaining)', () => { + it('can update an optimistically inserted item using tempId', async () => { + qc = createQueryClient([]); + const helpers = buildHelpers(qc, QUERY_KEY); + + const insertCtx = await helpers.insert({ + name: 'Draft', + status: 'pending', + }); + assertDefined(insertCtx); + await helpers.update(insertCtx.tempId, { status: 'active' }); + + const cache = getCache(qc); + assertDefined(cache); + expect(cache).toHaveLength(1); + expect(cache[0]._id).toBe(insertCtx.tempId); + expect(cache[0].name).toBe('Draft'); + expect(cache[0].status).toBe('active'); + }); + + it('insert context captures pre-insert state for full rollback', async () => { + const initial = [{ _id: 'existing', name: 'Existing' }]; + qc = createQueryClient(initial); + const helpers = buildHelpers(qc, QUERY_KEY); + + const insertCtx = await helpers.insert({ name: 'New' }); + assertDefined(insertCtx); + await helpers.update(insertCtx.tempId, { name: 'Updated New' }); + + qc.setQueryData(insertCtx.queryKey, insertCtx.previous); + + expect(getCache(qc)).toEqual(initial); + }); + + it('can remove an optimistically inserted item using tempId', async () => { + qc = createQueryClient([{ _id: 'existing', name: 'Existing' }]); + const helpers = buildHelpers(qc, QUERY_KEY); + + const insertCtx = await helpers.insert({ name: 'Temporary' }); + assertDefined(insertCtx); + expect(getCache(qc)).toHaveLength(2); + + await helpers.remove(insertCtx.tempId); + expect(getCache(qc)).toHaveLength(1); + const cache = getCache(qc); + assertDefined(cache); + expect(cache[0]._id).toBe('existing'); + }); + + it('can toggle a field on an optimistically inserted item', async () => { + qc = createQueryClient([]); + const helpers = buildHelpers(qc, QUERY_KEY); + + const insertCtx = await helpers.insert({ + name: 'Webhook', + isActive: false, + }); + assertDefined(insertCtx); + await helpers.toggle(insertCtx.tempId, 'isActive'); + + const cache = getCache(qc); + assertDefined(cache); + expect(cache[0].isActive).toBe(true); + }); + }); + + describe('rollback', () => { + it('restores previous state when rolling back after insert', async () => { + const initial = [{ _id: 'a', name: 'Alice' }]; + qc = createQueryClient(initial); + const helpers = buildHelpers(qc, QUERY_KEY); + + const ctx = await helpers.insert({ name: 'Optimistic' }); + expect(getCache(qc)).toHaveLength(2); + + assertDefined(ctx); + qc.setQueryData(ctx.queryKey, ctx.previous); + expect(getCache(qc)).toEqual(initial); + }); + + it('restores previous state when rolling back after update', async () => { + const initial = [{ _id: 'a', name: 'Alice', role: 'admin' }]; + qc = createQueryClient(initial); + const helpers = buildHelpers(qc, QUERY_KEY); + + const ctx = await helpers.update('a', { role: 'member' }); + const cache = getCache(qc); + assertDefined(cache); + expect(cache[0].role).toBe('member'); + + assertDefined(ctx); + qc.setQueryData(ctx.queryKey, ctx.previous); + expect(getCache(qc)).toEqual(initial); + }); + + it('restores previous state when rolling back after remove', async () => { + const initial = [{ _id: 'a', name: 'Alice' }]; + qc = createQueryClient(initial); + const helpers = buildHelpers(qc, QUERY_KEY); + + const ctx = await helpers.remove('a'); + expect(getCache(qc)).toHaveLength(0); + + assertDefined(ctx); + qc.setQueryData(ctx.queryKey, ctx.previous); + expect(getCache(qc)).toEqual(initial); + }); + }); +}); diff --git a/services/platform/app/hooks/__tests__/use-convex-query.test.ts b/services/platform/app/hooks/__tests__/use-convex-query.test.ts index 2782ff0fb..42293884f 100644 --- a/services/platform/app/hooks/__tests__/use-convex-query.test.ts +++ b/services/platform/app/hooks/__tests__/use-convex-query.test.ts @@ -62,4 +62,24 @@ describe('useConvexQuery', () => { expect(result).toBe(mockResult); }); + + it('merges cache options into useQuery call', () => { + const args = { organizationId: 'org-123' }; + const options = { staleTime: 10_000, gcTime: 60_000 }; + + useConvexQuery(mockQueryRef, args, options); + + const passedOptions = mockUseQuery.mock.calls[0]?.[0]; + expect(passedOptions).toMatchObject(options); + }); + + it('does not include undefined options when omitted', () => { + const args = { organizationId: 'org-123' }; + + useConvexQuery(mockQueryRef, args); + + const passedOptions = mockUseQuery.mock.calls[0]?.[0]; + expect(passedOptions).not.toHaveProperty('staleTime'); + expect(passedOptions).not.toHaveProperty('gcTime'); + }); }); diff --git a/services/platform/app/hooks/use-convex-optimistic-mutation.ts b/services/platform/app/hooks/use-convex-optimistic-mutation.ts new file mode 100644 index 000000000..db70a14be --- /dev/null +++ b/services/platform/app/hooks/use-convex-optimistic-mutation.ts @@ -0,0 +1,180 @@ +import type { + QueryClient, + QueryKey, + UseMutationOptions, +} from '@tanstack/react-query'; +import type { + FunctionArgs, + FunctionReference, + FunctionReturnType, +} from 'convex/server'; + +import { convexQuery } from '@convex-dev/react-query'; +import { useMutation } from '@tanstack/react-query'; + +import { useConvexClient } from './use-convex-client'; +import { useOrganizationId } from './use-organization-id'; +import { useReactQueryClient } from './use-react-query-client'; + +interface OptimisticContext { + previous: unknown; + queryKey: QueryKey; +} + +function isOptimisticContext(value: unknown): value is OptimisticContext { + return ( + typeof value === 'object' && + value !== null && + 'previous' in value && + 'queryKey' in value + ); +} + +type CacheItem = Record & { _id: string }; + +async function snapshot( + queryClient: QueryClient, + queryKey: QueryKey, +): Promise { + await queryClient.cancelQueries({ queryKey }); + return { + previous: queryClient.getQueryData(queryKey), + queryKey, + }; +} + +let tempIdCounter = 0; + +export interface InsertContext extends OptimisticContext { + tempId: string; +} + +export interface OptimisticHelpers { + insert( + this: void, + item: Record, + ): Promise; + remove(this: void, id: string): Promise; + update( + this: void, + id: string, + changes: Record, + ): Promise; + bulkUpdate( + this: void, + ids: string[], + changes: Record, + ): Promise; + toggle( + this: void, + id: string, + field: string, + ): Promise; +} + +export function buildHelpers( + queryClient: QueryClient, + queryKey: QueryKey | undefined, +): OptimisticHelpers { + return { + async insert(item) { + if (!queryKey) return undefined; + const ctx = await snapshot(queryClient, queryKey); + const tempId = `__optimistic_${Date.now()}_${tempIdCounter++}`; + queryClient.setQueryData(queryKey, (old) => [ + ...(old ?? []), + { ...item, _id: tempId }, + ]); + return { ...ctx, tempId }; + }, + async remove(id) { + if (!queryKey) return undefined; + const ctx = await snapshot(queryClient, queryKey); + queryClient.setQueryData(queryKey, (old) => + old?.filter((item) => item._id !== id), + ); + return ctx; + }, + async update(id, changes) { + if (!queryKey) return undefined; + const ctx = await snapshot(queryClient, queryKey); + queryClient.setQueryData(queryKey, (old) => + old?.map((item) => (item._id === id ? { ...item, ...changes } : item)), + ); + return ctx; + }, + async bulkUpdate(ids, changes) { + if (!queryKey) return undefined; + const idSet = new Set(ids); + const ctx = await snapshot(queryClient, queryKey); + queryClient.setQueryData(queryKey, (old) => + old?.map((item) => + idSet.has(item._id) ? { ...item, ...changes } : item, + ), + ); + return ctx; + }, + async toggle(id, field) { + if (!queryKey) return undefined; + const ctx = await snapshot(queryClient, queryKey); + queryClient.setQueryData(queryKey, (old) => + old?.map((item) => + item._id === id ? { ...item, [field]: !item[field] } : item, + ), + ); + return ctx; + }, + }; +} + +interface OptimisticConfig> { + queryArgs: + | Record + | ((organizationId: string) => Record) + | undefined; + onMutate: ( + args: FunctionArgs, + helpers: OptimisticHelpers, + ) => Promise; +} + +export function useConvexOptimisticMutation< + MFunc extends FunctionReference<'mutation'>, +>( + mutationFunc: MFunc, + queryFunc: FunctionReference<'query'>, + config: OptimisticConfig, +) { + const convexClient = useConvexClient(); + const queryClient = useReactQueryClient(); + const organizationId = useOrganizationId(); + + const resolvedArgs = + typeof config.queryArgs === 'function' + ? organizationId + ? config.queryArgs(organizationId) + : undefined + : config.queryArgs; + + const queryKey = resolvedArgs + ? convexQuery(queryFunc, resolvedArgs).queryKey + : undefined; + + const helpers = buildHelpers(queryClient, queryKey); + + const options: UseMutationOptions< + FunctionReturnType, + Error, + FunctionArgs + > = { + mutationFn: (args) => convexClient.mutation(mutationFunc, args), + onMutate: (args) => config.onMutate(args, helpers), + onError: (_err, _vars, ctx) => { + if (isOptimisticContext(ctx)) { + queryClient.setQueryData(ctx.queryKey, ctx.previous); + } + }, + }; + + return useMutation(options); +} diff --git a/services/platform/app/hooks/use-convex-query.ts b/services/platform/app/hooks/use-convex-query.ts index 1d5a2e97b..0fdd5a6b6 100644 --- a/services/platform/app/hooks/use-convex-query.ts +++ b/services/platform/app/hooks/use-convex-query.ts @@ -5,16 +5,26 @@ import { useQuery } from '@tanstack/react-query'; type EmptyObject = Record; +interface ConvexQueryOptions { + staleTime?: number; + gcTime?: number; +} + type QueryArgs> = keyof FunctionArgs extends never - ? [args?: EmptyObject | 'skip'] + ? [args?: EmptyObject | 'skip', options?: ConvexQueryOptions] : EmptyObject extends FunctionArgs - ? [args?: FunctionArgs | 'skip'] - : [args: FunctionArgs | 'skip']; + ? [args?: FunctionArgs | 'skip', options?: ConvexQueryOptions] + : [args: FunctionArgs | 'skip', options?: ConvexQueryOptions]; export function useConvexQuery>( func: Func, - ...[args]: QueryArgs + ...[args, options]: QueryArgs ) { - return useQuery(convexQuery(func, args ?? {})); + return useQuery({ + ...convexQuery(func, args ?? {}), + ...options, + }); } + +export type { ConvexQueryOptions }; diff --git a/services/platform/app/router.tsx b/services/platform/app/router.tsx index b2d93cea1..3591c77c4 100644 --- a/services/platform/app/router.tsx +++ b/services/platform/app/router.tsx @@ -23,6 +23,7 @@ export function createRouter() { queryKeyHashFn: convexQueryClient.hashFn(), queryFn: convexQueryClient.queryFn(), staleTime: 5 * 60 * 1000, + gcTime: 120 * 60 * 1000, }, }, }); diff --git a/services/platform/convex/README.md b/services/platform/convex/README.md index a19c61471..91a9db2d0 100644 --- a/services/platform/convex/README.md +++ b/services/platform/convex/README.md @@ -1,184 +1,90 @@ -# Convex Backend +# Welcome to your Convex functions directory! -Backend functions for the Tale platform. All queries and mutations are protected by row-level security (RLS). +Write your Convex functions here. +See https://docs.convex.dev/functions for more. -## Architecture +A query function that takes two arguments looks like: -``` -Convex WebSocket → @convex-dev/react-query → TanStack Query cache → UI -``` - -### Data Layer - -- **Queries** are consumed via `useConvexQuery` which bridges Convex real-time subscriptions to TanStack Query. Queries return raw data without pagination, filtering, or sorting — all handled client-side -- **Mutations** are called via `useConvexMutation` (wraps TanStack Query's `useMutation`) — Convex WebSocket auto-syncs queries when mutations complete -- **Actions** are called via `useConvexAction` for operations with side effects (external APIs, bulk operations) - -### Validation - -Shared Zod schemas in `lib/shared/schemas/` are used on both client and server. On the server side, `zodToConvex()` bridges Zod schemas to Convex validators. - -## Directory Structure - -Each domain is organized as a folder with: - -``` -convex/ -├── [domain]/ -│ ├── schema.ts # Table definition (defineTable) -│ ├── validators.ts # Convex validators (from shared Zod schemas) -│ ├── queries.ts # queryWithRLS functions -│ ├── mutations.ts # mutationWithRLS functions -│ ├── actions.ts # action functions (side effects) -│ └── [helpers].ts # Business logic helpers -├── lib/ -│ ├── rls/ # Row-level security wrappers -│ └── ... -└── schema.ts # Root schema aggregating all tables -``` +```ts +// convex/myFunctions.ts +import { query } from "./_generated/server"; +import { v } from "convex/values"; -## Server-Side Patterns +export const myQueryFunction = query({ + // Validators for arguments. + args: { + first: v.number(), + second: v.string(), + }, -### Query + // Function implementation. + handler: async (ctx, args) => { + // Read the database as many times as you need here. + // See https://docs.convex.dev/database/reading-data. + const documents = await ctx.db.query("tablename").collect(); -```ts -// convex/customers/queries.ts -import { v } from 'convex/values'; -import { queryWithRLS } from '../lib/rls/query_with_rls'; + // Arguments passed from the client are properties of the args object. + console.log(args.first, args.second); -export const listCustomers = queryWithRLS({ - args: { organizationId: v.string() }, - returns: v.array(customerValidator), - handler: async (ctx, args) => { - const results = []; - for await (const customer of ctx.db - .query('customers') - .withIndex('by_organizationId', (q) => - q.eq('organizationId', args.organizationId), - )) { - results.push(customer); - } - return results; + // Write arbitrary JavaScript here: filter, aggregate, build derived data, + // remove non-public properties, or create new objects. + return documents; }, }); ``` -### Mutation +Using this query function in a React component looks like: ```ts -// convex/customers/mutations.ts -import { v } from 'convex/values'; -import { mutationWithRLS } from '../lib/rls/mutation_with_rls'; - -export const updateCustomer = mutationWithRLS({ - args: { - customerId: v.id('customers'), - name: v.optional(v.string()), - }, - returns: v.union(customerValidator, v.null()), - handler: async (ctx, args) => { - const { customerId, ...updates } = args; - await ctx.db.patch(customerId, updates); - return await ctx.db.get(customerId); - }, +const data = useQuery(api.myFunctions.myQueryFunction, { + first: 10, + second: "hello", }); ``` -### Action +A mutation function looks like: ```ts -// convex/documents/actions.ts -import { v } from 'convex/values'; - -import { internal } from '../_generated/api'; -import { action } from '../_generated/server'; +// convex/myFunctions.ts +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; -export const retryRagIndexing = action({ +export const myMutationFunction = mutation({ + // Validators for arguments. args: { - documentId: v.id('documents'), + first: v.string(), + second: v.string(), }, - returns: v.object({ - success: v.boolean(), - jobId: v.optional(v.string()), - error: v.optional(v.string()), - }), + + // Function implementation. handler: async (ctx, args) => { - // Actions can call queries/mutations and interact with external systems - const document = await ctx.runQuery( - internal.documents.internal_queries.getDocumentByIdRaw, - { documentId: args.documentId }, - ); - - if (!document) { - return { success: false, error: 'Document not found' }; - } - - const result = await ragAction.execute(ctx, { - operation: 'upload_document', - recordId: args.documentId, - }); - - return { success: result.success, jobId: result.jobId }; + // Insert or modify documents in the database here. + // Mutations can also read from the database like queries. + // See https://docs.convex.dev/database/writing-data. + const message = { body: args.first, author: args.second }; + const id = await ctx.db.insert("messages", message); + + // Optionally, return a value from your mutation. + return await ctx.db.get("messages", id); }, }); ``` -## Client-Side Patterns - -### Query Hooks - -All data fetching uses `useConvexQuery` which bridges Convex real-time subscriptions to TanStack Query. Types are extracted with `ConvexItemOf`. - -```ts -// features/customers/hooks/queries.ts -import type { ConvexItemOf } from '@/lib/types/convex-helpers'; -import { useConvexQuery } from '@/app/hooks/use-convex-query'; -import { api } from '@/convex/_generated/api'; - -export type Customer = ConvexItemOf; - -export function useCustomers(organizationId: string) { - const { data, isLoading } = useConvexQuery( - api.customers.queries.listCustomers, - { organizationId }, - ); - return { customers: data ?? [], isLoading }; -} -``` - -Conditional/skippable queries pass `'skip'` as args: +Using this mutation function in a React component looks like: ```ts -export function useWorkflow(wfDefinitionId: string | undefined) { - return useConvexQuery( - api.wf_definitions.queries.getWorkflow, - wfDefinitionId ? { wfDefinitionId } : 'skip', +const mutation = useMutation(api.myFunctions.myMutationFunction); +function handleButtonPress() { + // fire and forget, the most common way to use mutations + mutation({ first: "Hello!", second: "me" }); + // OR + // use the result once the mutation has completed + mutation({ first: "Hello!", second: "me" }).then((result) => + console.log(result), ); } ``` -### Mutations (via TanStack Query + Convex WebSocket) - -```ts -// features/customers/hooks/mutations.ts -export function useUpdateCustomer() { - return useConvexMutation(api.customers.mutations.updateCustomer); -} -``` - -### Actions (for side effects) - -```ts -// features/documents/hooks/actions.ts -export function useRetryRagIndexing() { - return useConvexAction(api.documents.actions.retryRagIndexing); -} -``` - -## Key Rules - -- **Never use `.collect()`** — use `for await (const item of query)` instead -- **Always use `queryWithRLS` / `mutationWithRLS`** for authenticated endpoints -- **Backend returns raw data only** — no pagination, filtering, or sorting server-side -- **Share validation schemas** via `lib/shared/schemas/` between client and server -- **No deprecated functions** — remove them entirely instead of marking `@deprecated` +Use the Convex CLI to push your functions to a deployment. See everything +the Convex CLI can do by running `npx convex -h` in your project root +directory. To learn more, launch the docs with `npx convex docs`. diff --git a/services/platform/convex/tsconfig.json b/services/platform/convex/tsconfig.json index 8a6fe6c36..73741270b 100644 --- a/services/platform/convex/tsconfig.json +++ b/services/platform/convex/tsconfig.json @@ -1,13 +1,24 @@ { - "extends": "../../../tsconfig.base.json", + /* This TypeScript project config describes the environment that + * Convex functions run in and is used to typecheck them. + * You can modify it, but some settings are required to use Convex. + */ "compilerOptions": { + /* These settings are not required by Convex and can be modified. */ "allowJs": true, + "strict": true, + "moduleResolution": "Bundler", "jsx": "react-jsx", + "skipLibCheck": true, "allowSyntheticDefaultImports": true, + + /* These compiler options are required by Convex */ "target": "ESNext", "lib": ["ES2021", "dom"], "forceConsistentCasingInFileNames": true, - "module": "ESNext" + "module": "ESNext", + "isolatedModules": true, + "noEmit": true }, "include": ["./**/*"], "exclude": ["./_generated"] diff --git a/turbo.json b/turbo.json index 012e40370..c5234805c 100644 --- a/turbo.json +++ b/turbo.json @@ -41,10 +41,12 @@ "outputs": [] }, "lint:fix": { + "dependsOn": ["setup"], "cache": false, "outputs": [] }, "format": { + "dependsOn": ["setup"], "cache": false, "outputs": [] }, From fc8aec87a1081537abf4f4d9e7eed8e6f151c0fa Mon Sep 17 00:00:00 2001 From: methosiea Date: Sat, 14 Feb 2026 12:40:25 +0100 Subject: [PATCH 4/8] fix: issues --- .../platform/app/features/automations/hooks/queries.ts | 3 ++- .../triggers/components/event-create-dialog.tsx | 4 ++-- .../automations/triggers/components/events-section.tsx | 6 ++++-- .../automations/triggers/components/schedules-section.tsx | 6 ++++-- .../app/features/automations/triggers/hooks/queries.ts | 7 ++++--- services/platform/app/features/chat/hooks/queries.ts | 7 ++++--- .../components/custom-agent-delete-dialog.tsx | 4 +++- .../custom-agents/components/custom-agent-knowledge.tsx | 2 +- .../platform/app/features/custom-agents/hooks/queries.ts | 5 +++-- .../customers/components/customer-delete-dialog.tsx | 2 +- .../customers/components/customer-edit-dialog.tsx | 2 +- .../documents/components/document-team-tags-dialog.tsx | 2 +- services/platform/app/features/documents/hooks/queries.ts | 8 ++++---- .../features/products/components/product-row-actions.tsx | 2 +- .../products/components/products-import-dialog.tsx | 2 +- .../organization/components/member-delete-dialog.tsx | 2 +- .../settings/teams/components/team-members-dialog.tsx | 4 ++-- .../platform/app/features/settings/teams/hooks/queries.ts | 7 ++++++- .../routes/dashboard/$id/custom-agents/$agentId/index.tsx | 2 +- .../dashboard/$id/custom-agents/$agentId/instructions.tsx | 2 +- .../routes/dashboard/$id/custom-agents/$agentId/tools.tsx | 4 ++-- .../app/routes/dashboard/$id/custom-agents/index.tsx | 2 +- 22 files changed, 50 insertions(+), 35 deletions(-) diff --git a/services/platform/app/features/automations/hooks/queries.ts b/services/platform/app/features/automations/hooks/queries.ts index 6811e2741..40f4d59e1 100644 --- a/services/platform/app/features/automations/hooks/queries.ts +++ b/services/platform/app/features/automations/hooks/queries.ts @@ -7,6 +7,7 @@ import { useCachedPaginatedQuery } from '@/app/hooks/use-cached-paginated-query' import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { useDebounce } from '@/app/hooks/use-debounce'; import { api } from '@/convex/_generated/api'; +import { toId } from '@/lib/utils/type-guards'; export type AutomationRoot = ConvexItemOf< typeof api.wf_definitions.queries.listAutomationRoots @@ -47,7 +48,7 @@ export function useAutomations(organizationId: string) { export function useWorkflowSteps(wfDefinitionId: string) { const { data, isLoading } = useConvexQuery( api.wf_step_defs.queries.getWorkflowSteps, - { wfDefinitionId }, + { wfDefinitionId: toId<'wfDefinitions'>(wfDefinitionId) }, ); return { diff --git a/services/platform/app/features/automations/triggers/components/event-create-dialog.tsx b/services/platform/app/features/automations/triggers/components/event-create-dialog.tsx index 8d4b354e8..1a9676b1c 100644 --- a/services/platform/app/features/automations/triggers/components/event-create-dialog.tsx +++ b/services/platform/app/features/automations/triggers/components/event-create-dialog.tsx @@ -127,7 +127,7 @@ export function EventCreateDialog({ Object.keys(filterValues).length > 0 ? filterValues : undefined; if (isEditMode && editing) { - await updateEventSubscription({ + await updateEventSubscription.mutateAsync({ subscriptionId: editing._id, eventFilter: filterPayload, }); @@ -136,7 +136,7 @@ export function EventCreateDialog({ variant: 'success', }); } else { - await createEventSubscription({ + await createEventSubscription.mutateAsync({ organizationId, workflowRootId, eventType: selectedEventType, diff --git a/services/platform/app/features/automations/triggers/components/events-section.tsx b/services/platform/app/features/automations/triggers/components/events-section.tsx index 6d583b634..99d0e6693 100644 --- a/services/platform/app/features/automations/triggers/components/events-section.tsx +++ b/services/platform/app/features/automations/triggers/components/events-section.tsx @@ -70,7 +70,7 @@ export function EventsSection({ const handleToggle = useCallback( async (subscriptionId: Id<'wfEventSubscriptions'>, isActive: boolean) => { try { - await toggleSubscription({ subscriptionId, isActive }); + await toggleSubscription.mutateAsync({ subscriptionId, isActive }); toast({ title: isActive ? t('triggers.events.toast.enabled') @@ -91,7 +91,9 @@ export function EventsSection({ if (!deleteTarget) return; setIsDeleting(true); try { - await deleteSubscriptionMutation({ subscriptionId: deleteTarget._id }); + await deleteSubscriptionMutation.mutateAsync({ + subscriptionId: deleteTarget._id, + }); toast({ title: t('triggers.events.toast.deleted'), variant: 'success', diff --git a/services/platform/app/features/automations/triggers/components/schedules-section.tsx b/services/platform/app/features/automations/triggers/components/schedules-section.tsx index b93c3a446..1151b150b 100644 --- a/services/platform/app/features/automations/triggers/components/schedules-section.tsx +++ b/services/platform/app/features/automations/triggers/components/schedules-section.tsx @@ -47,7 +47,7 @@ export function SchedulesSection({ const handleToggle = useCallback( async (scheduleId: Id<'wfSchedules'>, isActive: boolean) => { try { - await toggleSchedule({ scheduleId, isActive }); + await toggleSchedule.mutateAsync({ scheduleId, isActive }); toast({ title: isActive ? t('triggers.schedules.toast.enabled') @@ -68,7 +68,9 @@ export function SchedulesSection({ if (!deleteTarget) return; setIsDeleting(true); try { - await deleteScheduleMutation({ scheduleId: deleteTarget._id }); + await deleteScheduleMutation.mutateAsync({ + scheduleId: deleteTarget._id, + }); toast({ title: t('triggers.schedules.toast.deleted'), variant: 'success', diff --git a/services/platform/app/features/automations/triggers/hooks/queries.ts b/services/platform/app/features/automations/triggers/hooks/queries.ts index e9cb49a7c..c811274a0 100644 --- a/services/platform/app/features/automations/triggers/hooks/queries.ts +++ b/services/platform/app/features/automations/triggers/hooks/queries.ts @@ -2,6 +2,7 @@ import type { ConvexItemOf } from '@/lib/types/convex-helpers'; import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { api } from '@/convex/_generated/api'; +import { toId } from '@/lib/utils/type-guards'; export type WfSchedule = ConvexItemOf< typeof api.workflows.triggers.queries.getSchedules @@ -18,7 +19,7 @@ export type WfEventSubscription = ConvexItemOf< export function useSchedules(workflowRootId: string) { const { data, isLoading } = useConvexQuery( api.workflows.triggers.queries.getSchedules, - { workflowRootId }, + { workflowRootId: toId<'wfDefinitions'>(workflowRootId) }, ); return { @@ -30,7 +31,7 @@ export function useSchedules(workflowRootId: string) { export function useWebhooks(workflowRootId: string) { const { data, isLoading } = useConvexQuery( api.workflows.triggers.queries.getWebhooks, - { workflowRootId }, + { workflowRootId: toId<'wfDefinitions'>(workflowRootId) }, ); return { @@ -42,7 +43,7 @@ export function useWebhooks(workflowRootId: string) { export function useEventSubscriptions(workflowRootId: string) { const { data, isLoading } = useConvexQuery( api.workflows.triggers.queries.getEventSubscriptions, - { workflowRootId }, + { workflowRootId: toId<'wfDefinitions'>(workflowRootId) }, ); return { diff --git a/services/platform/app/features/chat/hooks/queries.ts b/services/platform/app/features/chat/hooks/queries.ts index cc5f23dd1..44cb72c59 100644 --- a/services/platform/app/features/chat/hooks/queries.ts +++ b/services/platform/app/features/chat/hooks/queries.ts @@ -10,6 +10,7 @@ import { useApprovals } from '@/app/features/approvals/hooks/queries'; import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { useTeamFilter } from '@/app/hooks/use-team-filter'; import { api } from '@/convex/_generated/api'; +import { toId } from '@/lib/utils/type-guards'; export type Thread = ConvexItemOf; @@ -97,7 +98,7 @@ export function useHumanInputRequests( a.metadata !== undefined, ) .map((a) => ({ - _id: a._id, + _id: toId<'approvals'>(a._id), status: a.status, // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Convex metadata uses v.any(); cast to specific schema type metadata: a.metadata as unknown as HumanInputRequestMetadata, @@ -153,7 +154,7 @@ export function useIntegrationApprovals( a.metadata !== undefined, ) .map((a) => ({ - _id: a._id, + _id: toId<'approvals'>(a._id), status: a.status, // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Convex metadata uses v.any(); cast to specific metadata shape metadata: a.metadata as IntegrationOperationMetadata, @@ -196,7 +197,7 @@ export function useWorkflowCreationApprovals( a.metadata !== undefined, ) .map((a) => ({ - _id: a._id, + _id: toId<'approvals'>(a._id), status: a.status, // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Convex metadata uses v.any(); cast to specific metadata shape metadata: a.metadata as WorkflowCreationMetadata, diff --git a/services/platform/app/features/custom-agents/components/custom-agent-delete-dialog.tsx b/services/platform/app/features/custom-agents/components/custom-agent-delete-dialog.tsx index 14551b08b..939b7b674 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-delete-dialog.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-delete-dialog.tsx @@ -31,7 +31,9 @@ export function CustomAgentDeleteDialog({ if (isDeleting) return; setIsDeleting(true); try { - await deleteAgent({ customAgentId: toId<'customAgents'>(agent._id) }); + await deleteAgent.mutateAsync({ + customAgentId: toId<'customAgents'>(agent._id), + }); toast({ title: t('customAgents.agentDeleted'), variant: 'success', diff --git a/services/platform/app/features/custom-agents/components/custom-agent-knowledge.tsx b/services/platform/app/features/custom-agents/components/custom-agent-knowledge.tsx index aa6942610..b77de8639 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-knowledge.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-knowledge.tsx @@ -121,7 +121,7 @@ export function CustomAgentKnowledge({ knowledgeEnabled?: boolean; includeOrgKnowledge?: boolean; }) => { - await updateAgent({ + await updateAgent.mutateAsync({ customAgentId: toId<'customAgents'>(agentId), knowledgeEnabled: data.knowledgeEnabled, includeOrgKnowledge: data.includeOrgKnowledge, diff --git a/services/platform/app/features/custom-agents/hooks/queries.ts b/services/platform/app/features/custom-agents/hooks/queries.ts index 57f9ce211..4e2f1c923 100644 --- a/services/platform/app/features/custom-agents/hooks/queries.ts +++ b/services/platform/app/features/custom-agents/hooks/queries.ts @@ -6,6 +6,7 @@ import type { ConvexItemOf } from '@/lib/types/convex-helpers'; import { useConvexQuery } from '@/app/hooks/use-convex-query'; import { useTeamFilter } from '@/app/hooks/use-team-filter'; import { api } from '@/convex/_generated/api'; +import { toId } from '@/lib/utils/type-guards'; export type CustomAgent = ConvexItemOf< typeof api.custom_agents.queries.listCustomAgents @@ -43,7 +44,7 @@ export type CustomAgentVersion = ConvexItemOf< export function useCustomAgentVersions(customAgentId: string) { const { data, isLoading } = useConvexQuery( api.custom_agents.queries.getCustomAgentVersions, - { customAgentId }, + { customAgentId: toId<'customAgents'>(customAgentId) }, ); return { @@ -59,7 +60,7 @@ export type CustomAgentWebhook = ConvexItemOf< export function useCustomAgentWebhooks(customAgentId: string) { const { data, isLoading } = useConvexQuery( api.custom_agents.webhooks.queries.getWebhooks, - { customAgentId }, + { customAgentId: toId<'customAgents'>(customAgentId) }, ); return { diff --git a/services/platform/app/features/customers/components/customer-delete-dialog.tsx b/services/platform/app/features/customers/components/customer-delete-dialog.tsx index e4513f1e1..5dbf583c9 100644 --- a/services/platform/app/features/customers/components/customer-delete-dialog.tsx +++ b/services/platform/app/features/customers/components/customer-delete-dialog.tsx @@ -49,7 +49,7 @@ export function CustomerDeleteDialog({ const handleDelete = useCallback( async (c: Doc<'customers'>) => { - await deleteCustomer({ customerId: c._id }); + await deleteCustomer.mutateAsync({ customerId: c._id }); }, [deleteCustomer], ); diff --git a/services/platform/app/features/customers/components/customer-edit-dialog.tsx b/services/platform/app/features/customers/components/customer-edit-dialog.tsx index 150ef8c04..365c648b3 100644 --- a/services/platform/app/features/customers/components/customer-edit-dialog.tsx +++ b/services/platform/app/features/customers/components/customer-edit-dialog.tsx @@ -100,7 +100,7 @@ export function CustomerEditDialog({ const onSubmit = async (data: CustomerFormData) => { try { - await updateCustomer({ + await updateCustomer.mutateAsync({ customerId: customer._id, name: data.name.trim(), email: data.email.trim(), diff --git a/services/platform/app/features/documents/components/document-team-tags-dialog.tsx b/services/platform/app/features/documents/components/document-team-tags-dialog.tsx index acbe46447..9fd79114c 100644 --- a/services/platform/app/features/documents/components/document-team-tags-dialog.tsx +++ b/services/platform/app/features/documents/components/document-team-tags-dialog.tsx @@ -69,7 +69,7 @@ function DocumentTeamTagsDialogContent({ setIsSubmitting(true); try { - await updateDocument({ + await updateDocument.mutateAsync({ documentId: toId<'documents'>(documentId), teamTags: Array.from(selectedTeams), }); diff --git a/services/platform/app/features/documents/hooks/queries.ts b/services/platform/app/features/documents/hooks/queries.ts index c31289e41..b54573924 100644 --- a/services/platform/app/features/documents/hooks/queries.ts +++ b/services/platform/app/features/documents/hooks/queries.ts @@ -29,7 +29,7 @@ export function useOneDriveFiles( return useReactQuery({ queryKey: ['onedrive-items', folderId], queryFn: async () => { - const result = await listOneDriveFiles({ folderId }); + const result = await listOneDriveFiles.mutateAsync({ folderId }); if (!result.success || !result.items) { throw new Error(result.error || 'Failed to load OneDrive files'); } @@ -49,7 +49,7 @@ export function useSharePointSites(enabled: boolean) { return useReactQuery({ queryKey: ['sharepoint-sites'], queryFn: async () => { - const result = await listSharePointSites({}); + const result = await listSharePointSites.mutateAsync({}); if (!result.success || !result.sites) { throw new Error(result.error || 'Failed to load SharePoint sites'); } @@ -73,7 +73,7 @@ export function useSharePointDrives( queryKey: ['sharepoint-drives', siteId], queryFn: async () => { if (!siteId) throw new Error('No site selected'); - const result = await listSharePointDrives({ siteId }); + const result = await listSharePointDrives.mutateAsync({ siteId }); if (!result.success || !result.drives) { throw new Error(result.error || 'Failed to load SharePoint drives'); } @@ -115,7 +115,7 @@ export function useSharePointFiles( queryKey: ['sharepoint-files', siteId, driveId, folderId], queryFn: async () => { if (!siteId || !driveId) throw new Error('No site/drive selected'); - const result = await listSharePointFiles({ + const result = await listSharePointFiles.mutateAsync({ siteId, driveId, folderId, diff --git a/services/platform/app/features/products/components/product-row-actions.tsx b/services/platform/app/features/products/components/product-row-actions.tsx index 637b66f9c..a20c69b6c 100644 --- a/services/platform/app/features/products/components/product-row-actions.tsx +++ b/services/platform/app/features/products/components/product-row-actions.tsx @@ -31,7 +31,7 @@ export function ProductRowActions({ product }: ProductRowActionsProps) { const handleDeleteConfirm = useCallback(async () => { try { setIsDeleting(true); - await deleteProduct({ + await deleteProduct.mutateAsync({ productId: product._id, }); dialogs.setOpen.delete(false); diff --git a/services/platform/app/features/products/components/products-import-dialog.tsx b/services/platform/app/features/products/components/products-import-dialog.tsx index 09bcb173a..76d41fa20 100644 --- a/services/platform/app/features/products/components/products-import-dialog.tsx +++ b/services/platform/app/features/products/components/products-import-dialog.tsx @@ -154,7 +154,7 @@ export function ProductsImportDialog({ const results = await Promise.allSettled( products.map((product) => - createProduct({ + createProduct.mutateAsync({ organizationId, name: product.name, description: product.description, diff --git a/services/platform/app/features/settings/organization/components/member-delete-dialog.tsx b/services/platform/app/features/settings/organization/components/member-delete-dialog.tsx index 48e18c914..ffa5b626c 100644 --- a/services/platform/app/features/settings/organization/components/member-delete-dialog.tsx +++ b/services/platform/app/features/settings/organization/components/member-delete-dialog.tsx @@ -38,7 +38,7 @@ export function DeleteMemberDialog({ const handleConfirm = async () => { setIsDeleting(true); try { - await removeMember({ + await removeMember.mutateAsync({ memberId: member._id, }); diff --git a/services/platform/app/features/settings/teams/components/team-members-dialog.tsx b/services/platform/app/features/settings/teams/components/team-members-dialog.tsx index 23ac33c89..6b2c223d4 100644 --- a/services/platform/app/features/settings/teams/components/team-members-dialog.tsx +++ b/services/platform/app/features/settings/teams/components/team-members-dialog.tsx @@ -70,7 +70,7 @@ export function TeamMembersDialog({ setIsAdding(true); try { - await addTeamMember({ + await addTeamMember.mutateAsync({ teamId: team.id, userId: selectedMemberId, organizationId, @@ -96,7 +96,7 @@ export function TeamMembersDialog({ const handleRemoveMember = async (teamMemberId: string) => { setRemovingMemberId(teamMemberId); try { - await removeTeamMember({ + await removeTeamMember.mutateAsync({ teamMemberId, organizationId, }); diff --git a/services/platform/app/features/settings/teams/hooks/queries.ts b/services/platform/app/features/settings/teams/hooks/queries.ts index 1598bcd81..532b92bd0 100644 --- a/services/platform/app/features/settings/teams/hooks/queries.ts +++ b/services/platform/app/features/settings/teams/hooks/queries.ts @@ -1,12 +1,17 @@ import type { ConvexItemOf } from '@/lib/types/convex-helpers'; import { useConvexQuery } from '@/app/hooks/use-convex-query'; +import { useOrganizationId } from '@/app/hooks/use-organization-id'; import { api } from '@/convex/_generated/api'; export type Team = ConvexItemOf; export function useTeams() { - const { data, isLoading } = useConvexQuery(api.members.queries.getMyTeams); + const organizationId = useOrganizationId(); + const { data, isLoading } = useConvexQuery( + api.members.queries.getMyTeams, + organizationId ? { organizationId } : 'skip', + ); return { teams: data ?? null, diff --git a/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/index.tsx b/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/index.tsx index 3419b64e7..bc56e5291 100644 --- a/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/index.tsx +++ b/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/index.tsx @@ -71,7 +71,7 @@ function GeneralTab() { const handleSave = useCallback( async (data: CombinedSaveData) => { - await updateMetadata({ + await updateMetadata.mutateAsync({ customAgentId: toId<'customAgents'>(agentId), name: data.name, displayName: data.displayName, diff --git a/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/instructions.tsx b/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/instructions.tsx index efe359e7d..633f325a9 100644 --- a/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/instructions.tsx +++ b/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/instructions.tsx @@ -62,7 +62,7 @@ function InstructionsTab() { const handleSave = useCallback( async (data: InstructionsFormData) => { - await updateAgent({ + await updateAgent.mutateAsync({ customAgentId: toId<'customAgents'>(agentId), systemInstructions: data.systemInstructions, modelPreset: data.modelPreset, diff --git a/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/tools.tsx b/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/tools.tsx index 95daad639..c57329043 100644 --- a/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/tools.tsx +++ b/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/tools.tsx @@ -59,7 +59,7 @@ function ToolsTab() { ? [...tools, 'rag_search'] : tools; await saveWithStatus(() => - updateAgent({ + updateAgent.mutateAsync({ customAgentId: toId<'customAgents'>(agentId), toolNames: finalTools, }), @@ -72,7 +72,7 @@ function ToolsTab() { async (bindings: string[]) => { if (isReadOnly) return; await saveWithStatus(() => - updateAgent({ + updateAgent.mutateAsync({ customAgentId: toId<'customAgents'>(agentId), integrationBindings: bindings, }), diff --git a/services/platform/app/routes/dashboard/$id/custom-agents/index.tsx b/services/platform/app/routes/dashboard/$id/custom-agents/index.tsx index 6dd6cc0cf..8ce872584 100644 --- a/services/platform/app/routes/dashboard/$id/custom-agents/index.tsx +++ b/services/platform/app/routes/dashboard/$id/custom-agents/index.tsx @@ -34,7 +34,7 @@ function CustomAgentsIndexPage() { From 6845d2d1c0aa06037877e0f4fe45ad07eb6019de Mon Sep 17 00:00:00 2001 From: methosiea Date: Sat, 14 Feb 2026 12:41:00 +0100 Subject: [PATCH 5/8] fix: format --- services/platform/convex/README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/services/platform/convex/README.md b/services/platform/convex/README.md index 91a9db2d0..15456319f 100644 --- a/services/platform/convex/README.md +++ b/services/platform/convex/README.md @@ -7,8 +7,8 @@ A query function that takes two arguments looks like: ```ts // convex/myFunctions.ts -import { query } from "./_generated/server"; -import { v } from "convex/values"; +import { query } from './_generated/server'; +import { v } from 'convex/values'; export const myQueryFunction = query({ // Validators for arguments. @@ -21,7 +21,7 @@ export const myQueryFunction = query({ handler: async (ctx, args) => { // Read the database as many times as you need here. // See https://docs.convex.dev/database/reading-data. - const documents = await ctx.db.query("tablename").collect(); + const documents = await ctx.db.query('tablename').collect(); // Arguments passed from the client are properties of the args object. console.log(args.first, args.second); @@ -38,7 +38,7 @@ Using this query function in a React component looks like: ```ts const data = useQuery(api.myFunctions.myQueryFunction, { first: 10, - second: "hello", + second: 'hello', }); ``` @@ -46,8 +46,8 @@ A mutation function looks like: ```ts // convex/myFunctions.ts -import { mutation } from "./_generated/server"; -import { v } from "convex/values"; +import { mutation } from './_generated/server'; +import { v } from 'convex/values'; export const myMutationFunction = mutation({ // Validators for arguments. @@ -62,10 +62,10 @@ export const myMutationFunction = mutation({ // Mutations can also read from the database like queries. // See https://docs.convex.dev/database/writing-data. const message = { body: args.first, author: args.second }; - const id = await ctx.db.insert("messages", message); + const id = await ctx.db.insert('messages', message); // Optionally, return a value from your mutation. - return await ctx.db.get("messages", id); + return await ctx.db.get('messages', id); }, }); ``` @@ -76,10 +76,10 @@ Using this mutation function in a React component looks like: const mutation = useMutation(api.myFunctions.myMutationFunction); function handleButtonPress() { // fire and forget, the most common way to use mutations - mutation({ first: "Hello!", second: "me" }); + mutation({ first: 'Hello!', second: 'me' }); // OR // use the result once the mutation has completed - mutation({ first: "Hello!", second: "me" }).then((result) => + mutation({ first: 'Hello!', second: 'me' }).then((result) => console.log(result), ); } From 472ba0caaf4c384cbe845c2e07291e06f7369d1f Mon Sep 17 00:00:00 2001 From: methosiea Date: Sat, 14 Feb 2026 13:11:54 +0100 Subject: [PATCH 6/8] fix: invalidate correctly --- .../app/features/approvals/hooks/actions.ts | 13 +- .../app/features/approvals/hooks/mutations.ts | 4 +- .../features/automations/hooks/mutations.ts | 29 ++- .../app/features/chat/hooks/mutations.ts | 24 ++- .../features/conversations/hooks/mutations.ts | 10 +- .../features/custom-agents/hooks/mutations.ts | 11 +- .../app/features/customers/hooks/mutations.ts | 4 +- .../app/features/documents/hooks/actions.ts | 8 +- .../app/features/documents/hooks/mutations.ts | 3 + .../features/organization/hooks/actions.ts | 7 +- .../settings/integrations/hooks/actions.ts | 20 ++- .../settings/integrations/hooks/mutations.ts | 4 +- .../hooks/use-save-oauth2-credentials.ts | 4 +- .../features/tone-of-voice/hooks/actions.ts | 4 +- .../features/tone-of-voice/hooks/mutations.ts | 16 +- .../app/features/vendors/hooks/mutations.ts | 4 +- .../app/features/websites/hooks/mutations.ts | 4 +- .../hooks/__tests__/use-convex-action.test.ts | 112 +++++++++++- .../__tests__/use-convex-mutation.test.ts | 169 ++++++++++++++++++ .../use-convex-optimistic-mutation.test.ts | 158 ++++++++++++++++ services/platform/app/hooks/invalidate.ts | 17 ++ .../platform/app/hooks/use-convex-action.ts | 16 +- .../platform/app/hooks/use-convex-mutation.ts | 16 +- .../hooks/use-convex-optimistic-mutation.ts | 13 +- services/platform/convex/README.md | 90 ---------- 25 files changed, 629 insertions(+), 131 deletions(-) create mode 100644 services/platform/app/hooks/__tests__/use-convex-mutation.test.ts create mode 100644 services/platform/app/hooks/__tests__/use-convex-optimistic-mutation.test.ts create mode 100644 services/platform/app/hooks/invalidate.ts delete mode 100644 services/platform/convex/README.md diff --git a/services/platform/app/features/approvals/hooks/actions.ts b/services/platform/app/features/approvals/hooks/actions.ts index 8be0c0b5e..f26d39829 100644 --- a/services/platform/app/features/approvals/hooks/actions.ts +++ b/services/platform/app/features/approvals/hooks/actions.ts @@ -4,9 +4,20 @@ import { api } from '@/convex/_generated/api'; export function useExecuteApprovedIntegrationOperation() { return useConvexAction( api.approvals.actions.executeApprovedIntegrationOperation, + { + invalidates: [api.approvals.queries.listApprovalsByOrganization], + }, ); } export function useExecuteApprovedWorkflowCreation() { - return useConvexAction(api.approvals.actions.executeApprovedWorkflowCreation); + return useConvexAction( + api.approvals.actions.executeApprovedWorkflowCreation, + { + invalidates: [ + api.approvals.queries.listApprovalsByOrganization, + api.wf_definitions.queries.listAutomations, + ], + }, + ); } diff --git a/services/platform/app/features/approvals/hooks/mutations.ts b/services/platform/app/features/approvals/hooks/mutations.ts index ee20d4ddf..a027f7a57 100644 --- a/services/platform/app/features/approvals/hooks/mutations.ts +++ b/services/platform/app/features/approvals/hooks/mutations.ts @@ -3,7 +3,9 @@ import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-m import { api } from '@/convex/_generated/api'; export function useRemoveRecommendedProduct() { - return useConvexMutation(api.approvals.mutations.removeRecommendedProduct); + return useConvexMutation(api.approvals.mutations.removeRecommendedProduct, { + invalidates: [api.approvals.queries.listApprovalsByOrganization], + }); } export function useUpdateApprovalStatus() { diff --git a/services/platform/app/features/automations/hooks/mutations.ts b/services/platform/app/features/automations/hooks/mutations.ts index 29106ec2c..68e2571f8 100644 --- a/services/platform/app/features/automations/hooks/mutations.ts +++ b/services/platform/app/features/automations/hooks/mutations.ts @@ -9,11 +9,22 @@ export function useStartWorkflow() { export function useCreateAutomation() { return useConvexMutation( api.wf_definitions.mutations.createWorkflowWithSteps, + { + invalidates: [ + api.wf_definitions.queries.listAutomations, + api.wf_definitions.queries.listAutomationRoots, + ], + }, ); } export function useDuplicateAutomation() { - return useConvexMutation(api.wf_definitions.mutations.duplicateWorkflow); + return useConvexMutation(api.wf_definitions.mutations.duplicateWorkflow, { + invalidates: [ + api.wf_definitions.queries.listAutomations, + api.wf_definitions.queries.listAutomationRoots, + ], + }); } export function usePublishAutomationDraft() { @@ -53,19 +64,27 @@ export function useRepublishAutomation() { } export function useCreateDraftFromActive() { - return useConvexMutation(api.wf_definitions.mutations.createDraftFromActive); + return useConvexMutation(api.wf_definitions.mutations.createDraftFromActive, { + invalidates: [api.wf_definitions.queries.listAutomations], + }); } export function useCreateStep() { - return useConvexMutation(api.wf_step_defs.mutations.createStep); + return useConvexMutation(api.wf_step_defs.mutations.createStep, { + invalidates: [api.wf_step_defs.queries.getWorkflowSteps], + }); } export function useUpdateStep() { - return useConvexMutation(api.wf_step_defs.mutations.updateStep); + return useConvexMutation(api.wf_step_defs.mutations.updateStep, { + invalidates: [api.wf_step_defs.queries.getWorkflowSteps], + }); } export function useUpdateAutomation() { - return useConvexMutation(api.wf_definitions.mutations.updateWorkflow); + return useConvexMutation(api.wf_definitions.mutations.updateWorkflow, { + invalidates: [api.wf_definitions.queries.listAutomations], + }); } export function useDeleteAutomation() { diff --git a/services/platform/app/features/chat/hooks/mutations.ts b/services/platform/app/features/chat/hooks/mutations.ts index ed1d34521..ae613fddb 100644 --- a/services/platform/app/features/chat/hooks/mutations.ts +++ b/services/platform/app/features/chat/hooks/mutations.ts @@ -3,20 +3,38 @@ import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-m import { api } from '@/convex/_generated/api'; export function useChatWithAgent() { - return useConvexMutation(api.agents.chat.mutations.chatWithAgent); + return useConvexMutation(api.agents.chat.mutations.chatWithAgent, { + invalidates: [ + api.conversations.queries.getConversationWithMessages, + api.threads.queries.listThreads, + ], + }); } export function useChatWithBuiltinAgent() { - return useConvexMutation(api.agents.builtin_agents.chatWithBuiltinAgent); + return useConvexMutation(api.agents.builtin_agents.chatWithBuiltinAgent, { + invalidates: [ + api.conversations.queries.getConversationWithMessages, + api.threads.queries.listThreads, + ], + }); } export function useChatWithCustomAgent() { - return useConvexMutation(api.custom_agents.chat.chatWithCustomAgent); + return useConvexMutation(api.custom_agents.chat.chatWithCustomAgent, { + invalidates: [ + api.conversations.queries.getConversationWithMessages, + api.threads.queries.listThreads, + ], + }); } export function useSubmitHumanInputResponse() { return useConvexMutation( api.agent_tools.human_input.mutations.submitHumanInputResponse, + { + invalidates: [api.conversations.queries.getConversationWithMessages], + }, ); } diff --git a/services/platform/app/features/conversations/hooks/mutations.ts b/services/platform/app/features/conversations/hooks/mutations.ts index 5d7065fac..60a619768 100644 --- a/services/platform/app/features/conversations/hooks/mutations.ts +++ b/services/platform/app/features/conversations/hooks/mutations.ts @@ -9,6 +9,9 @@ export function useGenerateUploadUrl() { export function useAddMessage() { return useConvexMutation( api.conversations.mutations.addMessageToConversation, + { + invalidates: [api.conversations.queries.getConversationWithMessages], + }, ); } @@ -39,6 +42,9 @@ export function useBulkReopenConversations() { export function useSendMessageViaIntegration() { return useConvexMutation( api.conversations.mutations.sendMessageViaIntegration, + { + invalidates: [api.conversations.queries.getConversationWithMessages], + }, ); } @@ -67,7 +73,9 @@ export function useReopenConversation() { } export function useMarkAsRead() { - return useConvexMutation(api.conversations.mutations.markConversationAsRead); + return useConvexMutation(api.conversations.mutations.markConversationAsRead, { + invalidates: [api.conversations.queries.listConversations], + }); } export function useMarkAsSpam() { diff --git a/services/platform/app/features/custom-agents/hooks/mutations.ts b/services/platform/app/features/custom-agents/hooks/mutations.ts index 38964a413..b38e122e3 100644 --- a/services/platform/app/features/custom-agents/hooks/mutations.ts +++ b/services/platform/app/features/custom-agents/hooks/mutations.ts @@ -25,17 +25,24 @@ export function useCreateCustomAgent() { } export function useDuplicateCustomAgent() { - return useConvexMutation(api.custom_agents.mutations.duplicateCustomAgent); + return useConvexMutation(api.custom_agents.mutations.duplicateCustomAgent, { + invalidates: [api.custom_agents.queries.listCustomAgents], + }); } export function useActivateCustomAgentVersion() { return useConvexMutation( api.custom_agents.mutations.activateCustomAgentVersion, + { + invalidates: [api.custom_agents.queries.listCustomAgents], + }, ); } export function useCreateDraftFromVersion() { - return useConvexMutation(api.custom_agents.mutations.createDraftFromVersion); + return useConvexMutation(api.custom_agents.mutations.createDraftFromVersion, { + invalidates: [api.custom_agents.queries.listCustomAgents], + }); } export function usePublishCustomAgent() { diff --git a/services/platform/app/features/customers/hooks/mutations.ts b/services/platform/app/features/customers/hooks/mutations.ts index 8b280498f..bce594c3c 100644 --- a/services/platform/app/features/customers/hooks/mutations.ts +++ b/services/platform/app/features/customers/hooks/mutations.ts @@ -3,7 +3,9 @@ import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-m import { api } from '@/convex/_generated/api'; export function useBulkCreateCustomers() { - return useConvexMutation(api.customers.mutations.bulkCreateCustomers); + return useConvexMutation(api.customers.mutations.bulkCreateCustomers, { + invalidates: [api.customers.queries.listCustomers], + }); } export function useDeleteCustomer() { diff --git a/services/platform/app/features/documents/hooks/actions.ts b/services/platform/app/features/documents/hooks/actions.ts index bcfe41361..fba6ee41e 100644 --- a/services/platform/app/features/documents/hooks/actions.ts +++ b/services/platform/app/features/documents/hooks/actions.ts @@ -2,9 +2,13 @@ import { useConvexAction } from '@/app/hooks/use-convex-action'; import { api } from '@/convex/_generated/api'; export function useRetryRagIndexing() { - return useConvexAction(api.documents.actions.retryRagIndexing); + return useConvexAction(api.documents.actions.retryRagIndexing, { + invalidates: [api.documents.queries.listDocuments], + }); } export function useImportOneDriveFiles() { - return useConvexAction(api.onedrive.actions.importFiles); + return useConvexAction(api.onedrive.actions.importFiles, { + invalidates: [api.documents.queries.listDocuments], + }); } diff --git a/services/platform/app/features/documents/hooks/mutations.ts b/services/platform/app/features/documents/hooks/mutations.ts index 7f8cad790..aecebab4a 100644 --- a/services/platform/app/features/documents/hooks/mutations.ts +++ b/services/platform/app/features/documents/hooks/mutations.ts @@ -59,6 +59,9 @@ export function useDocumentUpload(options: UploadOptions) { ); const { mutateAsync: createDocumentFromUpload } = useConvexMutation( api.documents.mutations.createDocumentFromUpload, + { + invalidates: [api.documents.queries.listDocuments], + }, ); const uploadFiles = async ( diff --git a/services/platform/app/features/organization/hooks/actions.ts b/services/platform/app/features/organization/hooks/actions.ts index d0046a9bf..7a86705e5 100644 --- a/services/platform/app/features/organization/hooks/actions.ts +++ b/services/platform/app/features/organization/hooks/actions.ts @@ -2,5 +2,10 @@ import { useConvexAction } from '@/app/hooks/use-convex-action'; import { api } from '@/convex/_generated/api'; export function useInitializeDefaultWorkflows() { - return useConvexAction(api.organizations.actions.initializeDefaultWorkflows); + return useConvexAction(api.organizations.actions.initializeDefaultWorkflows, { + invalidates: [ + api.wf_definitions.queries.listAutomations, + api.wf_definitions.queries.listAutomationRoots, + ], + }); } diff --git a/services/platform/app/features/settings/integrations/hooks/actions.ts b/services/platform/app/features/settings/integrations/hooks/actions.ts index 2a39d6f2e..380ba9da3 100644 --- a/services/platform/app/features/settings/integrations/hooks/actions.ts +++ b/services/platform/app/features/settings/integrations/hooks/actions.ts @@ -14,19 +14,27 @@ export function useTestExistingSsoConfig() { } export function useCreateIntegration() { - return useConvexAction(api.integrations.actions.create); + return useConvexAction(api.integrations.actions.create, { + invalidates: [api.integrations.queries.list], + }); } export function useUpdateIntegration() { - return useConvexAction(api.integrations.actions.update); + return useConvexAction(api.integrations.actions.update, { + invalidates: [api.integrations.queries.list], + }); } export function useUpsertSsoProvider() { - return useConvexAction(api.sso_providers.actions.upsert); + return useConvexAction(api.sso_providers.actions.upsert, { + invalidates: [api.sso_providers.queries.get], + }); } export function useRemoveSsoProvider() { - return useConvexAction(api.sso_providers.actions.remove); + return useConvexAction(api.sso_providers.actions.remove, { + invalidates: [api.sso_providers.queries.get], + }); } export function useSsoFullConfig() { @@ -38,5 +46,7 @@ export function useGenerateIntegrationOAuth2Url() { } export function useSaveOAuth2Credentials() { - return useConvexAction(api.integrations.actions.saveOAuth2ClientCredentials); + return useConvexAction(api.integrations.actions.saveOAuth2ClientCredentials, { + invalidates: [api.integrations.queries.list], + }); } diff --git a/services/platform/app/features/settings/integrations/hooks/mutations.ts b/services/platform/app/features/settings/integrations/hooks/mutations.ts index 9dc82d80d..089df819c 100644 --- a/services/platform/app/features/settings/integrations/hooks/mutations.ts +++ b/services/platform/app/features/settings/integrations/hooks/mutations.ts @@ -7,7 +7,9 @@ export function useGenerateUploadUrl() { } export function useUpdateIntegrationIcon() { - return useConvexMutation(api.integrations.mutations.updateIcon); + return useConvexMutation(api.integrations.mutations.updateIcon, { + invalidates: [api.integrations.queries.list], + }); } export function useDeleteIntegration() { diff --git a/services/platform/app/features/settings/integrations/hooks/use-save-oauth2-credentials.ts b/services/platform/app/features/settings/integrations/hooks/use-save-oauth2-credentials.ts index ad1c7e768..66f07c028 100644 --- a/services/platform/app/features/settings/integrations/hooks/use-save-oauth2-credentials.ts +++ b/services/platform/app/features/settings/integrations/hooks/use-save-oauth2-credentials.ts @@ -2,5 +2,7 @@ import { useConvexAction } from '@/app/hooks/use-convex-action'; import { api } from '@/convex/_generated/api'; export function useSaveOAuth2Credentials() { - return useConvexAction(api.integrations.actions.saveOAuth2ClientCredentials); + return useConvexAction(api.integrations.actions.saveOAuth2ClientCredentials, { + invalidates: [api.integrations.queries.list], + }); } diff --git a/services/platform/app/features/tone-of-voice/hooks/actions.ts b/services/platform/app/features/tone-of-voice/hooks/actions.ts index 93c9ef0c9..6d48cb861 100644 --- a/services/platform/app/features/tone-of-voice/hooks/actions.ts +++ b/services/platform/app/features/tone-of-voice/hooks/actions.ts @@ -2,5 +2,7 @@ import { useConvexAction } from '@/app/hooks/use-convex-action'; import { api } from '@/convex/_generated/api'; export function useGenerateTone() { - return useConvexAction(api.tone_of_voice.actions.generateToneOfVoice); + return useConvexAction(api.tone_of_voice.actions.generateToneOfVoice, { + invalidates: [api.tone_of_voice.queries.getToneOfVoiceWithExamples], + }); } diff --git a/services/platform/app/features/tone-of-voice/hooks/mutations.ts b/services/platform/app/features/tone-of-voice/hooks/mutations.ts index 297fe3132..eb317d42e 100644 --- a/services/platform/app/features/tone-of-voice/hooks/mutations.ts +++ b/services/platform/app/features/tone-of-voice/hooks/mutations.ts @@ -2,17 +2,25 @@ import { useConvexMutation } from '@/app/hooks/use-convex-mutation'; import { api } from '@/convex/_generated/api'; export function useUpsertTone() { - return useConvexMutation(api.tone_of_voice.mutations.upsertToneOfVoice); + return useConvexMutation(api.tone_of_voice.mutations.upsertToneOfVoice, { + invalidates: [api.tone_of_voice.queries.getToneOfVoiceWithExamples], + }); } export function useUpdateExample() { - return useConvexMutation(api.tone_of_voice.mutations.updateExampleMessage); + return useConvexMutation(api.tone_of_voice.mutations.updateExampleMessage, { + invalidates: [api.tone_of_voice.queries.getToneOfVoiceWithExamples], + }); } export function useDeleteExample() { - return useConvexMutation(api.tone_of_voice.mutations.deleteExampleMessage); + return useConvexMutation(api.tone_of_voice.mutations.deleteExampleMessage, { + invalidates: [api.tone_of_voice.queries.getToneOfVoiceWithExamples], + }); } export function useAddExample() { - return useConvexMutation(api.tone_of_voice.mutations.addExampleMessage); + return useConvexMutation(api.tone_of_voice.mutations.addExampleMessage, { + invalidates: [api.tone_of_voice.queries.getToneOfVoiceWithExamples], + }); } diff --git a/services/platform/app/features/vendors/hooks/mutations.ts b/services/platform/app/features/vendors/hooks/mutations.ts index 0837ad8ce..f02f0eabe 100644 --- a/services/platform/app/features/vendors/hooks/mutations.ts +++ b/services/platform/app/features/vendors/hooks/mutations.ts @@ -3,7 +3,9 @@ import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-m import { api } from '@/convex/_generated/api'; export function useBulkCreateVendors() { - return useConvexMutation(api.vendors.mutations.bulkCreateVendors); + return useConvexMutation(api.vendors.mutations.bulkCreateVendors, { + invalidates: [api.vendors.queries.listVendors], + }); } export function useDeleteVendor() { diff --git a/services/platform/app/features/websites/hooks/mutations.ts b/services/platform/app/features/websites/hooks/mutations.ts index 2fcb228fc..1797a9f79 100644 --- a/services/platform/app/features/websites/hooks/mutations.ts +++ b/services/platform/app/features/websites/hooks/mutations.ts @@ -3,7 +3,9 @@ import { useConvexOptimisticMutation } from '@/app/hooks/use-convex-optimistic-m import { api } from '@/convex/_generated/api'; export function useRescanWebsite() { - return useConvexMutation(api.websites.mutations.rescanWebsite); + return useConvexMutation(api.websites.mutations.rescanWebsite, { + invalidates: [api.websites.queries.listWebsites], + }); } export function useCreateWebsite() { diff --git a/services/platform/app/hooks/__tests__/use-convex-action.test.ts b/services/platform/app/hooks/__tests__/use-convex-action.test.ts index 91391d5ec..d3a5770ea 100644 --- a/services/platform/app/hooks/__tests__/use-convex-action.test.ts +++ b/services/platform/app/hooks/__tests__/use-convex-action.test.ts @@ -1,15 +1,27 @@ +import type { FunctionReference } from 'convex/server'; + import { describe, it, expect, vi, beforeEach } from 'vitest'; +const mockInvalidateQueries = vi.fn().mockResolvedValue(undefined); + +vi.mock('@convex-dev/react-query', () => ({ + convexQuery: vi.fn((func: { _name: string }) => ({ + queryKey: ['convexQuery', func._name, {}], + queryFn: vi.fn(), + })), +})); + vi.mock('@tanstack/react-query', () => ({ - useMutation: vi.fn((options: { mutationFn: unknown }) => ({ - mutate: options.mutationFn, - mutateAsync: options.mutationFn, + useMutation: vi.fn((options: Record) => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), isPending: false, isError: false, isSuccess: false, error: null, data: undefined, reset: vi.fn(), + _options: options, })), })); @@ -19,9 +31,21 @@ vi.mock('../use-convex-client', () => ({ }), })); +vi.mock('../use-react-query-client', () => ({ + useReactQueryClient: () => ({ + invalidateQueries: mockInvalidateQueries, + }), +})); + +import { useMutation } from '@tanstack/react-query'; + import { useConvexAction } from '../use-convex-action'; +const mockUseMutation = vi.mocked(useMutation); const mockActionRef = {} as Parameters[0]; +const mockQueryRef = { + _name: 'items:list', +} as unknown as FunctionReference<'query'>; describe('useConvexAction', () => { beforeEach(() => { @@ -39,4 +63,86 @@ describe('useConvexAction', () => { const result = useConvexAction(mockActionRef); expect(result.isPending).toBe(false); }); + + it('does not invalidate when no invalidates option is provided', async () => { + useConvexAction(mockActionRef); + + const options = mockUseMutation.mock.calls[0]?.[0]; + // @ts-expect-error -- calling mock onSettled directly for testing + await options.onSettled?.(); + + expect(mockInvalidateQueries).not.toHaveBeenCalled(); + }); + + it('invalidates specified query functions on settled', async () => { + useConvexAction(mockActionRef, { + invalidates: [mockQueryRef], + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + // @ts-expect-error -- calling mock onSettled directly for testing + await options.onSettled?.(); + + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['convexQuery', 'items:list'], + }); + }); + + it('calls user-provided onSettled after invalidation', async () => { + const userOnSettled = vi.fn(); + useConvexAction(mockActionRef, { + invalidates: [mockQueryRef], + onSettled: userOnSettled, + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + // @ts-expect-error -- calling mock onSettled directly for testing + await options.onSettled?.(); + + expect(mockInvalidateQueries).toHaveBeenCalled(); + expect(userOnSettled).toHaveBeenCalled(); + }); + + it('invalidates before calling user onSettled', async () => { + const callOrder: string[] = []; + mockInvalidateQueries.mockImplementation(async () => { + callOrder.push('invalidate'); + }); + const userOnSettled = vi.fn(() => { + callOrder.push('userOnSettled'); + }); + + useConvexAction(mockActionRef, { + invalidates: [mockQueryRef], + onSettled: userOnSettled, + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + // @ts-expect-error -- calling mock onSettled directly for testing + await options.onSettled?.(); + + expect(callOrder).toEqual(['invalidate', 'userOnSettled']); + }); + + it('preserves other user options', () => { + const userOnSuccess = vi.fn(); + const userOnError = vi.fn(); + useConvexAction(mockActionRef, { + onSuccess: userOnSuccess, + onError: userOnError, + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + expect(options.onSuccess).toBe(userOnSuccess); + expect(options.onError).toBe(userOnError); + }); + + it('does not pass invalidates to useMutation options', () => { + useConvexAction(mockActionRef, { + invalidates: [mockQueryRef], + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + expect(options).not.toHaveProperty('invalidates'); + }); }); diff --git a/services/platform/app/hooks/__tests__/use-convex-mutation.test.ts b/services/platform/app/hooks/__tests__/use-convex-mutation.test.ts new file mode 100644 index 000000000..03c4f35ab --- /dev/null +++ b/services/platform/app/hooks/__tests__/use-convex-mutation.test.ts @@ -0,0 +1,169 @@ +import type { FunctionReference } from 'convex/server'; + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockInvalidateQueries = vi.fn().mockResolvedValue(undefined); + +vi.mock('@convex-dev/react-query', () => ({ + convexQuery: vi.fn((func: { _name: string }) => ({ + queryKey: ['convexQuery', func._name, {}], + queryFn: vi.fn(), + })), +})); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: vi.fn((options: Record) => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), + _options: options, + })), +})); + +vi.mock('../use-convex-client', () => ({ + useConvexClient: () => ({ + mutation: vi.fn(), + }), +})); + +vi.mock('../use-react-query-client', () => ({ + useReactQueryClient: () => ({ + invalidateQueries: mockInvalidateQueries, + }), +})); + +import { useMutation } from '@tanstack/react-query'; + +import { useConvexMutation } from '../use-convex-mutation'; + +const mockUseMutation = vi.mocked(useMutation); +const mockMutationRef = {} as Parameters[0]; +const mockQueryRef = { + _name: 'items:list', +} as unknown as FunctionReference<'query'>; + +describe('useConvexMutation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns a mutation result object', () => { + const result = useConvexMutation(mockMutationRef); + expect(result).toHaveProperty('mutateAsync'); + expect(result).toHaveProperty('mutate'); + expect(result).toHaveProperty('isPending'); + }); + + it('returns isPending as false initially', () => { + const result = useConvexMutation(mockMutationRef); + expect(result.isPending).toBe(false); + }); + + it('does not invalidate when no invalidates option is provided', async () => { + useConvexMutation(mockMutationRef); + + const options = mockUseMutation.mock.calls[0]?.[0]; + // @ts-expect-error -- calling mock onSettled directly for testing + await options.onSettled?.(); + + expect(mockInvalidateQueries).not.toHaveBeenCalled(); + }); + + it('invalidates specified query functions on settled', async () => { + useConvexMutation(mockMutationRef, { + invalidates: [mockQueryRef], + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + // @ts-expect-error -- calling mock onSettled directly for testing + await options.onSettled?.(); + + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['convexQuery', 'items:list'], + }); + }); + + it('invalidates multiple query functions', async () => { + const secondQueryRef = { + _name: 'items:get', + } as unknown as FunctionReference<'query'>; + useConvexMutation(mockMutationRef, { + invalidates: [mockQueryRef, secondQueryRef], + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + // @ts-expect-error -- calling mock onSettled directly for testing + await options.onSettled?.(); + + expect(mockInvalidateQueries).toHaveBeenCalledTimes(2); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['convexQuery', 'items:list'], + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['convexQuery', 'items:get'], + }); + }); + + it('calls user-provided onSettled after invalidation', async () => { + const userOnSettled = vi.fn(); + useConvexMutation(mockMutationRef, { + invalidates: [mockQueryRef], + onSettled: userOnSettled, + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + // @ts-expect-error -- calling mock onSettled directly for testing + await options.onSettled?.(); + + expect(mockInvalidateQueries).toHaveBeenCalled(); + expect(userOnSettled).toHaveBeenCalled(); + }); + + it('invalidates before calling user onSettled', async () => { + const callOrder: string[] = []; + mockInvalidateQueries.mockImplementation(async () => { + callOrder.push('invalidate'); + }); + const userOnSettled = vi.fn(() => { + callOrder.push('userOnSettled'); + }); + + useConvexMutation(mockMutationRef, { + invalidates: [mockQueryRef], + onSettled: userOnSettled, + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + // @ts-expect-error -- calling mock onSettled directly for testing + await options.onSettled?.(); + + expect(callOrder).toEqual(['invalidate', 'userOnSettled']); + }); + + it('preserves other user options', () => { + const userOnSuccess = vi.fn(); + const userOnError = vi.fn(); + useConvexMutation(mockMutationRef, { + onSuccess: userOnSuccess, + onError: userOnError, + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + expect(options.onSuccess).toBe(userOnSuccess); + expect(options.onError).toBe(userOnError); + }); + + it('does not pass invalidates to useMutation options', () => { + useConvexMutation(mockMutationRef, { + invalidates: [mockQueryRef], + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + expect(options).not.toHaveProperty('invalidates'); + }); +}); diff --git a/services/platform/app/hooks/__tests__/use-convex-optimistic-mutation.test.ts b/services/platform/app/hooks/__tests__/use-convex-optimistic-mutation.test.ts new file mode 100644 index 000000000..e60238298 --- /dev/null +++ b/services/platform/app/hooks/__tests__/use-convex-optimistic-mutation.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockInvalidateQueries = vi.fn().mockResolvedValue(undefined); +const mockCancelQueries = vi.fn().mockResolvedValue(undefined); +const mockSetQueryData = vi.fn(); +const mockGetQueryData = vi.fn(); + +const MOCK_QUERY_KEY = [ + 'convexQuery', + 'listItems', + { organizationId: 'org-1' }, +]; + +vi.mock('@convex-dev/react-query', () => ({ + convexQuery: vi.fn(() => ({ + queryKey: MOCK_QUERY_KEY, + queryFn: vi.fn(), + })), +})); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: vi.fn((options: Record) => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: undefined, + reset: vi.fn(), + _options: options, + })), +})); + +vi.mock('../use-convex-client', () => ({ + useConvexClient: () => ({ + mutation: vi.fn(), + }), +})); + +vi.mock('../use-react-query-client', () => ({ + useReactQueryClient: () => ({ + invalidateQueries: mockInvalidateQueries, + cancelQueries: mockCancelQueries, + setQueryData: mockSetQueryData, + getQueryData: mockGetQueryData, + }), +})); + +vi.mock('../use-organization-id', () => ({ + useOrganizationId: () => 'org-1', +})); + +const mockInvalidateConvexQueries = vi.fn().mockResolvedValue(undefined); + +vi.mock('../invalidate', () => ({ + invalidateConvexQueries: (...args: unknown[]) => + mockInvalidateConvexQueries(...args), +})); + +import type { FunctionReference } from 'convex/server'; + +import { useMutation } from '@tanstack/react-query'; + +import { useConvexOptimisticMutation } from '../use-convex-optimistic-mutation'; + +const mockUseMutation = vi.mocked(useMutation); + +const mockMutationRef = {} as FunctionReference<'mutation'>; +const mockQueryRef = {} as FunctionReference<'query'>; + +describe('useConvexOptimisticMutation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('provides onSettled that invalidates the target query', async () => { + useConvexOptimisticMutation(mockMutationRef, mockQueryRef, { + queryArgs: { organizationId: 'org-1' }, + onMutate: async () => undefined, + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + expect(options).toHaveProperty('onSettled'); + + // @ts-expect-error -- calling mock onSettled directly for testing + await options.onSettled?.(); + + expect(mockInvalidateConvexQueries).toHaveBeenCalledWith( + expect.objectContaining({ invalidateQueries: mockInvalidateQueries }), + [mockQueryRef], + ); + }); + + it('invalidates even when queryArgs is undefined', async () => { + useConvexOptimisticMutation(mockMutationRef, mockQueryRef, { + queryArgs: undefined, + onMutate: async () => undefined, + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + + // @ts-expect-error -- calling mock onSettled directly for testing + await options.onSettled?.(); + + expect(mockInvalidateConvexQueries).toHaveBeenCalledWith( + expect.objectContaining({ invalidateQueries: mockInvalidateQueries }), + [mockQueryRef], + ); + }); + + it('supports queryArgs as a function', async () => { + useConvexOptimisticMutation(mockMutationRef, mockQueryRef, { + queryArgs: (organizationId) => ({ organizationId }), + onMutate: async () => undefined, + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + + // @ts-expect-error -- calling mock onSettled directly for testing + await options.onSettled?.(); + + expect(mockInvalidateConvexQueries).toHaveBeenCalledWith( + expect.objectContaining({ invalidateQueries: mockInvalidateQueries }), + [mockQueryRef], + ); + }); + + it('provides onError that rolls back optimistic update', () => { + useConvexOptimisticMutation(mockMutationRef, mockQueryRef, { + queryArgs: { organizationId: 'org-1' }, + onMutate: async () => undefined, + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + const previousData = [{ _id: 'a', name: 'Alice' }]; + const ctx = { previous: previousData, queryKey: MOCK_QUERY_KEY }; + + // @ts-expect-error -- calling mock onError directly for testing + options.onError?.(new Error('fail'), {}, ctx); + + expect(mockSetQueryData).toHaveBeenCalledWith(MOCK_QUERY_KEY, previousData); + }); + + it('does not roll back when context is not OptimisticContext', () => { + useConvexOptimisticMutation(mockMutationRef, mockQueryRef, { + queryArgs: { organizationId: 'org-1' }, + onMutate: async () => undefined, + }); + + const options = mockUseMutation.mock.calls[0]?.[0]; + + // @ts-expect-error -- calling mock onError directly for testing + options.onError?.(new Error('fail'), {}, undefined); + + expect(mockSetQueryData).not.toHaveBeenCalled(); + }); +}); diff --git a/services/platform/app/hooks/invalidate.ts b/services/platform/app/hooks/invalidate.ts new file mode 100644 index 000000000..4a8046759 --- /dev/null +++ b/services/platform/app/hooks/invalidate.ts @@ -0,0 +1,17 @@ +import type { QueryClient } from '@tanstack/react-query'; +import type { FunctionReference } from 'convex/server'; + +import { convexQuery } from '@convex-dev/react-query'; + +export function invalidateConvexQueries( + queryClient: QueryClient, + funcs: FunctionReference<'query'>[], +) { + return Promise.all( + funcs.map((func) => + queryClient.invalidateQueries({ + queryKey: convexQuery(func, {}).queryKey.slice(0, 2), + }), + ), + ); +} diff --git a/services/platform/app/hooks/use-convex-action.ts b/services/platform/app/hooks/use-convex-action.ts index b6dd3c0c9..4c4531d82 100644 --- a/services/platform/app/hooks/use-convex-action.ts +++ b/services/platform/app/hooks/use-convex-action.ts @@ -7,19 +7,31 @@ import type { import { useMutation } from '@tanstack/react-query'; +import { invalidateConvexQueries } from './invalidate'; import { useConvexClient } from './use-convex-client'; +import { useReactQueryClient } from './use-react-query-client'; export function useConvexAction>( func: Func, options?: Omit< UseMutationOptions, Error, FunctionArgs>, 'mutationFn' - >, + > & { + invalidates?: FunctionReference<'query'>[]; + }, ) { + const { invalidates, ...mutationOptions } = options ?? {}; const convexClient = useConvexClient(); + const queryClient = useReactQueryClient(); return useMutation({ mutationFn: (args: FunctionArgs) => convexClient.action(func, args), - ...options, + ...mutationOptions, + onSettled: async (...args) => { + if (invalidates?.length) { + await invalidateConvexQueries(queryClient, invalidates); + } + return mutationOptions.onSettled?.(...args); + }, }); } diff --git a/services/platform/app/hooks/use-convex-mutation.ts b/services/platform/app/hooks/use-convex-mutation.ts index 295221824..2ca905210 100644 --- a/services/platform/app/hooks/use-convex-mutation.ts +++ b/services/platform/app/hooks/use-convex-mutation.ts @@ -7,19 +7,31 @@ import type { import { useMutation } from '@tanstack/react-query'; +import { invalidateConvexQueries } from './invalidate'; import { useConvexClient } from './use-convex-client'; +import { useReactQueryClient } from './use-react-query-client'; export function useConvexMutation>( func: Func, options?: Omit< UseMutationOptions, Error, FunctionArgs>, 'mutationFn' - >, + > & { + invalidates?: FunctionReference<'query'>[]; + }, ) { + const { invalidates, ...mutationOptions } = options ?? {}; const convexClient = useConvexClient(); + const queryClient = useReactQueryClient(); return useMutation({ mutationFn: (args: FunctionArgs) => convexClient.mutation(func, args), - ...options, + ...mutationOptions, + onSettled: async (...args) => { + if (invalidates?.length) { + await invalidateConvexQueries(queryClient, invalidates); + } + return mutationOptions.onSettled?.(...args); + }, }); } diff --git a/services/platform/app/hooks/use-convex-optimistic-mutation.ts b/services/platform/app/hooks/use-convex-optimistic-mutation.ts index db70a14be..cadf6b5b0 100644 --- a/services/platform/app/hooks/use-convex-optimistic-mutation.ts +++ b/services/platform/app/hooks/use-convex-optimistic-mutation.ts @@ -12,6 +12,7 @@ import type { import { convexQuery } from '@convex-dev/react-query'; import { useMutation } from '@tanstack/react-query'; +import { invalidateConvexQueries } from './invalidate'; import { useConvexClient } from './use-convex-client'; import { useOrganizationId } from './use-organization-id'; import { useReactQueryClient } from './use-react-query-client'; @@ -169,11 +170,17 @@ export function useConvexOptimisticMutation< > = { mutationFn: (args) => convexClient.mutation(mutationFunc, args), onMutate: (args) => config.onMutate(args, helpers), - onError: (_err, _vars, ctx) => { - if (isOptimisticContext(ctx)) { - queryClient.setQueryData(ctx.queryKey, ctx.previous); + onError: (_err, _vars, onMutateResult) => { + if (isOptimisticContext(onMutateResult)) { + queryClient.setQueryData( + onMutateResult.queryKey, + onMutateResult.previous, + ); } }, + onSettled: async () => { + await invalidateConvexQueries(queryClient, [queryFunc]); + }, }; return useMutation(options); diff --git a/services/platform/convex/README.md b/services/platform/convex/README.md deleted file mode 100644 index 15456319f..000000000 --- a/services/platform/convex/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# Welcome to your Convex functions directory! - -Write your Convex functions here. -See https://docs.convex.dev/functions for more. - -A query function that takes two arguments looks like: - -```ts -// convex/myFunctions.ts -import { query } from './_generated/server'; -import { v } from 'convex/values'; - -export const myQueryFunction = query({ - // Validators for arguments. - args: { - first: v.number(), - second: v.string(), - }, - - // Function implementation. - handler: async (ctx, args) => { - // Read the database as many times as you need here. - // See https://docs.convex.dev/database/reading-data. - const documents = await ctx.db.query('tablename').collect(); - - // Arguments passed from the client are properties of the args object. - console.log(args.first, args.second); - - // Write arbitrary JavaScript here: filter, aggregate, build derived data, - // remove non-public properties, or create new objects. - return documents; - }, -}); -``` - -Using this query function in a React component looks like: - -```ts -const data = useQuery(api.myFunctions.myQueryFunction, { - first: 10, - second: 'hello', -}); -``` - -A mutation function looks like: - -```ts -// convex/myFunctions.ts -import { mutation } from './_generated/server'; -import { v } from 'convex/values'; - -export const myMutationFunction = mutation({ - // Validators for arguments. - args: { - first: v.string(), - second: v.string(), - }, - - // Function implementation. - handler: async (ctx, args) => { - // Insert or modify documents in the database here. - // Mutations can also read from the database like queries. - // See https://docs.convex.dev/database/writing-data. - const message = { body: args.first, author: args.second }; - const id = await ctx.db.insert('messages', message); - - // Optionally, return a value from your mutation. - return await ctx.db.get('messages', id); - }, -}); -``` - -Using this mutation function in a React component looks like: - -```ts -const mutation = useMutation(api.myFunctions.myMutationFunction); -function handleButtonPress() { - // fire and forget, the most common way to use mutations - mutation({ first: 'Hello!', second: 'me' }); - // OR - // use the result once the mutation has completed - mutation({ first: 'Hello!', second: 'me' }).then((result) => - console.log(result), - ); -} -``` - -Use the Convex CLI to push your functions to a deployment. See everything -the Convex CLI can do by running `npx convex -h` in your project root -directory. To learn more, launch the docs with `npx convex docs`. From 02e85cf04afcf470a2afb6d86dbc135a87da9111 Mon Sep 17 00:00:00 2001 From: methosiea Date: Sat, 14 Feb 2026 13:18:50 +0100 Subject: [PATCH 7/8] fix: queryKey in useConvexQuery --- .../platform/app/hooks/__tests__/use-convex-query.test.ts | 6 ++++-- services/platform/app/hooks/use-convex-query.ts | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/services/platform/app/hooks/__tests__/use-convex-query.test.ts b/services/platform/app/hooks/__tests__/use-convex-query.test.ts index 42293884f..800c55ffc 100644 --- a/services/platform/app/hooks/__tests__/use-convex-query.test.ts +++ b/services/platform/app/hooks/__tests__/use-convex-query.test.ts @@ -54,13 +54,15 @@ describe('useConvexQuery', () => { expect(mockUseQuery).toHaveBeenCalledTimes(1); }); - it('returns useQuery result', () => { + it('returns useQuery result with queryKey', () => { const mockResult = { data: [1, 2, 3], isLoading: false, error: null }; mockUseQuery.mockReturnValueOnce(mockResult as ReturnType); const result = useConvexQuery(mockQueryRef, {}); - expect(result).toBe(mockResult); + expect(result.data).toEqual([1, 2, 3]); + expect(result.isLoading).toBe(false); + expect(result.queryKey).toEqual(['convexQuery', mockQueryRef, {}]); }); it('merges cache options into useQuery call', () => { diff --git a/services/platform/app/hooks/use-convex-query.ts b/services/platform/app/hooks/use-convex-query.ts index 0fdd5a6b6..e11fb0b41 100644 --- a/services/platform/app/hooks/use-convex-query.ts +++ b/services/platform/app/hooks/use-convex-query.ts @@ -21,10 +21,13 @@ export function useConvexQuery>( func: Func, ...[args, options]: QueryArgs ) { - return useQuery({ - ...convexQuery(func, args ?? {}), + const { queryKey, ...convexOptions } = convexQuery(func, args ?? {}); + const query = useQuery({ + queryKey, + ...convexOptions, ...options, }); + return Object.assign(query, { queryKey }); } export type { ConvexQueryOptions }; From 715a12490c1cba30c57123ddd3db41955be6d581 Mon Sep 17 00:00:00 2001 From: methosiea Date: Sat, 14 Feb 2026 13:31:07 +0100 Subject: [PATCH 8/8] fix: query key --- .../hooks/__tests__/use-convex-action.test.ts | 22 ++++++++++---- .../__tests__/use-convex-mutation.test.ts | 29 ++++++++++++++----- .../platform/app/hooks/use-convex-action.ts | 4 +++ .../platform/app/hooks/use-convex-mutation.ts | 4 +++ 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/services/platform/app/hooks/__tests__/use-convex-action.test.ts b/services/platform/app/hooks/__tests__/use-convex-action.test.ts index d3a5770ea..fca20b2a0 100644 --- a/services/platform/app/hooks/__tests__/use-convex-action.test.ts +++ b/services/platform/app/hooks/__tests__/use-convex-action.test.ts @@ -11,6 +11,10 @@ vi.mock('@convex-dev/react-query', () => ({ })), })); +vi.mock('convex/server', () => ({ + getFunctionName: (ref: { _name?: string }) => ref._name ?? 'unknown', +})); + vi.mock('@tanstack/react-query', () => ({ useMutation: vi.fn((options: Record) => ({ mutate: vi.fn(), @@ -42,7 +46,9 @@ import { useMutation } from '@tanstack/react-query'; import { useConvexAction } from '../use-convex-action'; const mockUseMutation = vi.mocked(useMutation); -const mockActionRef = {} as Parameters[0]; +const mockActionRef = { + _name: 'items:process', +} as unknown as Parameters[0]; const mockQueryRef = { _name: 'items:list', } as unknown as FunctionReference<'query'>; @@ -64,17 +70,20 @@ describe('useConvexAction', () => { expect(result.isPending).toBe(false); }); - it('does not invalidate when no invalidates option is provided', async () => { + it('invalidates own key when no invalidates option is provided', async () => { useConvexAction(mockActionRef); const options = mockUseMutation.mock.calls[0]?.[0]; // @ts-expect-error -- calling mock onSettled directly for testing await options.onSettled?.(); - expect(mockInvalidateQueries).not.toHaveBeenCalled(); + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['convexQuery', 'items:process'], + }); }); - it('invalidates specified query functions on settled', async () => { + it('invalidates own key and specified query functions on settled', async () => { useConvexAction(mockActionRef, { invalidates: [mockQueryRef], }); @@ -83,6 +92,9 @@ describe('useConvexAction', () => { // @ts-expect-error -- calling mock onSettled directly for testing await options.onSettled?.(); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['convexQuery', 'items:process'], + }); expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['convexQuery', 'items:list'], }); @@ -121,7 +133,7 @@ describe('useConvexAction', () => { // @ts-expect-error -- calling mock onSettled directly for testing await options.onSettled?.(); - expect(callOrder).toEqual(['invalidate', 'userOnSettled']); + expect(callOrder).toEqual(['invalidate', 'invalidate', 'userOnSettled']); }); it('preserves other user options', () => { diff --git a/services/platform/app/hooks/__tests__/use-convex-mutation.test.ts b/services/platform/app/hooks/__tests__/use-convex-mutation.test.ts index 03c4f35ab..4115d1178 100644 --- a/services/platform/app/hooks/__tests__/use-convex-mutation.test.ts +++ b/services/platform/app/hooks/__tests__/use-convex-mutation.test.ts @@ -11,6 +11,10 @@ vi.mock('@convex-dev/react-query', () => ({ })), })); +vi.mock('convex/server', () => ({ + getFunctionName: (ref: { _name?: string }) => ref._name ?? 'unknown', +})); + vi.mock('@tanstack/react-query', () => ({ useMutation: vi.fn((options: Record) => ({ mutate: vi.fn(), @@ -42,7 +46,9 @@ import { useMutation } from '@tanstack/react-query'; import { useConvexMutation } from '../use-convex-mutation'; const mockUseMutation = vi.mocked(useMutation); -const mockMutationRef = {} as Parameters[0]; +const mockMutationRef = { + _name: 'items:update', +} as unknown as Parameters[0]; const mockQueryRef = { _name: 'items:list', } as unknown as FunctionReference<'query'>; @@ -64,17 +70,20 @@ describe('useConvexMutation', () => { expect(result.isPending).toBe(false); }); - it('does not invalidate when no invalidates option is provided', async () => { + it('invalidates own key when no invalidates option is provided', async () => { useConvexMutation(mockMutationRef); const options = mockUseMutation.mock.calls[0]?.[0]; // @ts-expect-error -- calling mock onSettled directly for testing await options.onSettled?.(); - expect(mockInvalidateQueries).not.toHaveBeenCalled(); + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['convexQuery', 'items:update'], + }); }); - it('invalidates specified query functions on settled', async () => { + it('invalidates own key and specified query functions on settled', async () => { useConvexMutation(mockMutationRef, { invalidates: [mockQueryRef], }); @@ -83,12 +92,15 @@ describe('useConvexMutation', () => { // @ts-expect-error -- calling mock onSettled directly for testing await options.onSettled?.(); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['convexQuery', 'items:update'], + }); expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['convexQuery', 'items:list'], }); }); - it('invalidates multiple query functions', async () => { + it('invalidates own key and multiple query functions', async () => { const secondQueryRef = { _name: 'items:get', } as unknown as FunctionReference<'query'>; @@ -100,7 +112,10 @@ describe('useConvexMutation', () => { // @ts-expect-error -- calling mock onSettled directly for testing await options.onSettled?.(); - expect(mockInvalidateQueries).toHaveBeenCalledTimes(2); + expect(mockInvalidateQueries).toHaveBeenCalledTimes(3); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['convexQuery', 'items:update'], + }); expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['convexQuery', 'items:list'], }); @@ -142,7 +157,7 @@ describe('useConvexMutation', () => { // @ts-expect-error -- calling mock onSettled directly for testing await options.onSettled?.(); - expect(callOrder).toEqual(['invalidate', 'userOnSettled']); + expect(callOrder).toEqual(['invalidate', 'invalidate', 'userOnSettled']); }); it('preserves other user options', () => { diff --git a/services/platform/app/hooks/use-convex-action.ts b/services/platform/app/hooks/use-convex-action.ts index 4c4531d82..b57138d27 100644 --- a/services/platform/app/hooks/use-convex-action.ts +++ b/services/platform/app/hooks/use-convex-action.ts @@ -6,6 +6,7 @@ import type { } from 'convex/server'; import { useMutation } from '@tanstack/react-query'; +import { getFunctionName } from 'convex/server'; import { invalidateConvexQueries } from './invalidate'; import { useConvexClient } from './use-convex-client'; @@ -28,6 +29,9 @@ export function useConvexAction>( mutationFn: (args: FunctionArgs) => convexClient.action(func, args), ...mutationOptions, onSettled: async (...args) => { + await queryClient.invalidateQueries({ + queryKey: ['convexQuery', getFunctionName(func)], + }); if (invalidates?.length) { await invalidateConvexQueries(queryClient, invalidates); } diff --git a/services/platform/app/hooks/use-convex-mutation.ts b/services/platform/app/hooks/use-convex-mutation.ts index 2ca905210..d99f0905e 100644 --- a/services/platform/app/hooks/use-convex-mutation.ts +++ b/services/platform/app/hooks/use-convex-mutation.ts @@ -6,6 +6,7 @@ import type { } from 'convex/server'; import { useMutation } from '@tanstack/react-query'; +import { getFunctionName } from 'convex/server'; import { invalidateConvexQueries } from './invalidate'; import { useConvexClient } from './use-convex-client'; @@ -28,6 +29,9 @@ export function useConvexMutation>( mutationFn: (args: FunctionArgs) => convexClient.mutation(func, args), ...mutationOptions, onSettled: async (...args) => { + await queryClient.invalidateQueries({ + queryKey: ['convexQuery', getFunctionName(func)], + }); if (invalidates?.length) { await invalidateConvexQueries(queryClient, invalidates); }