diff --git a/.agents/skills/databuddy-internal/SKILL.md b/.agents/skills/databuddy-internal/SKILL.md index b855ac0ebc..192a8792af 100644 --- a/.agents/skills/databuddy-internal/SKILL.md +++ b/.agents/skills/databuddy-internal/SKILL.md @@ -21,30 +21,32 @@ Keep additions **minimal**: one bullet, a new `rg` hint, or a routing note—eno - Prod infrastructure repo is local at `/Users/iza/Documents/GitHub/databuddy-infra` (`databuddy-analytics/infra`); ClickHouse cluster inventory is `clickhouse/ansible/inventory.yml`, not `/Users/iza/Dev/Databuddy/infra` or `DatabuddyOPS`. - Never use production/customer data as tests, fixtures, snapshots, examples, or copied output. Tests must use placeholders/mocks only (example.com, example IDs). If production ClickHouse is queried for investigation, summarize anonymized aggregates and do not paste customer domains, client IDs, emails, or other identifiers into code or responses. -- `@databuddy/test/env` replaces database/Redis URLs with local defaults unless `CI=true`; set `CI=true` when pointing integration tests at an isolated temporary database. +- `@databuddy/test/env` targets local `databuddy_test` unless `CI=true`, so a normal `db:push` may update a different database; sync that test database explicitly before debugging removed-column failures. - `apps/dashboard`: Next.js app on port `3000` (per-website **agent** chat: `@ai-sdk/react` `useChat` via `contexts/chat-context.tsx` — not the separate `chat-sdk` package; overlapping sends while streaming are queued client-side to mirror a “queue latest” strategy.) - Dashboard Playwright webServer commands run under CI PATH from setup-bun; avoid `bash -lc` because login shells can drop Bun from PATH. Build dist-only workspace packages such as `@databuddy/sdk` and `@databuddy/devtools` before starting the API/dashboard. Client `NEXT_PUBLIC_*` flags must use direct env access so Next can inline them. `readBooleanEnv` only treats the literal string `"true"` as enabled, so CI E2E booleans must use `"true"`/`"false"`, not `"1"`/`"0"`. - Local E2E dashboard smokes that need `/api/test/e2e/*` should start the API/dashboard directly (or through Playwright's webServer command), not via `bun run dev:dashboard`; Turbo runs in strict env mode and drops `DATABUDDY_E2E_MODE`/`DATABUDDY_E2E_TEST_KEY` unless they are added to `turbo.json` `globalEnv`. - Dashboard Playwright public/demo analytics specs call API `/v1/query` anonymously from the browser; keep `DATABUDDY_E2E_MODE` query behavior isolated from production rate limits so CI retries do not exhaust `anon:unknown`. - `apps/api`: Elysia API on port `3001` - Public REST docs live in `apps/api/src/rpc/openapi.ts`: `/spec.json` is the generated spec, `/` is the reference UI, and hiding a router there also makes its top-level REST paths return 404 because `/*` uses the same filtered docs router. -- `apps/slack`: Slack agent adapter; Slack installs must resolve through org-scoped DB integration records, not a single env bot token/default website. Agent calls must use an encrypted per-integration Databuddy API key secret as a normal bearer token, never a global internal secret. +- `apps/slack`: Slack agent adapter; Slack installs resolve through org-scoped DB integration records, not a single env bot token/default website. Agent calls use the org-scoped internal principal synthesized from the active integration in `slack/installations.ts`, never a global internal secret. - Slack OAuth lives in `apps/api`, but slash commands/events require `apps/slack` to be running too; local `bun run dev:dashboard` runs dashboard + API only, so use `bun run dev:slack` when working on Slack. The Slack package scripts read the root `.env`. -- Slack routing is organization-scoped: OAuth binds a Slack workspace to a Databuddy organization, app mentions from the installed workspace auto-bind channels including Slack Connect, and `/bind` is now a manual fallback for unknown/unapproved channels. DMs/assistant threads work after workspace install. Analytics questions should go through app mentions/DMs using MCP-style website discovery inside the installed organization, never by fanning out across the message sender's user memberships. Slack emits evlog events under `apps/slack/.evlog/logs` in development/`SLACK_EVLOG_FS=1`; Axiom uses `AXIOM_TOKEN` with `SLACK_AXIOM_DATASET` defaulting to `slack`; and reactions need the `reactions:write` bot scope. Remote manifest updates need `SLACK_APP_ID` plus a Slack app configuration token in `SLACK_APP_CONFIG_TOKEN`; trust Slack API errors over token-prefix guesses. +- Run Slack tests through `bun run test` inside `apps/slack`; the package script supplies `SKIP_VALIDATION=true`, while direct `bun test` can fail during environment validation before loading tests. +- Slack routing is organization-scoped: OAuth binds a Slack workspace to a Databuddy organization, app mentions from the installed workspace auto-bind channels including Slack Connect, and `/bind` is now a manual fallback for unknown/unapproved channels. DMs/assistant threads work after workspace install. Analytics questions should go through app mentions/DMs using MCP-style website discovery inside the installed organization, never by fanning out across the message sender's user memberships. Slack emits evlog events under `apps/slack/.evlog/logs` in development/`SLACK_EVLOG_FS=1`; Axiom uses `AXIOM_TOKEN` with `SLACK_AXIOM_DATASET` defaulting to `slack`; and reactions need the `reactions:write` bot scope. - Slack scope changes require reinstalling/reauthorizing the workspace; updating the local/remote manifest alone does not grant newly-added bot scopes to an existing installation. - Slack agent billing flows through an org-scoped automation API key; existing keys may have `userId: null`, so the agent billing resolver must fall back to the organization owner when an API key has `organizationId`. - Slack memory is separate from billing/auth: pass a Slack-scoped `memoryUserId` such as `slack-{team}-{user}` plus current-speaker context so one Slack user's saved name/preferences do not bleed into another user's replies. -- Slack agent write tools need the integration automation API key to include the matching Databuddy API scopes (currently `read:data`, `read:links`, `write:links`, `manage:websites`, `manage:flags`); older installs may need reconnecting so a new key is minted. +- Slack agent tools use the scopes on the internal principal in `slack/installations.ts`; changing those Databuddy scopes applies without Slack reauthorization. Only changes to Slack OAuth scopes require reconnecting the workspace. - Shared agent integrations should call `@databuddy/ai/agent` (`askDatabuddyAgent` / `streamDatabuddyAgent`) instead of importing internal MCP run/history helpers directly. - First-party ads attribution work should start by preserving UTMs into registration and signup events only; do not add RPC plumbing, conversion destinations, env hooks, tables, workers, or UI until explicitly needed. - Insights generation logic belongs in `apps/insights` and should reuse `@databuddy/ai`; `apps/api` should only read insight data or queue runs, not own prompts, model calls, tool loops, validation, or persistence orchestration. -- Historical insight quality uses `bun run eval:insights`; it is synthetic-only, does not load `.env`, and must never accept customer website identifiers or fall through to live data sources. -- Insight repairs require confirmation scoped to the exact goal or funnel; site-wide revenue totals are context, never proof that one definition completed. -- Automated insight digests have one organization-wide schedule (`off`, `daily`, or `weekly`) and one organization-wide delivery set; website selection is only for manual runs. Do not reintroduce per-website overrides, hourly/custom cadence, or cron input. +- `SPEC.md` is the insights product contract. Keep one investigation agent on the shared analytics/investigation toolkit; do not add a parallel evidence API, fixed query choreography, or an action-specific lifecycle to the core loop. +- Production insight shadows must freeze `--reference-time`, retain a tool-name trace, and pass available GitHub context before supporting quality claims. Postgres and ClickHouse are read-only, but connector token refreshes or cache writes can still occur; never describe the whole run as zero-write. +- Automatic investigations have one organization-wide schedule (`off`, `daily`, or `weekly`) and one organization-wide delivery set; website selection is only for manual runs. Do not reintroduce per-website overrides, hourly/custom cadence, or cron input. - Insight run items are execution metadata, not rendered insight content; previews should use run status/counts or query real insights, never infer titles or bodies from run items. - Insight Slack delivery must resolve each channel binding to its active same-organization integration; never choose an arbitrary organization bot token. +- Replies beneath delivered Slack investigations must resolve the delivery and enter the existing durable reply/resume path; never route them through generic Slack chat or relevance scoring. +- One-off insight previews must preserve the real signal entity and use customer-facing product output. Never hand-write Slack copy from eval metadata or expose evaluation and suppression mechanics. - Agent ClickHouse SQL must use the canonical analytics.events schema: `client_id`, `time`, `path`, `event_name`, and pageviews as `event_name = 'screen_view'`; never `website_id`, `created_at`, `page_path`, `event_type`, or `pageview`. -- Slack agent evals live in `packages/evals`: use `bun run eval --surface slack` for the whole Slack surface. `--tag slack` is only a tiny smoke subset, and `cost_fallback` in agent telemetry is pricing-catalog fallback, not proof the model request fell back. - Slack agent expected stops such as exhausted Databunny credits should throw `DatabuddyAgentUserError` from `@databuddy/ai/agent/errors`; Slack surfaces those messages directly and reserves the generic reconnect copy for real infrastructure failures. - Slack Docker builds use `bun build --compile --bytecode`; keep `apps/slack/src/index.ts` bootstrapping inside an async `main()` instead of top-level `await`, which can fail during compile even when typecheck passes. - Insights Docker builds also use `bun build --compile --bytecode`; keep `apps/insights/src/index.ts` startup work inside async functions instead of top-level `await`. @@ -116,8 +118,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin - Funnel rows keep the action menu outside the main toggle button; put row padding on the sibling `Button`, not only on `List.Row`, so the visible row surface is clickable without nesting buttons. - Demo website navigation must be public-safe and route-backed; hide sensitive, configuration-heavy, or unavailable website features such as Agent, Feature Flags, Revenue, Users, Realtime, Anomalies, and website Settings instead of inheriting the full website nav. Goals and Funnels may be public demo surfaces, but keep them read-only. - Dashboard definitions for feature flags and target groups are admin surfaces; do not expose even sanitized rows to demo-tier/public website access. -- Insights feed (`use-insights-feed`) collapses persisted history by `insightSignalDedupeKey` in `apps/dashboard/lib/insight-signal-key.ts` so the list is one row per signal (latest wins); reads must not invoke AI generation. -- Insights page (`app/(main)/insights`) should stay focused on the brief + signal queue; do not add generic global analytics KPI cards or top pages/referrers/countries tables there. +- Insights history is grouped by its backend-owned subject key in the RPC layer so every client sees one current row per investigation; reads must not invoke AI generation. - Theme: `apps/dashboard/app/globals.css`. **`--border` is intentionally subtle**; do not crank it darker for “contrast” unless **iza** asks—prefer text tokens or layout for readability. - Website analytics filters are two-way synced between Jotai and the `filters` URL param in `app/(main)/websites/[id]/layout.tsx`; guard URL-driven atom writes from echoing stale atom state back into `nuqs`, or adding a filter can lock the page during form submit. - Do not centralize, relocate, or otherwise refactor dashboard E2E API route access gates during cleanup; keep test-only access checks local to each route unless iza explicitly asks for that change. @@ -137,7 +138,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin - Start in `apps/api/src` - Shared API contracts and procedure logic live in `packages/rpc` - Prefer changing shared router logic in `packages/rpc` rather than duplicating validation in the dashboard -- Analytics AI insights: `apps/api/src/routes/insights.ts` — dedupe key is `websiteId|type|direction` (direction from **signed** `changePercent`, not sentiment); within the cooldown window, matching rows are **updated** (same `id`) instead of inserting duplicates. **Do not** show `changePercent` in the UI with sentiment-based sign flips; the stored value is already signed. +- Investigations run in `apps/insights`; RPC only reads cases and accepts durable replies. Case identity is `websiteId|subjectKey`, where the backend owns the subject key. Persist a new observation for each turn while updating the existing insight row. The stored `changePercent` is already signed. ### Ingestion and analytics pipeline @@ -220,4 +221,4 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin - Use `rg "clickHouse|ClickHouse|TABLE_NAMES" packages/db apps/basket apps/api` - Use `rg "betterAuth|drizzleAdapter|organization" packages/auth packages/rpc apps/dashboard` - Use `rg "trackRoute|basketRouter|llmRouter|structured-errors" apps/basket` -- Use `rg "insightDedupeKey|collapseInsightsBySignal|insightSignalDedupeKey" apps/api apps/dashboard` +- Use `rg "signalKey|subjectKey|insightDedupeKey" apps/insights packages/rpc packages/shared` diff --git a/.agents/skills/databuddy-internal/references/codebase-map.md b/.agents/skills/databuddy-internal/references/codebase-map.md index 777cc57206..b350f33603 100644 --- a/.agents/skills/databuddy-internal/references/codebase-map.md +++ b/.agents/skills/databuddy-internal/references/codebase-map.md @@ -25,7 +25,7 @@ Use this file when the task spans multiple packages or when the right edit locat - Elysia API service - Default dev port: `3001` - **Autumn (`autumn-js/fetch`)**: Do not `mount(autumnHandler(...))` with a single argument — Elysia treats that as a catch‑all `/*` and Autumn returns `{"code":"not_found",...}` for every non‑Autumn path (breaks OpenAPI docs at `/` and `/spec.json`). Mount at `/api/autumn` and pass requests through `withAutumnApiPath` so routed paths stay under `/api/autumn` (see [`apps/api/src/lib/autumn-mount.ts`](/Users/iza/Dev/Databuddy/apps/api/src/lib/autumn-mount.ts)). -- AI insights: [`apps/api/src/routes/insights.ts`](/Users/iza/Dev/Databuddy/apps/api/src/routes/insights.ts) — Top Pages rows include a **Human label** (opaque path segments → “Demo page”, etc.); system prompt asks for risk/watch insights in good weeks and data-grounded suggestions. Dashboard [`insight-card`](/Users/iza/Dev/Databuddy/apps/dashboard/app/(main)/insights/_components/insight-card.tsx): **Ask agent** + path-aware **View events** (`/events/stream?path=…` when a path is parsed from copy). Dedupes on cooldown-window DB keys `(websiteId|type|direction)`; failed insert returns no new insights. Funnel/MRR/retention insights require those metrics in query data—do not invent in prompts alone. +- Investigations: [`apps/insights/src/agent.ts`](/Users/iza/Dev/Databuddy/apps/insights/src/agent.ts) runs one signal through the shared analytics/code toolkit. [`apps/insights/src/persistence.ts`](/Users/iza/Dev/Databuddy/apps/insights/src/persistence.ts) persists one current insight row per `(websiteId, subjectKey)` and appends observations. [`packages/rpc/src/routers/insights.ts`](/Users/iza/Dev/Databuddy/packages/rpc/src/routers/insights.ts) serves the case timeline and queues durable replies. The dashboard opens each case at `/insights/[id]`. - Handles routes such as public endpoints, webhooks, health, query, MCP, and agent-related APIs - Typical work: - route handlers diff --git a/.agents/skills/databuddy-mcp/SKILL.md b/.agents/skills/databuddy-mcp/SKILL.md index 50aebefb16..3dc92c9452 100644 --- a/.agents/skills/databuddy-mcp/SKILL.md +++ b/.agents/skills/databuddy-mcp/SKILL.md @@ -1,17 +1,19 @@ --- name: databuddy-mcp -description: Use whenever the Databuddy MCP server is available and the user wants their analytics, errors, vitals, insights, comparisons, anomalies, top movers, flags, links, or annotations queried. Covers get_data, ask, capabilities, summarize_insights, compare_metric, top_movers, detect_anomalies, get_schema, list/save/forget memory, and workspace mutations. Not for SDK integration help (use databuddy) or monorepo implementation (use databuddy-internal). +description: Use whenever the Databuddy MCP server is available and the user wants analytics, errors, vitals, investigations, flags, links, annotations, funnels, or goals queried or changed. Covers get_data, capabilities, get_schema, the investigation lifecycle, and workspace mutations. Not for SDK integration help (use databuddy) or monorepo implementation (use databuddy-internal). --- # Databuddy MCP -The MCP server ships its own session-start `instructions` and a `databuddy://guide` resource — those are the canonical reference. This skill exists so Claude Code surfaces the routing context up front instead of waiting for the MCP `initialize` flow. +The MCP server's session-start instructions, live `tools/list`, and `databuddy://guide` resource are canonical. Do not rely on a static tool catalog. ## Quick routing - Known shape (top pages, recent errors, summary metrics) → `get_data`. Batch 2-10 with `queries[]`. -- "What changed", anomalies, top movers, compare last week → `summarize_insights` / `compare_metric` / `top_movers` / `detect_anomalies` (faster and cheaper than `ask`). -- Open-ended question → `ask`. Reuse `conversationId`. +- Existing issue or change → `list_investigations`, then `get_investigation` for its evidence and history. +- User context for a case → `reply_to_investigation`. This resumes the same durable investigation. +- Queued/running reply → poll `get_investigation`; retry with the same `replyId`, never a new one. +- Ad hoc comparison → batch the current and comparison windows in `get_data`. - Discovery → `capabilities` (catalog) or `get_schema` (columns). ## Conventions @@ -19,8 +21,8 @@ The MCP server ships its own session-start `instructions` and a `databuddy://gui - Website: pass `websiteId`, `websiteName`, or `websiteDomain` — any one works. - Dates: a `preset` OR both `from`+`to` (`YYYY-MM-DD`). Defaults to `last_7d`. Don't pass only one of `from`/`to`. - Filters: `field` is the ClickHouse column name. Errors list allowed fields and suggest matches on typos. -- Mutations (flags, links, folders, memory): preview with `confirmed=false`, get user approval, then `confirmed=true`. +- Mutations follow each tool's confirmation metadata and required API-key scope. ## For more depth -Fetch the `databuddy://guide` MCP resource. That's the single source of truth — including the error-table footguns (`message` vs `error_type`) and the custom-events filter shape. +Fetch `databuddy://guide` for query conventions and investigation behavior. Use live tool schemas for exact inputs. diff --git a/.changeset/pre.json b/.changeset/pre.json index c8333cd046..0c1e87f313 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -16,7 +16,6 @@ "@databuddy/devtools": "0.0.0", "@databuddy/email": "0.0.0", "@databuddy/env": "0.1.0", - "@databuddy/evals": "0.0.1", "@databuddy/notifications": "0.0.1", "@databuddy/redis": "1.0.0", "@databuddy/rpc": "0.1.0", diff --git a/.env.example b/.env.example index 1bab4eb6e8..1e686ad4dd 100644 --- a/.env.example +++ b/.env.example @@ -64,7 +64,6 @@ DATABUDDY_E2E_SEED_CLICKHOUSE="true" DATABUDDY_E2E_CLICKHOUSE_EVENTS="250" # Slack bot / AI agent adapter -SLACK_APP_ID="" SLACK_APP_TOKEN="" SLACK_SIGNING_SECRET="" SLACK_CLIENT_ID="" @@ -73,7 +72,6 @@ SLACK_REDIRECT_URI="http://localhost:3001/v1/integrations/slack/callback" SLACK_SOCKET_MODE="true" SLACK_PORT="3010" SLACK_EVLOG_FS="" -SLACK_APP_CONFIG_TOKEN="" DATABUDDY_ENCRYPTION_KEY="" # Not Necessary unless using blog diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 873e7d5d74..6d44bc9b3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,3 +172,8 @@ jobs: UPTIME_ROUTER_INTEGRATION: "true" BULLMQ_REDIS_URL: redis://localhost:6379/3 run: cd apps/api && bun test src/integration/uptime-handlers.test.ts + - name: Insights router integration + env: + NODE_ENV: test + BULLMQ_REDIS_URL: redis://localhost:6379/5 + run: cd apps/api && bun test src/integration/insights-handlers.test.ts diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000000..3363d3b587 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,37 @@ +# Investigation Roadmap + +[`SPEC.md`](./SPEC.md) is the product contract. This is direction, not a promise. + +## Shipped foundation + +- One signal enters one investigation agent with shared analytics and read-only code/deploy tools; one `act | ask | watch | resolve` outcome is saved in the existing observation timeline. The old bounded classifier, repair lifecycle, duplicate evidence stack, and synthetic evaluator are gone. +- Each signal now owns a durable investigation: historical agent observations and human replies share one chronological case timeline. +- Dashboard, Slack, and MCP replies enter the same durable reply path, resume the same-signal case, re-check current data, and append the new outcome. +- Scheduled revisits remeasure the same error, goal, funnel, or metric even after it falls below the detector threshold; recovered cases close instead of disappearing. +- The generic agent exposes the same `list | get | reply` investigation path, and recurring Slack updates stay in the original case thread. +- The agent outcome controls delivery directly: `act` and `ask` notify, `watch` rechecks quietly, and `resolve` closes. + +## Now + +- Use the read-only production shadow—not synthetic prose graders—until root cause, impact, next action, and verification are consistently useful. +- Preserve exact entity definitions and business meaning through detection, investigation, Slack, and the dashboard; research missing context before asking a teammate. +- During an investigation, propose missing goal or funnel meaning and capture it after user confirmation. + +## Next + +- Let the agent produce a patch and verification plan without write credentials. +- Have Databuddy validate the patch, open one linked PR, and resume on review events. +- Verify the signal after merge; resolve or reopen the case. + +## Later + +- One-click telemetry, deploy, ad, support, CRM, and infrastructure connectors. +- Cross-source grouping when several signals share a root cause. +- Approved campaign, configuration, and infrastructure actions. +- Project memory and evals learned from accepted, rejected, and corrected work. + +## Not building + +- Another generic analytics chat or digest. +- A diagnosis rule engine or a subsystem per action type. +- Automatic mutation before investigation quality is proven. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000000..26f09318c8 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,113 @@ +# Databuddy Investigations + +## Product job + +Databuddy finds an important change, explains what caused it and who it affects, recommends the exact next action, and stays with the problem until it is resolved. + +The product is not an insight-card generator. It is an investigation and resolution loop. + +> Checkout conversion fell after deploy `abc123`. Mobile sessions fail at the payment step because the new form no longer emits `payment_submitted`. Restore the event in `CheckoutForm.tsx`, then verify at least five completions in 24 hours. + +## Principles + +1. **Cases, not cards.** One signal owns one persistent investigation whose latest outcome is the current state. +2. **Let the agent investigate.** Code owns identity, tenancy, measured facts, and side effects. The agent chooses what to inspect and which hypotheses to test. +3. **Change the situation.** A useful result proposes a concrete action, asks one answerable question, defines a watch trigger, or resolves the case. +4. **Keep the thread.** New evidence, human replies, recurrence, and PR activity continue the same investigation. +5. **Stay quiet.** Weak or duplicate signals do not become customer work. + +## Core model + +### Signal + +A measured symptom with an exact entity, time window, baseline, and stable key. Examples: an error fingerprint, a broken funnel step, a goal with zero completions, or a campaign whose paid traffic stopped converting. + +### Investigation + +The durable customer object. It contains: + +- one primary signal and related signals; +- current state: `open` or `resolved`; a watch outcome schedules a quiet recheck with an exact trigger; +- outcomes, evidence, human messages, actions, and recurrence history; +- durable prior agent turns so follow-ups continue with the same case context. + +### Action + +An optional proposed change with a target and verification condition. A code action may become a patch and PR. Other actions may target tracking, a goal, a campaign, configuration, or operations. + +Do not introduce another product object unless these three cannot represent a real use case. + +## Loop + +```text +detect signal + → open or update investigation + → inspect analytics, telemetry, history, deploys, and code + → report what changed, why, impact, and the next move + → act | ask | watch | resolve + → resume on new evidence or a human reply + → verify the result +``` + +One exact signal starts the run. The agent does not choose from a bag of unrelated regressions. + +## Agent context + +The agent receives: + +- the exact named subject, its definition and business description, comparison windows, and prior outcomes; +- website identity and the ability to inspect relevant pages before asking a person; +- relevant analytics, errors, sessions, funnels, goals, vitals, and revenue tools; +- connected repositories, deploys, commits, code search, and file reads; +- project instructions and durable corrections; +- human replies and open actions or PRs. + +Tools are discoverable. There is no fixed first query, query family, receipt choreography, or two-read limit. + +## Outcome contract + +Every completed turn reports: + +- **summary:** what happened; +- **impact:** who or what is affected, with measured scope when available; +- **root cause:** the known mechanism, or `unknown`; +- **evidence:** the few facts that support or contradict it; +- **next:** exactly one outcome. + +The next outcome is one of: + +- `act` — exact change, target, and verification condition; +- `ask` — one self-contained question that says what the answer unlocks; +- `watch` — keep the backend-owned signal trigger active and state when to escalate; +- `resolve` — why no further work remains. + +Outcomes may be updated repeatedly. They are operational state, not prose templates. + +Customer copy names the exact goal, funnel, page, event, error, or campaign. It describes the operational change, never the detector, agent, evaluation, suppression decision, or other internal mechanics. + +## Continuity + +- A dashboard, Slack, or MCP reply resumes the same investigation. +- A GitHub comment or review resumes the agent working on that PR. +- A materially worse resolved signal reopens the same investigation with its prior outcomes. +- Corrections such as terminology, ownership, or known infrastructure become project memory. + +`act` and `ask` notify people. `watch` schedules another agent check without creating noise. `resolve` closes the case. + +## Actions and PRs + +The agent may inspect code without write credentials. For a code action it returns a patch and verification plan. Databuddy validates and applies the patch, creates the branch and PR, records updates in the investigation, and resumes the agent on review feedback. + +Only the outer boundary is deterministic: authorization, tenant scope, patch validation, approvals, idempotency, and delivery. Investigation strategy is not. + +## Quality bar + +An investigation is useful when a teammate can act without opening another analytics tab or asking “what exactly should I do?” + +Reject output that merely restates a percentage, invents a cause, asks for data Databuddy can read, gives a generic recommendation, or creates a duplicate case. + +When business meaning is missing, inspect the definition, site, events, and connected code first. If the remaining ambiguity changes the decision, ask one specific question that names the subject and proposes the most evidence-backed interpretation. + +## Initial implementation constraint + +Use the existing insight as the current investigation and observations as its agent timeline. Human replies are durable timeline events that resume that agent. Add more lifecycle storage only when PR events cannot fit this model; automated execution comes later. diff --git a/apps/api/src/integration/insights-handlers.test.ts b/apps/api/src/integration/insights-handlers.test.ts new file mode 100644 index 0000000000..c3e91e2cd6 --- /dev/null +++ b/apps/api/src/integration/insights-handlers.test.ts @@ -0,0 +1,621 @@ +import "@databuddy/test/env"; + +import { createMcpTools } from "@databuddy/ai/mcp/tools"; +import { eq } from "@databuddy/db"; +import { + analyticsInsights, + insightObservations, + insightReplies, +} from "@databuddy/db/schema"; +import { + appRouter, + createInternalPrincipal, + createRPCContext, + type Context, +} from "@databuddy/rpc"; +import { + closeInsightsQueue, + getInsightsQueue, + insightsResumeJobId, +} from "@databuddy/redis"; +import type { InvestigationOutcome } from "@databuddy/shared/insights"; +import { + addToOrganization, + cleanup, + db, + expectCode, + hasTestDb, + insertOrganization, + insertWebsite, + reset, + signUp, + userContext, +} from "@databuddy/test"; +import { createProcedureClient, type AnyProcedure } from "@orpc/server"; +import { randomUUIDv7 } from "bun"; +import { afterAll, beforeEach, describe, expect, it } from "bun:test"; + +const iit = hasTestDb ? it : it.skip; + +function investigationOutcome(nextType: "act" | "watch"): InvestigationOutcome { + const next: InvestigationOutcome["next"] = + nextType === "act" + ? { + action: "Restore signup_completed emission in the signup submit handler.", + target: "Signup submit handler", + type: "act", + verification: + "The handler emits signup_completed and signup conversion recovers for 24 hours.", + } + : { + escalation: "Escalate if signup conversion falls another 10%.", + type: "watch", + }; + return { + evidence: ["Signup conversion changed in the measured window."], + impact: "Signup completion is affected.", + next, + rootCause: + nextType === "act" + ? "The signup submit handler stopped emitting completions." + : null, + summary: "Signup conversion needs attention.", + title: "Signup conversion changed", + }; +} + +function call(procedure: T, context: Context) { + return createProcedureClient(procedure, { context }); +} + +beforeEach(() => reset()); +afterAll(async () => { + await closeInsightsQueue(); + await cleanup(); +}); + +describe("insight investigation timeline", () => { + iit("paginates one latest row per stable investigation", async () => { + const member = await signUp(); + const organization = await insertOrganization(); + await addToOrganization(member.id, organization.id, "member"); + const website = await insertWebsite({ organizationId: organization.id }); + const ids = { + olderSignup: randomUUIDv7(), + latestSignup: randomUUIDv7(), + checkout: randomUUIDv7(), + activation: randomUUIDv7(), + legacy: randomUUIDv7(), + }; + await db().insert(analyticsInsights).values([ + { + ...insightRow({ + id: ids.olderSignup, + organizationId: organization.id, + subjectKey: "goal:signup", + websiteId: website.id, + }), + createdAt: new Date("2026-01-01T00:00:00.000Z"), + dedupeKey: `${website.id}|legacy|signup|older`, + title: "Older signup finding", + }, + { + ...insightRow({ + id: ids.latestSignup, + organizationId: organization.id, + subjectKey: "goal:signup", + websiteId: website.id, + }), + createdAt: new Date("2026-01-04T00:00:00.000Z"), + dedupeKey: `${website.id}|legacy|signup|latest`, + title: "Latest signup finding", + }, + { + ...insightRow({ + id: ids.checkout, + organizationId: organization.id, + subjectKey: "goal:checkout", + websiteId: website.id, + }), + createdAt: new Date("2026-01-03T00:00:00.000Z"), + }, + { + ...insightRow({ + id: ids.activation, + organizationId: organization.id, + subjectKey: "goal:activation", + websiteId: website.id, + }), + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }, + { + ...insightRow({ + id: ids.legacy, + organizationId: organization.id, + subjectKey: "legacy:card", + websiteId: website.id, + }), + createdAt: new Date("2026-01-05T00:00:00.000Z"), + }, + ]); + await db().insert(insightObservations).values( + (["goal:signup", "goal:checkout", "goal:activation"] as const).map( + (subjectKey, index) => ({ + asOf: new Date(`2026-01-0${index + 1}T12:00:00.000Z`), + id: randomUUIDv7(), + insightId: + subjectKey === "goal:signup" + ? ids.latestSignup + : subjectKey === "goal:checkout" + ? ids.checkout + : ids.activation, + organizationId: organization.id, + outcome: investigationOutcome("watch"), + recheckAt: new Date("2026-01-10T00:00:00.000Z"), + signal: signal(subjectKey), + signalKey: subjectKey, + websiteId: website.id, + })) + ); + + const context = userContext(member, organization.id); + const firstPage = await call(appRouter.insights.history, context)({ + limit: 2, + offset: 0, + organizationId: organization.id, + }); + expect(firstPage.insights.map((insight) => insight.id)).toEqual([ + ids.latestSignup, + ids.checkout, + ]); + expect(firstPage.hasMore).toBe(true); + + const secondPage = await call(appRouter.insights.history, context)({ + limit: 2, + offset: 2, + organizationId: organization.id, + }); + expect(secondPage.insights.map((insight) => insight.id)).toEqual([ + ids.activation, + ]); + expect(secondPage.hasMore).toBe(false); + }); + + iit("persists a reply beside every observation for the same signal", async () => { + const member = await signUp(); + const organization = await insertOrganization(); + await addToOrganization(member.id, organization.id, "member"); + const website = await insertWebsite({ organizationId: organization.id }); + const previousInsightId = randomUUIDv7(); + const insightId = randomUUIDv7(); + const signalKey = "goal:signup"; + + await db().insert(analyticsInsights).values([ + { + ...insightRow({ + id: previousInsightId, + organizationId: organization.id, + subjectKey: signalKey, + websiteId: website.id, + }), + dedupeKey: `${website.id}|previous|${signalKey}`, + }, + insightRow({ + id: insightId, + organizationId: organization.id, + subjectKey: signalKey, + websiteId: website.id, + }), + ]); + + const firstObservationId = randomUUIDv7(); + const secondObservationId = randomUUIDv7(); + await db().insert(insightObservations).values([ + { + id: firstObservationId, + organizationId: organization.id, + websiteId: website.id, + insightId, + signalKey, + asOf: new Date("2026-01-10T00:00:00.000Z"), + createdAt: new Date("2026-01-10T12:00:00.000Z"), + signal: signal(signalKey), + outcome: investigationOutcome("act"), + recheckAt: new Date("2026-01-17T00:00:00.000Z"), + }, + { + id: secondObservationId, + organizationId: organization.id, + websiteId: website.id, + insightId: null, + signalKey, + asOf: new Date("2026-01-01T00:00:00.000Z"), + createdAt: new Date("2026-01-11T12:00:00.000Z"), + signal: signal(signalKey), + outcome: investigationOutcome("watch"), + recheckAt: new Date("2026-01-18T00:00:00.000Z"), + }, + ]); + + const context = userContext(member, organization.id); + const added = await call(appRouter.insights.reply, context)({ + body: " The signup form changed in yesterday's deploy. ", + insightId: previousInsightId, + }); + expect(added.reply.body).toBe( + "The signup form changed in yesterday's deploy." + ); + expect(added.reply.status).toBe("queued"); + expect( + (await getInsightsQueue().getJob(insightsResumeJobId(added.reply.id)))?.data + ).toEqual({ replyId: added.reply.id }); + + const result = await call(appRouter.insights.getById, context)({ + insightId: previousInsightId, + }); + expect(result.canReply).toBe(true); + expect(result.insight?.id).toBe(insightId); + expect(result.timeline.map((item) => item.id)).toEqual([ + firstObservationId, + secondObservationId, + added.reply.id, + ]); + expect(result.timeline[1]).toMatchObject({ + kind: "investigation", + period: { + current: { from: "2026-01-04", to: "2026-01-10" }, + previous: { from: "2025-12-28", to: "2026-01-03" }, + }, + }); + expect(result.timeline[0]).toMatchObject({ + outcome: { + next: { type: "act" }, + title: "Signup conversion changed", + }, + }); + expect(result.timeline[0]).not.toHaveProperty("asOf"); + expect(result.timeline[2]).toMatchObject({ + author: "test", + body: "The signup form changed in yesterday's deploy.", + kind: "reply", + status: "queued", + }); + expect( + await db().select().from(insightReplies) + ).toEqual([ + expect.objectContaining({ + authorId: member.id, + authorName: "test", + body: "The signup form changed in yesterday's deploy.", + insightId, + status: "queued", + }), + ]); + + await db() + .update(insightReplies) + .set({ status: "succeeded" }) + .where(eq(insightReplies.id, added.reply.id)); + const competing = await Promise.allSettled([ + call(appRouter.insights.reply, context)({ + body: "First simultaneous reply", + insightId, + }), + call(appRouter.insights.reply, context)({ + body: "Second simultaneous reply", + insightId, + }), + ]); + expect(competing.filter((item) => item.status === "fulfilled")).toHaveLength( + 1 + ); + expect(competing.filter((item) => item.status === "rejected")).toHaveLength( + 1 + ); + expect(await db().select().from(insightReplies)).toHaveLength(2); + }); + + iit("uses one scoped API-key reply across retries", async () => { + const organization = await insertOrganization(); + const website = await insertWebsite({ organizationId: organization.id }); + await insertWebsite({ organizationId: organization.id }); + const insightId = randomUUIDv7(); + await db().insert(analyticsInsights).values( + insightRow({ + id: insightId, + organizationId: organization.id, + subjectKey: "goal:signup", + websiteId: website.id, + }) + ); + await db().insert(insightObservations).values({ + asOf: new Date("2026-01-10T00:00:00.000Z"), + id: randomUUIDv7(), + insightId, + organizationId: organization.id, + outcome: investigationOutcome("watch"), + recheckAt: new Date("2026-01-17T00:00:00.000Z"), + signal: signal("goal:signup"), + signalKey: "goal:signup", + websiteId: website.id, + }); + + const principal = createInternalPrincipal({ + metadata: { + resources: { + global: ["manage:flags"], + [`website:${website.id}`]: ["read:data", "manage:websites"], + }, + }, + name: "MCP client", + organizationId: organization.id, + scopes: [], + }); + const context = await createRPCContext( + { headers: new Headers() }, + principal + ); + const input = { + body: "The deploy completed at noon.", + insightId, + replyId: "mcp-request-1", + }; + const first = await call(appRouter.insights.reply, context)(input); + const mcpTools = createMcpTools({ + apiKey: principal.apiKey, + organizationId: organization.id, + requestHeaders: new Headers(), + userId: null, + }); + const replyTool = mcpTools.find( + (tool) => tool.name === "reply_to_investigation" + ); + const retry = await replyTool?.handler({ + body: input.body, + investigationId: insightId, + replyId: input.replyId, + }); + + expect(retry?.isError).toBe(false); + expect(retry?.structuredContent).toEqual({ reply: first.reply }); + expect( + (await mcpTools.find((tool) => tool.name === "list_websites")?.handler({})) + ?.structuredContent + ).toEqual({ + total: 1, + websites: [expect.objectContaining({ id: website.id })], + }); + const listed = await mcpTools + .find((tool) => tool.name === "list_investigations") + ?.handler({ limit: 20, offset: 0, websiteId: website.id }); + expect(listed?.isError).toBe(false); + expect(listed?.structuredContent).toMatchObject({ + investigations: [expect.objectContaining({ id: insightId })], + }); + expect(await db().select().from(insightReplies)).toEqual([ + expect.objectContaining({ + authorId: null, + authorName: "MCP client", + body: input.body, + id: input.replyId, + insightId, + }), + ]); + + const readOnlyPrincipal = createInternalPrincipal({ + name: "Read-only MCP client", + organizationId: organization.id, + scopes: ["read:data"], + }); + const readOnlyContext = await createRPCContext( + { headers: new Headers() }, + readOnlyPrincipal + ); + expect( + (await call(appRouter.insights.getById, readOnlyContext)({ insightId })) + .canReply + ).toBe(false); + await expectCode( + call(appRouter.insights.reply, readOnlyContext)({ + body: "I should not be able to reply.", + insightId, + }), + "FORBIDDEN" + ); + const denied = await createMcpTools({ + apiKey: readOnlyPrincipal.apiKey, + organizationId: organization.id, + requestHeaders: new Headers(), + userId: null, + }) + .find((tool) => tool.name === "reply_to_investigation") + ?.handler({ + body: "I should not be able to reply.", + investigationId: insightId, + replyId: "mcp-denied-request", + }); + expect(denied?.isError).toBe(true); + expect(denied?.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining('"code":"unauthorized"'), + }); + }); + + iit("keeps investigation replies read-only for viewers", async () => { + const viewer = await signUp(); + const member = await signUp(); + const organization = await insertOrganization(); + await addToOrganization(viewer.id, organization.id, "viewer"); + await addToOrganization(member.id, organization.id, "member"); + const website = await insertWebsite({ organizationId: organization.id }); + const insightId = randomUUIDv7(); + await db().insert(analyticsInsights).values( + insightRow({ + id: insightId, + organizationId: organization.id, + subjectKey: "goal:signup", + websiteId: website.id, + }) + ); + await db().insert(insightObservations).values({ + asOf: new Date("2026-01-10T00:00:00.000Z"), + id: randomUUIDv7(), + insightId, + organizationId: organization.id, + outcome: investigationOutcome("watch"), + recheckAt: new Date("2026-01-17T00:00:00.000Z"), + signal: signal("goal:signup"), + signalKey: "goal:signup", + websiteId: website.id, + }); + + await expectCode( + call(appRouter.insights.reply, userContext(viewer, organization.id))({ + body: "Viewer context", + insightId, + }), + "FORBIDDEN" + ); + expect(await db().select().from(insightReplies)).toHaveLength(0); + + const failedReplyId = randomUUIDv7(); + await db().insert(insightReplies).values({ + authorId: viewer.id, + authorName: "Viewer", + body: "Retry this", + createdAt: new Date("2026-01-10T00:00:00.000Z"), + id: failedReplyId, + insightId, + status: "failed", + }); + const newerReplyId = randomUUIDv7(); + await db().insert(insightReplies).values({ + authorId: member.id, + authorName: "Member", + body: "Newer context", + createdAt: new Date("2026-01-11T00:00:00.000Z"), + id: newerReplyId, + insightId, + status: "succeeded", + }); + await expectCode( + call( + appRouter.insights.retryReply, + userContext(viewer, organization.id) + )({ replyId: failedReplyId }), + "FORBIDDEN" + ); + expect( + ( + await db() + .select({ status: insightReplies.status }) + .from(insightReplies) + .where(eq(insightReplies.id, failedReplyId)) + )[0]?.status + ).toBe("failed"); + expect( + await getInsightsQueue().getJob(insightsResumeJobId(failedReplyId)) + ).toBeUndefined(); + await expectCode( + call( + appRouter.insights.retryReply, + userContext(member, organization.id) + )({ replyId: failedReplyId }), + "BAD_REQUEST" + ); + expect( + await getInsightsQueue().getJob(insightsResumeJobId(failedReplyId)) + ).toBeUndefined(); + await db() + .delete(insightReplies) + .where(eq(insightReplies.id, newerReplyId)); + + const retried = await call( + appRouter.insights.retryReply, + userContext(member, organization.id) + )({ replyId: failedReplyId }); + expect(retried.status).toBe("queued"); + expect( + (await getInsightsQueue().getJob(insightsResumeJobId(failedReplyId)))?.data + ).toEqual({ replyId: failedReplyId }); + }); + + iit("does not expose or mutate another organization's investigation", async () => { + const owner = await signUp(); + const outsider = await signUp(); + const organization = await insertOrganization(); + const outsiderOrganization = await insertOrganization(); + await addToOrganization(owner.id, organization.id, "owner"); + await addToOrganization(outsider.id, outsiderOrganization.id, "owner"); + const website = await insertWebsite({ organizationId: organization.id }); + const insightId = randomUUIDv7(); + await db().insert(analyticsInsights).values( + insightRow({ + id: insightId, + organizationId: organization.id, + subjectKey: "goal:purchase", + websiteId: website.id, + }) + ); + const unavailable = await call( + appRouter.insights.getById, + userContext(owner, organization.id) + )({ insightId }); + expect(unavailable.canReply).toBe(false); + expect(unavailable.insight).toBeNull(); + + const context = userContext(outsider, outsiderOrganization.id); + const hidden = await call(appRouter.insights.getById, context)({ insightId }); + expect(hidden).toEqual({ + canReply: false, + insight: null, + timeline: [], + }); + await expectCode( + call(appRouter.insights.reply, context)({ body: "Not mine", insightId }), + "FORBIDDEN" + ); + expect(await db().select().from(insightReplies)).toHaveLength(0); + }); +}); + +function insightRow(input: { + id: string; + organizationId: string; + subjectKey: string; + websiteId: string; +}): typeof analyticsInsights.$inferInsert { + return { + id: input.id, + organizationId: input.organizationId, + websiteId: input.websiteId, + title: "Signup conversion fell", + description: "Signup conversion fell from 40% to 20%.", + severity: "warning", + sentiment: "negative", + changePercent: -50, + dedupeKey: `${input.websiteId}|${input.subjectKey}`, + subjectKey: input.subjectKey, + timezone: "UTC", + }; +} + +function signal(signalKey: string) { + return { + signalKey, + entity: { type: "goal" as const, id: "signup", label: "Signup" }, + metric: { + label: "Signup conversion", + current: 20, + previous: 40, + format: "percent" as const, + }, + changePercent: -50, + severity: "warning" as const, + sentiment: "negative" as const, + period: { + current: { from: "2026-01-04", to: "2026-01-10" }, + previous: { from: "2025-12-28", to: "2026-01-03" }, + }, + }; +} diff --git a/apps/api/src/routes/mcp.ts b/apps/api/src/routes/mcp.ts index 8fa6d2aa66..752556cc1d 100644 --- a/apps/api/src/routes/mcp.ts +++ b/apps/api/src/routes/mcp.ts @@ -1,6 +1,8 @@ import { + getAccessibleWebsiteIds, getApiKeyFromHeader, hasKeyScope, + hasWebsiteScope, isApiKeyPresent, } from "@databuddy/api-keys/resolve"; import { @@ -13,12 +15,25 @@ import { Elysia } from "elysia"; const PROTECTED_RESOURCE_METADATA_URL = `${config.urls.api}/.well-known/oauth-protected-resource`; +function canReadMcp( + apiKey: NonNullable>> +) { + return ( + hasKeyScope(apiKey, "read:data") || + getAccessibleWebsiteIds(apiKey).some((websiteId) => + hasWebsiteScope(apiKey, websiteId, "read:data") + ) + ); +} + async function handleMcpRequest({ request, user, apiKey, + organizationId, }: { apiKey: Awaited> | null; + organizationId: string | null; request: Request; user: { id: string } | null; }) { @@ -27,22 +42,26 @@ async function handleMcpRequest({ requestHeaders: request.headers, userId: user?.id ?? null, apiKey, + organizationId, }); } export const mcp = new Elysia({ name: "mcp" }) .derive(async ({ request }) => { const hasApiKey = isApiKeyPresent(request.headers); - const [apiKey, session] = await Promise.all([ - hasApiKey ? getApiKeyFromHeader(request.headers) : null, - auth.api.getSession({ headers: request.headers }), - ]); + const apiKey = hasApiKey + ? await getApiKeyFromHeader(request.headers) + : null; + const session = hasApiKey + ? null + : await auth.api.getSession({ headers: request.headers }); - if (apiKey && !hasKeyScope(apiKey, "read:data")) { + if (hasApiKey && !(apiKey && canReadMcp(apiKey))) { return { user: null, apiKey: null, isAuthenticated: false, + organizationId: null, }; } @@ -51,6 +70,8 @@ export const mcp = new Elysia({ name: "mcp" }) user, apiKey, isAuthenticated: Boolean(user ?? apiKey), + organizationId: + apiKey?.organizationId ?? session?.session.activeOrganizationId ?? null, }; }) .onBeforeHandle(async ({ request, isAuthenticated, set }) => { @@ -63,26 +84,26 @@ export const mcp = new Elysia({ name: "mcp" }) }) .all( "/v1/mcp", - async ({ request, user, apiKey }) => - await handleMcpRequest({ request, user, apiKey }) + async ({ request, user, apiKey, organizationId }) => + await handleMcpRequest({ request, user, apiKey, organizationId }) ) .all( "/v1/mcp/", - async ({ request, user, apiKey }) => - await handleMcpRequest({ request, user, apiKey }) + async ({ request, user, apiKey, organizationId }) => + await handleMcpRequest({ request, user, apiKey, organizationId }) ) .all( "/mcp", - async ({ request, user, apiKey }) => - await handleMcpRequest({ request, user, apiKey }) + async ({ request, user, apiKey, organizationId }) => + await handleMcpRequest({ request, user, apiKey, organizationId }) ) .all( "/mcp/", - async ({ request, user, apiKey }) => - await handleMcpRequest({ request, user, apiKey }) + async ({ request, user, apiKey, organizationId }) => + await handleMcpRequest({ request, user, apiKey, organizationId }) ) .all( "/.well-known/mcp", - async ({ request, user, apiKey }) => - await handleMcpRequest({ request, user, apiKey }) + async ({ request, user, apiKey, organizationId }) => + await handleMcpRequest({ request, user, apiKey, organizationId }) ); diff --git a/apps/basket/src/lib/event-service.ts b/apps/basket/src/lib/event-service.ts index b54bcb3344..d4da14945f 100644 --- a/apps/basket/src/lib/event-service.ts +++ b/apps/basket/src/lib/event-service.ts @@ -240,9 +240,7 @@ export function insertOutgoingLink( }); } -export function insertTrackEventsBatch( - events: EventsInsert[] -): Promise { +export function insertTrackEventsBatch(events: EventsInsert[]): Promise { return record("insertTrackEventsBatch", async () => { if (events.length === 0) { return; diff --git a/apps/dashboard/app/(main)/home/_components/investigations-section.tsx b/apps/dashboard/app/(main)/home/_components/investigations-section.tsx new file mode 100644 index 0000000000..d791f5f90c --- /dev/null +++ b/apps/dashboard/app/(main)/home/_components/investigations-section.tsx @@ -0,0 +1,176 @@ +"use client"; + +import Link from "next/link"; +import { InvestigationRow } from "@/app/(main)/insights/_components/investigation-row"; +import type { Insight } from "@/lib/insight-api"; +import { cn } from "@/lib/utils"; +import { + ArrowClockwiseIcon, + LightbulbIcon, + WarningCircleIcon, +} from "@databuddy/ui/icons"; +import { Button, Card, Skeleton } from "@databuddy/ui"; + +function InvestigationSkeleton({ wide }: { wide?: boolean }) { + return ( +
+ +
+
+
+ + +
+ +
+ +
+
+ ); +} + +function InvestigationsLoadingState() { + return ( +
+
+
+ +
+
+

+ Loading investigations… +

+

+ Fetching cases from the last completed analysis +

+
+
+ + +
+ ); +} + +function InvestigationsEmptyState() { + return ( +
+
+ +
+
+

+ No investigations yet +

+

+ Databuddy has not opened a case from the latest analysis +

+
+
+ ); +} + +function InvestigationsErrorState({ + onRetryAction, +}: { + onRetryAction: () => void; +}) { + return ( +
+
+ +
+
+

+ Couldn't load investigations +

+

+ Stored investigations couldn't be loaded +

+
+ +
+ ); +} + +interface InvestigationsSectionProps { + insights: Insight[]; + isError?: boolean; + isFetching?: boolean; + isLoading?: boolean; + onRefreshAction: () => void; +} + +export function InvestigationsSection({ + insights, + isLoading, + isFetching, + isError, + onRefreshAction, +}: InvestigationsSectionProps) { + const showInsights = !(isLoading || isError) && insights.length > 0; + + return ( + + +
+
+ + Investigations +
+
+ {!(isLoading || isError) && ( +
+ {showInsights && ( + + {insights.length} {insights.length === 1 ? "case" : "cases"} + + )} + + View all + + +
+ )} +
+ {isLoading && } + {!isLoading && isError && ( + + )} + {!(isLoading || isError || showInsights) && } + {showInsights && ( +
+ {insights.map((insight) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/dashboard/app/(main)/home/_components/smart-insights-section.tsx b/apps/dashboard/app/(main)/home/_components/smart-insights-section.tsx deleted file mode 100644 index 2c360d1719..0000000000 --- a/apps/dashboard/app/(main)/home/_components/smart-insights-section.tsx +++ /dev/null @@ -1,221 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useState } from "react"; -import { InsightCard } from "@/app/(main)/insights/_components/insight-card"; -import type { Insight } from "@/lib/insight-types"; -import { cn } from "@/lib/utils"; -import { - ArrowClockwiseIcon, - LightbulbIcon, - WarningCircleIcon, -} from "@databuddy/ui/icons"; -import { Button, Card, Skeleton } from "@databuddy/ui"; - -function InsightRowWrapper({ insight }: { insight: Insight }) { - const [expanded, setExpanded] = useState(false); - return ( - setExpanded((prev) => !prev)} - variant="compact" - /> - ); -} - -function InsightSkeleton({ wide }: { wide?: boolean }) { - return ( -
- -
-
-
- - -
- -
- -
-
- ); -} - -function InsightsLoadingState() { - return ( -
-
-
- -
-
-

- Loading stored findings… -

-

- Fetching findings from the last completed analysis -

-
-
- - -
- ); -} - -function InsightsEmptyState() { - return ( -
-
- -
-
-

- No priority findings -

-

- No stored priority findings from the last completed analysis -

-
-
- ); -} - -function InsightsErrorState({ onRetryAction }: { onRetryAction?: () => void }) { - return ( -
-
- -
-
-

- Couldn't load findings -

-

- Stored findings couldn't be loaded -

-
- {onRetryAction && ( - - )} -
- ); -} - -interface InsightsSectionProps { - insights: Insight[]; - isError?: boolean; - isFetching?: boolean; - isLoading?: boolean; - onRefreshAction?: () => void; - variant?: "compact" | "full"; -} - -export function SmartInsightsSection({ - insights, - isLoading, - isFetching, - isError, - onRefreshAction, - variant = "compact", -}: InsightsSectionProps) { - if (isLoading) { - return ( - - -
- - Findings -
-
- -
- ); - } - - if (isError) { - return ( - - -
- - Findings -
-
- -
- ); - } - - const showInsights = insights.length > 0; - const showEmpty = insights.length === 0; - - return ( - - -
-
- - Findings -
-
-
- {showInsights && ( - - {insights.length} {insights.length === 1 ? "finding" : "findings"} - - )} - - View all - - {onRefreshAction && ( - - )} -
-
- {showEmpty && } - {showInsights && ( -
- {insights.map((insight) => ( - - ))} -
- )} -
- ); -} diff --git a/apps/dashboard/app/(main)/home/hooks/use-smart-insights.ts b/apps/dashboard/app/(main)/home/hooks/use-smart-insights.ts deleted file mode 100644 index 03f56c4c8b..0000000000 --- a/apps/dashboard/app/(main)/home/hooks/use-smart-insights.ts +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { useInsightsFeed } from "@/app/(main)/insights/hooks/use-insights-feed"; - -const INSIGHTS_MAX = 20; - -export function useSmartInsights() { - const feed = useInsightsFeed(); - const insights = useMemo( - () => feed.insights.slice(0, INSIGHTS_MAX), - [feed.insights] - ); - return { - insights, - isLoading: feed.isLoading, - isRefreshing: feed.isRefreshing, - isFetching: feed.isFetching, - isError: feed.isError, - refetch: feed.refetch, - }; -} diff --git a/apps/dashboard/app/(main)/home/page.tsx b/apps/dashboard/app/(main)/home/page.tsx index 6f7b526744..7315171f86 100644 --- a/apps/dashboard/app/(main)/home/page.tsx +++ b/apps/dashboard/app/(main)/home/page.tsx @@ -8,15 +8,16 @@ import { useWebsites } from "@/hooks/use-websites"; import { cn } from "@/lib/utils"; import { WebsiteCard } from "../websites/_components/website-card"; import { MonitorsSection } from "./_components/monitors-section"; -import { SmartInsightsSection } from "./_components/smart-insights-section"; +import { InvestigationsSection } from "./_components/investigations-section"; import { SummaryStats } from "./_components/summary-stats"; +import { useInsightsFeed } from "../insights/hooks/use-insights-feed"; import { useGlobalAnalytics } from "./hooks/use-global-analytics"; import { usePulseStatus } from "./hooks/use-pulse-status"; -import { useSmartInsights } from "./hooks/use-smart-insights"; import { ArrowClockwiseIcon, GlobeIcon, PlusIcon } from "@databuddy/ui/icons"; import { Button, Card, EmptyState, Skeleton } from "@databuddy/ui"; const WEBSITE_PREVIEW_LIMIT = 3; +const INSIGHT_PREVIEW_LIMIT = 20; function WebsiteCardSkeleton() { return ( @@ -74,14 +75,15 @@ export default function HomePage() { refetch: refetchMonitors, } = usePulseStatus(); + const insightFeed = useInsightsFeed(); const { - insights, isLoading: isInsightsLoading, isRefreshing: isInsightsRefreshing, isFetching: isInsightsFetching, isError: isInsightsError, refetch: refetchInsights, - } = useSmartInsights(); + } = insightFeed; + const insights = insightFeed.insights.slice(0, INSIGHT_PREVIEW_LIMIT); const handleRefetch = async () => { await Promise.all([ @@ -153,7 +155,7 @@ export default function HomePage() { />
- ; +type InvestigationNext = InvestigationItem["outcome"]["next"]; export default function InsightDetailPage() { - return ; + const params = useParams(); + const router = useRouter(); + const insightId = typeof params.id === "string" ? params.id : ""; + + const { data, isLoading, isError } = useQuery({ + ...insightQueries.byId(insightId || undefined), + refetchInterval: (query) => + query.state.data?.timeline.some( + (item) => + item.kind === "reply" && + (item.status === "queued" || item.status === "running") + ) + ? 2000 + : false, + }); + + const insight = data?.insight ?? null; + + return ( +
+ +

Investigation

+
+ +
+ + + All investigations + + + {isLoading && ( + +
+ + + +
+
+ )} + + {!isLoading && insight && ( + +
+
+

+ {insight.websiteName ?? insight.websiteDomain} +

+ + {insight.status === "resolved" ? "Resolved" : "Open"} + +
+

+ {insight.title} +

+
+ +
+ )} + + {!(isLoading || insight) && ( + router.push("/insights"), + }} + description={ + isError + ? "This investigation is unavailable, or it belongs to a workspace you can't access." + : "This investigation no longer exists." + } + icon={} + title="Investigation not available" + variant="minimal" + /> + )} +
+
+ ); +} + +function CaseActivity({ + canReply, + insightId, + items, +}: { + canReply: boolean; + insightId: string; + items: TimelineItem[]; +}) { + const queryClient = useQueryClient(); + const retry = useMutation({ + ...orpc.insights.retryReply.mutationOptions(), + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Could not retry"); + }, + onSuccess: (result) => { + queryClient.invalidateQueries({ + queryKey: insightQueries.byId(insightId).queryKey, + }); + if (result.status === "failed") { + toast.error( + "The reply was saved, but the investigation could not start" + ); + } else { + toast.success("Investigation resumed"); + } + }, + }); + const active = + retry.isPending || + items.some( + (item) => + item.kind === "reply" && + (item.status === "queued" || item.status === "running") + ); + const latestReplyId = items.findLast((item) => item.kind === "reply")?.id; + + return ( +
+
+

+ Activity +

+

+ Investigation history and context from your team. +

+
+ +
    + {items.map((item, index) => ( + retry.mutate({ replyId }) + : undefined + } + retrying={retry.isPending && retry.variables.replyId === item.id} + /> + ))} +
+ + {canReply && } +
+ ); +} + +function TimelineEntry({ + isLast, + item, + onRetry, + retrying, +}: { + isLast: boolean; + item: TimelineItem; + onRetry?: (replyId: string) => void; + retrying: boolean; +}) { + return ( +
  • +
    + {!isLast && ( + + )} + {item.kind === "reply" ? ( + + + + ) : ( + + + + )} +
    + +
    +
    +
    + + {item.kind === "reply" ? item.author : "Databuddy"} + + + · + + +
    +
    + {item.kind === "reply" ? ( + <> +

    + {item.body} +

    + {(item.status === "queued" || item.status === "running") && ( +

    + + {item.status === "queued" + ? "Queued for investigation…" + : "Databuddy is investigating…"} +

    + )} + {item.status === "failed" && ( +
    + Investigation failed. + {onRetry && ( + + )} +
    + )} + + ) : ( + + )} +
    +
  • + ); +} + +function InvestigationActivity({ item }: { item: InvestigationItem }) { + const { outcome } = item; + + return ( +
    +
    +

    {item.subject}

    +

    + {formatPeriod(item.period.current)} compared with{" "} + {formatPeriod(item.period.previous)} +

    +
    + +
    +

    + {outcome.title} +

    +

    + {outcome.summary} +

    +
    + + {(outcome.impact || outcome.rootCause) && ( +
    + {outcome.impact && ( +
    +
    + Impact +
    +
    + {outcome.impact} +
    +
    + )} + {outcome.rootCause && ( +
    +
    + Cause +
    +
    + {outcome.rootCause} +
    +
    + )} +
    + )} + +
    +

    + Evidence +

    +
      + {outcome.evidence.map((entry) => ( +
    • + + • + + {entry} +
    • + ))} +
    +
    + + +
    + ); +} + +function NextStep({ next }: { next: InvestigationNext }) { + const copy = nextCopy(next); + return ( +
    +

    + {copy.label} +

    +

    + {copy.body} +

    + {copy.detail && ( +

    + {copy.detail} +

    + )} +
    + ); +} + +function ReplyComposer({ + disabled, + insightId, +}: { + disabled: boolean; + insightId: string; +}) { + const queryClient = useQueryClient(); + const [body, setBody] = useState(""); + const replyMutation = useMutation({ + ...orpc.insights.reply.mutationOptions(), + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Could not add reply" + ); + }, + onSuccess: (data) => { + setBody(""); + queryClient.invalidateQueries({ + queryKey: insightQueries.byId(insightId).queryKey, + }); + if (data.reply.status === "failed") { + toast.error("Reply saved, but the investigation could not start"); + } else { + toast.success("Databuddy is investigating your reply"); + } + }, + }); + + const submitReply = (event: FormEvent) => { + event.preventDefault(); + const trimmed = body.trim(); + if (!trimmed) { + return; + } + replyMutation.mutate({ body: trimmed, insightId }); + }; + + return ( +
    + + Add context +