diff --git a/AGENTS.md b/AGENTS.md index 23f8b4a8..76787a30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,12 @@ | `src/modules/events.js` | Event handler registration (wires modules to Discord events) | | `src/api/server.js` | Express API server setup (createApp, startServer, stopServer) | | `src/api/index.js` | API route mounting | -| `src/api/routes/guilds.js` | Guild REST API endpoints (info, config, stats, members, moderation, actions) | +| `src/api/routes/guilds.js` | Guild REST API endpoints (info, config, stats, members, moderation, analytics, actions) | +| `web/src/components/dashboard/analytics-dashboard.tsx` | Analytics dashboard React component — charts, KPIs, date range controls | +| `web/src/types/analytics.ts` | Shared analytics TypeScript contracts used by dashboard UI and analytics API responses | +| `web/src/app/api/guilds/[guildId]/analytics/route.ts` | Next.js API route — proxies analytics requests to bot API with param allowlisting | +| `web/src/lib/guild-selection.ts` | Guild selection state — localStorage persistence (`SELECTED_GUILD_KEY`) and cross-tab broadcast (`broadcastSelectedGuild`) | +| `web/src/lib/bot-api.ts` | Bot API URL normalization — `getBotApiBaseUrl` for constructing stable v1 API endpoint | | `src/api/middleware/auth.js` | API authentication middleware | | `src/api/middleware/rateLimit.js` | Rate limiting middleware | | `src/utils/errors.js` | Error classes and handling utilities | diff --git a/README.md b/README.md index 7a4a3b96..2fab1bf5 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ The `web/` directory contains a Next.js admin dashboard for managing Bill Bot th - **Discord OAuth2 Login** — Sign in with your Discord account via NextAuth.js - **Server Selector** — Choose from mutual guilds (servers where both you and the bot are present) - **Token Refresh** — Automatic Discord token refresh with graceful error handling +- **Analytics Dashboard** — KPI cards, message/AI usage charts, channel activity filtering, and activity heatmaps - **Responsive UI** — Mobile-friendly layout with sidebar navigation and dark mode support ### Setup diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 234c28b2..4a2d2b40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: react-dom: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) + recharts: + specifier: ^3.5.0 + version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1) server-only: specifier: ^0.0.1 version: 0.0.1 @@ -1300,6 +1303,17 @@ packages: peerDependencies: '@redis/client': ^1.0.0 + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1465,6 +1479,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@supabase/auth-js@2.95.3': resolution: {integrity: sha512-vD2YoS8E2iKIX0F7EwXTmqhUpaNsmbU6X2R0/NdFcs02oEfnHyNP/3M716f3wVJ2E5XHGiTFXki6lRckhJ0Thg==} engines: {node: '>=20.0.0'} @@ -1635,6 +1652,33 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1691,6 +1735,9 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} @@ -2042,6 +2089,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -2063,6 +2154,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -2204,6 +2298,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.44.0: + resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -2234,6 +2331,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2488,6 +2588,12 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -2509,6 +2615,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -3341,6 +3451,18 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -3383,6 +3505,14 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + recharts@3.7.0: + resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -3390,6 +3520,17 @@ packages: redis@4.7.1: resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -3644,6 +3785,9 @@ packages: text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3780,6 +3924,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5045,6 +5192,18 @@ snapshots: dependencies: '@redis/client': 1.6.1 + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.57.1': @@ -5142,6 +5301,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@supabase/auth-js@2.95.3': dependencies: tslib: 2.8.1 @@ -5318,6 +5479,30 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -5380,6 +5565,8 @@ snapshots: '@types/triple-beam@1.3.5': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/uuid@10.0.0': {} '@types/ws@8.18.1': @@ -5794,6 +5981,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-uri-to-buffer@4.0.1: {} data-urls@5.0.0: @@ -5807,6 +6032,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} decompress-response@6.0.0: @@ -5939,6 +6166,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.44.0: {} + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -5984,6 +6213,8 @@ snapshots: eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} + events@3.3.0: {} expand-template@2.0.3: {} @@ -6313,6 +6544,10 @@ snapshots: ieee754@1.2.1: {} + immer@10.2.0: {} + + immer@11.1.4: {} + imurmurhash@0.1.4: optional: true @@ -6331,6 +6566,8 @@ snapshots: ini@1.3.8: {} + internmap@2.0.3: {} + ip-address@10.1.0: optional: true @@ -7180,6 +7417,15 @@ snapshots: react-is@18.3.1: {} + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): @@ -7217,6 +7463,26 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.44.0 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 18.3.1 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -7231,6 +7497,14 @@ snapshots: '@redis/search': 1.2.0(@redis/client@1.6.1) '@redis/time-series': 1.1.0(@redis/client@1.6.1) + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + + reselect@5.1.1: {} + retry@0.12.0: optional: true @@ -7575,6 +7849,8 @@ snapshots: text-hex@1.0.0: {} + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@1.0.2: {} @@ -7681,6 +7957,23 @@ snapshots: vary@1.1.2: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.3 diff --git a/src/api/routes/guilds.js b/src/api/routes/guilds.js index 5263a90f..6523986e 100644 --- a/src/api/routes/guilds.js +++ b/src/api/routes/guilds.js @@ -55,6 +55,119 @@ function parsePagination(query) { return { page, limit, offset }; } +const MAX_ANALYTICS_RANGE_DAYS = 90; +const ACTIVE_CONVERSATION_WINDOW_MINUTES = 15; + +class AnalyticsRangeValidationError extends Error { + constructor(message) { + super(message); + this.name = 'AnalyticsRangeValidationError'; + } +} + +/** + * Parse and validate a date-ish query param. + * @param {unknown} value + * @returns {Date|null} + */ +function parseDateParam(value) { + if (typeof value !== 'string' || value.trim() === '') return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +/** + * Build a date range from query params. + * Supports presets: today, week, month, custom. + * + * @param {Object} query - Express req.query + * @returns {{ from: Date, to: Date, range: 'today'|'week'|'month'|'custom' }} + */ +function parseAnalyticsRange(query) { + const now = new Date(); + const rawRange = typeof query.range === 'string' ? query.range.toLowerCase() : 'week'; + const range = ['today', 'week', 'month', 'custom'].includes(rawRange) ? rawRange : 'week'; + + if (range === 'custom') { + const from = parseDateParam(query.from); + const to = parseDateParam(query.to); + + if (!from || !to) { + throw new AnalyticsRangeValidationError( + 'Custom range requires valid "from" and "to" query params', + ); + } + if (from > to) { + throw new AnalyticsRangeValidationError('"from" must be before "to"'); + } + + const maxRangeMs = MAX_ANALYTICS_RANGE_DAYS * 24 * 60 * 60 * 1000; + if (to.getTime() - from.getTime() > maxRangeMs) { + throw new AnalyticsRangeValidationError( + `Custom range cannot exceed ${MAX_ANALYTICS_RANGE_DAYS} days`, + ); + } + + return { from, to, range: 'custom' }; + } + + const from = new Date(now); + if (range === 'today') { + from.setUTCHours(0, 0, 0, 0); + } else if (range === 'month') { + // Use UTC-based date arithmetic for consistency with setUTCHours above + const utcTime = Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate() - 30); + from.setTime(utcTime); + } else { + // Default: week - use UTC-based date arithmetic + const utcTime = Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate() - 7); + from.setTime(utcTime); + } + + return { from, to: now, range }; +} + +/** + * Infer/validate analytics interval bucket size. + * + * @param {Object} query - Express req.query + * @param {Date} from + * @param {Date} to + * @returns {'hour'|'day'} + */ +function parseAnalyticsInterval(query, from, to) { + if (query.interval === 'hour' || query.interval === 'day') { + return query.interval; + } + + // Auto: use hour for short windows (<= 48h), day otherwise. + const diffMs = to.getTime() - from.getTime(); + return diffMs <= 48 * 60 * 60 * 1000 ? 'hour' : 'day'; +} + +/** + * Human-friendly chart label for a time bucket. + * @param {Date} bucket + * @param {'hour'|'day'} interval + * @returns {string} + */ +function formatBucketLabel(bucket, interval) { + if (interval === 'hour') { + return new Intl.DateTimeFormat('en-US', { + timeZone: 'UTC', + month: 'short', + day: 'numeric', + hour: 'numeric', + }).format(bucket); + } + + return new Intl.DateTimeFormat('en-US', { + timeZone: 'UTC', + month: 'short', + day: 'numeric', + }).format(bucket); +} + /** * Check if an OAuth2 user has the specified permission flags on a guild. * Fetches fresh guild list from Discord using the access token from the session store. @@ -381,6 +494,289 @@ router.get('/:id/stats', requireGuildAdmin, validateGuild, async (req, res) => { } }); +/** + * GET /:id/analytics — Dashboard analytics dataset + * Query params: + * - range=today|week|month|custom + * - from= (required for custom) + * - to= (required for custom) + * - interval=hour|day (optional; auto-derived when omitted) + * - channelId= (optional filter) + */ +router.get('/:id/analytics', requireGuildAdmin, validateGuild, async (req, res) => { + const { dbPool } = req.app.locals; + + if (!dbPool) { + return res.status(503).json({ error: 'Database not available' }); + } + + let rangeConfig; + try { + rangeConfig = parseAnalyticsRange(req.query); + } catch (err) { + if (err instanceof AnalyticsRangeValidationError) { + return res.status(400).json({ error: err.message }); + } + + warn('Unexpected analytics range parsing error', { + guild: req.params.id, + error: err instanceof Error ? err.message : String(err), + }); + return res.status(400).json({ error: 'Invalid range parameter' }); + } + + const { from, to, range } = rangeConfig; + const interval = parseAnalyticsInterval(req.query, from, to); + + const channelId = typeof req.query.channelId === 'string' ? req.query.channelId.trim() : ''; + const activeChannelFilter = channelId.length > 0 ? channelId : null; + + const conversationWhereParts = ['guild_id = $1', 'created_at >= $2', 'created_at <= $3']; + const conversationValues = [req.params.id, from.toISOString(), to.toISOString()]; + + if (activeChannelFilter) { + conversationValues.push(activeChannelFilter); + conversationWhereParts.push(`channel_id = $${conversationValues.length}`); + } + + const conversationWhere = conversationWhereParts.join(' AND '); + const bucketExpr = + interval === 'hour' ? "date_trunc('hour', created_at)" : "date_trunc('day', created_at)"; + + const logsWhereParts = [ + "message = 'AI usage'", + "metadata->>'guildId' = $1", + 'timestamp >= $2', + 'timestamp <= $3', + ]; + const logsValues = [req.params.id, from.toISOString(), to.toISOString()]; + + if (activeChannelFilter) { + logsValues.push(activeChannelFilter); + logsWhereParts.push(`metadata->>'channelId' = $${logsValues.length}`); + } + + const logsWhere = logsWhereParts.join(' AND '); + + try { + const [kpiResult, volumeResult, channelResult, heatmapResult, activeResult, modelUsageResult] = + await Promise.all([ + dbPool.query( + `SELECT + COUNT(*)::int AS total_messages, + COUNT(*) FILTER (WHERE role = 'assistant')::int AS ai_requests, + COUNT(DISTINCT CASE WHEN role = 'user' THEN username END)::int AS active_users + FROM conversations + WHERE ${conversationWhere}`, + conversationValues, + ), + dbPool.query( + `SELECT + ${bucketExpr} AS bucket, + COUNT(*)::int AS messages, + COUNT(*) FILTER (WHERE role = 'assistant')::int AS ai_requests + FROM conversations + WHERE ${conversationWhere} + GROUP BY 1 + ORDER BY 1 ASC`, + conversationValues, + ), + dbPool.query( + `SELECT channel_id, COUNT(*)::int AS messages + FROM conversations + WHERE ${conversationWhere} + GROUP BY channel_id + ORDER BY messages DESC + LIMIT 10`, + conversationValues, + ), + dbPool.query( + `SELECT + EXTRACT(DOW FROM created_at)::int AS day_of_week, + EXTRACT(HOUR FROM created_at)::int AS hour_of_day, + COUNT(*)::int AS messages + FROM conversations + WHERE ${conversationWhere} + GROUP BY 1, 2 + ORDER BY 1 ASC, 2 ASC`, + conversationValues, + ), + // Active AI conversations - filter by channel if specified + activeChannelFilter + ? dbPool.query( + `SELECT COUNT(DISTINCT channel_id)::int AS count + FROM conversations + WHERE guild_id = $1 + AND channel_id = $2 + AND role = 'assistant' + AND created_at >= NOW() - make_interval(mins => $3)`, + [req.params.id, activeChannelFilter, ACTIVE_CONVERSATION_WINDOW_MINUTES], + ) + : dbPool.query( + `SELECT COUNT(DISTINCT channel_id)::int AS count + FROM conversations + WHERE guild_id = $1 + AND role = 'assistant' + AND created_at >= NOW() - make_interval(mins => $2)`, + [req.params.id, ACTIVE_CONVERSATION_WINDOW_MINUTES], + ), + dbPool + .query( + `SELECT + COALESCE(NULLIF(metadata->>'model', ''), 'unknown') AS model, + COUNT(*)::bigint AS requests, + SUM( + CASE + WHEN (metadata->>'promptTokens') ~ '^[0-9]+$' + THEN (metadata->>'promptTokens')::int + ELSE 0 + END + )::bigint AS prompt_tokens, + SUM( + CASE + WHEN (metadata->>'completionTokens') ~ '^[0-9]+$' + THEN (metadata->>'completionTokens')::int + ELSE 0 + END + )::bigint AS completion_tokens, + SUM( + CASE + WHEN (metadata->>'estimatedCostUsd') ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (metadata->>'estimatedCostUsd')::numeric + ELSE 0 + END + ) AS cost_usd + FROM logs + WHERE ${logsWhere} + GROUP BY 1 + ORDER BY requests DESC`, + logsValues, + ) + .catch((err) => { + warn('Analytics logs query failed; returning empty AI usage dataset', { + guild: req.params.id, + error: err.message, + }); + return { rows: [] }; + }), + ]); + + const kpiRow = kpiResult.rows[0] || { + total_messages: 0, + ai_requests: 0, + active_users: 0, + }; + + const volume = volumeResult.rows.map((row) => { + const bucketDate = new Date(row.bucket); + return { + bucket: bucketDate.toISOString(), + label: formatBucketLabel(bucketDate, interval), + messages: Number(row.messages || 0), + aiRequests: Number(row.ai_requests || 0), + }; + }); + + const channelActivity = channelResult.rows.map((row) => { + const channelName = req.guild.channels.cache.get(row.channel_id)?.name || row.channel_id; + return { + channelId: row.channel_id, + name: channelName, + messages: Number(row.messages || 0), + }; + }); + + const heatmap = heatmapResult.rows.map((row) => ({ + dayOfWeek: Number(row.day_of_week || 0), + hour: Number(row.hour_of_day || 0), + messages: Number(row.messages || 0), + })); + + const usageByModel = modelUsageResult.rows.map((row) => ({ + model: row.model, + requests: Number(row.requests || 0), + promptTokens: Number(row.prompt_tokens || 0), + completionTokens: Number(row.completion_tokens || 0), + costUsd: Number(row.cost_usd || 0), + })); + + const promptTokenTotal = usageByModel.reduce((sum, model) => sum + model.promptTokens, 0); + const completionTokenTotal = usageByModel.reduce( + (sum, model) => sum + model.completionTokens, + 0, + ); + const aiCostUsd = usageByModel.reduce((sum, model) => sum + model.costUsd, 0); + + const fromMs = from.getTime(); + const toMs = to.getTime(); + /** + * NOTE: guild.members.cache only contains members Discord has sent to the + * bot (typically those with recent activity/presence). Both newMembers and + * onlineMemberCount will undercount relative to the true guild population. + * This is a known Discord gateway limitation — a complete count would + * require guild.members.fetch(), which is expensive and rate-limited. + */ + const newMembers = Array.from(req.guild.members.cache.values()).reduce((count, member) => { + if (member.user?.bot) return count; + const joinedAt = member.joinedTimestamp; + if (!joinedAt) return count; + return joinedAt >= fromMs && joinedAt <= toMs ? count + 1 : count; + }, 0); + + let onlineMemberCount = 0; + let membersWithPresence = 0; + // Same cache limitation as above — only evaluates cached members with known presence. + for (const member of req.guild.members.cache.values()) { + const status = member.presence?.status; + if (!status) continue; + membersWithPresence++; + if (status !== 'offline') onlineMemberCount++; + } + + return res.json({ + guildId: req.params.id, + range: { + type: range, + from: from.toISOString(), + to: to.toISOString(), + interval, + channelId: activeChannelFilter, + }, + kpis: { + totalMessages: Number(kpiRow.total_messages || 0), + aiRequests: Number(kpiRow.ai_requests || 0), + aiCostUsd: Number(aiCostUsd.toFixed(6)), + activeUsers: Number(kpiRow.active_users || 0), + newMembers, + }, + realtime: { + onlineMembers: membersWithPresence > 0 ? onlineMemberCount : null, + activeAiConversations: Number(activeResult.rows[0]?.count || 0), + }, + messageVolume: volume, + aiUsage: { + byModel: usageByModel, + tokens: { + prompt: promptTokenTotal, + completion: completionTokenTotal, + }, + }, + channelActivity, + heatmap, + }); + } catch (err) { + error('Failed to fetch analytics', { + error: err.message, + guild: req.params.id, + from: from.toISOString(), + to: to.toISOString(), + interval, + channelId: activeChannelFilter, + }); + return res.status(500).json({ error: 'Failed to fetch analytics' }); + } +}); + /** * GET /:id/members — Cursor-based paginated member list with roles * Query params: ?limit=25&after= (max 100) diff --git a/src/modules/ai.js b/src/modules/ai.js index dfe2cf1a..a6e03b17 100644 --- a/src/modules/ai.js +++ b/src/modules/ai.js @@ -114,6 +114,71 @@ export const OPENCLAW_URL = 'http://localhost:18789/v1/chat/completions'; export const OPENCLAW_TOKEN = process.env.OPENCLAW_API_KEY || process.env.OPENCLAW_TOKEN || ''; +/** + * Approximate model pricing (USD per 1M tokens). + * Used for dashboard-level cost estimation only. + * + * NOTE: This table requires manual updates when Anthropic releases new models. + * Unknown models return $0 and log a warning (see logWarn in estimateAiCostUsd). + * Pricing reference: https://www.anthropic.com/pricing + */ +const MODEL_PRICING_PER_MILLION = { + 'claude-opus-4-1-20250805': { input: 15, output: 75 }, + 'claude-opus-4-20250514': { input: 15, output: 75 }, + 'claude-sonnet-4-20250514': { input: 3, output: 15 }, + // Haiku 4.5: $1/M input, $5/M output (https://www.anthropic.com/pricing) + 'claude-haiku-4-5': { input: 1, output: 5 }, + 'claude-haiku-4-5-20251001': { input: 1, output: 5 }, + // Haiku 3.5: $0.80/M input, $4/M output (https://www.anthropic.com/pricing) + 'claude-3-5-haiku-20241022': { input: 0.8, output: 4 }, +}; + +/** Track models we've already warned about to avoid log flooding. */ +const warnedUnknownModels = new Set(); + +/** Test-only helper to clear unknown-model warning dedupe state. */ +export function _resetWarnedUnknownModels() { + warnedUnknownModels.clear(); +} + +/** + * Safely convert a value to a non-negative finite number. + * @param {unknown} value + * @returns {number} + */ +function toNonNegativeNumber(value) { + const num = Number(value); + if (!Number.isFinite(num) || num < 0) return 0; + return num; +} + +/** + * Estimate request cost from token usage and model pricing. + * Returns 0 when pricing for the model is unknown. + * + * @param {string} model + * @param {number} promptTokens + * @param {number} completionTokens + * @returns {number} + */ +function estimateAiCostUsd(model, promptTokens, completionTokens) { + const pricing = MODEL_PRICING_PER_MILLION[model]; + if (!pricing) { + // Only warn once per unknown model to avoid log flooding + if (!warnedUnknownModels.has(model)) { + logWarn('Unknown model for cost estimation, returning $0', { model }); + warnedUnknownModels.add(model); + } + return 0; + } + + const inputCost = (promptTokens / 1_000_000) * pricing.input; + const outputCost = (completionTokens / 1_000_000) * pricing.output; + + // Keep precision stable in logs for easier DB aggregation + return Number((inputCost + outputCost).toFixed(6)); +} + /** * Hydrate conversation history for a channel from DB. * Dedupes concurrent hydrations and merges DB rows with in-flight in-memory writes. @@ -469,7 +534,30 @@ You can use Discord markdown formatting.`; } const data = await response.json(); - const reply = data.choices?.[0]?.message?.content || 'I got nothing. Try again?'; + const reply = data?.choices?.[0]?.message?.content || 'I got nothing. Try again?'; + + const modelUsed = + typeof data?.model === 'string' && data.model.trim().length > 0 + ? data.model + : guildConfig.ai?.model || 'claude-sonnet-4-20250514'; + + const promptTokens = toNonNegativeNumber(data?.usage?.prompt_tokens); + const completionTokens = toNonNegativeNumber(data?.usage?.completion_tokens); + // Derive totalTokens from prompt + completion as a fallback for proxies that don't return it + const totalTokens = + toNonNegativeNumber(data?.usage?.total_tokens) || promptTokens + completionTokens; + const estimatedCostUsd = estimateAiCostUsd(modelUsed, promptTokens, completionTokens); + + // Structured usage log powers analytics aggregation in /api/v1/guilds/:id/analytics. + info('AI usage', { + guildId: guildId || null, + channelId, + model: modelUsed, + promptTokens, + completionTokens, + totalTokens, + estimatedCostUsd, + }); // Log AI response info('AI response', { channelId, username, response: reply.substring(0, 500) }); diff --git a/tests/api/routes/guilds.test.js b/tests/api/routes/guilds.test.js index e9a4d46f..f4b9792d 100644 --- a/tests/api/routes/guilds.test.js +++ b/tests/api/routes/guilds.test.js @@ -59,10 +59,12 @@ describe('guilds routes', () => { const mockMember = { id: 'user1', - user: { username: 'testuser' }, + user: { username: 'testuser', bot: false }, displayName: 'Test User', roles: { cache: new Map([['role1', { id: 'role1', name: 'Admin' }]]) }, joinedAt: new Date('2024-01-01'), + joinedTimestamp: new Date('2024-01-01').getTime(), + presence: { status: 'online' }, }; const mockGuild = { @@ -72,6 +74,7 @@ describe('guilds routes', () => { memberCount: 100, channels: { cache: channelCache }, members: { + cache: new Map([['user1', mockMember]]), list: vi.fn().mockResolvedValue(new Map([['user1', mockMember]])), }, }; @@ -527,6 +530,149 @@ describe('guilds routes', () => { }); }); + describe('GET /:id/analytics', () => { + it('should return analytics payload with KPIs, charts, and realtime indicators', async () => { + mockPool.query + .mockResolvedValueOnce({ + rows: [ + { + total_messages: 120, + ai_requests: 40, + active_users: 10, + }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { + bucket: '2026-02-17T00:00:00.000Z', + messages: 120, + ai_requests: 40, + }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { + channel_id: 'ch1', + messages: 80, + }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { + day_of_week: 2, + hour_of_day: 14, + messages: 12, + }, + ], + }) + .mockResolvedValueOnce({ rows: [{ count: 3 }] }) + .mockResolvedValueOnce({ + rows: [ + { + model: 'claude-sonnet-4-20250514', + requests: 40, + prompt_tokens: 5000, + completion_tokens: 2000, + cost_usd: '0.0456', + }, + ], + }); + + const res = await request(app) + .get('/api/v1/guilds/guild1/analytics?range=week') + .set('x-api-secret', SECRET); + + expect(res.status).toBe(200); + expect(res.body.guildId).toBe('guild1'); + expect(res.body.kpis.totalMessages).toBe(120); + expect(res.body.kpis.aiRequests).toBe(40); + expect(res.body.kpis.activeUsers).toBe(10); + expect(res.body.kpis.aiCostUsd).toBeCloseTo(0.0456, 6); + expect(res.body.kpis.newMembers).toBeTypeOf('number'); + expect(res.body.realtime.activeAiConversations).toBe(3); + expect(res.body.aiUsage.tokens.prompt).toBe(5000); + expect(res.body.aiUsage.tokens.completion).toBe(2000); + expect(res.body.channelActivity[0]).toEqual({ + channelId: 'ch1', + name: 'general', + messages: 80, + }); + expect(res.body.messageVolume).toHaveLength(1); + expect(res.body.heatmap).toHaveLength(1); + }); + + it('should return 400 for invalid custom range params', async () => { + const res = await request(app) + .get('/api/v1/guilds/guild1/analytics?range=custom') + .set('x-api-secret', SECRET); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/from/i); + }); + + it('should anchor today range to UTC midnight', async () => { + const setUTCHoursSpy = vi.spyOn(Date.prototype, 'setUTCHours'); + + mockPool.query + .mockResolvedValueOnce({ rows: [{ total_messages: 1, ai_requests: 1, active_users: 1 }] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ count: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await request(app) + .get('/api/v1/guilds/guild1/analytics?range=today') + .set('x-api-secret', SECRET); + + expect(res.status).toBe(200); + expect(setUTCHoursSpy).toHaveBeenCalledWith(0, 0, 0, 0); + }); + + it('should include channelId in query filters when provided', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total_messages: 1, ai_requests: 1, active_users: 1 }] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ count: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await request(app) + .get('/api/v1/guilds/guild1/analytics?range=week&channelId=ch1') + .set('x-api-secret', SECRET); + + expect(res.status).toBe(200); + expect( + mockPool.query.mock.calls.some(([, params]) => + Array.isArray(params) ? params.includes('ch1') : false, + ), + ).toBe(true); + }); + + it('should gracefully degrade when logs query fails', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total_messages: 1, ai_requests: 1, active_users: 1 }] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ count: 0 }] }) + .mockRejectedValueOnce(new Error('logs relation missing')); + + const res = await request(app) + .get('/api/v1/guilds/guild1/analytics?range=week') + .set('x-api-secret', SECRET); + + expect(res.status).toBe(200); + expect(res.body.aiUsage.byModel).toEqual([]); + expect(res.body.kpis.aiCostUsd).toBe(0); + expect(res.body.kpis.newMembers).toBeTypeOf('number'); + }); + }); + describe('GET /:id/members', () => { it('should return cursor-paginated members', async () => { const res = await request(app) diff --git a/tests/modules/ai.test.js b/tests/modules/ai.test.js index f6452da6..1f76bbf8 100644 --- a/tests/modules/ai.test.js +++ b/tests/modules/ai.test.js @@ -16,7 +16,9 @@ vi.mock('../../src/modules/memory.js', () => ({ extractAndStoreMemories: vi.fn(() => Promise.resolve(false)), })); +import { info, warn } from '../../src/logger.js'; import { + _resetWarnedUnknownModels, _setPoolGetter, addToHistory, generateResponse, @@ -44,6 +46,7 @@ describe('ai module', () => { setConversationHistory(new Map()); setPool(null); _setPoolGetter(null); + _resetWarnedUnknownModels(); vi.clearAllMocks(); // Reset config mock to defaults getConfig.mockReturnValue({ ai: { historyLength: 20, historyTTLDays: 30 } }); @@ -248,6 +251,105 @@ describe('ai module', () => { expect(globalThis.fetch).toHaveBeenCalled(); }); + it('should log structured AI usage metadata for analytics', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + model: 'claude-sonnet-4-20250514', + usage: { + prompt_tokens: 200, + completion_tokens: 100, + total_tokens: 300, + }, + choices: [{ message: { content: 'Usage logged' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + await generateResponse('ch1', 'Hi', 'user1', null, null, 'guild-analytics'); + + expect(info).toHaveBeenCalledWith( + 'AI usage', + expect.objectContaining({ + guildId: 'guild-analytics', + channelId: 'ch1', + model: 'claude-sonnet-4-20250514', + promptTokens: 200, + completionTokens: 100, + totalTokens: 300, + estimatedCostUsd: expect.any(Number), + }), + ); + }); + + it.each([ + { + model: 'claude-haiku-4-5-20251001', + expectedCostUsd: 0.0007, + }, + { + model: 'claude-3-5-haiku-20241022', + expectedCostUsd: 0.00056, + }, + ])('should use explicit pricing for $model in AI usage cost estimation', async ({ + model, + expectedCostUsd, + }) => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + model, + usage: { + prompt_tokens: 200, + completion_tokens: 100, + total_tokens: 300, + }, + choices: [{ message: { content: 'Usage logged' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + await generateResponse('ch1', 'Hi', 'user1', null, null, 'guild-analytics'); + + expect(info).toHaveBeenCalledWith( + 'AI usage', + expect.objectContaining({ + model, + estimatedCostUsd: expectedCostUsd, + }), + ); + expect(warn).not.toHaveBeenCalledWith( + 'Unknown model for cost estimation, returning $0', + expect.objectContaining({ model }), + ); + }); + + it('should warn only once for repeated unknown model cost estimation', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve({ + ok: true, + json: vi.fn().mockResolvedValue({ + model: 'claude-custom-unknown-1', + usage: { + prompt_tokens: 200, + completion_tokens: 100, + total_tokens: 300, + }, + choices: [{ message: { content: 'Unknown model response' } }], + }), + }), + ); + + await generateResponse('ch1', 'Hi', 'user1'); + await generateResponse('ch1', 'Hi again', 'user1'); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + 'Unknown model for cost estimation, returning $0', + expect.objectContaining({ model: 'claude-custom-unknown-1' }), + ); + }); + it('should include correct headers in fetch request', async () => { const mockResponse = { ok: true, diff --git a/web/package.json b/web/package.json index 05028ae7..dea0f50c 100644 --- a/web/package.json +++ b/web/package.json @@ -24,6 +24,7 @@ "next-auth": "^4.24.13", "react": "^19.2.4", "react-dom": "^19.2.4", + "recharts": "^3.5.0", "server-only": "^0.0.1", "tailwind-merge": "^3.4.1" }, diff --git a/web/src/app/api/guilds/[guildId]/analytics/route.ts b/web/src/app/api/guilds/[guildId]/analytics/route.ts new file mode 100644 index 00000000..49023baa --- /dev/null +++ b/web/src/app/api/guilds/[guildId]/analytics/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { getToken } from "next-auth/jwt"; +import { getBotApiBaseUrl } from "@/lib/bot-api"; +import { getMutualGuilds } from "@/lib/discord.server"; +import { logger } from "@/lib/logger"; + +export const dynamic = "force-dynamic"; + +/** Request timeout for analytics proxy calls (10 seconds). */ +const REQUEST_TIMEOUT_MS = 10_000; +const ADMINISTRATOR_PERMISSION = 0x8n; + +function hasAdministratorPermission(permissions: string): boolean { + try { + return (BigInt(permissions) & ADMINISTRATOR_PERMISSION) === ADMINISTRATOR_PERMISSION; + } catch { + return false; + } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ guildId: string }> | { guildId: string } }, +) { + const token = await getToken({ req: request }); + + if (typeof token?.accessToken !== "string" || token.accessToken.length === 0) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (token.error === "RefreshTokenError") { + return NextResponse.json( + { error: "Token expired. Please sign in again." }, + { status: 401 }, + ); + } + + const { guildId } = await params; + if (!guildId) { + return NextResponse.json({ error: "Missing guildId" }, { status: 400 }); + } + + let mutualGuilds: Awaited>; + try { + mutualGuilds = await getMutualGuilds( + token.accessToken, + AbortSignal.timeout(REQUEST_TIMEOUT_MS), + ); + } catch (error) { + logger.error( + "[api/guilds/:guildId/analytics] Failed to verify guild permissions:", + error, + ); + return NextResponse.json( + { error: "Failed to verify guild permissions" }, + { status: 502 }, + ); + } + + const targetGuild = mutualGuilds.find((guild) => guild.id === guildId); + if ( + !targetGuild || + !(targetGuild.owner || hasAdministratorPermission(targetGuild.permissions)) + ) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const botApiBaseUrl = getBotApiBaseUrl(); + const botApiSecret = process.env.BOT_API_SECRET; + + if (!botApiBaseUrl || !botApiSecret) { + logger.error( + "[api/guilds/:guildId/analytics] BOT_API_URL and BOT_API_SECRET are required", + ); + return NextResponse.json( + { error: "Bot API is not configured" }, + { status: 500 }, + ); + } + + let upstreamUrl: URL; + try { + upstreamUrl = new URL( + `${botApiBaseUrl}/guilds/${encodeURIComponent(guildId)}/analytics`, + ); + } catch { + logger.error("[api/guilds/:guildId/analytics] Invalid BOT_API_URL", { + botApiBaseUrl, + }); + return NextResponse.json( + { error: "Bot API is not configured correctly" }, + { status: 500 }, + ); + } + + const allowedParams = ["range", "from", "to", "interval", "channelId"]; + for (const key of allowedParams) { + const value = request.nextUrl.searchParams.get(key); + if (value !== null) { + upstreamUrl.searchParams.set(key, value); + } + } + + try { + const response = await fetch(upstreamUrl.toString(), { + headers: { + "x-api-secret": botApiSecret, + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + cache: "no-store", + }); + + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + const data: unknown = await response.json(); + return NextResponse.json(data, { status: response.status }); + } + + const text = await response.text(); + return NextResponse.json( + { error: text || "Unexpected response from bot API" }, + { status: response.status }, + ); + } catch (error) { + logger.error("[api/guilds/:guildId/analytics] Failed to proxy analytics:", error); + return NextResponse.json( + { error: "Failed to fetch analytics" }, + { status: 500 }, + ); + } +} diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 318e677d..e8da75d7 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -1,90 +1,5 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { MessageSquare, Shield, Users, Activity } from "lucide-react"; - -const stats = [ - { - title: "Members", - value: "—", - description: "Total server members", - icon: Users, - }, - { - title: "Mod Cases", - value: "—", - description: "Total moderation actions", - icon: Shield, - }, - { - title: "Messages", - value: "—", - description: "AI messages this week", - icon: MessageSquare, - }, - { - title: "Uptime", - value: "—", - description: "Bot uptime", - icon: Activity, - }, -]; +import { AnalyticsDashboard } from "@/components/dashboard/analytics-dashboard"; export default function DashboardPage() { - return ( -
-
-

Dashboard

-

- Overview of your Bill Bot server. -

-
- -
- {stats.map((stat) => ( - - - - {stat.title} - - - - -
{stat.value}
- - {stat.description} - -
-
- ))} -
- - - - Getting Started - - Welcome to the Bill Bot dashboard. This is the foundation — more - features are coming soon. - - - -
-

- Use the sidebar to navigate between sections. Select a server - from the dropdown to manage its settings. -

-

- The dashboard will show real-time stats and management tools as - they're built out. For now, you can verify your Discord - authentication and server access are working correctly. -

-
-
-
-
- ); + return ; } diff --git a/web/src/components/dashboard/analytics-dashboard.tsx b/web/src/components/dashboard/analytics-dashboard.tsx new file mode 100644 index 00000000..8e6a8c10 --- /dev/null +++ b/web/src/components/dashboard/analytics-dashboard.tsx @@ -0,0 +1,919 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Activity, + Bot, + Coins, + MessageSquare, + RefreshCw, + UserPlus, + Users, +} from "lucide-react"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + Line, + LineChart, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + GUILD_SELECTED_EVENT, + SELECTED_GUILD_KEY, +} from "@/lib/guild-selection"; +import type { + AnalyticsRangePreset, + DashboardAnalytics, +} from "@/types/analytics"; + +const RANGE_PRESETS: Array<{ label: string; value: AnalyticsRangePreset }> = [ + { label: "Today", value: "today" }, + { label: "Week", value: "week" }, + { label: "Month", value: "month" }, + { label: "Custom", value: "custom" }, +]; + +const PIE_COLORS = ["#5865F2", "#22C55E", "#F59E0B", "#A855F7", "#06B6D4"]; +const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const HOURS = Array.from({ length: 24 }, (_, i) => i); + +function formatDateInput(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function parseLocalDateInput(dateInput: string): { + year: number; + monthIndex: number; + day: number; +} | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateInput); + if (!match) return null; + + const year = Number.parseInt(match[1], 10); + const monthIndex = Number.parseInt(match[2], 10) - 1; + const day = Number.parseInt(match[3], 10); + + if (!Number.isFinite(year) || !Number.isFinite(monthIndex) || !Number.isFinite(day)) { + return null; + } + + return { year, monthIndex, day }; +} + +function startOfDayIso(dateInput: string): string { + const parsed = parseLocalDateInput(dateInput); + if (!parsed) return `${dateInput}T00:00:00.000Z`; + + return new Date( + parsed.year, + parsed.monthIndex, + parsed.day, + 0, + 0, + 0, + 0, + ).toISOString(); +} + +function endOfDayIso(dateInput: string): string { + const parsed = parseLocalDateInput(dateInput); + if (!parsed) return `${dateInput}T23:59:59.999Z`; + + return new Date( + parsed.year, + parsed.monthIndex, + parsed.day, + 23, + 59, + 59, + 999, + ).toISOString(); +} + +function formatUsd(value: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: value < 1 ? 4 : 2, + maximumFractionDigits: value < 1 ? 4 : 2, + }).format(value); +} + +function formatNumber(value: number): string { + return value.toLocaleString("en-US"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isString(value: unknown): value is string { + return typeof value === "string"; +} + +function isAnalyticsRangePreset(value: unknown): value is AnalyticsRangePreset { + return value === "today" || value === "week" || value === "month" || value === "custom"; +} + +function isDashboardAnalyticsPayload(value: unknown): value is DashboardAnalytics { + if (!isRecord(value)) return false; + + const range = value.range; + const kpis = value.kpis; + const realtime = value.realtime; + const aiUsage = value.aiUsage; + + if (!isString(value.guildId)) return false; + if (!isRecord(range)) return false; + if (!isRecord(kpis)) return false; + if (!isRecord(realtime)) return false; + if (!isRecord(aiUsage)) return false; + + if (!isAnalyticsRangePreset(range.type)) return false; + if (!isString(range.from) || !isString(range.to)) return false; + if (range.interval !== "hour" && range.interval !== "day") return false; + if (range.channelId !== null && !isString(range.channelId)) return false; + + if ( + !isFiniteNumber(kpis.totalMessages) || + !isFiniteNumber(kpis.aiRequests) || + !isFiniteNumber(kpis.aiCostUsd) || + !isFiniteNumber(kpis.activeUsers) || + !isFiniteNumber(kpis.newMembers) + ) { + return false; + } + + if ( + (realtime.onlineMembers !== null && !isFiniteNumber(realtime.onlineMembers)) || + !isFiniteNumber(realtime.activeAiConversations) + ) { + return false; + } + + if ( + !Array.isArray(value.messageVolume) || + !value.messageVolume.every( + (point) => + isRecord(point) && + isString(point.bucket) && + isString(point.label) && + isFiniteNumber(point.messages) && + isFiniteNumber(point.aiRequests), + ) + ) { + return false; + } + + if ( + !isRecord(aiUsage.tokens) || + !isFiniteNumber(aiUsage.tokens.prompt) || + !isFiniteNumber(aiUsage.tokens.completion) + ) { + return false; + } + + if ( + !Array.isArray(aiUsage.byModel) || + !aiUsage.byModel.every( + (entry) => + isRecord(entry) && + isString(entry.model) && + isFiniteNumber(entry.requests) && + isFiniteNumber(entry.promptTokens) && + isFiniteNumber(entry.completionTokens) && + isFiniteNumber(entry.costUsd), + ) + ) { + return false; + } + + if ( + !Array.isArray(value.channelActivity) || + !value.channelActivity.every( + (entry) => + isRecord(entry) && + isString(entry.channelId) && + isString(entry.name) && + isFiniteNumber(entry.messages), + ) + ) { + return false; + } + + if ( + !Array.isArray(value.heatmap) || + !value.heatmap.every( + (entry) => + isRecord(entry) && + isFiniteNumber(entry.dayOfWeek) && + isFiniteNumber(entry.hour) && + isFiniteNumber(entry.messages), + ) + ) { + return false; + } + + return true; +} + +function formatLastUpdatedTime(value: Date): string { + return new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }).format(value); +} + +export function AnalyticsDashboard() { + const [now] = useState(() => new Date()); + const [guildId, setGuildId] = useState(null); + const [rangePreset, setRangePreset] = useState("week"); + const [customFromDraft, setCustomFromDraft] = useState( + formatDateInput(new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)), + ); + const [customToDraft, setCustomToDraft] = useState(formatDateInput(now)); + const [customFromApplied, setCustomFromApplied] = useState( + formatDateInput(new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)), + ); + const [customToApplied, setCustomToApplied] = useState(formatDateInput(now)); + const [channelFilter, setChannelFilter] = useState(null); + const [analytics, setAnalytics] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [customRangeError, setCustomRangeError] = useState(null); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + const abortControllerRef = useRef(null); + + useEffect(() => { + if (typeof window === "undefined") return; + + try { + const savedGuild = window.localStorage.getItem(SELECTED_GUILD_KEY); + if (savedGuild) { + setGuildId(savedGuild); + } + } catch { + // localStorage may be unavailable in strict browser contexts + } + + const handleGuildSelect = (event: Event) => { + const selectedGuild = (event as CustomEvent).detail; + if (!selectedGuild) return; + setGuildId(selectedGuild); + setChannelFilter(null); + }; + + const handleStorage = (event: StorageEvent) => { + if (event.key !== SELECTED_GUILD_KEY || !event.newValue) return; + setGuildId(event.newValue); + setChannelFilter(null); + }; + + window.addEventListener( + GUILD_SELECTED_EVENT, + handleGuildSelect as EventListener, + ); + window.addEventListener("storage", handleStorage); + + return () => { + window.removeEventListener( + GUILD_SELECTED_EVENT, + handleGuildSelect as EventListener, + ); + window.removeEventListener("storage", handleStorage); + }; + }, []); + + const queryString = useMemo(() => { + const params = new URLSearchParams(); + params.set("range", rangePreset); + + if (rangePreset === "custom") { + params.set("from", startOfDayIso(customFromApplied)); + params.set("to", endOfDayIso(customToApplied)); + } + + // Only set interval for non-custom ranges; let server auto-detect for custom ranges + if (rangePreset !== "custom") { + params.set("interval", rangePreset === "today" ? "hour" : "day"); + } + + if (channelFilter) { + params.set("channelId", channelFilter); + } + + return params.toString(); + }, [channelFilter, customFromApplied, customToApplied, rangePreset]); + + const fetchAnalytics = useCallback( + async (backgroundRefresh = false) => { + if (!guildId) return; + + // Abort any previous in-flight request before starting a new one. + // Always uses the ref-based controller so both the initial load + // and background refresh share a single cancellation path. + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + if (!backgroundRefresh) { + setLoading(true); + } + setError(null); + + try { + const encodedGuildId = encodeURIComponent(guildId); + const response = await fetch( + `/api/guilds/${encodedGuildId}/analytics?${queryString}`, + { + cache: "no-store", + signal: controller.signal, + }, + ); + + if (response.status === 401) { + window.location.href = "/login"; + return; + } + + let payload: unknown = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + + if (!response.ok) { + const message = + typeof payload === "object" && + payload !== null && + "error" in payload && + typeof payload.error === "string" + ? payload.error + : "Failed to fetch analytics"; + throw new Error(message); + } + + if (!isDashboardAnalyticsPayload(payload)) { + throw new Error("Invalid analytics payload from server"); + } + + setAnalytics(payload); + setLastUpdatedAt(new Date()); + } catch (fetchError) { + // Don't treat aborted fetches as errors + if (fetchError instanceof DOMException && fetchError.name === "AbortError") return; + setError( + fetchError instanceof Error + ? fetchError.message + : "Failed to fetch analytics", + ); + } finally { + // Only reset loading if this request is still the current one. + // When fetchAnalytics is called again, the previous request + // is aborted and a new controller replaces the ref. Without this + // guard the aborted request's finally block would set loading=false, + // cancelling out the new request's loading=true. + if (abortControllerRef.current === controller) { + setLoading(false); + } + } + }, + [guildId, queryString], + ); + + useEffect(() => { + void fetchAnalytics(); + return () => abortControllerRef.current?.abort(); + }, [fetchAnalytics]); + + useEffect(() => { + if (!guildId) return; + + const intervalId = window.setInterval(() => { + void fetchAnalytics(true); + }, 30_000); + + return () => window.clearInterval(intervalId); + }, [fetchAnalytics, guildId]); + + const applyCustomRange = () => { + if (!customFromDraft || !customToDraft) { + setCustomRangeError("Select both a from and to date."); + return; + } + + if (customFromDraft > customToDraft) { + setCustomRangeError("\"From\" date must be on or before \"To\" date."); + return; + } + + setCustomRangeError(null); + setCustomFromApplied(customFromDraft); + setCustomToApplied(customToDraft); + }; + + const heatmapLookup = useMemo(() => { + const map = new Map(); + let max = 0; + + for (const bucket of analytics?.heatmap ?? []) { + const key = `${bucket.dayOfWeek}-${bucket.hour}`; + map.set(key, bucket.messages); + max = Math.max(max, bucket.messages); + } + + return { map, max }; + }, [analytics?.heatmap]); + + const modelUsageData = useMemo( + () => + (analytics?.aiUsage.byModel ?? []).map((entry, index) => ({ + ...entry, + fill: PIE_COLORS[index % PIE_COLORS.length], + })), + [analytics?.aiUsage.byModel], + ); + + const tokenBreakdownData = useMemo( + () => [ + { + label: "Tokens", + prompt: analytics?.aiUsage.tokens.prompt ?? 0, + completion: analytics?.aiUsage.tokens.completion ?? 0, + }, + ], + [analytics?.aiUsage.tokens.completion, analytics?.aiUsage.tokens.prompt], + ); + + if (!guildId) { + return ( + + + Select a server + + Choose a server from the sidebar to load analytics. + + + + ); + } + + const kpis = analytics?.kpis; + + return ( +
+
+
+

Analytics Dashboard

+

+ Usage trends, AI performance, and community activity for your server. +

+ {lastUpdatedAt ? ( +

+ Last updated {formatLastUpdatedTime(lastUpdatedAt)} +

+ ) : null} +
+ +
+ {RANGE_PRESETS.map((preset) => ( + + ))} + + {rangePreset === "custom" ? ( + <> + { + setCustomFromDraft(event.target.value); + setCustomRangeError(null); + }} + className="h-9 rounded-md border bg-background px-3 text-sm" + /> + { + setCustomToDraft(event.target.value); + setCustomRangeError(null); + }} + className="h-9 rounded-md border bg-background px-3 text-sm" + /> + + {customRangeError ? ( +

+ {customRangeError} +

+ ) : null} + + ) : null} + + +
+
+ + {error ? ( + + + Failed to load analytics + {error} + + + + + + ) : null} + +
+ + + Total messages + + +
+ + {kpis ? formatNumber(kpis.totalMessages) : "—"} + + +
+
+
+ + + + AI requests + + +
+ + {kpis ? formatNumber(kpis.aiRequests) : "—"} + + +
+
+
+ + + + AI cost (est.) + + +
+ + {kpis ? formatUsd(kpis.aiCostUsd) : "—"} + + +
+
+
+ + + + Active users + + +
+ + {kpis ? formatNumber(kpis.activeUsers) : "—"} + + +
+
+
+ + + + New members + + +
+ + {kpis ? formatNumber(kpis.newMembers) : "—"} + + +
+
+
+
+ +
+ + + Real-time indicators + + Live status updates every 30 seconds. + + + +
+
+ + Online members +
+

+ {analytics == null + ? "—" + : analytics.realtime.onlineMembers === null + ? "N/A" + : formatNumber(analytics.realtime.onlineMembers)} +

+
+
+
+ + Active AI conversations +
+

+ {loading || analytics == null + ? "—" + : analytics.realtime.activeAiConversations === null + ? "N/A" + : formatNumber(analytics.realtime.activeAiConversations)} +

+
+
+
+ + + + Channel filter + + Click a channel in the chart to filter all metrics. + + + + + {(analytics?.channelActivity ?? []).map((channel) => ( + + ))} + + +
+ +
+ + + Message volume + + Messages and AI requests over the selected range. + + + +
+ + + + + + + + + + + +
+
+
+ + + + AI usage breakdown + + Request distribution by model and token usage. + + + +
+ + + + {modelUsageData.map((entry) => ( + + ))} + + + + +
+
+ + + + + + + + + + + +
+
+
+
+ +
+ + + Channel activity + + Most active channels in the selected period. + + + +
+ + + + + + + { + const selected = analytics?.channelActivity[index]?.channelId; + if (!selected) return; + setChannelFilter((current) => + current === selected ? null : selected, + ); + }} + > + {(analytics?.channelActivity ?? []).map((channel) => ( + + ))} + + + +
+
+
+ + + + Activity heatmap + + Message density by day of week and hour of day. + + + + + + + + {HOURS.map((hour) => ( + + ))} + + + + {DAYS.map((day, dayIndex) => ( + + + {HOURS.map((hour) => { + const value = heatmapLookup.map.get(`${dayIndex}-${hour}`) ?? 0; + const alpha = + value === 0 || heatmapLookup.max === 0 + ? 0 + : 0.2 + (value / heatmapLookup.max) * 0.8; + + return ( + + ); + })} + + ))} + +
Day + {hour % 3 === 0 ? hour : ""} +
+ {day} + +
+
+
+
+
+
+ ); +} diff --git a/web/src/components/layout/server-selector.tsx b/web/src/components/layout/server-selector.tsx index 7cba6aae..ed982832 100644 --- a/web/src/components/layout/server-selector.tsx +++ b/web/src/components/layout/server-selector.tsx @@ -14,14 +14,16 @@ import { } from "@/components/ui/dropdown-menu"; import type { MutualGuild } from "@/types/discord"; import { getBotInviteUrl, getGuildIconUrl } from "@/lib/discord"; +import { + broadcastSelectedGuild, + SELECTED_GUILD_KEY, +} from "@/lib/guild-selection"; import { cn } from "@/lib/utils"; interface ServerSelectorProps { className?: string; } -const SELECTED_GUILD_KEY = "bills-bot-selected-guild"; - export function ServerSelector({ className }: ServerSelectorProps) { const [guilds, setGuilds] = useState([]); const [selectedGuild, setSelectedGuild] = useState(null); @@ -30,14 +32,15 @@ export function ServerSelector({ className }: ServerSelectorProps) { const abortControllerRef = useRef(null); // Persist selected guild to localStorage - const selectGuild = (guild: MutualGuild) => { + const selectGuild = useCallback((guild: MutualGuild) => { setSelectedGuild(guild); try { localStorage.setItem(SELECTED_GUILD_KEY, guild.id); } catch { // localStorage may be unavailable (e.g. incognito) } - }; + broadcastSelectedGuild(guild.id); + }, []); const loadGuilds = useCallback(async () => { // Abort any previous in-flight request before starting a new one. @@ -78,7 +81,7 @@ export function ServerSelector({ className }: ServerSelectorProps) { } if (!restored && data.length > 0) { - setSelectedGuild(data[0]); + selectGuild(data[0]); } } catch (err) { // Don't treat aborted fetches as errors @@ -94,7 +97,7 @@ export function ServerSelector({ className }: ServerSelectorProps) { setLoading(false); } } - }, []); + }, [selectGuild]); useEffect(() => { loadGuilds(); @@ -188,7 +191,12 @@ export function ServerSelector({ className }: ServerSelectorProps) { {guilds.map((guild) => ( selectGuild(guild)} + onClick={() => { + if (selectedGuild?.id === guild.id) { + return; + } + selectGuild(guild); + }} className="flex items-center gap-2" > {guild.icon ? ( diff --git a/web/src/lib/bot-api.ts b/web/src/lib/bot-api.ts new file mode 100644 index 00000000..2c01aa17 --- /dev/null +++ b/web/src/lib/bot-api.ts @@ -0,0 +1,18 @@ +/** + * Normalize BOT_API_URL into a stable v1 API base URL. + * + * Examples: + * - http://bot.internal:3001 -> http://bot.internal:3001/api/v1 + * - http://bot.internal:3001/api/v1 -> http://bot.internal:3001/api/v1 + */ +export function getBotApiBaseUrl(): string | null { + const raw = process.env.BOT_API_URL; + if (!raw) return null; + + const trimmed = raw.replace(/\/+$/, ""); + if (trimmed.endsWith("/api/v1")) { + return trimmed; + } + + return `${trimmed}/api/v1`; +} diff --git a/web/src/lib/discord.server.ts b/web/src/lib/discord.server.ts index 72e1023d..74299913 100644 --- a/web/src/lib/discord.server.ts +++ b/web/src/lib/discord.server.ts @@ -1,6 +1,7 @@ import "server-only"; import type { BotGuild, DiscordGuild, MutualGuild } from "@/types/discord"; +import { getBotApiBaseUrl } from "@/lib/bot-api"; import { logger } from "@/lib/logger"; const DISCORD_API_BASE = "https://discord.com/api/v10"; @@ -140,9 +141,9 @@ export interface BotGuildResult { } export async function fetchBotGuilds(signal?: AbortSignal): Promise { - const botApiUrl = process.env.BOT_API_URL; + const botApiBaseUrl = getBotApiBaseUrl(); - if (!botApiUrl) { + if (!botApiBaseUrl) { logger.warn( "[discord] BOT_API_URL is not set — cannot filter guilds by bot presence. " + "Set BOT_API_URL to enable mutual guild filtering.", @@ -160,9 +161,9 @@ export async function fetchBotGuilds(signal?: AbortSignal): Promise(GUILD_SELECTED_EVENT, { + detail: normalizedGuildId, + }), + ); +} diff --git a/web/src/types/analytics.ts b/web/src/types/analytics.ts new file mode 100644 index 00000000..de69b13c --- /dev/null +++ b/web/src/types/analytics.ts @@ -0,0 +1,63 @@ +export type AnalyticsRangePreset = "today" | "week" | "month" | "custom"; +export type AnalyticsInterval = "hour" | "day"; + +export interface AnalyticsRange { + type: AnalyticsRangePreset; + from: string; + to: string; + interval: AnalyticsInterval; + channelId: string | null; +} + +export interface DashboardKpis { + totalMessages: number; + aiRequests: number; + aiCostUsd: number; + activeUsers: number; + newMembers: number; +} + +export interface DashboardRealtime { + onlineMembers: number | null; + activeAiConversations: number; +} + +export interface MessageVolumePoint { + bucket: string; + label: string; + messages: number; + aiRequests: number; +} + +export interface ModelUsage { + model: string; + requests: number; + promptTokens: number; + completionTokens: number; + costUsd: number; +} + +export interface DashboardAnalytics { + guildId: string; + range: AnalyticsRange; + kpis: DashboardKpis; + realtime: DashboardRealtime; + messageVolume: MessageVolumePoint[]; + aiUsage: { + byModel: ModelUsage[]; + tokens: { + prompt: number; + completion: number; + }; + }; + channelActivity: Array<{ + channelId: string; + name: string; + messages: number; + }>; + heatmap: Array<{ + dayOfWeek: number; + hour: number; + messages: number; + }>; +} diff --git a/web/tests/api/guild-analytics.test.ts b/web/tests/api/guild-analytics.test.ts new file mode 100644 index 00000000..ff37f6c6 --- /dev/null +++ b/web/tests/api/guild-analytics.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const { mockGetToken, mockGetMutualGuilds } = vi.hoisted(() => ({ + mockGetToken: vi.fn(), + mockGetMutualGuilds: vi.fn(), +})); + +vi.mock("next-auth/jwt", () => ({ + getToken: mockGetToken, +})); + +vi.mock("@/lib/discord.server", () => ({ + getMutualGuilds: mockGetMutualGuilds, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, +})); + +import { GET } from "@/app/api/guilds/[guildId]/analytics/route"; + +function createRequest( + url = "http://localhost:3000/api/guilds/guild-1/analytics?range=week", +): NextRequest { + return new NextRequest(new URL(url)); +} + +describe("GET /api/guilds/[guildId]/analytics", () => { + const originalBotApiUrl = process.env.BOT_API_URL; + const originalBotApiSecret = process.env.BOT_API_SECRET; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.BOT_API_URL = "http://bot.internal:3001"; + process.env.BOT_API_SECRET = "bot-secret"; + mockGetMutualGuilds.mockResolvedValue([ + { + id: "guild-1", + permissions: String(0x8), + owner: false, + }, + ]); + }); + + afterEach(() => { + if (originalBotApiUrl === undefined) delete process.env.BOT_API_URL; + else process.env.BOT_API_URL = originalBotApiUrl; + + if (originalBotApiSecret === undefined) delete process.env.BOT_API_SECRET; + else process.env.BOT_API_SECRET = originalBotApiSecret; + }); + + it("returns 401 when unauthenticated", async () => { + mockGetToken.mockResolvedValue(null); + + const response = await GET(createRequest(), { + params: Promise.resolve({ guildId: "guild-1" }), + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: "Unauthorized" }); + }); + + it("returns 403 when user does not have admin access to requested guild", async () => { + mockGetToken.mockResolvedValue({ accessToken: "discord-token" }); + mockGetMutualGuilds.mockResolvedValue([ + { + id: "guild-1", + permissions: "0", + owner: false, + }, + ]); + + const response = await GET(createRequest(), { + params: Promise.resolve({ guildId: "guild-1" }), + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: "Forbidden" }); + }); + + it("returns 403 when requested guild is not in user's mutual guilds", async () => { + mockGetToken.mockResolvedValue({ accessToken: "discord-token" }); + mockGetMutualGuilds.mockResolvedValue([ + { + id: "guild-other", + permissions: String(0x8), + owner: false, + }, + ]); + + const response = await GET(createRequest(), { + params: Promise.resolve({ guildId: "guild-1" }), + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: "Forbidden" }); + }); + + it("allows guild owner access even without ADMINISTRATOR permission bit", async () => { + mockGetToken.mockResolvedValue({ accessToken: "discord-token" }); + mockGetMutualGuilds.mockResolvedValue([ + { + id: "guild-1", + permissions: "0", + owner: true, + }, + ]); + + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve({ ok: true }), + } as Response); + + const response = await GET(createRequest(), { + params: Promise.resolve({ guildId: "guild-1" }), + }); + + expect(response.status).toBe(200); + }); + + it("returns 502 when guild permission verification fails", async () => { + mockGetToken.mockResolvedValue({ accessToken: "discord-token" }); + mockGetMutualGuilds.mockRejectedValue(new Error("Discord unavailable")); + + const response = await GET(createRequest(), { + params: Promise.resolve({ guildId: "guild-1" }), + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "Failed to verify guild permissions", + }); + }); + + it("returns 500 when bot API env vars are missing", async () => { + mockGetToken.mockResolvedValue({ accessToken: "discord-token" }); + delete process.env.BOT_API_URL; + + const response = await GET(createRequest(), { + params: Promise.resolve({ guildId: "guild-1" }), + }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error).toMatch(/not configured/i); + }); + + it("returns 500 when BOT_API_SECRET is missing", async () => { + mockGetToken.mockResolvedValue({ accessToken: "discord-token" }); + delete process.env.BOT_API_SECRET; + + const response = await GET(createRequest(), { + params: Promise.resolve({ guildId: "guild-1" }), + }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error).toMatch(/not configured/i); + }); + + it("returns 500 when BOT_API_URL is malformed", async () => { + mockGetToken.mockResolvedValue({ accessToken: "discord-token" }); + process.env.BOT_API_URL = "http://["; + + const response = await GET(createRequest(), { + params: Promise.resolve({ guildId: "guild-1" }), + }); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + error: "Bot API is not configured correctly", + }); + }); + + it("proxies analytics request to bot API v1 with x-api-secret", async () => { + mockGetToken.mockResolvedValue({ accessToken: "discord-token" }); + + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve({ guildId: "guild-1", kpis: { totalMessages: 1 } }), + } as Response); + + const response = await GET(createRequest(), { + params: Promise.resolve({ guildId: "guild-1" }), + }); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining( + "http://bot.internal:3001/api/v1/guilds/guild-1/analytics?range=week", + ), + expect.objectContaining({ + headers: { "x-api-secret": "bot-secret" }, + }), + ); + }); + + it("forwards upstream error status and message", async () => { + mockGetToken.mockResolvedValue({ accessToken: "discord-token" }); + + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: false, + status: 404, + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve({ error: "Guild not found" }), + } as Response); + + const response = await GET(createRequest(), { + params: Promise.resolve({ guildId: "guild-1" }), + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ error: "Guild not found" }); + }); +}); diff --git a/web/tests/app/dashboard.test.tsx b/web/tests/app/dashboard.test.tsx index 29ab0e86..fe2300ce 100644 --- a/web/tests/app/dashboard.test.tsx +++ b/web/tests/app/dashboard.test.tsx @@ -27,29 +27,17 @@ vi.mock("@/components/layout/dashboard-shell", () => ({ ), })); +vi.mock("@/components/dashboard/analytics-dashboard", () => ({ + AnalyticsDashboard: () =>
Analytics dashboard component
, +})); + import DashboardPage from "@/app/dashboard/page"; import DashboardLayout from "@/app/dashboard/layout"; describe("DashboardPage", () => { - it("renders the dashboard heading", () => { - render(); - expect(screen.getByText("Dashboard")).toBeInTheDocument(); - expect( - screen.getByText("Overview of your Bill Bot server."), - ).toBeInTheDocument(); - }); - - it("renders stat cards", () => { - render(); - expect(screen.getByText("Total server members")).toBeInTheDocument(); - expect(screen.getByText("Total moderation actions")).toBeInTheDocument(); - expect(screen.getByText("AI messages this week")).toBeInTheDocument(); - expect(screen.getByText("Bot uptime")).toBeInTheDocument(); - }); - - it("renders getting started card", () => { + it("renders analytics dashboard component", () => { render(); - expect(screen.getByText("Getting Started")).toBeInTheDocument(); + expect(screen.getByText("Analytics dashboard component")).toBeInTheDocument(); }); }); diff --git a/web/tests/components/dashboard/analytics-dashboard.test.tsx b/web/tests/components/dashboard/analytics-dashboard.test.tsx new file mode 100644 index 00000000..90674e33 --- /dev/null +++ b/web/tests/components/dashboard/analytics-dashboard.test.tsx @@ -0,0 +1,363 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { AnalyticsDashboard } from "@/components/dashboard/analytics-dashboard"; +import { SELECTED_GUILD_KEY } from "@/lib/guild-selection"; + +vi.mock("recharts", () => { + const Wrapper = ({ children }: { children?: ReactNode }) => ( +
{children}
+ ); + + return { + ResponsiveContainer: Wrapper, + LineChart: Wrapper, + Line: () => null, + BarChart: Wrapper, + Bar: Wrapper, + PieChart: Wrapper, + Pie: Wrapper, + Cell: () => null, + CartesianGrid: () => null, + XAxis: () => null, + YAxis: () => null, + Tooltip: () => null, + Legend: () => null, + }; +}); + +const analyticsPayload = { + guildId: "guild-1", + range: { + type: "week", + from: "2026-02-10T00:00:00.000Z", + to: "2026-02-17T23:59:59.999Z", + interval: "day", + channelId: null, + }, + kpis: { + totalMessages: 1234, + aiRequests: 456, + aiCostUsd: 1.2345, + activeUsers: 88, + newMembers: 7, + }, + realtime: { + onlineMembers: 12, + activeAiConversations: 3, + }, + messageVolume: [ + { + bucket: "2026-02-15T00:00:00.000Z", + label: "Feb 15", + messages: 100, + aiRequests: 20, + }, + ], + aiUsage: { + byModel: [ + { + model: "claude-sonnet-4-20250514", + requests: 456, + promptTokens: 90000, + completionTokens: 45000, + costUsd: 1.2345, + }, + ], + tokens: { + prompt: 90000, + completion: 45000, + }, + }, + channelActivity: [ + { + channelId: "channel-1", + name: "general", + messages: 500, + }, + ], + heatmap: [ + { + dayOfWeek: 1, + hour: 10, + messages: 12, + }, + ], +}; + +describe("AnalyticsDashboard", () => { + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("shows select server state when no guild is selected", () => { + render(); + + expect(screen.getByText("Select a server")).toBeInTheDocument(); + }); + + it("fetches analytics for selected guild and renders KPIs", async () => { + localStorage.setItem(SELECTED_GUILD_KEY, "guild-1"); + + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(analyticsPayload), + } as Response); + + render(); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalled(); + }); + + expect(screen.getByText("Total messages")).toBeInTheDocument(); + expect(screen.getByText("1,234")).toBeInTheDocument(); + expect(screen.getByText("456")).toBeInTheDocument(); + expect(screen.getByText("88")).toBeInTheDocument(); + }); + + it("shows em dash for online members before initial load completes", async () => { + localStorage.setItem(SELECTED_GUILD_KEY, "guild-1"); + vi.spyOn(global, "fetch").mockReturnValue(new Promise(() => {})); + + render(); + + await waitFor(() => { + expect(screen.getByText("Online members")).toBeInTheDocument(); + }); + + expect(screen.getByLabelText("Online members value")).toHaveTextContent(/^—$/); + }); + + it("shows em dash for active AI conversations before initial load completes", async () => { + localStorage.setItem(SELECTED_GUILD_KEY, "guild-1"); + vi.spyOn(global, "fetch").mockReturnValue(new Promise(() => {})); + + render(); + + await waitFor(() => { + expect(screen.getByText("Active AI conversations")).toBeInTheDocument(); + }); + + expect(screen.getByLabelText("Active AI conversations value")).toHaveTextContent(/^—$/); + expect(screen.getByLabelText("Active AI conversations value")).not.toHaveTextContent(/^0$/); + }); + + it("omits interval query param for custom range so server can auto-detect", async () => { + localStorage.setItem(SELECTED_GUILD_KEY, "guild-1"); + + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(analyticsPayload), + } as Response); + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalled(); + }); + + await user.click(screen.getByRole("button", { name: "Custom" })); + + await waitFor(() => { + const customCall = fetchSpy.mock.calls + .map(([url]) => String(url)) + .find((url) => url.includes("range=custom")); + expect(customCall).toBeDefined(); + expect(customCall).not.toContain("interval="); + }); + }); + + it("applies accessible scope attributes to heatmap table headers", async () => { + localStorage.setItem(SELECTED_GUILD_KEY, "guild-1"); + + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(analyticsPayload), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByText("Activity heatmap")).toBeInTheDocument(); + }); + + expect(screen.getByRole("columnheader", { name: "Day" })).toHaveAttribute( + "scope", + "col", + ); + expect(screen.getByRole("columnheader", { name: "0" })).toHaveAttribute( + "scope", + "col", + ); + expect(screen.getByRole("rowheader", { name: "Sun" })).toHaveAttribute( + "scope", + "row", + ); + }); + + it("applies channel filter and refetches with channelId query param", async () => { + localStorage.setItem(SELECTED_GUILD_KEY, "guild-1"); + + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(analyticsPayload), + } as Response); + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("general")).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: "general" })); + + await waitFor(() => { + const calledWithChannelFilter = fetchSpy.mock.calls.some(([url]) => + String(url).includes("channelId=channel-1"), + ); + expect(calledWithChannelFilter).toBe(true); + }); + }); + + it("converts custom local date boundaries to UTC ISO values in query params", async () => { + localStorage.setItem(SELECTED_GUILD_KEY, "guild-1"); + + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(analyticsPayload), + } as Response); + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalled(); + }); + + await user.click(screen.getByRole("button", { name: "Custom" })); + fireEvent.change(screen.getByLabelText("From date"), { + target: { value: "2026-01-15" }, + }); + fireEvent.change(screen.getByLabelText("To date"), { + target: { value: "2026-01-16" }, + }); + await user.click(screen.getByRole("button", { name: "Apply" })); + + await waitFor(() => { + const customCalls = fetchSpy.mock.calls.filter(([url]) => + String(url).includes("range=custom"), + ); + expect(customCalls.length).toBeGreaterThan(0); + + const latestCustomCall = customCalls[customCalls.length - 1]; + const parsedUrl = new URL(String(latestCustomCall[0]), "http://localhost"); + expect(parsedUrl.searchParams.get("from")).toBe( + new Date(2026, 0, 15, 0, 0, 0, 0).toISOString(), + ); + expect(parsedUrl.searchParams.get("to")).toBe( + new Date(2026, 0, 16, 23, 59, 59, 999).toISOString(), + ); + }); + }); + + it("shows a validation error and does not apply custom range when from date is after to date", async () => { + localStorage.setItem(SELECTED_GUILD_KEY, "guild-1"); + + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(analyticsPayload), + } as Response); + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalled(); + }); + + await user.click(screen.getByRole("button", { name: "Custom" })); + + const fromInput = screen.getByLabelText("From date"); + const toInput = screen.getByLabelText("To date"); + + fireEvent.change(fromInput, { target: { value: "2026-02-20" } }); + fireEvent.change(toInput, { target: { value: "2026-02-10" } }); + + await waitFor(() => { + expect(fetchSpy.mock.calls.some(([url]) => String(url).includes("range=custom"))).toBe(true); + }); + + const callCountBeforeApply = fetchSpy.mock.calls.length; + await user.click(screen.getByRole("button", { name: "Apply" })); + + expect( + screen.getByText('"From" date must be on or before "To" date.'), + ).toBeInTheDocument(); + + const requestedInvalidRange = fetchSpy.mock.calls.some(([url]) => { + const text = String(url); + return ( + text.includes("range=custom") && + text.includes("from=2026-02-20T00%3A00%3A00.000Z") && + text.includes("to=2026-02-10T23%3A59%3A59.999Z") + ); + }); + + expect(fetchSpy).toHaveBeenCalledTimes(callCountBeforeApply); + expect(requestedInvalidRange).toBe(false); + }); + + it("shows error card with retry button when API returns error", async () => { + localStorage.setItem(SELECTED_GUILD_KEY, "guild-1"); + + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: false, + status: 500, + json: () => Promise.resolve({ error: "Internal server error" }), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByText(/failed to load analytics/i)).toBeInTheDocument(); + }); + + expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument(); + }); + + it("redirects to /login when API returns 401", async () => { + localStorage.setItem(SELECTED_GUILD_KEY, "guild-1"); + + const originalLocation = window.location; + // @ts-expect-error -- mocking location + delete window.location; + // @ts-expect-error -- mocking location + window.location = { href: "" }; + + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({ error: "Unauthorized" }), + } as Response); + + render(); + + await waitFor(() => { + expect(window.location.href).toBe("/login"); + }); + + // @ts-expect-error -- restoring location mock + window.location = originalLocation; + }); +}); diff --git a/web/tests/components/layout/server-selector.test.tsx b/web/tests/components/layout/server-selector.test.tsx index d07f384f..6d49e5bb 100644 --- a/web/tests/components/layout/server-selector.test.tsx +++ b/web/tests/components/layout/server-selector.test.tsx @@ -9,12 +9,27 @@ vi.mock("next/image", () => ({ ), })); +const mockBroadcastSelectedGuild = vi.fn(); +vi.mock("@/lib/guild-selection", async () => { + const actual = await vi.importActual( + "@/lib/guild-selection", + ); + return { + ...actual, + broadcastSelectedGuild: (...args: unknown[]) => + mockBroadcastSelectedGuild(...args), + }; +}); + import { ServerSelector } from "@/components/layout/server-selector"; +import { SELECTED_GUILD_KEY } from "@/lib/guild-selection"; describe("ServerSelector", () => { let fetchSpy: ReturnType; beforeEach(() => { + localStorage.clear(); + mockBroadcastSelectedGuild.mockReset(); fetchSpy = vi.spyOn(global, "fetch"); }); @@ -64,6 +79,101 @@ describe("ServerSelector", () => { }); }); + it("does not rebroadcast restored guild selection from localStorage", async () => { + localStorage.setItem(SELECTED_GUILD_KEY, "1"); + + const guilds = [ + { + id: "1", + name: "Restored Server", + icon: null, + owner: true, + permissions: "8", + features: [], + botPresent: true, + }, + ]; + + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(guilds), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByText("Restored Server")).toBeInTheDocument(); + }); + + expect(mockBroadcastSelectedGuild).not.toHaveBeenCalled(); + }); + + it("broadcasts selected guild when defaulting to first guild", async () => { + const guilds = [ + { + id: "1", + name: "Default Server", + icon: null, + owner: true, + permissions: "8", + features: [], + botPresent: true, + }, + ]; + + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(guilds), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByText("Default Server")).toBeInTheDocument(); + }); + + expect(mockBroadcastSelectedGuild).toHaveBeenCalledWith("1"); + }); + + it("does nothing when clicking the currently selected guild", async () => { + const user = userEvent.setup(); + const guilds = [ + { + id: "1", + name: "Default Server", + icon: null, + owner: true, + permissions: "8", + features: [], + botPresent: true, + }, + ]; + + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(guilds), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByText("Default Server")).toBeInTheDocument(); + }); + + expect(mockBroadcastSelectedGuild).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + await user.click( + screen.getByRole("button", { name: /Default Server/i }), + ); + await user.click( + await screen.findByRole("menuitem", { name: "Default Server" }), + ); + + expect(mockBroadcastSelectedGuild).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + it("shows error state with retry button on fetch failure", async () => { fetchSpy.mockRejectedValue(new Error("Network error")); render(); diff --git a/web/tests/lib/discord.test.ts b/web/tests/lib/discord.test.ts index 2b19c66c..e033a8c6 100644 --- a/web/tests/lib/discord.test.ts +++ b/web/tests/lib/discord.test.ts @@ -399,14 +399,14 @@ describe("fetchBotGuilds", () => { // Verify signal was forwarded to fetch expect(fetchSpy).toHaveBeenCalledWith( - "http://localhost:3001/api/guilds", + "http://localhost:3001/api/v1/guilds", expect.objectContaining({ signal: controller.signal, }), ); }); - it("sends Authorization header with BOT_API_SECRET", async () => { + it("sends x-api-secret header with BOT_API_SECRET", async () => { process.env.BOT_API_URL = "http://localhost:3001"; process.env.BOT_API_SECRET = "my-secret"; @@ -419,9 +419,9 @@ describe("fetchBotGuilds", () => { expect(result).toEqual({ available: true, guilds: [] }); expect(fetchSpy).toHaveBeenCalledWith( - "http://localhost:3001/api/guilds", + "http://localhost:3001/api/v1/guilds", expect.objectContaining({ - headers: { Authorization: "Bearer my-secret" }, + headers: { "x-api-secret": "my-secret" }, }), ); }); @@ -471,7 +471,7 @@ describe("getMutualGuilds", () => { if (urlStr.includes("/users/@me/guilds")) { return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(userGuilds) } as Response); } - if (urlStr.includes("/api/guilds")) { + if (urlStr.includes("/api/v1/guilds")) { return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(botGuilds) } as Response); } return Promise.reject(new Error(`Unexpected fetch URL: ${urlStr}`)); @@ -499,7 +499,7 @@ describe("getMutualGuilds", () => { if (urlStr.includes("/users/@me/guilds")) { return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(userGuilds) } as Response); } - if (urlStr.includes("/api/guilds")) { + if (urlStr.includes("/api/v1/guilds")) { return Promise.resolve({ ok: false, status: 500, statusText: "Internal Server Error" } as Response); } return Promise.reject(new Error(`Unexpected fetch URL: ${urlStr}`)); diff --git a/web/tests/lib/guild-selection.test.ts b/web/tests/lib/guild-selection.test.ts new file mode 100644 index 00000000..b980890f --- /dev/null +++ b/web/tests/lib/guild-selection.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + broadcastSelectedGuild, + GUILD_SELECTED_EVENT, + SELECTED_GUILD_KEY, +} from "@/lib/guild-selection"; + +describe("guild-selection", () => { + afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it("persists and dispatches normalized guild ID for non-empty values", () => { + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + + broadcastSelectedGuild(" guild-123 "); + + expect(localStorage.getItem(SELECTED_GUILD_KEY)).toBe("guild-123"); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const event = dispatchSpy.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe(GUILD_SELECTED_EVENT); + expect(event.detail).toBe("guild-123"); + }); + + it("does not persist or dispatch event for empty or whitespace guild IDs", () => { + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + + broadcastSelectedGuild(""); + broadcastSelectedGuild(" "); + + expect(localStorage.getItem(SELECTED_GUILD_KEY)).toBeNull(); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); +});