diff --git a/.agents/skills/databuddy-internal/SKILL.md b/.agents/skills/databuddy-internal/SKILL.md index 38dc9779f..121a2fec4 100644 --- a/.agents/skills/databuddy-internal/SKILL.md +++ b/.agents/skills/databuddy-internal/SKILL.md @@ -21,6 +21,7 @@ Keep additions **minimal**: one bullet, a new `rg` hint, or a routing note—eno - 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. - `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` - `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. diff --git a/apps/api/package.json b/apps/api/package.json index 7a489d0fd..ae2578b9e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -34,7 +34,7 @@ "@orpc/openapi": "^1.14.0", "@orpc/server": "^1.14.0", "@orpc/zod": "^1.14.0", - "ai": "^6.0.154", + "ai": "^6.0.188", "autumn-js": "catalog:", "bullmq": "^5.66.5", "dayjs": "^1.11.19", diff --git a/apps/api/src/billing/autumn.ts b/apps/api/src/billing/autumn.ts index 58e563010..c09411ad6 100644 --- a/apps/api/src/billing/autumn.ts +++ b/apps/api/src/billing/autumn.ts @@ -48,69 +48,64 @@ async function stripPrivilegedBody(request: Request): Promise { } const text = await request.text(); - if (!text) { - return new Request(request.url, request); - } - - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - return new Request(request.url, { - method: request.method, - headers: request.headers, - body: text, - }); + let body: string | null = text || null; + if (text) { + try { + body = JSON.stringify(sanitize(JSON.parse(text))); + } catch { + body = text; + } } return new Request(request.url, { method: request.method, headers: request.headers, - body: JSON.stringify(sanitize(parsed)), + body, }); } +const autumn = autumnHandler({ identify: identifyAutumnCustomer }); + export async function handleAutumnRequest(request: Request) { const sanitized = await stripPrivilegedBody(request); - return autumnHandler({ - identify: identifyAutumnCustomer, - })(withAutumnApiPath(sanitized)); + return autumn(withAutumnApiPath(sanitized)); } -async function identifyAutumnCustomer(request: Request) { +async function loadSession(request: Request) { try { - const session = await auth.api.getSession({ headers: request.headers }); - if (!session?.user) { - return null; - } + return await auth.api.getSession({ headers: request.headers }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + useLogger().error(err, { + autumn: "identify", + autumn_stage: "getSession", + }); + throw err; + } +} + +async function identifyAutumnCustomer(request: Request) { + const session = await loadSession(request); + if (!session?.user) { + return null; + } - const activeOrgId = ( - session.session as { activeOrganizationId?: string | null } - )?.activeOrganizationId; + const activeOrgId = session.session.activeOrganizationId ?? null; - if (activeOrgId) { - const role = await getMemberRole(session.user.id, activeOrgId); - if (role !== "owner" && role !== "admin") { - return null; - } + if (activeOrgId) { + const role = await getMemberRole(session.user.id, activeOrgId); + if (role !== "owner" && role !== "admin") { + return null; } + } - const customerId = await getBillingCustomerId(session.user.id, activeOrgId); + const customerId = await getBillingCustomerId(session.user.id, activeOrgId); - return { - customerId, - customerData: { - name: session.user.name, - email: session.user.email, - }, - }; - } catch (error) { - useLogger().error( - error instanceof Error ? error : new Error(String(error)), - { - autumn: "identify", - } - ); - return null; - } + return { + customerId, + customerData: { + name: session.user.name, + email: session.user.email, + }, + }; } diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index 999c03d23..75df5fb03 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -1,7 +1,5 @@ import { - getAccessibleWebsiteIds, getApiKeyFromHeader, - hasGlobalAccess, hasKeyScope, isApiKeyPresent, } from "@databuddy/api-keys/resolve"; @@ -60,7 +58,7 @@ import { Elysia, t } from "elysia"; import { log, parseError } from "evlog"; import { useLogger } from "evlog/elysia"; import { - checkWebsiteReadPermissionCached, + type AgentContextSnapshotResult, getAgentContextSnapshot, getMemoryContextCached, shouldLoadMemoryContext, @@ -69,7 +67,7 @@ import { getAILogger } from "../lib/ai-logger"; import { trackAgentEvent } from "../lib/databuddy"; import { getResolvedAuth } from "../lib/auth-wide-event"; import { captureError, mergeWideEvent } from "../lib/tracing"; -import { validateWebsite } from "../lib/website-utils"; +import { getAccessibleWebsites } from "../lib/accessible-websites"; function jsonError(status: number, code: string, message: string): Response { return new Response( @@ -228,8 +226,12 @@ const UIMessageSchema = t.Object({ ), }); +const MAX_MENTIONS = 20; + const AgentRequestSchema = t.Object({ - websiteId: t.String(), + organizationId: t.Optional(t.String()), + websiteId: t.Optional(t.String()), + mentions: t.Optional(t.Array(t.String(), { maxItems: MAX_MENTIONS })), messages: t.Array(UIMessageSchema, { maxItems: MAX_MESSAGES }), id: t.Optional(t.String()), timezone: t.Optional(t.String()), @@ -475,25 +477,31 @@ function createPlainTextStreamResponse( export const agent = new Elysia({ prefix: "/v1/agent" }) .derive(async ({ request }) => { const preResolved = getResolvedAuth(request.headers); - let user = preResolved?.session?.user ?? null; + let session = preResolved?.session ?? null; let apiKey = preResolved?.apiKeyResult?.key ?? null; if (!preResolved) { const hasApiKey = isApiKeyPresent(request.headers); - const [resolvedApiKey, session] = await Promise.all([ + const [resolvedApiKey, freshSession] = await Promise.all([ hasApiKey ? getApiKeyFromHeader(request.headers) : null, auth.api.getSession({ headers: request.headers }), ]); - user = session?.user ?? null; + session = freshSession; apiKey = resolvedApiKey; } + const user = session?.user ?? null; + const activeOrganizationId = + (session?.session as { activeOrganizationId?: string | null } | undefined) + ?.activeOrganizationId ?? null; + const validApiKey = apiKey && hasKeyScope(apiKey, "read:data") ? apiKey : null; return { user, apiKey: validApiKey, + activeOrganizationId, isAuthenticated: Boolean(user ?? validApiKey), }; }) @@ -591,14 +599,13 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) ) .post( "/chat", - function agentChat({ body, user, apiKey, request }) { + function agentChat({ body, user, apiKey, activeOrganizationId, request }) { return (async () => { const chatId = body.id ?? generateId(); const t0 = performance.now(); let organizationId: string | null = null; mergeWideEvent({ - agent_website_id: body.websiteId, agent_user_id: user?.id ?? "unknown", agent_chat_id: chatId, }); @@ -609,8 +616,27 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) } const userId = user?.id ?? `apikey:${apiKey?.id}`; + organizationId = + body.organizationId ?? + activeOrganizationId ?? + apiKey?.organizationId ?? + null; + + if (!organizationId) { + return jsonError( + 400, + "WORKSPACE_REQUIRED", + "No active workspace. Select an organization and try again." + ); + } + + mergeWideEvent({ + organization_id: organizationId, + ...(body.websiteId ? { agent_website_id: body.websiteId } : {}), + }); + const rl = await ratelimit( - `agent:chat:${userId}:${body.websiteId}`, + `agent:chat:${userId}:${organizationId}`, 30, 60 ); @@ -622,50 +648,15 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) ); } - const websiteValidation = await timeAgentPhase( - "validate_website", - validateWebsite(body.websiteId) - ); - - if (!(websiteValidation.success && websiteValidation.website)) { - return jsonError( - 404, - "WEBSITE_NOT_FOUND", - websiteValidation.error ?? "Website not found" - ); - } - - const { website } = websiteValidation; - organizationId = website.organizationId ?? null; - - const resolvePermission = (): Promise => { - if (apiKey) { - if (hasGlobalAccess(apiKey)) { - return Promise.resolve( - apiKey.organizationId != null && - apiKey.organizationId === website.organizationId - ); - } - return Promise.resolve( - getAccessibleWebsiteIds(apiKey).includes(body.websiteId) - ); - } - if (!(user && website.organizationId)) { - return Promise.resolve(false); - } - return checkWebsiteReadPermissionCached( - user.id, - website.organizationId, - request.headers - ); - }; - const permissionCheck = timeAgentPhase( - "permission_check", - resolvePermission() - ); - - const [hasPermission, billingCustomerId] = await Promise.all([ - permissionCheck, + const [accessibleWebsites, billingCustomerId] = await Promise.all([ + timeAgentPhase( + "accessible_websites", + getAccessibleWebsites({ + user: user ? { id: user.id } : null, + apiKey, + organizationId, + }) + ), timeAgentPhase( "resolve_billing", resolveAgentBillingCustomerId({ @@ -676,30 +667,53 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) ), ]); - if (!hasPermission) { + if (accessibleWebsites.length === 0) { return jsonError( 403, "ACCESS_DENIED", - "Access denied to this website" + "No accessible websites in this workspace" ); } + let defaultWebsiteId: string | null = null; + let defaultDomain: string | undefined; + if (body.websiteId) { + const match = accessibleWebsites.find( + (w) => w.id === body.websiteId + ); + if (!match) { + return jsonError( + 403, + "ACCESS_DENIED", + "Access denied to this website" + ); + } + defaultWebsiteId = match.id; + defaultDomain = match.domain ?? undefined; + } + + const mentionedWebsites = (body.mentions ?? []) + .map((id) => accessibleWebsites.find((w) => w.id === id)) + .filter((w): w is (typeof accessibleWebsites)[number] => + Boolean(w) + ); + if (body.id) { const existingChat = await db.query.agentChats.findFirst({ where: { id: chatId }, - columns: { userId: true, websiteId: true }, + columns: { userId: true, organizationId: true }, }); if ( existingChat && (existingChat.userId !== userId || - existingChat.websiteId !== body.websiteId) + (existingChat.organizationId != null && + existingChat.organizationId !== organizationId)) ) { return jsonError(403, "ACCESS_DENIED", "Access denied to chat"); } } const timezone = body.timezone ?? "UTC"; - const domain = website.domain ?? "unknown"; const lastMessage = getLastMessagePreview(body.messages); const agentTier: AgentTier = body.tier ?? "balanced"; @@ -714,7 +728,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) action: "chat_started", source: "dashboard", agent_type: AGENT_TYPE, - website_id: body.websiteId, + website_id: defaultWebsiteId, organization_id: organizationId, user_id: userId, }); @@ -722,7 +736,8 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) useLogger().info("Creating agent", { agent: { type: AGENT_TYPE, - websiteId: body.websiteId, + websiteId: defaultWebsiteId, + accessibleWebsiteCount: accessibleWebsites.length, messageCount: body.messages.length, lastMessage, }, @@ -735,7 +750,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) captureError(err, { agent_credit_check_error: true, agent_chat_id: chatId, - agent_website_id: body.websiteId, + ...(defaultWebsiteId + ? { agent_website_id: defaultWebsiteId } + : {}), }); return true; }) @@ -759,28 +776,41 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) "memory_enrich", Promise.all([ creditsCheck, - loadMemoryContext + loadMemoryContext && defaultWebsiteId ? optionalAgentContext( "memory", - getMemoryContextCached(lastMessage, userId, body.websiteId), + getMemoryContextCached( + lastMessage, + userId, + defaultWebsiteId + ), EMPTY_MEMORY_CONTEXT, AGENT_MEMORY_CONTEXT_TIMEOUT_MS, { agent_chat_id: chatId, - agent_website_id: body.websiteId, + agent_website_id: defaultWebsiteId, } ) : Promise.resolve(EMPTY_MEMORY_CONTEXT), - optionalAgentContext( - "enrichment", - getAgentContextSnapshot(userId, body.websiteId, organizationId), - { context: "", source: "error" }, - AGENT_ENRICHMENT_CONTEXT_TIMEOUT_MS, - { - agent_chat_id: chatId, - agent_website_id: body.websiteId, - } - ), + defaultWebsiteId + ? optionalAgentContext( + "enrichment", + getAgentContextSnapshot( + userId, + defaultWebsiteId, + organizationId + ), + { context: "", source: "error" }, + AGENT_ENRICHMENT_CONTEXT_TIMEOUT_MS, + { + agent_chat_id: chatId, + agent_website_id: defaultWebsiteId, + } + ) + : Promise.resolve({ + context: "", + source: "miss", + }), ]) ); mergeWideEvent({ @@ -804,8 +834,11 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) const config = createAgentConfig( { userId, - websiteId: body.websiteId, - websiteDomain: domain, + organizationId: organizationId ?? undefined, + websiteId: defaultWebsiteId ?? undefined, + websiteDomain: defaultDomain, + defaultWebsiteId, + accessibleWebsites, timezone, chatId, requestHeaders: request.headers, @@ -816,9 +849,20 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) modelOverride ); + const mentionContext = + mentionedWebsites.length > 0 + ? `\nThe user referenced these websites in their message. Prioritize them when choosing which website(s) to query:\n${mentionedWebsites + .map( + (w) => + `- ${w.name ?? w.domain ?? w.id} (id: ${w.id}${w.domain ? `, domain: ${w.domain}` : ""})` + ) + .join("\n")}\n` + : ""; + const extras = [ memoryCtx ? formatMemoryForPrompt(memoryCtx) : "", enrichment.context, + mentionContext, ] .filter(Boolean) .join("\n\n"); @@ -862,14 +906,18 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) const dashboardTelemetryMetadata: Record = { source: "dashboard", userId, - websiteId: body.websiteId, - websiteDomain: domain, chatId, agentType: AGENT_TYPE, timezone, "tcc.sessionId": chatId, "tcc.conversational": "true", }; + if (defaultWebsiteId) { + dashboardTelemetryMetadata.websiteId = defaultWebsiteId; + } + if (defaultDomain) { + dashboardTelemetryMetadata.websiteDomain = defaultDomain; + } if (organizationId) { dashboardTelemetryMetadata.organizationId = organizationId; } @@ -880,7 +928,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) metadata: dashboardTelemetryMetadata, }); - if (isMemoryEnabled() && lastMessage) { + if (isMemoryEnabled() && lastMessage && defaultWebsiteId) { storeConversation( [{ role: "user", content: lastMessage }], userId, @@ -889,9 +937,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) metadata: { source: "dashboard", }, - websiteId: body.websiteId, + websiteId: defaultWebsiteId, conversationId: chatId, - domain, + domain: defaultDomain ?? "unknown", } ); } @@ -940,7 +988,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) modelId: modelNames[modelKey], source: "dashboard", agentType: AGENT_TYPE, - websiteId: body.websiteId, + websiteId: defaultWebsiteId ?? undefined, organizationId, userId: persistedUserId ?? null, chatId, @@ -951,14 +999,17 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) captureError(usageError, { agent_usage_telemetry_error: true, agent_chat_id: chatId, - agent_website_id: body.websiteId, + ...(defaultWebsiteId + ? { agent_website_id: defaultWebsiteId } + : {}), }); }); + const streamScope = userId; const streamId = generateId(); - const streamKey = streamBufferKey(body.websiteId, chatId, streamId); + const streamKey = streamBufferKey(streamScope, chatId, streamId); await timeAgentPhase("stream_setup", () => - setActiveStream(body.websiteId, chatId, streamId) + setActiveStream(streamScope, chatId, streamId) ); if (persistedUserId) { @@ -968,7 +1019,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) .insert(agentChats) .values({ id: chatId, - websiteId: body.websiteId, + websiteId: defaultWebsiteId, userId: persistedUserId, organizationId: persistedOrgId, title: fallbackTitle, @@ -987,7 +1038,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) captureError(persistError, { agent_user_message_persist_error: true, agent_chat_id: chatId, - agent_website_id: body.websiteId, + ...(defaultWebsiteId + ? { agent_website_id: defaultWebsiteId } + : {}), }); } } @@ -997,7 +1050,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) originalMessages: validation.data, onFinish: async ({ messages }) => { try { - await clearActiveStream(body.websiteId, chatId, streamId); + await clearActiveStream(streamScope, chatId, streamId); } catch {} if (!persistedUserId) { return; @@ -1007,7 +1060,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) .insert(agentChats) .values({ id: chatId, - websiteId: body.websiteId, + websiteId: defaultWebsiteId, userId: persistedUserId, organizationId: persistedOrgId, title: fallbackTitle, @@ -1035,7 +1088,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) captureError(persistError, { agent_persist_error: true, agent_chat_id: chatId, - agent_website_id: body.websiteId, + ...(defaultWebsiteId + ? { agent_website_id: defaultWebsiteId } + : {}), }); } }, @@ -1068,7 +1123,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) captureError(storageError, { agent_stream_persist_error: true, agent_chat_id: chatId, - agent_website_id: body.websiteId, + ...(defaultWebsiteId + ? { agent_website_id: defaultWebsiteId } + : {}), }); }); return new Response(forClient, { @@ -1087,7 +1144,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) agentType: AGENT_TYPE, phase: "dashboard_chat_stream", userId: user?.id ?? null, - websiteId: body.websiteId, + websiteId: body.websiteId ?? null, }, ...(parsed.fix !== "" && parsed.fix != null ? { fix: parsed.fix } @@ -1103,7 +1160,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) error_message: err.message, error_name: err.name, service: "api", - websiteId: body.websiteId, + websiteId: body.websiteId ?? null, ...(parsed.fix !== "" && parsed.fix != null ? { fix: parsed.fix } : {}), @@ -1120,13 +1177,13 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) error_type: getErrorName(error), organization_id: organizationId, user_id: user?.id ?? null, - website_id: body.websiteId, + website_id: body.websiteId ?? null, }); captureError(error, { agent_error: true, agent_type: AGENT_TYPE, agent_chat_id: chatId, - agent_website_id: body.websiteId, + ...(body.websiteId ? { agent_website_id: body.websiteId } : {}), agent_user_id: user?.id ?? "unknown", error_type: getErrorName(error), }); @@ -1142,16 +1199,16 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) } const chat = await db.query.agentChats.findFirst({ where: { id: params.chatId, userId: user.id }, - columns: { id: true, websiteId: true }, + columns: { id: true }, }); if (!chat) { return new Response(null, { status: 204 }); } - const streamId = await getActiveStream(chat.websiteId, chat.id); + const streamId = await getActiveStream(user.id, chat.id); if (!streamId) { return new Response(null, { status: 204 }); } - const key = streamBufferKey(chat.websiteId, chat.id, streamId); + const key = streamBufferKey(user.id, chat.id, streamId); const abortController = new AbortController(); request.signal?.addEventListener("abort", () => { diff --git a/apps/api/src/routes/webhooks/autumn.ts b/apps/api/src/routes/webhooks/autumn.ts index 96fff5c7d..c71865177 100644 --- a/apps/api/src/routes/webhooks/autumn.ts +++ b/apps/api/src/routes/webhooks/autumn.ts @@ -21,6 +21,8 @@ import { Elysia } from "elysia"; import { useLogger } from "evlog/elysia"; import { Resend } from "resend"; import { Webhook } from "svix"; +// biome-ignore lint/performance/noNamespaceImport: vitest+bun fails to bind zod's named `z` export; namespace import is the reliable form +import * as z from "zod"; import { mergeWideEvent } from "../../lib/tracing"; const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; @@ -32,43 +34,52 @@ const resend = new Resend(process.env.RESEND_API_KEY); const svix = SVIX_SECRET ? new Webhook(SVIX_SECRET) : null; const slack = SLACK_URL ? new SlackProvider({ webhookUrl: SLACK_URL }) : null; -interface LimitReachedData { - customer_id: string; - feature_id: string; - limit_type: "included" | "max_purchase" | "spend_limit"; -} - -interface UsageAlertData { - customer_id: string; - feature_id: string; - usage_alert: { - name?: string; - threshold: number; - threshold_type: string; - }; -} - -type ProductScenario = - | "new" - | "upgrade" - | "downgrade" - | "renew" - | "cancel" - | "expired" - | "past_due" - | "scheduled"; - -interface ProductsUpdatedData { - customer: { - id: string | null; - name: string | null; - email: string | null; - env: string; - products: Array<{ id: string; name: string; status: string }>; - }; - scenario: ProductScenario; - updated_product: { id: string; name: string | null }; -} +const limitReachedSchema = z.object({ + customer_id: z.string(), + feature_id: z.string(), + limit_type: z.enum(["included", "max_purchase", "spend_limit"]), +}); + +const usageAlertSchema = z.object({ + customer_id: z.string(), + feature_id: z.string(), + usage_alert: z.object({ + name: z.string().optional(), + threshold: z.number(), + threshold_type: z.string(), + }), +}); + +const productScenarioSchema = z.enum([ + "new", + "upgrade", + "downgrade", + "renew", + "cancel", + "expired", + "past_due", + "scheduled", +]); + +type ProductScenario = z.infer; + +const productsUpdatedSchema = z.object({ + customer: z.object({ + id: z.string().nullable(), + name: z.string().nullable(), + email: z.string().nullable(), + env: z.string(), + products: z.array( + z.object({ id: z.string(), name: z.string(), status: z.string() }) + ), + }), + scenario: productScenarioSchema, + updated_product: z.object({ id: z.string(), name: z.string().nullable() }), +}); + +type LimitReachedData = z.infer; +type UsageAlertData = z.infer; +type ProductsUpdatedData = z.infer; interface RawAutumnEvent { data: unknown; @@ -386,11 +397,11 @@ function dispatch( ): Promise | WebhookResult { switch (event.type) { case "balances.limit_reached": - return handleLimitReached(event.data as LimitReachedData); + return handleLimitReached(limitReachedSchema.parse(event.data)); case "balances.usage_alert_triggered": - return handleUsageAlert(event.data as UsageAlertData); + return handleUsageAlert(usageAlertSchema.parse(event.data)); case "customer.products.updated": - return handleProductsUpdated(event.data as ProductsUpdatedData); + return handleProductsUpdated(productsUpdatedSchema.parse(event.data)); default: useLogger().warn("Unknown webhook type", { autumn: { type: event.type }, @@ -446,7 +457,18 @@ export const autumnWebhook = new Elysia().post( }); log.info("Autumn webhook", { autumn: { type: event.type } }); - return dispatch(event); + try { + return await dispatch(event); + } catch (error) { + if (error instanceof z.ZodError) { + log.error(new Error("Invalid Autumn webhook payload"), { + autumn: { type: event.type, issues: error.issues }, + }); + set.status = 400; + return { success: false, message: "Invalid event payload" }; + } + throw error; + } }, { parse: "none" } ); diff --git a/apps/basket/src/lib/billing.ts b/apps/basket/src/lib/billing.ts index 3aa51bdf8..9941f7ad1 100644 --- a/apps/basket/src/lib/billing.ts +++ b/apps/basket/src/lib/billing.ts @@ -39,6 +39,16 @@ export function checkAutumnUsage( }); if (!response.allowed) { + log.warn("Event quota exceeded", { + customerId, + featureId, + properties, + billing: { + usage: b?.usage, + granted: b?.granted, + unlimited: b?.unlimited, + }, + }); throw basketErrors.billingLimitExceeded(); } diff --git a/apps/basket/src/lib/event-service.ts b/apps/basket/src/lib/event-service.ts index 05c1246f3..8e2681bf6 100644 --- a/apps/basket/src/lib/event-service.ts +++ b/apps/basket/src/lib/event-service.ts @@ -117,9 +117,6 @@ export function buildTrackEvent( }; } -/** - * Insert a track event (pageview/analytics event) via Kafka - */ export function insertTrackEvent( trackData: any, clientId: string, @@ -180,9 +177,6 @@ export function insertTrackEvent( }); } -/** - * Insert an outgoing link click event into the database - */ export function insertOutgoingLink( linkData: any, clientId: string, @@ -246,9 +240,6 @@ export function insertTrackEventsBatch( }); } -/** - * Insert lean error spans (v2.x format) - */ export function insertErrorSpans( errors: ErrorSpan[], clientId: string @@ -294,10 +285,6 @@ export function insertErrorSpans( }); } -/** - * Insert individual vital metrics (v2.x format) as spans - * Each metric is stored as a separate row - no aggregation - */ export function insertIndividualVitals( vitals: IndividualVital[], clientId: string @@ -341,13 +328,6 @@ export function insertOutgoingLinksBatch( }); } -/** - * Insert organization-scoped custom events - * owner_id: The org or user ID that owns this data (from API key) - * website_id: Optional website scope - * namespace: Optional logical grouping (e.g., 'billing', 'auth', 'api') - * source: Optional origin identifier (e.g., 'backend', 'webhook', 'cli') - */ export function insertCustomEvents( events: Array<{ owner_id: string; diff --git a/apps/basket/src/lib/evlog-basket.ts b/apps/basket/src/lib/evlog-basket.ts index 279265eb4..dfa581f91 100644 --- a/apps/basket/src/lib/evlog-basket.ts +++ b/apps/basket/src/lib/evlog-basket.ts @@ -18,9 +18,6 @@ const pipeline = createDrainPipeline({ const axiomDrain = createAxiomDrain(); -/** - * Batched Axiom drain; call {@link flushBatchedAxiomDrain} on shutdown. - */ const batchedAxiomDrain = pipeline(axiomDrain); const devFsLogsDir = join( @@ -40,9 +37,6 @@ const devFsDrain = useLocalEvlogFiles const DURATION_MS_REGEX = /^([\d.]+)(ms|s)$/; -/** - * Before Axiom: fix `error` string vs object collision; downgrade 4xx to warn. - */ function normalizeWideEventForAxiom(event: Record): void { if (typeof event.error === "string") { event.error_message = event.error; @@ -78,10 +72,6 @@ function parseDurationMs(duration: unknown): number | undefined { : Math.round(Number.parseFloat(match[1])); } -/** - * In development, writes NDJSON wide events to `apps/basket/.evlog/logs/` (analyze-logs skill) - * and still sends to Axiom via the batched pipeline. Production: Axiom only. - */ export async function basketLoggerDrain(ctx: DrainContext): Promise { if (ctx.event.method === "OPTIONS") { return; diff --git a/apps/basket/src/lib/request-validation.ts b/apps/basket/src/lib/request-validation.ts index 788cea889..f6fd33bf0 100644 --- a/apps/basket/src/lib/request-validation.ts +++ b/apps/basket/src/lib/request-validation.ts @@ -50,10 +50,6 @@ export function getWebsiteSecuritySettings( }; } -/** - * Validate incoming request for analytics events. - * Throws basket ingest EvlogErrors on failure; returns `{ error: billing.response }` when quota is exceeded. - */ export function validateRequest( body: unknown, query: unknown, @@ -242,12 +238,6 @@ export function validateRequest( }); } -/** - * Check if request is from a bot - * - ALLOW: Process normally (search engines, social media) - * - TRACK_ONLY: Log to ai_traffic_spans but don't count as pageview (AI crawlers) - * - BLOCK: Reject and log to blocked_traffic (scrapers, malicious bots) - */ export function checkForBot( request: Request, body: unknown, diff --git a/apps/basket/src/lib/structured-errors.ts b/apps/basket/src/lib/structured-errors.ts index 75ba25d10..21371b8f7 100644 --- a/apps/basket/src/lib/structured-errors.ts +++ b/apps/basket/src/lib/structured-errors.ts @@ -1,11 +1,6 @@ import { createError, EvlogError, parseError } from "evlog"; import type { z } from "zod"; -/** - * Structured errors for the basket API (evlog EvlogError). - * Prefer throwing these over ad-hoc Response bodies so the global handler can - * emit consistent JSON and wide-event context. - */ export const basketErrors = { trackPayloadTooLarge: () => createError({ @@ -194,9 +189,6 @@ export function isIngestSchemaValidationError( ); } -/** - * Re-throw EvlogErrors; wrap anything else as a 500 and log it. - */ export function rethrowOrWrap( error: unknown, log?: { error: (err: Error) => void } diff --git a/apps/basket/src/lib/tracing.ts b/apps/basket/src/lib/tracing.ts index 4e5065791..bc641daae 100644 --- a/apps/basket/src/lib/tracing.ts +++ b/apps/basket/src/lib/tracing.ts @@ -1,11 +1,6 @@ import { EvlogError, log } from "evlog"; import { useLogger } from "evlog/elysia"; -/** - * Merge fields into the active request wide event, falling back to a - * global structured log line if there is no active request context - * (background workers, startup, async callbacks, etc.). - */ export function mergeWideEvent(fields: Record): void { try { useLogger().set(fields); @@ -14,11 +9,6 @@ export function mergeWideEvent(fields: Record): void { } } -/** - * Run a named operation and attach its duration (ms) to the active wide event - * as `timing.`. Nested calls accumulate — the wide event ends up with one - * `timing.*` field per `record()` call in the request. - */ export async function record( name: string, fn: () => Promise | T @@ -34,10 +24,6 @@ export async function record( } } -/** - * Attach an error to the active request wide event when inside the evlog - * middleware; otherwise emit a global structured log line. - */ export function captureError( error: unknown, attributes?: Record diff --git a/apps/basket/src/routes/basket.ts b/apps/basket/src/routes/basket.ts index 31512b3fb..abf150004 100644 --- a/apps/basket/src/routes/basket.ts +++ b/apps/basket/src/routes/basket.ts @@ -2,6 +2,10 @@ import type { AnalyticsEvent, CustomOutgoingLink, } from "@databuddy/db/clickhouse/schema"; +import type { + AnalyticsEventInput, + OutgoingLinkInput, +} from "@databuddy/validation"; import { analyticsEventSchema, batchedCustomEventSpansSchema, @@ -57,7 +61,7 @@ import { EvlogError } from "evlog"; import { useLogger } from "evlog/elysia"; function processTrackEventData( - trackData: any, + trackData: AnalyticsEventInput, clientId: string, userAgent: string, ip: string, @@ -90,7 +94,7 @@ function processTrackEventData( } async function processOutgoingLinkData( - linkData: any, + linkData: OutgoingLinkInput, clientId: string ): Promise { const timestamp = parseTimestamp(linkData.timestamp); @@ -339,7 +343,8 @@ const app = new Elysia() query, request ); - const eventType = (body as any).type || "track"; + const eventType = + (body as { type?: string } | null | undefined)?.type ?? "track"; log.set({ clientId, eventType }); if (eventType === "track") { @@ -364,7 +369,7 @@ const app = new Elysia() throw createIngestSchemaValidationError(parseResult.error.issues); } - insertTrackEvent(body, clientId, userAgent, ip, request); + insertTrackEvent(parseResult.data, clientId, userAgent, ip, request); return Response.json({ status: "success", type: "track" }); } @@ -390,7 +395,7 @@ const app = new Elysia() throw createIngestSchemaValidationError(parseResult.error.issues); } - insertOutgoingLink(body, clientId, userAgent, ip); + insertOutgoingLink(parseResult.data, clientId, userAgent, ip); return Response.json({ status: "success", type: "outgoing_link" }); } @@ -474,7 +479,7 @@ const app = new Elysia() } const trackEvent = await processTrackEventData( - event, + parseResult.data, clientId, userAgent, ip, @@ -484,7 +489,7 @@ const app = new Elysia() results.push({ status: "success", type: "track", - eventId: event.eventId, + eventId: parseResult.data.eventId, }); } else if (eventType === "outgoing_link") { const botError = await checkForBot( @@ -518,12 +523,15 @@ const app = new Elysia() continue; } - const linkEvent = await processOutgoingLinkData(event, clientId); + const linkEvent = await processOutgoingLinkData( + parseResult.data, + clientId + ); outgoingLinkEvents.push(linkEvent); results.push({ status: "success", type: "outgoing_link", - eventId: event.eventId, + eventId: parseResult.data.eventId, }); } else { results.push({ diff --git a/apps/basket/src/utils/parsing-helpers.ts b/apps/basket/src/utils/parsing-helpers.ts index 4d58c2ae0..ee85af583 100644 --- a/apps/basket/src/utils/parsing-helpers.ts +++ b/apps/basket/src/utils/parsing-helpers.ts @@ -7,9 +7,6 @@ type ParseResult = | { success: true; data: T } | { success: false; error: { issues: z.core.$ZodIssue[] } }; -/** - * Validates event schema in production, skips validation in development - */ export function validateEventSchema( schema: z.ZodSchema, event: unknown, @@ -46,7 +43,6 @@ export function validateEventSchema( }); } -/** Per-item batch result when schema validation fails */ export function batchSchemaItemFailure( issues: z.core.$ZodIssue[], eventType: string, @@ -61,7 +57,6 @@ export function batchSchemaItemFailure( }; } -/** Per-item batch result when request is treated as bot (ignored) */ export function batchBotIgnoredItem(eventType: string) { return { status: "error" as const, @@ -71,23 +66,14 @@ export function batchBotIgnoredItem(eventType: string) { }; } -/** - * Validates timestamp, returns current time if invalid - */ export function parseTimestamp(timestamp: unknown): number { return typeof timestamp === "number" ? timestamp : Date.now(); } -/** - * Parses properties object to JSON string, defaults to empty object - */ export function parseProperties(properties: unknown): string { return properties ? JSON.stringify(properties) : "{}"; } -/** - * Parses and sanitizes event ID, generates UUID if missing - */ export function parseEventId( eventId: unknown, generateFn: () => string diff --git a/apps/dashboard/app/(main)/agent/[chatId]/page.tsx b/apps/dashboard/app/(main)/agent/[chatId]/page.tsx new file mode 100644 index 000000000..56af147e8 --- /dev/null +++ b/apps/dashboard/app/(main)/agent/[chatId]/page.tsx @@ -0,0 +1,11 @@ +import { GlobalAgentPage } from "../_components/global-agent-page"; + +interface Props { + params: Promise<{ chatId: string }>; +} + +export default async function AgentChatPage(props: Props) { + const { chatId } = await props.params; + + return ; +} diff --git a/apps/dashboard/app/(main)/agent/_components/agent-gate.tsx b/apps/dashboard/app/(main)/agent/_components/agent-gate.tsx new file mode 100644 index 000000000..f9c6ca29d --- /dev/null +++ b/apps/dashboard/app/(main)/agent/_components/agent-gate.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useGlobalAgent } from "@/components/agent/global-agent-provider"; +import { Skeleton } from "@databuddy/ui"; +import { AgentNoWebsites } from "./agent-no-websites"; + +type AgentGate = + | { status: "loading" | "no-websites" } + | { status: "ready"; organizationId: string }; + +export function useAgentGate(): AgentGate { + const { organizationId, hasWebsites, isLoading } = useGlobalAgent(); + if (isLoading || !organizationId) { + return { status: "loading" }; + } + if (!hasWebsites) { + return { status: "no-websites" }; + } + return { status: "ready", organizationId }; +} + +export function AgentGatePlaceholder({ + status, +}: { + status: "loading" | "no-websites"; +}) { + if (status === "no-websites") { + return ; + } + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/app/(main)/agent/_components/agent-no-websites.tsx b/apps/dashboard/app/(main)/agent/_components/agent-no-websites.tsx new file mode 100644 index 000000000..de5c57d95 --- /dev/null +++ b/apps/dashboard/app/(main)/agent/_components/agent-no-websites.tsx @@ -0,0 +1,28 @@ +import Link from "next/link"; +import { Button } from "@databuddy/ui"; +import { Avatar } from "@databuddy/ui/client"; + +export function AgentNoWebsites() { + return ( +
+ +
+

+ Add a website to use Databunny +

+

+ Databunny analyzes your analytics. Connect your first website to start + asking questions. +

+
+ +
+ ); +} diff --git a/apps/dashboard/app/(main)/agent/_components/global-agent-page.tsx b/apps/dashboard/app/(main)/agent/_components/global-agent-page.tsx new file mode 100644 index 000000000..090116bde --- /dev/null +++ b/apps/dashboard/app/(main)/agent/_components/global-agent-page.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { AgentWorkspace } from "@/components/agent/agent-workspace"; +import { clearLastChatId } from "@/components/agent/hooks/use-chat-db"; +import { ChatProvider } from "@/contexts/chat-context"; +import { AgentGatePlaceholder, useAgentGate } from "./agent-gate"; + +export function GlobalAgentPage({ chatId }: { chatId: string }) { + const router = useRouter(); + const gate = useAgentGate(); + + if (gate.status !== "ready") { + return ; + } + + const { organizationId } = gate; + + return ( + + { + if (nextChatId) { + router.push(`/agent/${nextChatId}`); + } else { + clearLastChatId(organizationId); + router.push("/agent"); + } + }} + onNewChat={(newChatId) => router.push(`/agent/${newChatId}`)} + onSelectChat={(nextChatId) => router.push(`/agent/${nextChatId}`)} + organizationId={organizationId} + /> + + ); +} diff --git a/apps/dashboard/app/(main)/agent/_components/global-agent-redirect.tsx b/apps/dashboard/app/(main)/agent/_components/global-agent-redirect.tsx new file mode 100644 index 000000000..39fb84a9d --- /dev/null +++ b/apps/dashboard/app/(main)/agent/_components/global-agent-redirect.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useGlobalAgent } from "@/components/agent/global-agent-provider"; +import { AgentGatePlaceholder, useAgentGate } from "./agent-gate"; + +export function GlobalAgentRedirect() { + const router = useRouter(); + const { chatId } = useGlobalAgent(); + const gate = useAgentGate(); + const ready = gate.status === "ready"; + + useEffect(() => { + if (ready && chatId) { + router.replace(`/agent/${chatId}`); + } + }, [ready, chatId, router]); + + return ; +} diff --git a/apps/dashboard/app/(main)/agent/layout.tsx b/apps/dashboard/app/(main)/agent/layout.tsx new file mode 100644 index 000000000..82cd8a247 --- /dev/null +++ b/apps/dashboard/app/(main)/agent/layout.tsx @@ -0,0 +1,6 @@ +import type { ReactNode } from "react"; +import { GlobalAgentProvider } from "@/components/agent/global-agent-provider"; + +export default function AgentLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/dashboard/app/(main)/agent/page.tsx b/apps/dashboard/app/(main)/agent/page.tsx new file mode 100644 index 000000000..8a91dbd83 --- /dev/null +++ b/apps/dashboard/app/(main)/agent/page.tsx @@ -0,0 +1,5 @@ +import { GlobalAgentRedirect } from "./_components/global-agent-redirect"; + +export default function AgentIndexPage() { + return ; +} diff --git a/apps/dashboard/app/(main)/insights/_components/insight-card.tsx b/apps/dashboard/app/(main)/insights/_components/insight-card.tsx index 784de8a5d..02d29c194 100644 --- a/apps/dashboard/app/(main)/insights/_components/insight-card.tsx +++ b/apps/dashboard/app/(main)/insights/_components/insight-card.tsx @@ -22,7 +22,7 @@ import { changePercentChipClassName, formatSignedChangePercent, } from "@/lib/insight-signal-key"; -import type { Insight, InsightType } from "@/lib/insight-types"; +import type { Insight, InsightAction, InsightType } from "@/lib/insight-types"; import { cn } from "@/lib/utils"; import { ArrowRightIcon, @@ -375,6 +375,52 @@ function InsightCardPanel({ ); } +const ACTION_ICONS: Record = { + fix_goal: , + create_funnel: , + add_custom_event: , + create_annotation: ( + + ), + update_config: , + add_tracking: , + investigate_further: , + code_fix: , +}; + +function InsightActionPill({ action }: { action: InsightAction }) { + const handleClick = async () => { + if ( + (action.type === "code_fix" || action.type === "investigate_further") && + action.params.prompt + ) { + try { + await navigator.clipboard.writeText(action.params.prompt); + toast.success( + action.type === "code_fix" + ? "Copied to clipboard -- paste in Cursor or Claude Code" + : "Copied investigation prompt" + ); + } catch { + toast.error("Could not copy to clipboard"); + } + return; + } + toast.info(`${action.label}`); + }; + + return ( + + ); +} + function InsightCopy({ view }: { view: InsightCardViewModel }) { return ( <> @@ -387,6 +433,38 @@ function InsightCopy({ view }: { view: InsightCardViewModel }) {

+ {view.rootCause && ( +
+

+ Root cause +

+

+ {view.rootCause} +

+
+ )} + + {view.investigationEvidence.length > 0 && ( +
+

+ Evidence +

+
    + {view.investigationEvidence.map((e, i) => ( +
  • + + • + + {e.description} +
  • + ))} +
+
+ )} + {view.nextStep && (
@@ -401,23 +479,30 @@ function InsightCopy({ view }: { view: InsightCardViewModel }) {

{view.nextStep}

+ {view.actions.length > 0 && ( +
+ {view.actions.map((action, i) => ( + + ))} +
+ )}
)} ); } -function InsightEvidence({ view }: { view: InsightCardViewModel }) { - if (view.evidence.length === 0) { +function InsightMetricsSection({ view }: { view: InsightCardViewModel }) { + if (view.metrics.length === 0) { return null; } return (

- Evidence + Metrics

- +
); } @@ -645,7 +730,7 @@ export function InsightCard({ - {!isCompact && } + {!isCompact && } { expect(view.headline).toBe("Interactions got slower"); expect(view.metaLabel).toBe("Marketing"); expect(view.primaryActionLabel).toBe("Review speed"); - expect(view.evidence[0]?.label).toBe("Interaction delay"); + expect(view.metrics[0]?.label).toBe("Interaction delay"); }); it("falls back to domain and default action when needed", () => { @@ -41,4 +41,28 @@ describe("insight card view model", () => { expect(view.metaLabel).toBe("databuddy.cc"); expect(view.primaryActionLabel).toBe("Open analytics"); }); + + it("keeps investigation evidence separate from metric evidence", () => { + const view = toInsightCardViewModel({ + ...baseInsight, + rootCause: "The homepage script bundle delayed hydration.", + evidence: [ + { + description: "LCP moved after the new checkout banner shipped.", + type: "deploy_correlation", + }, + ], + }); + + expect(view.rootCause).toBe( + "The homepage script bundle delayed hydration." + ); + expect(view.investigationEvidence).toEqual([ + { + description: "LCP moved after the new checkout banner shipped.", + type: "deploy_correlation", + }, + ]); + expect(view.metrics[0]?.label).toBe("Interaction delay"); + }); }); diff --git a/apps/dashboard/app/(main)/insights/lib/insight-card-view-model.ts b/apps/dashboard/app/(main)/insights/lib/insight-card-view-model.ts index 0222a5c40..c1209eb10 100644 --- a/apps/dashboard/app/(main)/insights/lib/insight-card-view-model.ts +++ b/apps/dashboard/app/(main)/insights/lib/insight-card-view-model.ts @@ -1,4 +1,10 @@ -import type { Insight, InsightMetric, InsightType } from "@/lib/insight-types"; +import type { + Insight, + InsightAction, + InsightEvidence, + InsightMetric, + InsightType, +} from "@/lib/insight-types"; const DEFAULT_PRIMARY_ACTION_LABEL = "Open analytics"; @@ -25,22 +31,28 @@ const PRIMARY_ACTION_LABELS: Partial> = { }; export interface InsightCardViewModel { - evidence: InsightMetric[]; + actions: InsightAction[]; headline: string; + investigationEvidence: InsightEvidence[]; metaLabel: string; + metrics: InsightMetric[]; nextStep: string; primaryActionLabel: string; + rootCause: string | null; whyItMatters: string; } export function toInsightCardViewModel(insight: Insight): InsightCardViewModel { return { - evidence: insight.metrics ?? [], + actions: insight.actions ?? [], headline: insight.title, + investigationEvidence: insight.evidence ?? [], metaLabel: insight.websiteName ?? insight.websiteDomain, + metrics: insight.metrics ?? [], nextStep: insight.suggestion, primaryActionLabel: PRIMARY_ACTION_LABELS[insight.type] ?? DEFAULT_PRIMARY_ACTION_LABEL, + rootCause: insight.rootCause ?? null, whyItMatters: insight.description, }; } diff --git a/apps/dashboard/app/(main)/layout.tsx b/apps/dashboard/app/(main)/layout.tsx index df1269d0e..360797178 100644 --- a/apps/dashboard/app/(main)/layout.tsx +++ b/apps/dashboard/app/(main)/layout.tsx @@ -1,6 +1,5 @@ import { publicConfig } from "@databuddy/env/public"; import { FeedbackPrompt } from "@/components/feedback-prompt"; -import { GlobalAgentProvider } from "@/components/agent/global-agent-provider"; import { isDashboardE2E } from "@/lib/e2e-mode"; import { Sidebar } from "@/components/layout/sidebar"; import { @@ -48,20 +47,18 @@ export default function MainLayout({ - -
- }> - - - - -
- {children} -
-
- -
-
+
+ }> + + + + +
+ {children} +
+
+ +
diff --git a/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx b/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx index 7aef6d150..9a6b8b7c7 100644 --- a/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx +++ b/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx @@ -55,6 +55,8 @@ const SIMPLE_ICONS = { "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z", cloudflare: "M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727", + googlesearchconsole: + "M8.548 1.156L6.832 2.872v1.682h1.716zm0 3.398v.035H6.832v-.035H3.386L0 7.844v3.577h2.826V8.94c0-.525.429-.954.954-.954h16.476c.525 0 .954.43.954.954v2.48h2.754V7.844l-3.386-3.29H17.3v.035h-1.717v-.035zm7.035 0H17.3V2.872l-1.717-1.716zM8.679 1.188V2.84h6.773V1.188zm11.471 7.07a.834.834 0 00-.132.01l-.543.002c-5.216.014-10.432-.008-15.648.01-.435-.063-.794.436-.716.883v2.264h17.812c-.016-.888.045-1.782-.034-2.666-.104-.342-.427-.502-.739-.502zm-15.422.634a.689.698 0 01.689.698.689.698 0 01-.689.697.689.698 0 01-.688-.697.689.698 0 01.688-.698zm2.134 0a.689.698 0 01.689.698.689.698 0 01-.689.697.689.698 0 01-.688-.697.689.698 0 01.688-.698zM.036 11.645v9.156c0 1.05.858 1.908 1.907 1.908h.883V11.645zm21.174 0v11.064h.882c1.05 0 1.908-.858 1.908-1.908v-9.156zM4.057 13.133v6.85h6.137v-6.85zm13.243.021v3.777l-1.708.977-1.708-.977v-3.758a4.006 4.006 0 000 7.23v2.441h3.457v-2.442a4.006 4.006 0 00-.041-7.248zm-13.243 8.26v1.43h7.925v-1.43z", googleAnalytics: "M22.84 2.9982v17.9987c.0086 1.6473-1.3197 2.9897-2.967 2.9984a2.9808 2.9808 0 01-.3677-.0208c-1.528-.226-2.6477-1.5558-2.6105-3.1V3.1204c-.0369-1.5458 1.0856-2.8762 2.6157-3.1 1.6361-.1915 3.1178.9796 3.3093 2.6158.014.1201.0208.241.0202.3619zM4.1326 18.0548c-1.6417 0-2.9726 1.331-2.9726 2.9726C1.16 22.6691 2.4909 24 4.1326 24s2.9726-1.3309 2.9726-2.9726-1.331-2.9726-2.9726-2.9726zm7.8728-9.0098c-.0171 0-.0342 0-.0513.0003-1.6495.0904-2.9293 1.474-2.891 3.1256v7.9846c0 2.167.9535 3.4825 2.3505 3.763 1.6118.3266 3.1832-.7152 3.5098-2.327.04-.1974.06-.3983.0593-.5998v-8.9585c.003-1.6474-1.33-2.9852-2.9773-2.9882z", notion: @@ -90,6 +92,18 @@ const GITHUB_ITEM: IntegrationCatalogItem = { const GITHUB_SCOPES = ["repo:status", "read:org"]; +const GSC_ITEM: IntegrationCatalogItem = { + accent: "#4285F4", + category: "Intelligence", + description: + "Surface keyword ranking changes, impression drops, and CTR shifts in investigations.", + iconPath: SIMPLE_ICONS.googlesearchconsole, + id: "google-search-console", + name: "Google Search Console", +}; + +const GSC_SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]; + const COMING_SOON_INTEGRATIONS: IntegrationCatalogItem[] = [ { accent: "#5E6AD2", @@ -195,6 +209,25 @@ function useLinkedAccounts() { }); } +function useOAuthConnect(provider: string, scopes: string[], label: string) { + return useMutation({ + mutationFn: async () => { + const result = await authClient.linkSocial({ + provider, + scopes, + callbackURL: window.location.href, + }); + if (result.error) { + throw new Error(result.error.message); + } + return result; + }, + onError: (err) => { + toast.error(err.message || `Could not connect ${label}`); + }, + }); +} + function ConnectionBadge({ connected, loading, @@ -337,6 +370,8 @@ export function IntegrationsSettings({ + + {COMING_SOON_INTEGRATIONS.map((item) => ( a.providerId === "google"); + + const gscCheck = useQuery({ + ...orpc.integrations.checkSearchConsoleAccess.queryOptions({ + input: {}, + }), + enabled: Boolean(googleAccount), + }); + + const hasGscAccess = gscCheck.data?.hasAccess === true; + const connect = useOAuthConnect( + "google", + GSC_SCOPES, + "Google Search Console" + ); + + let action: React.ReactNode; + if (accounts.isLoading || gscCheck.isLoading) { + action = ; + } else if (hasGscAccess) { + action = ( + + ); + } else { + action = ( + + ); + } + + return ( + + } + item={GSC_ITEM} + /> + ); +} + function GitHubIntegrationRow({ organizationId }: { organizationId: string }) { const queryClient = useQueryClient(); const accounts = useLinkedAccounts(); @@ -392,22 +484,7 @@ function GitHubIntegrationRow({ organizationId }: { organizationId: string }) { enabled: Boolean(githubAccount), }); - const connect = useMutation({ - mutationFn: async () => { - const result = await authClient.linkSocial({ - provider: "github", - scopes: GITHUB_SCOPES, - callbackURL: window.location.href, - }); - if (result.error) { - throw new Error(result.error.message); - } - return result; - }, - onError: (err) => { - toast.error(err.message || "Could not connect GitHub"); - }, - }); + const connect = useOAuthConnect("github", GITHUB_SCOPES, "GitHub"); const disconnect = useMutation({ mutationFn: async () => { diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/[chatId]/page.tsx b/apps/dashboard/app/(main)/websites/[id]/agent/[chatId]/page.tsx index 81b5de6b7..371aa86ad 100644 --- a/apps/dashboard/app/(main)/websites/[id]/agent/[chatId]/page.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/agent/[chatId]/page.tsx @@ -1,4 +1,3 @@ -import { ChatProvider } from "@/contexts/chat-context"; import { AgentPageClient } from "../_components/agent-page-client"; interface Props { @@ -8,9 +7,5 @@ interface Props { export default async function AgentPage(props: Props) { const { id, chatId } = await props.params; - return ( - - - - ); + return ; } diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-client.tsx b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-client.tsx index 26aba8bfc..5e1912820 100644 --- a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-client.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-client.tsx @@ -1,6 +1,11 @@ "use client"; -import { AgentPageContent } from "./agent-page-content"; +import { Skeleton } from "@databuddy/ui"; +import { useRouter } from "next/navigation"; +import { AgentWorkspace } from "@/components/agent/agent-workspace"; +import { clearLastChatId } from "@/components/agent/hooks/use-chat-db"; +import { useOrganizationsContext } from "@/components/providers/organizations-provider"; +import { ChatProvider } from "@/contexts/chat-context"; interface AgentPageClientProps { chatId: string; @@ -8,9 +13,40 @@ interface AgentPageClientProps { } export function AgentPageClient({ chatId, websiteId }: AgentPageClientProps) { + const router = useRouter(); + const { activeOrganizationId, isLoading } = useOrganizationsContext(); + const basePath = `/websites/${websiteId}/agent`; + + if (isLoading) { + return ( +
+ + +
+ ); + } + return ( -
- -
+ + { + if (nextChatId) { + router.push(`${basePath}/${nextChatId}`); + } else { + clearLastChatId(websiteId); + router.push(basePath); + } + }} + onNewChat={(newChatId) => router.push(`${basePath}/${newChatId}`)} + onSelectChat={(nextChatId) => router.push(`${basePath}/${nextChatId}`)} + organizationId={activeOrganizationId} + /> + ); } diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-content.tsx b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-content.tsx deleted file mode 100644 index e53db476a..000000000 --- a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-content.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { AgentChatSurface } from "@/components/agent/agent-chat-surface"; -import { AgentCreditBalance } from "@/components/agent/agent-credit-balance"; -import { ChatHistory } from "@/components/agent/chat-history"; -import { NewChatButton } from "@/components/agent/new-chat-button"; -import { TopBar } from "@/components/layout/top-bar"; -import { orpc } from "@/lib/orpc"; -import { Tooltip } from "@databuddy/ui"; -import { Avatar } from "@databuddy/ui/client"; - -interface AgentPageContentProps { - chatId: string; - websiteId: string; -} - -export function AgentPageContent({ chatId, websiteId }: AgentPageContentProps) { - const { data: chatMeta, isPending: isChatMetaPending } = useQuery({ - ...orpc.agentChats.get.queryOptions({ input: { id: chatId } }), - refetchOnWindowFocus: false, - staleTime: Number.POSITIVE_INFINITY, - }); - - const chatTitle = chatMeta?.title?.trim() ?? ""; - const showChatTitle = chatTitle.length > 0; - const chatTitleDisplayed = - chatTitle === "" - ? chatTitle - : `${chatTitle.slice(0, 1).toLocaleUpperCase()}${chatTitle.slice(1)}`; - - return ( -
-
- -
- -
-

- Databunny -

- {!showChatTitle && ( - - Alpha - - )} - {isChatMetaPending ? ( - <> - - - - ) : ( - showChatTitle && ( - <> - - -

- {chatTitleDisplayed} -

- - ) - )} -
-
-
- - - - -
- -
-
- -
- - -
-
- ); -} diff --git a/apps/dashboard/components/agent/agent-atoms.ts b/apps/dashboard/components/agent/agent-atoms.ts index 636484dd3..8346db6bd 100644 --- a/apps/dashboard/components/agent/agent-atoms.ts +++ b/apps/dashboard/components/agent/agent-atoms.ts @@ -3,6 +3,14 @@ import { atomWithStorage } from "jotai/utils"; export const agentInputAtom = atom(""); +export interface AgentMention { + domain?: string; + id: string; + label: string; +} + +export const agentMentionsAtom = atom([]); + export type AgentThinking = "off" | "low" | "medium" | "high"; export type AgentTier = "quick" | "balanced" | "deep"; diff --git a/apps/dashboard/components/agent/agent-chat-surface.tsx b/apps/dashboard/components/agent/agent-chat-surface.tsx index bdd931d8e..86dd40dec 100644 --- a/apps/dashboard/components/agent/agent-chat-surface.tsx +++ b/apps/dashboard/components/agent/agent-chat-surface.tsx @@ -20,8 +20,11 @@ import { LightningIcon, TableIcon, } from "@databuddy/ui/icons"; +import { useSetAtom } from "jotai"; +import { agentMentionsAtom } from "./agent-atoms"; import { AgentInput } from "./agent-input"; import { AgentMessages } from "./agent-messages"; +import { AGENT_COMMANDS } from "./agent-commands"; import { setLastChatId } from "./hooks/use-chat-db"; import { Avatar } from "@databuddy/ui/client"; import { Button, Skeleton } from "@databuddy/ui"; @@ -31,7 +34,8 @@ interface AgentChatSurfaceProps { chatId: string; className?: string; contentClassName?: string; - websiteId: string; + defaultWebsiteId?: string; + organizationId: string | null; } const FALLBACK_ICONS = [ @@ -43,20 +47,40 @@ const FALLBACK_ICONS = [ const LOADING_DELAY_MS = 250; +const DEFAULT_PROMPTS = AGENT_COMMANDS.filter( + (command) => command.action !== "clear" +) + .slice(0, 4) + .map((command) => ({ + label: command.title, + prompt: command.prompt, + source: "default" as const, + })); + export function AgentChatSurface({ autoSendPromptFromUrl = false, chatId, className, contentClassName, - websiteId, + defaultWebsiteId, + organizationId, }: AgentChatSurfaceProps) { + const lastChatScope = defaultWebsiteId ?? organizationId; + const setMentions = useSetAtom(agentMentionsAtom); + useEffect(() => { - setLastChatId(websiteId, chatId); - }, [websiteId, chatId]); + if (lastChatScope) { + setLastChatId(lastChatScope, chatId); + } + }, [lastChatScope, chatId]); + + useEffect(() => { + setMentions([]); + }, [chatId, setMentions]); const { messages, sendMessage } = useChat(); const { isRestoring, isEmpty } = useChatLoading(); - const { data: website } = useWebsite(websiteId); + const { data: website } = useWebsite(defaultWebsiteId ?? ""); const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); @@ -116,7 +140,7 @@ export function AgentChatSurface({ ) : null} @@ -164,15 +188,18 @@ function WelcomeState({ }: { domain: string | null; onPromptSelect: (text: string) => void; - websiteId: string; + websiteId?: string; }) { - const { data: prompts, isLoading } = useQuery({ + const { data: fetchedPrompts, isLoading } = useQuery({ ...orpc.agentChats.suggestedPrompts.queryOptions({ - input: { websiteId }, + input: { websiteId: websiteId ?? "" }, }), + enabled: Boolean(websiteId), staleTime: 5 * 60 * 1000, }); + const prompts = websiteId ? fetchedPrompts : DEFAULT_PROMPTS; + return (
@@ -186,11 +213,15 @@ function WelcomeState({

Meet Databunny

- Ask anything about{" "} - - {domain ?? "your"} - - 's analytics. + {domain ? ( + <> + Ask anything about{" "} + {domain}'s + analytics. + + ) : ( + "Ask anything about your analytics. Mention a site with @." + )}

diff --git a/apps/dashboard/components/agent/agent-input.tsx b/apps/dashboard/components/agent/agent-input.tsx index 3d2ffee22..06eca6001 100644 --- a/apps/dashboard/components/agent/agent-input.tsx +++ b/apps/dashboard/components/agent/agent-input.tsx @@ -17,23 +17,33 @@ import { } from "@databuddy/ui/icons"; import { useChat, usePendingQueue } from "@/contexts/chat-context"; import { cn } from "@/lib/utils"; +import { FaviconImage } from "@/components/analytics/favicon-image"; import { useBillingContext, useUsageFeature, } from "@/components/providers/billing-provider"; +import { type Website, useWebsitesLight } from "@/hooks/use-websites"; import { AGENT_TIERS, AGENT_THINKING_LEVELS, TIER_SUPPORTS_THINKING, + type AgentMention, type AgentTier, type AgentThinking, agentCreditShakeNonceAtom, agentInputAtom, + agentMentionsAtom, agentTierAtom, agentThinkingAtom, } from "./agent-atoms"; import { AgentCommandMenu } from "./agent-command-menu"; import { type AgentCommand, filterCommands } from "./agent-commands"; +import { AgentMentionMenu } from "./agent-mention-menu"; +import { + filterMentionWebsites, + getMentionQuery, + stripMentionQuery, +} from "./agent-mentions"; import { AgentTextSwitch, AGENT_INPUT_PLACEHOLDER_PHRASES, @@ -47,6 +57,8 @@ export function AgentInput() { const { messages: pendingMessages, removeAction } = usePendingQueue(); const isLoading = status === "streaming" || status === "submitted"; const [input, setInput] = useAtom(agentInputAtom); + const [mentions, setMentions] = useAtom(agentMentionsAtom); + const { websites } = useWebsitesLight(); const bumpCreditShake = useSetAtom(agentCreditShakeNonceAtom); const { balance, unlimited } = useUsageFeature("agent_credits"); const { customer, isLoading: billingLoading } = useBillingContext(); @@ -56,6 +68,8 @@ export function AgentInput() { const { formRef, onKeyDown } = useEnterSubmit(); const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); const [commandsDismissed, setCommandsDismissed] = useState(false); + const [selectedMentionIndex, setSelectedMentionIndex] = useState(0); + const [mentionsDismissed, setMentionsDismissed] = useState(false); const [placeholderReplayKey, setPlaceholderReplayKey] = useState(0); const textareaRef = useRef(null); const replayFrameRef = useRef(null); @@ -101,8 +115,28 @@ export function AgentInput() { return filterCommands(query); }, [input]); + const mentionQuery = useMemo(() => getMentionQuery(input), [input]); + const mentionedIds = useMemo( + () => new Set(mentions.map((m) => m.id)), + [mentions] + ); + const mentionResults = useMemo(() => { + if (mentionQuery === null) { + return []; + } + return filterMentionWebsites(websites, mentionQuery, mentionedIds); + }, [mentionQuery, websites, mentionedIds]); + + const showMentions = + !(mentionsDismissed || isLoading) && mentionResults.length > 0; + const safeMentionIndex = + mentionResults.length === 0 + ? 0 + : Math.min(selectedMentionIndex, mentionResults.length - 1); + const showCommands = - !(commandsDismissed || isLoading) && filteredCommands.length > 0; + !(commandsDismissed || isLoading || showMentions) && + filteredCommands.length > 0; const safeCommandIndex = filteredCommands.length === 0 ? 0 @@ -139,9 +173,66 @@ export function AgentInput() { setCommandsDismissed(true); }; + const selectMention = useCallback( + (website: Website) => { + setMentions((prev) => + prev.some((m) => m.id === website.id) + ? prev + : [ + ...prev, + { + id: website.id, + label: website.name ?? website.domain, + domain: website.domain, + }, + ] + ); + setInput(stripMentionQuery(inputSyncRef.current)); + setSelectedMentionIndex(0); + requestAnimationFrame(() => textareaRef.current?.focus()); + }, + [setMentions, setInput] + ); + + const removeMention = (id: string) => { + setMentions((prev) => prev.filter((m) => m.id !== id)); + }; + const handleMessageKeyDown = ( event: React.KeyboardEvent ) => { + if (showMentions) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedMentionIndex((prev) => (prev + 1) % mentionResults.length); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedMentionIndex( + (prev) => (prev - 1 + mentionResults.length) % mentionResults.length + ); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setMentionsDismissed(true); + return; + } + if ( + (event.key === "Enter" && + !event.shiftKey && + !event.nativeEvent.isComposing) || + event.key === "Tab" + ) { + event.preventDefault(); + const target = mentionResults[safeMentionIndex]; + if (target) { + selectMention(target); + } + return; + } + } if (showCommands) { if (event.key === "ArrowDown") { event.preventDefault(); @@ -191,8 +282,54 @@ export function AgentInput() { setCommandsDismissed(false); } setSelectedCommandIndex(0); + setMentionsDismissed(false); + setSelectedMentionIndex(0); }; + const inputSurface = ( +
+
+
+ +
+