From 4353d19c35e0fb264aeac3d4311abc2eec56fa2f Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 29 May 2026 18:49:09 +0300 Subject: [PATCH 01/34] fix(auth): make local-email verification explicit and raise password ceiling Set accountLinking.requireLocalEmailVerified: true explicitly so future config changes can't silently regress the OAuth pre-account-takeover protection. Bump maxPasswordLength from 32 to 128 to align with NIST 800-63B and unblock password manager output. --- packages/auth/src/auth.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index ffd3eed84..aba4ae8f5 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -213,6 +213,7 @@ export const auth = betterAuth({ enabled: true, trustedProviders: ["google", "github"], allowDifferentEmails: true, + requireLocalEmailVerified: true, }, }, databaseHooks: { @@ -364,7 +365,7 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, minPasswordLength: 8, - maxPasswordLength: 32, + maxPasswordLength: 128, autoSignIn: false, requireEmailVerification: shouldRequireEmailVerification(), sendResetPassword: async ({ user, url }: { user: any; url: string }) => { From 0362fd24cc4dcdbc04655114e2feca351bbc5874 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 29 May 2026 18:49:59 +0300 Subject: [PATCH 02/34] fix(auth): purge reset tokens and revoke sessions on password change Outstanding reset-password verification rows previously survived both a successful reset and a settings-driven password change, leaving the old link usable until its 1-hour TTL. Add an onPasswordReset hook and a databaseHooks.account.update.after hook on the credential account so both flows purge the user's outstanding reset tokens. Also enable revokeSessionsOnPasswordReset so existing sessions die when the email reset flow completes. --- packages/auth/src/auth.ts | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index aba4ae8f5..5de4b2072 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -1,12 +1,13 @@ import { randomUUID } from "node:crypto"; import { redisStorage } from "@better-auth/redis-storage"; import { sso } from "@better-auth/sso"; -import { db } from "@databuddy/db"; +import { and, db, eq, like } from "@databuddy/db"; // biome-ignore lint/performance/noNamespaceImport: Better Auth's Drizzle adapter expects a schema object map. import * as schema from "@databuddy/db/schema"; import { member as memberTable, organization as organizationTable, + verification as verificationTable, } from "@databuddy/db/schema"; import { DeleteAccountEmail, @@ -155,6 +156,26 @@ function notifySignUpSlackAction(input: { }); } +async function purgeOutstandingResetTokens(userId: string): Promise { + try { + await db + .delete(verificationTable) + .where( + and( + like(verificationTable.identifier, "reset-password:%"), + eq(verificationTable.value, userId) + ) + ); + } catch (error) { + log.error({ + service: "auth", + auth_hook: "purge_reset_tokens", + auth_user_id: userId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + async function invalidateMemberCaches(member: { organizationId: string; userId: string; @@ -217,6 +238,16 @@ export const auth = betterAuth({ }, }, databaseHooks: { + account: { + update: { + after: async (account) => { + if (account.providerId !== "credential" || !account.userId) { + return; + } + await purgeOutstandingResetTokens(account.userId); + }, + }, + }, user: { create: { after: async (createdUser) => { @@ -368,6 +399,10 @@ export const auth = betterAuth({ maxPasswordLength: 128, autoSignIn: false, requireEmailVerification: shouldRequireEmailVerification(), + revokeSessionsOnPasswordReset: true, + onPasswordReset: async ({ user }: { user: { id: string } }) => { + await purgeOutstandingResetTokens(user.id); + }, sendResetPassword: async ({ user, url }: { user: any; url: string }) => { const { success } = await ratelimit(`reset:${user.email}`, 3, 3600); if (!success) { From f89da70981e495596e82a6ea32743be8f412da04 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 29 May 2026 18:50:36 +0300 Subject: [PATCH 03/34] fix(auth): disable session cookie cache so revocation is immediate With cookieCache enabled, Better-Auth validates requests against the signed sessionData cookie payload for up to maxAge seconds without hitting Redis. That left a 5-minute window where a stolen cookie kept working after logout, password reset, ban-user, or organization member removal. secondaryStorage already points at Redis, so dropping the cache costs one fast round-trip per request and closes the post-revocation replay window entirely. --- packages/auth/src/auth.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index 5de4b2072..af867f9e2 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -205,8 +205,7 @@ export const auth = betterAuth({ session: { storeSessionInDatabase: true, cookieCache: { - enabled: true, - maxAge: 5 * 60, + enabled: false, }, }, rateLimit: { From 7f012458b3ae64b46a0d697c021f4ea6070333c4 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 29 May 2026 18:53:24 +0300 Subject: [PATCH 04/34] feat(basket): log quota-exceeded events with billing context Emit an explicit warn at the point of detection so blocked-ingest rows carry customerId, featureId, properties, and usage/grant fields. The wide-event context was previously lost by the time the error reached Axiom, leaving 114k quota-exceeded rows with no attribution. --- apps/basket/src/lib/billing.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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(); } From 01c1a151ab93d7ced4444654a6acb6a086aa1ef1 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 29 May 2026 18:53:35 +0300 Subject: [PATCH 05/34] refactor(basket): drop redundant JSDoc and centralize event input types Remove restating JSDoc across the basket lib/utils modules (one comment on validateRequest was also stale, describing a return shape the function no longer has). Promote AnalyticsEventInput and OutgoingLinkInput to exported z.infer types in the validation package and reuse them in the route handlers instead of re-deriving locally. --- apps/basket/src/lib/event-service.ts | 20 -------------- apps/basket/src/lib/evlog-basket.ts | 10 ------- apps/basket/src/lib/request-validation.ts | 10 ------- apps/basket/src/lib/structured-errors.ts | 8 ------ apps/basket/src/lib/tracing.ts | 14 ---------- apps/basket/src/routes/basket.ts | 26 ++++++++++++------- apps/basket/src/utils/parsing-helpers.ts | 14 ---------- packages/validation/src/schemas/analytics.ts | 2 ++ .../validation/src/schemas/custom-events.ts | 2 ++ 9 files changed, 21 insertions(+), 85 deletions(-) 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/packages/validation/src/schemas/analytics.ts b/packages/validation/src/schemas/analytics.ts index 1ad028474..844439be8 100644 --- a/packages/validation/src/schemas/analytics.ts +++ b/packages/validation/src/schemas/analytics.ts @@ -202,3 +202,5 @@ export const analyticsEventSchema = z.object({ .optional() .nullable(), }); + +export type AnalyticsEventInput = z.infer; diff --git a/packages/validation/src/schemas/custom-events.ts b/packages/validation/src/schemas/custom-events.ts index d94c44812..c4490f459 100644 --- a/packages/validation/src/schemas/custom-events.ts +++ b/packages/validation/src/schemas/custom-events.ts @@ -58,3 +58,5 @@ export const outgoingLinkSchema = z.object({ text: z.string().max(VALIDATION_LIMITS.TEXT_MAX_LENGTH).nullable().optional(), properties: boundedPropertiesJson.optional().nullable(), }); + +export type OutgoingLinkInput = z.infer; From 796a03a63a78d1329e5347407b1dc50edc15e52a Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 29 May 2026 18:53:43 +0300 Subject: [PATCH 06/34] refactor(api): sanitize Autumn request body and split out session loading Sanitize the parsed body in a single pass and reuse one autumn handler instance. Extract loadSession so a getSession failure is logged and rethrown instead of being swallowed into a null identity, and drop the manual activeOrganizationId cast now that the session type carries it. --- apps/api/src/billing/autumn.ts | 89 ++++++++++++++++------------------ 1 file changed, 42 insertions(+), 47 deletions(-) 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, + }, + }; } From ac30575137476b178cdda466fe0f7feed56d8e28 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 29 May 2026 18:53:52 +0300 Subject: [PATCH 07/34] refactor(rpc): dedupe billing-control handlers via shared upsert helper Collapse the near-identical auto-topup, usage-alert, and spend-limit mutation handlers into one generic upsertBillingControl that handles the owner check, getOrCreate/merge/update, and error logging. Replaces the three parallel entry interfaces with a single keyed BillingControlEntries. --- packages/rpc/src/routers/billing.ts | 213 +++++++++++----------------- 1 file changed, 81 insertions(+), 132 deletions(-) diff --git a/packages/rpc/src/routers/billing.ts b/packages/rpc/src/routers/billing.ts index 5545658df..b68025102 100644 --- a/packages/rpc/src/routers/billing.ts +++ b/packages/rpc/src/routers/billing.ts @@ -225,25 +225,65 @@ const spendLimitConfigSchema = z } ); -interface AutoTopupEntry { - enabled: boolean; - featureId: string; - quantity: number; - threshold: number; +interface BillingControlEntries { + autoTopups: { + enabled: boolean; + featureId: string; + quantity: number; + threshold: number; + }; + spendLimits: { + enabled: boolean; + featureId: string; + overageLimit: number; + }; + usageAlerts: { + enabled: boolean; + featureId: string; + threshold: number; + thresholdType: "usage_percentage"; + }; } -interface UsageAlertEntry { - enabled: boolean; - featureId: string; - name?: string; - threshold: number; - thresholdType: "usage_percentage"; -} +async function upsertBillingControl< + K extends keyof BillingControlEntries, +>(args: { + context: { user: { id: string }; organizationId?: string | null }; + key: K; + entry: BillingControlEntries[K]; + operation: string; +}): Promise { + const { customerId, canUserUpgrade } = await getBillingOwner( + args.context.user.id, + args.context.organizationId + ); + if (!canUserUpgrade) { + throw rpcError.forbidden( + "Only an organization owner or admin can change billing settings." + ); + } -interface SpendLimitEntry { - enabled: boolean; - featureId: string; - overageLimit: number; + try { + const autumn = getAutumn(); + const customer = await autumn.customers.getOrCreate({ customerId }); + const existing = (customer.billingControls?.[args.key] ?? []) as Array<{ + featureId: string; + }>; + const merged = [ + ...existing.filter((e) => e.featureId !== args.entry.featureId), + args.entry, + ]; + await autumn.customers.update({ + customerId, + billingControls: { [args.key]: merged } as Record, + }); + } catch (error) { + logger.error( + { error, customerId, userId: args.context.user.id }, + `Failed to update ${args.operation} configuration` + ); + throw rpcError.internal(`Failed to update ${args.operation} settings`); + } } export const billingRouter = { @@ -260,49 +300,18 @@ export const billingRouter = { .output(autoTopupConfigSchema) .handler(async ({ context, input }) => { setTrackProperties({ enabled: input.enabled }); - const { customerId, canUserUpgrade } = await getBillingOwner( - context.user.id, - context.organizationId - ); - if (!canUserUpgrade) { - throw rpcError.forbidden( - "Only an organization owner or admin can change billing settings." - ); - } - - try { - const autumn = getAutumn(); - const customer = await autumn.customers.getOrCreate({ customerId }); - const existing = (customer.billingControls?.autoTopups ?? - []) as AutoTopupEntry[]; - const nextEntry: AutoTopupEntry = { + await upsertBillingControl({ + context, + key: "autoTopups", + entry: { featureId: AUTO_TOPUP_FEATURE_ID, enabled: input.enabled, threshold: input.threshold, quantity: input.quantity, - }; - const merged = [ - ...existing.filter((t) => t.featureId !== AUTO_TOPUP_FEATURE_ID), - nextEntry, - ]; - - await autumn.customers.update({ - customerId, - billingControls: { autoTopups: merged }, - }); - - return { - enabled: nextEntry.enabled, - threshold: nextEntry.threshold, - quantity: nextEntry.quantity, - }; - } catch (error) { - logger.error( - { error, customerId, userId: context.user.id }, - "Failed to update auto top-up configuration" - ); - throw rpcError.internal("Failed to update auto top-up settings"); - } + }, + operation: "auto top-up", + }); + return input; }), setUsageAlert: trackedSessionProcedure @@ -318,48 +327,18 @@ export const billingRouter = { .output(usageAlertConfigSchema) .handler(async ({ context, input }) => { setTrackProperties({ enabled: input.enabled }); - const { customerId, canUserUpgrade } = await getBillingOwner( - context.user.id, - context.organizationId - ); - if (!canUserUpgrade) { - throw rpcError.forbidden( - "Only an organization owner or admin can change billing settings." - ); - } - - try { - const autumn = getAutumn(); - const customer = await autumn.customers.getOrCreate({ customerId }); - const existing = (customer.billingControls?.usageAlerts ?? - []) as UsageAlertEntry[]; - const nextEntry: UsageAlertEntry = { + await upsertBillingControl({ + context, + key: "usageAlerts", + entry: { featureId: EVENTS_FEATURE_ID, enabled: input.enabled, threshold: input.threshold, - thresholdType: "usage_percentage", - }; - const merged = [ - ...existing.filter((a) => a.featureId !== EVENTS_FEATURE_ID), - nextEntry, - ]; - - await autumn.customers.update({ - customerId, - billingControls: { usageAlerts: merged }, - }); - - return { - enabled: nextEntry.enabled, - threshold: nextEntry.threshold, - }; - } catch (error) { - logger.error( - { error, customerId, userId: context.user.id }, - "Failed to update usage alert configuration" - ); - throw rpcError.internal("Failed to update usage alert settings"); - } + thresholdType: "usage_percentage" as const, + }, + operation: "usage alert", + }); + return input; }), setSpendLimit: trackedSessionProcedure @@ -375,47 +354,17 @@ export const billingRouter = { .output(spendLimitConfigSchema) .handler(async ({ context, input }) => { setTrackProperties({ enabled: input.enabled }); - const { customerId, canUserUpgrade } = await getBillingOwner( - context.user.id, - context.organizationId - ); - if (!canUserUpgrade) { - throw rpcError.forbidden( - "Only an organization owner or admin can change billing settings." - ); - } - - try { - const autumn = getAutumn(); - const customer = await autumn.customers.getOrCreate({ customerId }); - const existing = (customer.billingControls?.spendLimits ?? - []) as SpendLimitEntry[]; - const nextEntry: SpendLimitEntry = { + await upsertBillingControl({ + context, + key: "spendLimits", + entry: { featureId: SPEND_LIMIT_FEATURE_ID, enabled: input.enabled, overageLimit: input.overageLimit, - }; - const merged = [ - ...existing.filter((s) => s.featureId !== SPEND_LIMIT_FEATURE_ID), - nextEntry, - ]; - - await autumn.customers.update({ - customerId, - billingControls: { spendLimits: merged }, - }); - - return { - enabled: nextEntry.enabled, - overageLimit: nextEntry.overageLimit, - }; - } catch (error) { - logger.error( - { error, customerId, userId: context.user.id }, - "Failed to update spend limit configuration" - ); - throw rpcError.internal("Failed to update spend limit settings"); - } + }, + operation: "spend limit", + }); + return input; }), getUsage: protectedProcedure From 6efb97d519abc5d8aa6fbc04f3e788fc0305b001 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 29 May 2026 18:54:05 +0300 Subject: [PATCH 08/34] feat(insights): add funnel and goal conversion signal detection Detect week-over-week conversion drops on funnel definitions and goals via a new funnel-detection module, reusing the shared wowWindow helper and makeWowSignal (both lifted out of detection.ts) and enrichment's window logic. Extract insight persistence and dedup out of generation.ts into persistence.ts, and expose the rpc analytics-utils entrypoint that funnel detection needs for funnel/goal analytics processing. --- apps/insights/src/detection.ts | 48 ++-- apps/insights/src/enrichment.ts | 13 +- apps/insights/src/funnel-detection.test.ts | 178 +++++++++++++ apps/insights/src/funnel-detection.ts | 256 ++++++++++++++++++ apps/insights/src/generation.ts | 290 ++------------------ apps/insights/src/persistence.ts | 292 +++++++++++++++++++++ packages/rpc/package.json | 1 + 7 files changed, 779 insertions(+), 299 deletions(-) create mode 100644 apps/insights/src/funnel-detection.test.ts create mode 100644 apps/insights/src/funnel-detection.ts create mode 100644 apps/insights/src/persistence.ts diff --git a/apps/insights/src/detection.ts b/apps/insights/src/detection.ts index e0b0039fd..980e5e968 100644 --- a/apps/insights/src/detection.ts +++ b/apps/insights/src/detection.ts @@ -104,6 +104,29 @@ const VITALS_METRICS: Record = { INP: "Interaction speed (INP)", }; +export interface WowWindow { + currentFrom: string; + currentTo: string; + previousFrom: string; + previousTo: string; +} + +export function wowWindow(today: dayjs.Dayjs, lookbackDays: number): WowWindow { + const windowDays = Math.max(3, lookbackDays); + return { + currentFrom: today.subtract(windowDays - 1, "day").format("YYYY-MM-DD"), + currentTo: today.format("YYYY-MM-DD"), + previousFrom: today + .subtract(windowDays * 2 - 1, "day") + .format("YYYY-MM-DD"), + previousTo: today.subtract(windowDays, "day").format("YYYY-MM-DD"), + }; +} + +function round2(value: number): number { + return Number(value.toFixed(2)); +} + type SignalFilter = (signal: DetectedSignal) => boolean; const METRIC_FILTERS: Record = { @@ -124,12 +147,13 @@ const DEFAULT_TRAFFIC_FILTER: SignalFilter = (s) => Math.max(s.current, s.baseline) >= FILTER_TRAFFIC_MIN_PEAK && Math.abs(s.current - s.baseline) >= FILTER_TRAFFIC_MIN_DELTA; -function makeWowSignal( +export function makeWowSignal( metric: string, label: string, current: number, baseline: number, - detectedAt: string + detectedAt: string, + round = false ): DetectedSignal { const pct = baseline === 0 ? 100 : safeDeltaPercent(current, baseline); return { @@ -137,9 +161,9 @@ function makeWowSignal( label, method: "wow", direction: current > baseline ? "up" : "down", - current, - baseline, - deltaPercent: Number(pct.toFixed(2)), + current: round ? round2(current) : current, + baseline: round ? round2(baseline) : baseline, + deltaPercent: round2(pct), severity: assignSeverity(undefined, pct), detectedAt, }; @@ -371,16 +395,10 @@ async function detectWow( queryFn: QueryFn ): Promise { const { websiteId, lookbackDays, timezone } = params; - const windowDays = Math.max(3, lookbackDays); - - const currentFrom = today - .subtract(windowDays - 1, "day") - .format("YYYY-MM-DD"); - const currentTo = today.format("YYYY-MM-DD"); - const previousFrom = today - .subtract(windowDays * 2 - 1, "day") - .format("YYYY-MM-DD"); - const previousTo = today.subtract(windowDays, "day").format("YYYY-MM-DD"); + const { currentFrom, currentTo, previousFrom, previousTo } = wowWindow( + today, + lookbackDays + ); function query(type: string, from: string, to: string) { return queryFn( diff --git a/apps/insights/src/enrichment.ts b/apps/insights/src/enrichment.ts index 602da8b6e..5880fe46d 100644 --- a/apps/insights/src/enrichment.ts +++ b/apps/insights/src/enrichment.ts @@ -6,6 +6,7 @@ import { safeDeltaPercent, type DetectedSignal, type QueryFn, + wowWindow, } from "./detection"; export interface SegmentMover { @@ -125,17 +126,7 @@ function computeWindow( }; } - const windowDays = Math.max(3, lookbackDays); - return { - currentFrom: detectedDay - .subtract(windowDays - 1, "day") - .format("YYYY-MM-DD"), - currentTo: detectedDay.format("YYYY-MM-DD"), - previousFrom: detectedDay - .subtract(windowDays * 2 - 1, "day") - .format("YYYY-MM-DD"), - previousTo: detectedDay.subtract(windowDays, "day").format("YYYY-MM-DD"), - }; + return wowWindow(detectedDay, lookbackDays); } function queryPeriodPair( diff --git a/apps/insights/src/funnel-detection.test.ts b/apps/insights/src/funnel-detection.test.ts new file mode 100644 index 000000000..5e21020ef --- /dev/null +++ b/apps/insights/src/funnel-detection.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "bun:test"; +import dayjs from "dayjs"; +import type { DetectSignalsParams } from "./detection"; +import { + detectFunnelGoalSignals, + type FunnelDef, + type FunnelGoalDeps, + type GoalDef, +} from "./funnel-detection"; + +const TODAY = dayjs("2026-05-29"); + +const PARAMS: DetectSignalsParams = { + websiteId: "test-site", + lookbackDays: 7, + timezone: "UTC", +}; + +const FUNNEL: FunnelDef = { + id: "f1", + name: "Checkout", + steps: [ + { name: "View", target: "/cart", type: "PAGE_VIEW" }, + { name: "Buy", target: "purchase", type: "EVENT" }, + ], + filters: null, +}; + +const GOAL: GoalDef = { + id: "g1", + name: "Signup", + type: "EVENT", + target: "sign_up", + filters: null, +}; + +function makeDeps(overrides: Partial): FunnelGoalDeps { + return { + fetchFunnels: async () => [], + fetchGoals: async () => [], + funnelConversion: async () => ({ rate: 0, entrants: 0 }), + goalConversion: async () => ({ rate: 0, completions: 0 }), + ...overrides, + }; +} + +describe("detectFunnelGoalSignals", () => { + it("returns empty when nothing is configured", async () => { + const signals = await detectFunnelGoalSignals(PARAMS, TODAY, makeDeps({})); + expect(signals).toEqual([]); + }); + + it("flags a funnel conversion drop above threshold", async () => { + let call = 0; + const deps = makeDeps({ + fetchFunnels: async () => [FUNNEL], + funnelConversion: async () => { + call += 1; + return call === 1 + ? { rate: 10, entrants: 100 } + : { rate: 20, entrants: 120 }; + }, + }); + + const signals = await detectFunnelGoalSignals(PARAMS, TODAY, deps); + + expect(signals.length).toBe(1); + const signal = signals[0]; + expect(signal.metric).toBe("funnel:f1"); + expect(signal.direction).toBe("down"); + expect(signal.deltaPercent).toBe(-50); + expect(signal.method).toBe("wow"); + expect(signal.detectedAt).toBe("2026-05-29"); + }); + + it("flags a funnel conversion rise above threshold", async () => { + let call = 0; + const deps = makeDeps({ + fetchFunnels: async () => [FUNNEL], + funnelConversion: async () => { + call += 1; + return call === 1 + ? { rate: 20, entrants: 120 } + : { rate: 10, entrants: 100 }; + }, + }); + + const signals = await detectFunnelGoalSignals(PARAMS, TODAY, deps); + + expect(signals.length).toBe(1); + expect(signals[0].direction).toBe("up"); + expect(signals[0].deltaPercent).toBe(100); + }); + + it("ignores funnel changes below threshold", async () => { + let call = 0; + const deps = makeDeps({ + fetchFunnels: async () => [FUNNEL], + funnelConversion: async () => { + call += 1; + return call === 1 + ? { rate: 18, entrants: 100 } + : { rate: 20, entrants: 100 }; + }, + }); + + const signals = await detectFunnelGoalSignals(PARAMS, TODAY, deps); + expect(signals.length).toBe(0); + }); + + it("ignores funnels with too few entrants", async () => { + let call = 0; + const deps = makeDeps({ + fetchFunnels: async () => [FUNNEL], + funnelConversion: async () => { + call += 1; + return call === 1 + ? { rate: 10, entrants: 10 } + : { rate: 40, entrants: 8 }; + }, + }); + + const signals = await detectFunnelGoalSignals(PARAMS, TODAY, deps); + expect(signals.length).toBe(0); + }); + + it("flags a goal completion-rate drop above threshold", async () => { + let call = 0; + const deps = makeDeps({ + fetchGoals: async () => [GOAL], + goalConversion: async () => { + call += 1; + return call === 1 + ? { rate: 2.5, completions: 50 } + : { rate: 5, completions: 100 }; + }, + }); + + const signals = await detectFunnelGoalSignals(PARAMS, TODAY, deps); + + expect(signals.length).toBe(1); + expect(signals[0].metric).toBe("goal:g1"); + expect(signals[0].direction).toBe("down"); + expect(signals[0].deltaPercent).toBe(-50); + }); + + it("ignores goals with too few completions", async () => { + let call = 0; + const deps = makeDeps({ + fetchGoals: async () => [GOAL], + goalConversion: async () => { + call += 1; + return call === 1 + ? { rate: 1, completions: 3 } + : { rate: 4, completions: 2 }; + }, + }); + + const signals = await detectFunnelGoalSignals(PARAMS, TODAY, deps); + expect(signals.length).toBe(0); + }); + + it("passes the correct week-over-week windows to the analytics deps", async () => { + const ranges: Array<{ from: string; to: string }> = []; + const deps = makeDeps({ + fetchFunnels: async () => [FUNNEL], + funnelConversion: async (_funnel, range) => { + ranges.push(range); + return { rate: 10, entrants: 100 }; + }, + }); + + await detectFunnelGoalSignals(PARAMS, TODAY, deps); + + expect(ranges).toContainEqual({ from: "2026-05-23", to: "2026-05-29" }); + expect(ranges).toContainEqual({ from: "2026-05-16", to: "2026-05-22" }); + }); +}); diff --git a/apps/insights/src/funnel-detection.ts b/apps/insights/src/funnel-detection.ts new file mode 100644 index 000000000..79fcb3d21 --- /dev/null +++ b/apps/insights/src/funnel-detection.ts @@ -0,0 +1,256 @@ +import { and, db, eq, isNull, sql } from "@databuddy/db"; +import { + type DataFilter, + funnelDefinitions, + type FunnelStep, + goals, +} from "@databuddy/db/schema"; +import { + type AnalyticsStep, + getTotalWebsiteUsers, + processFunnelAnalytics, + processGoalAnalytics, +} from "@databuddy/rpc/analytics-utils"; +import dayjs from "dayjs"; +import { + type DetectedSignal, + type DetectSignalsParams, + makeWowSignal, + safeDeltaPercent, + wowWindow, +} from "./detection"; + +const FUNNEL_CONVERSION_WOW_THRESHOLD = 20; +const FUNNEL_MIN_ENTRANTS = 30; +const GOAL_CONVERSION_WOW_THRESHOLD = 20; +const GOAL_MIN_COMPLETIONS = 10; +const MAX_DEFINITIONS = 10; + +export interface FunnelDef { + filters: DataFilter[] | null; + id: string; + name: string; + steps: FunnelStep[]; +} + +export interface GoalDef { + filters: DataFilter[] | null; + id: string; + name: string; + target: string; + type: "PAGE_VIEW" | "EVENT" | "CUSTOM"; +} + +export interface PeriodRange { + from: string; + to: string; +} + +export interface FunnelConversion { + entrants: number; + rate: number; +} + +export interface GoalConversion { + completions: number; + rate: number; +} + +export interface FunnelGoalDeps { + fetchFunnels: () => Promise; + fetchGoals: () => Promise; + funnelConversion: ( + funnel: FunnelDef, + range: PeriodRange + ) => Promise; + goalConversion: ( + goal: GoalDef, + range: PeriodRange + ) => Promise; +} + +function toAnalyticsSteps(steps: FunnelStep[]): AnalyticsStep[] { + return steps.map((step, index) => ({ + step_number: index + 1, + type: step.type as "PAGE_VIEW" | "EVENT", + target: step.target, + name: step.name, + })); +} + +export function defaultFunnelGoalDeps(websiteId: string): FunnelGoalDeps { + return { + fetchFunnels: () => + db + .select({ + id: funnelDefinitions.id, + name: funnelDefinitions.name, + steps: funnelDefinitions.steps, + filters: funnelDefinitions.filters, + }) + .from(funnelDefinitions) + .where( + and( + eq(funnelDefinitions.websiteId, websiteId), + eq(funnelDefinitions.isActive, true), + isNull(funnelDefinitions.deletedAt), + sql`jsonb_array_length(${funnelDefinitions.steps}) > 1` + ) + ) + .limit(MAX_DEFINITIONS), + fetchGoals: () => + db + .select({ + id: goals.id, + name: goals.name, + type: goals.type, + target: goals.target, + filters: goals.filters, + }) + .from(goals) + .where( + and( + eq(goals.websiteId, websiteId), + eq(goals.isActive, true), + isNull(goals.deletedAt) + ) + ) + .limit(MAX_DEFINITIONS), + funnelConversion: async (funnel, range) => { + const analytics = await processFunnelAnalytics( + toAnalyticsSteps(funnel.steps), + funnel.filters ?? [], + { + websiteId, + startDate: range.from, + endDate: `${range.to} 23:59:59`, + } + ); + return { + rate: analytics.overall_conversion_rate, + entrants: analytics.total_users_entered, + }; + }, + goalConversion: async (goal, range) => { + const steps: AnalyticsStep[] = [ + { + step_number: 1, + type: goal.type as "PAGE_VIEW" | "EVENT", + target: goal.target, + name: goal.name, + }, + ]; + const totalWebsiteUsers = await getTotalWebsiteUsers( + websiteId, + range.from, + range.to + ); + const analytics = await processGoalAnalytics( + steps, + goal.filters ?? [], + { + websiteId, + startDate: range.from, + endDate: `${range.to} 23:59:59`, + }, + totalWebsiteUsers + ); + return { + rate: analytics.overall_conversion_rate, + completions: analytics.total_users_completed, + }; + }, + }; +} + +export async function detectFunnelGoalSignals( + params: DetectSignalsParams, + today: dayjs.Dayjs = dayjs(), + deps: FunnelGoalDeps = defaultFunnelGoalDeps(params.websiteId) +): Promise { + const window = wowWindow(today, params.lookbackDays); + const current: PeriodRange = { + from: window.currentFrom, + to: window.currentTo, + }; + const previous: PeriodRange = { + from: window.previousFrom, + to: window.previousTo, + }; + + const [funnels, goalDefs] = await Promise.all([ + deps.fetchFunnels(), + deps.fetchGoals(), + ]); + + const funnelSignals = await Promise.all( + funnels.map(async (funnel) => { + try { + const [cur, prev] = await Promise.all([ + deps.funnelConversion(funnel, current), + deps.funnelConversion(funnel, previous), + ]); + if ( + cur.entrants < FUNNEL_MIN_ENTRANTS || + prev.entrants < FUNNEL_MIN_ENTRANTS || + prev.rate <= 0 + ) { + return null; + } + if ( + Math.abs(safeDeltaPercent(cur.rate, prev.rate)) < + FUNNEL_CONVERSION_WOW_THRESHOLD + ) { + return null; + } + return makeWowSignal( + `funnel:${funnel.id}`, + `Funnel "${funnel.name}" conversion`, + cur.rate, + prev.rate, + current.to, + true + ); + } catch { + return null; + } + }) + ); + + const goalSignals = await Promise.all( + goalDefs.map(async (goal) => { + try { + const [cur, prev] = await Promise.all([ + deps.goalConversion(goal, current), + deps.goalConversion(goal, previous), + ]); + if ( + Math.max(cur.completions, prev.completions) < GOAL_MIN_COMPLETIONS || + prev.rate <= 0 + ) { + return null; + } + if ( + Math.abs(safeDeltaPercent(cur.rate, prev.rate)) < + GOAL_CONVERSION_WOW_THRESHOLD + ) { + return null; + } + return makeWowSignal( + `goal:${goal.id}`, + `Goal "${goal.name}" completion rate`, + cur.rate, + prev.rate, + current.to, + true + ); + } catch { + return null; + } + }) + ); + + return [...funnelSignals, ...goalSignals].filter( + (signal): signal is DetectedSignal => signal !== null + ); +} diff --git a/apps/insights/src/generation.ts b/apps/insights/src/generation.ts index b02742417..89a2dd828 100644 --- a/apps/insights/src/generation.ts +++ b/apps/insights/src/generation.ts @@ -3,12 +3,8 @@ import { ANTHROPIC_CACHE_1H, createModelFromId, } from "@databuddy/ai/config/models"; -import { insightDedupeKey } from "@databuddy/ai/insights/dedupe"; import { hasWebInsightData } from "@databuddy/ai/insights/fetch-context"; -import type { - InsightMetricRow, - WeekOverWeekPeriod, -} from "@databuddy/ai/insights/types"; +import type { WeekOverWeekPeriod } from "@databuddy/ai/insights/types"; import { validateInsights } from "@databuddy/ai/insights/validate"; import { getAILogger } from "@databuddy/ai/lib/ai-logger"; import { storeAnalyticsSummary } from "@databuddy/ai/lib/supermemory"; @@ -16,34 +12,25 @@ import type { ParsedInsight } from "@databuddy/ai/schemas/smart-insights-output" import { insightSchema } from "@databuddy/ai/schemas/smart-insights-output"; import { createToolkit } from "@databuddy/ai/tools/toolkit"; import { createInsightsAgentTools } from "@databuddy/ai/tools/insights-agent-tools"; +import { and, db, eq, isNull } from "@databuddy/db"; import { - and, - db, - desc, - eq, - gte, - inArray, - isNotNull, - isNull, - sql, -} from "@databuddy/db"; -import { - analyticsInsights, type InsightGenerationConfigSnapshot, type InsightGenerationTool, websites, } from "@databuddy/db/schema"; -import { - invalidateAgentContextSnapshotsForWebsite, - invalidateInsightsCachesForOrganization, -} from "@databuddy/redis"; import { getCachedSiteContext } from "@databuddy/ai/tools/scrape-page"; import { getOAuthToken } from "@databuddy/ai/tools/utils/oauth-token"; import { stepCountIs, tool, ToolLoopAgent } from "ai"; import { randomUUIDv7 } from "bun"; import dayjs from "dayjs"; import { detectSignals } from "./detection"; +import { detectFunnelGoalSignals } from "./funnel-detection"; import { enrichSignals, type EnrichedSignal } from "./enrichment"; +import { + type GeneratedWebsiteInsight, + maxInsights, + persistWebsiteInsights, +} from "./persistence"; import { buildInvestigationPrompt, buildSystemPrompt, @@ -60,7 +47,6 @@ import { setInsightsLog, } from "./lib/evlog-insights"; -const DEFAULT_MAX_INSIGHTS = 2; const TOOL_NAMES = [ "web_metrics", "product_metrics", @@ -78,13 +64,6 @@ const ALWAYS_ON_TOOLS = new Set([ "create_goal", ]); -interface GeneratedWebsiteInsight extends ParsedInsight { - id: string; - websiteDomain: string; - websiteId: string; - websiteName: string | null; -} - export interface GenerateWebsiteInsightsInput { config: InsightGenerationConfigSnapshot; organizationId: string; @@ -101,13 +80,6 @@ export interface GenerateWebsiteInsightsResult { status: "skipped" | "succeeded"; } -function maxInsights(config: InsightGenerationConfigSnapshot): number { - return Math.max( - 1, - Math.min(10, config.maxInsightsPerWebsite || DEFAULT_MAX_INSIGHTS) - ); -} - function getComparisonPeriod(lookbackDays: number): WeekOverWeekPeriod { const days = Math.max(1, Math.min(90, lookbackDays)); const now = dayjs(); @@ -157,57 +129,6 @@ function normalizeAllowedTools( return TOOL_NAMES.filter((t) => allowed.has(t)); } -function dedupeKeyFor(insight: GeneratedWebsiteInsight): string { - return insightDedupeKey({ - ...insight, - changePercent: insight.changePercent ?? null, - }); -} - -async function fetchInsightDedupeKeyToIdMap( - organizationId: string, - cooldownHours: number -): Promise> { - const cutoff = dayjs().subtract(Math.max(1, cooldownHours), "hour").toDate(); - const rows = await db - .select({ - id: analyticsInsights.id, - websiteId: analyticsInsights.websiteId, - type: analyticsInsights.type, - sentiment: analyticsInsights.sentiment, - changePercent: analyticsInsights.changePercent, - dedupeKey: analyticsInsights.dedupeKey, - subjectKey: analyticsInsights.subjectKey, - title: analyticsInsights.title, - }) - .from(analyticsInsights) - .where( - and( - eq(analyticsInsights.organizationId, organizationId), - gte(analyticsInsights.createdAt, cutoff) - ) - ) - .orderBy(desc(analyticsInsights.createdAt)); - - const map = new Map(); - for (const row of rows) { - const key = - row.dedupeKey ?? - insightDedupeKey({ - websiteId: row.websiteId, - type: row.type as ParsedInsight["type"], - sentiment: row.sentiment as ParsedInsight["sentiment"], - changePercent: row.changePercent, - subjectKey: row.subjectKey, - title: row.title, - }); - if (!map.has(key)) { - map.set(key, row.id); - } - } - return map; -} - function validateCollectedInsights( insights: ParsedInsight[], context: { @@ -270,11 +191,18 @@ async function analyzeWebsite(params: { let enrichedSignals: EnrichedSignal[] = []; try { - const signals = await detectSignals({ + const detectParams = { websiteId: params.websiteId, lookbackDays: params.config.lookbackDays, timezone: params.config.timezone, - }); + }; + const [metricSignals, funnelGoalSignals] = await Promise.all([ + detectSignals(detectParams), + detectFunnelGoalSignals(detectParams), + ]); + const signals = [...metricSignals, ...funnelGoalSignals].sort( + (a, b) => Math.abs(b.deltaPercent) - Math.abs(a.deltaPercent) + ); if (signals.length > 0) { const githubToken = params.githubRepo ? await getOAuthToken("github", params.organizationId, params.userId) @@ -495,190 +423,6 @@ ${orgContext}${annotationContext}${historyBlock}${dismissedBlock}`; } } -async function persistWebsiteInsights(params: { - config: InsightGenerationConfigSnapshot; - insights: GeneratedWebsiteInsight[]; - organizationId: string; - period: WeekOverWeekPeriod; - runId: string; -}): Promise { - const startedAt = performance.now(); - const dedupeKeyToId = await fetchInsightDedupeKeyToIdMap( - params.organizationId, - params.config.cooldownHours - ); - const seenInBatch = new Set(); - const finalInsights: GeneratedWebsiteInsight[] = []; - let duplicateCandidates = 0; - - for (const insight of [...params.insights].sort( - (a, b) => b.priority - a.priority - )) { - const key = dedupeKeyFor(insight); - if (seenInBatch.has(key)) { - duplicateCandidates += 1; - continue; - } - seenInBatch.add(key); - const existingId = dedupeKeyToId.get(key); - finalInsights.push(existingId ? { ...insight, id: existingId } : insight); - if (finalInsights.length >= maxInsights(params.config)) { - break; - } - } - - if (finalInsights.length === 0) { - emitInsightsEvent("info", "generation.persistence.skipped_empty", { - organization_id: params.organizationId, - run_id: params.runId, - candidate_count: params.insights.length, - duplicate_candidate_count: duplicateCandidates, - dedupe_window_count: dedupeKeyToId.size, - }); - return []; - } - - function insightRow(insight: GeneratedWebsiteInsight, key: string) { - return { - id: insight.id, - organizationId: params.organizationId, - websiteId: insight.websiteId, - runId: params.runId, - title: insight.title, - description: insight.description, - suggestion: insight.suggestion, - severity: insight.severity, - sentiment: insight.sentiment, - type: insight.type, - priority: insight.priority, - changePercent: insight.changePercent ?? null, - dedupeKey: key, - subjectKey: insight.subjectKey, - sources: insight.sources, - confidence: insight.confidence, - impactSummary: insight.impactSummary ?? null, - rootCause: insight.rootCause ?? null, - evidence: insight.evidence ?? null, - investigationDepth: insight.investigationDepth ?? null, - actions: insight.actions ?? null, - metrics: - insight.metrics.length > 0 - ? (insight.metrics as InsightMetricRow[]) - : null, - timezone: params.config.timezone, - currentPeriodFrom: params.period.current.from, - currentPeriodTo: params.period.current.to, - previousPeriodFrom: params.period.previous.from, - previousPeriodTo: params.period.previous.to, - }; - } - - const insightsWithKeys = finalInsights.map((insight) => { - const key = dedupeKeyFor(insight); - const existingId = dedupeKeyToId.get(key); - const isRefresh = existingId !== undefined && insight.id === existingId; - return { insight, key, isRefresh }; - }); - - const toInsert = insightsWithKeys - .filter((i) => !i.isRefresh) - .map(({ insight, key }) => insightRow(insight, key)); - - const toRefresh = insightsWithKeys - .filter((i) => i.isRefresh) - .map(({ insight, key }) => ({ - id: insight.id, - row: insightRow(insight, key), - })); - - if (toInsert.length > 0) { - await db - .insert(analyticsInsights) - .values(toInsert) - .onConflictDoUpdate({ - target: [analyticsInsights.organizationId, analyticsInsights.dedupeKey], - targetWhere: isNotNull(analyticsInsights.dedupeKey), - set: { - runId: params.runId, - timezone: params.config.timezone, - currentPeriodFrom: params.period.current.from, - currentPeriodTo: params.period.current.to, - previousPeriodFrom: params.period.previous.from, - previousPeriodTo: params.period.previous.to, - createdAt: new Date(), - title: sql.raw("excluded.title"), - description: sql.raw("excluded.description"), - suggestion: sql.raw("excluded.suggestion"), - severity: sql.raw("excluded.severity"), - sentiment: sql.raw("excluded.sentiment"), - type: sql.raw("excluded.type"), - priority: sql.raw("excluded.priority"), - changePercent: sql.raw("excluded.change_percent"), - subjectKey: sql.raw("excluded.subject_key"), - sources: sql.raw("excluded.sources"), - confidence: sql.raw("excluded.confidence"), - impactSummary: sql.raw("excluded.impact_summary"), - rootCause: sql.raw("excluded.root_cause"), - evidence: sql.raw("excluded.evidence"), - investigationDepth: sql.raw("excluded.investigation_depth"), - actions: sql.raw("excluded.actions"), - metrics: sql.raw("excluded.metrics"), - }, - }); - } - await Promise.all( - toRefresh.map(({ id, row }) => - db.update(analyticsInsights).set(row).where(eq(analyticsInsights.id, id)) - ) - ); - - const persistedRows = await db - .select({ - dedupeKey: analyticsInsights.dedupeKey, - id: analyticsInsights.id, - }) - .from(analyticsInsights) - .where( - and( - eq(analyticsInsights.organizationId, params.organizationId), - inArray( - analyticsInsights.dedupeKey, - finalInsights.map((insight) => dedupeKeyFor(insight)) - ) - ) - ); - const persistedIdByDedupeKey = new Map( - persistedRows.flatMap((row) => - row.dedupeKey ? [[row.dedupeKey, row.id] as const] : [] - ) - ); - const persistedInsights = finalInsights.map((insight) => { - const persistedId = persistedIdByDedupeKey.get(dedupeKeyFor(insight)); - return persistedId ? { ...insight, id: persistedId } : insight; - }); - - const websiteInvalidations = [ - ...new Set(persistedInsights.map((insight) => insight.websiteId)), - ].map((websiteId) => invalidateAgentContextSnapshotsForWebsite(websiteId)); - - await Promise.all([ - invalidateInsightsCachesForOrganization(params.organizationId), - ...websiteInvalidations, - ]); - - emitInsightsEvent("info", "generation.persistence.completed", { - organization_id: params.organizationId, - run_id: params.runId, - duration_ms: Math.round(performance.now() - startedAt), - result_count: persistedInsights.length, - insert_count: toInsert.length, - refresh_count: toRefresh.length, - invalidated_website_count: websiteInvalidations.length, - }); - - return persistedInsights; -} - function storeWebsiteSummary( site: OrgWebsiteRow, insights: GeneratedWebsiteInsight[] diff --git a/apps/insights/src/persistence.ts b/apps/insights/src/persistence.ts new file mode 100644 index 000000000..9848a76fc --- /dev/null +++ b/apps/insights/src/persistence.ts @@ -0,0 +1,292 @@ +import { insightDedupeKey } from "@databuddy/ai/insights/dedupe"; +import type { + InsightMetricRow, + WeekOverWeekPeriod, +} from "@databuddy/ai/insights/types"; +import type { ParsedInsight } from "@databuddy/ai/schemas/smart-insights-output"; +import { + and, + db, + desc, + eq, + getTableColumns, + gte, + inArray, + isNotNull, + sql, +} from "@databuddy/db"; +import { + analyticsInsights, + type InsightGenerationConfigSnapshot, +} from "@databuddy/db/schema"; +import { + invalidateAgentContextSnapshotsForWebsite, + invalidateInsightsCachesForOrganization, +} from "@databuddy/redis"; +import dayjs from "dayjs"; +import { emitInsightsEvent } from "./lib/evlog-insights"; + +const DEFAULT_MAX_INSIGHTS = 2; + +const REFRESHED_INSIGHT_COLUMNS = [ + "title", + "description", + "suggestion", + "severity", + "sentiment", + "type", + "priority", + "changePercent", + "subjectKey", + "sources", + "confidence", + "impactSummary", + "rootCause", + "evidence", + "investigationDepth", + "actions", + "metrics", +] as const satisfies readonly (keyof typeof analyticsInsights.$inferInsert)[]; + +export interface GeneratedWebsiteInsight extends ParsedInsight { + id: string; + websiteDomain: string; + websiteId: string; + websiteName: string | null; +} + +export function maxInsights(config: InsightGenerationConfigSnapshot): number { + return Math.max( + 1, + Math.min(10, config.maxInsightsPerWebsite || DEFAULT_MAX_INSIGHTS) + ); +} + +function excludedRefreshSet() { + const columns = getTableColumns(analyticsInsights); + return Object.fromEntries( + REFRESHED_INSIGHT_COLUMNS.map((key) => [ + key, + sql.raw(`excluded.${columns[key].name}`), + ]) + ); +} + +function dedupeKeyFor(insight: GeneratedWebsiteInsight): string { + return insightDedupeKey({ + ...insight, + changePercent: insight.changePercent ?? null, + }); +} + +async function fetchInsightDedupeKeyToIdMap( + organizationId: string, + cooldownHours: number +): Promise> { + const cutoff = dayjs().subtract(Math.max(1, cooldownHours), "hour").toDate(); + const rows = await db + .select({ + id: analyticsInsights.id, + websiteId: analyticsInsights.websiteId, + type: analyticsInsights.type, + sentiment: analyticsInsights.sentiment, + changePercent: analyticsInsights.changePercent, + dedupeKey: analyticsInsights.dedupeKey, + subjectKey: analyticsInsights.subjectKey, + title: analyticsInsights.title, + }) + .from(analyticsInsights) + .where( + and( + eq(analyticsInsights.organizationId, organizationId), + gte(analyticsInsights.createdAt, cutoff) + ) + ) + .orderBy(desc(analyticsInsights.createdAt)); + + const map = new Map(); + for (const row of rows) { + const key = + row.dedupeKey ?? + insightDedupeKey({ + websiteId: row.websiteId, + type: row.type as ParsedInsight["type"], + sentiment: row.sentiment as ParsedInsight["sentiment"], + changePercent: row.changePercent, + subjectKey: row.subjectKey, + title: row.title, + }); + if (!map.has(key)) { + map.set(key, row.id); + } + } + return map; +} + +export async function persistWebsiteInsights(params: { + config: InsightGenerationConfigSnapshot; + insights: GeneratedWebsiteInsight[]; + organizationId: string; + period: WeekOverWeekPeriod; + runId: string; +}): Promise { + const startedAt = performance.now(); + const dedupeKeyToId = await fetchInsightDedupeKeyToIdMap( + params.organizationId, + params.config.cooldownHours + ); + const seenInBatch = new Set(); + const finalInsights: GeneratedWebsiteInsight[] = []; + let duplicateCandidates = 0; + + for (const insight of [...params.insights].sort( + (a, b) => b.priority - a.priority + )) { + const key = dedupeKeyFor(insight); + if (seenInBatch.has(key)) { + duplicateCandidates += 1; + continue; + } + seenInBatch.add(key); + const existingId = dedupeKeyToId.get(key); + finalInsights.push(existingId ? { ...insight, id: existingId } : insight); + if (finalInsights.length >= maxInsights(params.config)) { + break; + } + } + + if (finalInsights.length === 0) { + emitInsightsEvent("info", "generation.persistence.skipped_empty", { + organization_id: params.organizationId, + run_id: params.runId, + candidate_count: params.insights.length, + duplicate_candidate_count: duplicateCandidates, + dedupe_window_count: dedupeKeyToId.size, + }); + return []; + } + + function insightRow(insight: GeneratedWebsiteInsight, key: string) { + return { + id: insight.id, + organizationId: params.organizationId, + websiteId: insight.websiteId, + runId: params.runId, + title: insight.title, + description: insight.description, + suggestion: insight.suggestion, + severity: insight.severity, + sentiment: insight.sentiment, + type: insight.type, + priority: insight.priority, + changePercent: insight.changePercent ?? null, + dedupeKey: key, + subjectKey: insight.subjectKey, + sources: insight.sources, + confidence: insight.confidence, + impactSummary: insight.impactSummary ?? null, + rootCause: insight.rootCause ?? null, + evidence: insight.evidence ?? null, + investigationDepth: insight.investigationDepth ?? null, + actions: insight.actions ?? null, + metrics: + insight.metrics.length > 0 + ? (insight.metrics as InsightMetricRow[]) + : null, + timezone: params.config.timezone, + currentPeriodFrom: params.period.current.from, + currentPeriodTo: params.period.current.to, + previousPeriodFrom: params.period.previous.from, + previousPeriodTo: params.period.previous.to, + }; + } + + const insightsWithKeys = finalInsights.map((insight) => { + const key = dedupeKeyFor(insight); + const existingId = dedupeKeyToId.get(key); + const isRefresh = existingId !== undefined && insight.id === existingId; + return { insight, key, isRefresh }; + }); + + const toInsert = insightsWithKeys + .filter((i) => !i.isRefresh) + .map(({ insight, key }) => insightRow(insight, key)); + + const toRefresh = insightsWithKeys + .filter((i) => i.isRefresh) + .map(({ insight, key }) => ({ + id: insight.id, + row: insightRow(insight, key), + })); + + if (toInsert.length > 0) { + await db + .insert(analyticsInsights) + .values(toInsert) + .onConflictDoUpdate({ + target: [analyticsInsights.organizationId, analyticsInsights.dedupeKey], + targetWhere: isNotNull(analyticsInsights.dedupeKey), + set: { + runId: params.runId, + timezone: params.config.timezone, + currentPeriodFrom: params.period.current.from, + currentPeriodTo: params.period.current.to, + previousPeriodFrom: params.period.previous.from, + previousPeriodTo: params.period.previous.to, + createdAt: new Date(), + ...excludedRefreshSet(), + }, + }); + } + await Promise.all( + toRefresh.map(({ id, row }) => + db.update(analyticsInsights).set(row).where(eq(analyticsInsights.id, id)) + ) + ); + + const persistedRows = await db + .select({ + dedupeKey: analyticsInsights.dedupeKey, + id: analyticsInsights.id, + }) + .from(analyticsInsights) + .where( + and( + eq(analyticsInsights.organizationId, params.organizationId), + inArray( + analyticsInsights.dedupeKey, + finalInsights.map((insight) => dedupeKeyFor(insight)) + ) + ) + ); + const persistedIdByDedupeKey = new Map( + persistedRows.flatMap((row) => + row.dedupeKey ? [[row.dedupeKey, row.id] as const] : [] + ) + ); + const persistedInsights = finalInsights.map((insight) => { + const persistedId = persistedIdByDedupeKey.get(dedupeKeyFor(insight)); + return persistedId ? { ...insight, id: persistedId } : insight; + }); + + const websiteInvalidations = [ + ...new Set(persistedInsights.map((insight) => insight.websiteId)), + ].map((websiteId) => invalidateAgentContextSnapshotsForWebsite(websiteId)); + + await Promise.all([ + invalidateInsightsCachesForOrganization(params.organizationId), + ...websiteInvalidations, + ]); + + emitInsightsEvent("info", "generation.persistence.completed", { + organization_id: params.organizationId, + run_id: params.runId, + duration_ms: Math.round(performance.now() - startedAt), + result_count: persistedInsights.length, + insert_count: toInsert.length, + refresh_count: toRefresh.length, + invalidated_website_count: websiteInvalidations.length, + }); + + return persistedInsights; +} diff --git a/packages/rpc/package.json b/packages/rpc/package.json index 53de54656..fb5994ff9 100644 --- a/packages/rpc/package.json +++ b/packages/rpc/package.json @@ -12,6 +12,7 @@ }, "exports": { ".": "./src/index.ts", + "./analytics-utils": "./src/lib/analytics-utils.ts", "./autumn": "./src/lib/autumn-client.ts", "./billing": "./src/utils/billing.ts", "./flags": "./src/utils/flags.ts", From 33b62467af59139b94d86fba94ce1433370830b8 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 29 May 2026 19:25:01 +0300 Subject: [PATCH 09/34] fix(rpc): stop swallowing DB errors in workspace lookups Transient DB failures in getWebsiteById and getOrganizationRole were caught and degraded to null, surfacing as misleading 404/403 responses that hid the real incident. Let errors propagate to the ORPC handler. --- packages/rpc/src/procedures/with-workspace.ts | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/packages/rpc/src/procedures/with-workspace.ts b/packages/rpc/src/procedures/with-workspace.ts index 280e42ff1..aec279fd2 100644 --- a/packages/rpc/src/procedures/with-workspace.ts +++ b/packages/rpc/src/procedures/with-workspace.ts @@ -10,7 +10,7 @@ import { cacheNamespaces, cacheable } from "@databuddy/redis"; import type { PlanId } from "@databuddy/shared/types/features"; import { z } from "zod"; import { rpcError } from "../errors"; -import { logger, record } from "../lib/logger"; +import { record } from "../lib/logger"; import { type Context, os } from "../orpc"; type Website = NonNullable>>; @@ -42,14 +42,9 @@ const getWebsiteById = cacheable( if (!id) { return null; } - try { - return await db.query.websites.findFirst({ - where: { id }, - }); - } catch (error) { - logger.error({ error, id }, "Error fetching website by ID"); - return null; - } + return await db.query.websites.findFirst({ + where: { id }, + }); }, { expireInSec: 600, @@ -63,16 +58,11 @@ const _getOrganizationRole = async ( userId: string, organizationId: string ): Promise => { - try { - const membership = await db.query.member.findFirst({ - where: { userId, organizationId }, - columns: { role: true }, - }); - return membership?.role ?? null; - } catch (error) { - logger.error({ error, userId, organizationId }, "Error fetching org role"); - return null; - } + const membership = await db.query.member.findFirst({ + where: { userId, organizationId }, + columns: { role: true }, + }); + return membership?.role ?? null; }; const getOrganizationRole = cacheable(_getOrganizationRole, { From 93d92a94060f44b6e2eb23b49730446aaac9dad3 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 29 May 2026 19:27:54 +0300 Subject: [PATCH 10/34] feat(auth): forward Better-Auth internal logs to evlog Better-Auth wraps internal failures (e.g. session-refresh DB writes) as a generic "Failed to get session" and logs the real cause only to its default console logger, so the underlying error never reached Axiom. Wire a logger.log forwarder to evlog to surface the actual error. --- packages/auth/src/auth.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index af867f9e2..9fdc8e1a5 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -193,7 +193,34 @@ async function invalidateMemberCaches(member: { } } +type AuthLogLevel = "info" | "warn" | "error" | "debug"; + +function forwardAuthLog( + level: AuthLogLevel, + message: string, + ...args: unknown[] +): void { + const cause = args.find((arg): arg is Error => arg instanceof Error); + const fields = { + service: "auth", + auth_logger: message, + ...(cause && { error: cause.message, error_stack: cause.stack }), + }; + if (level === "error") { + log.error(fields); + return; + } + if (level === "warn") { + log.warn(fields); + return; + } + log.info(fields); +} + export const auth = betterAuth({ + logger: { + log: forwardAuthLog, + }, database: drizzleAdapter(db, { provider: "pg", schema, From 29ce1cb4f5df44410e5590a63db9d68d1cd3d650 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 29 May 2026 19:29:44 +0300 Subject: [PATCH 11/34] fix(rpc): stop swallowing DB errors when resolving billing owner A transient DB failure in _getOrganizationOwnerId was caught and degraded to null, which fell back to billing the member instead of the org owner. Let the error propagate so identity is never silently wrong. --- packages/rpc/src/utils/billing.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/rpc/src/utils/billing.ts b/packages/rpc/src/utils/billing.ts index ded5e467c..d3547b503 100644 --- a/packages/rpc/src/utils/billing.ts +++ b/packages/rpc/src/utils/billing.ts @@ -16,16 +16,11 @@ const _getOrganizationOwnerId = async ( if (!organizationId) { return null; } - try { - const orgMember = await db.query.member.findFirst({ - where: { organizationId, role: "owner" }, - columns: { userId: true }, - }); - return orgMember?.userId ?? null; - } catch (error) { - logger.error({ error }, "Error resolving organization owner"); - return null; - } + const orgMember = await db.query.member.findFirst({ + where: { organizationId, role: "owner" }, + columns: { userId: true }, + }); + return orgMember?.userId ?? null; }; export const getOrganizationOwnerId = cacheable(_getOrganizationOwnerId, { From a9c4c8b95d69f3a5adb1f98bce03bef3e9158430 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 29 May 2026 19:29:53 +0300 Subject: [PATCH 12/34] refactor(rpc): drop redundant JSDoc from billing plan helpers Names and signatures already document these pure helpers; the JSDoc only restated them and the example blocks duplicated the same call pattern. --- packages/rpc/src/types/billing.ts | 80 ------------------------------- 1 file changed, 80 deletions(-) diff --git a/packages/rpc/src/types/billing.ts b/packages/rpc/src/types/billing.ts index d6dc58a9c..503616f88 100644 --- a/packages/rpc/src/types/billing.ts +++ b/packages/rpc/src/types/billing.ts @@ -13,31 +13,13 @@ import { } from "@databuddy/shared/types/features"; import { ORPCError } from "@orpc/server"; -/** - * Billing context included in the RPC context - * This is automatically populated when a user is authenticated - */ export interface BillingContext { - /** Whether the current user can upgrade the plan */ canUserUpgrade: boolean; - /** The customer ID for billing (user ID or org owner ID) */ customerId: string; - /** Whether the billing is based on an organization */ isOrganization: boolean; - /** The current plan ID (e.g., 'free', 'hobby', 'pro', 'scale') */ planId: string; } -/** - * Helper to check if a user has a specific plan or higher - * - * @example - * ```ts - * if (hasPlan((await context.getBilling())?.planId, PLAN_IDS.PRO)) { - * // User has pro or higher - * } - * ``` - */ export function hasPlan( currentPlan: string | undefined, requiredPlan: PlanId @@ -56,31 +38,10 @@ export function hasPlan( return currentIndex >= requiredIndex; } -/** - * Helper to check if a user is on the free plan - * - * @example - * ```ts - * if (isFreePlan((await context.getBilling())?.planId)) { - * throw errors.FEATURE_UNAVAILABLE({ data: { feature: "export" } }); - * } - * ``` - */ export function isFreePlan(planId: string | undefined): boolean { return !planId || planId.toLowerCase() === PLAN_IDS.FREE; } -/** - * Get the feature limit for the user's plan - * - * @example - * ```ts - * const limit = getFeatureLimit((await context.getBilling())?.planId, GATED_FEATURES.FUNNELS); - * if (limit === false) { - * throw errors.FEATURE_UNAVAILABLE({ data: { feature: "funnels" } }); - * } - * ``` - */ export function getFeatureLimit( planId: string | undefined, feature: GatedFeatureId @@ -88,17 +49,6 @@ export function getFeatureLimit( return getPlanFeatureLimit(planId ?? null, feature); } -/** - * Check if current usage is within the plan's limit for a feature - * - * @example - * ```ts - * const funnelCount = await getFunnelCount(websiteId); - * if (!isUsageWithinLimit((await context.getBilling())?.planId, GATED_FEATURES.FUNNELS, funnelCount)) { - * throw errors.PLAN_LIMIT_EXCEEDED({ data: { limit: 5, current: funnelCount } }); - * } - * ``` - */ export function isUsageWithinLimit( planId: string | undefined, feature: GatedFeatureId, @@ -107,16 +57,6 @@ export function isUsageWithinLimit( return isWithinLimit(planId ?? null, feature, currentUsage); } -/** - * Throws an error if the feature is not available on the user's plan. - * Uses FEATURE_UNAVAILABLE error code for type-safe client handling. - * - * @example - * ```ts - * requireFeature((await context.getBilling())?.planId, GATED_FEATURES.FUNNELS); - * // Throws if user doesn't have access - * ``` - */ export function requireFeature( planId: string | undefined, feature: GatedFeatureId @@ -132,16 +72,6 @@ export function requireFeature( } } -/** - * Checks feature availability AND usage limit in one call. - * Throws FEATURE_UNAVAILABLE if the feature isn't on the plan, - * or PLAN_LIMIT_EXCEEDED if the usage limit is reached. - * - * @example - * ```ts - * requireFeatureWithLimit(workspace.plan, GATED_FEATURES.FUNNELS, existingCount); - * ``` - */ export function requireFeatureWithLimit( planId: string | undefined, feature: GatedFeatureId, @@ -151,16 +81,6 @@ export function requireFeatureWithLimit( requireUsageWithinLimit(planId, feature, currentUsage); } -/** - * Throws an error if current usage exceeds the plan's limit. - * Uses PLAN_LIMIT_EXCEEDED error code for type-safe client handling. - * - * @example - * ```ts - * const funnelCount = await db.query.funnels.findMany({ where: eq(funnels.websiteId, websiteId) }).length; - * requireUsageWithinLimit(workspace.plan, GATED_FEATURES.FUNNELS, funnelCount); - * ``` - */ export function requireUsageWithinLimit( planId: string | undefined, feature: GatedFeatureId, From a61d6682b67e20a9c0d0e460a5f6fb3d4ddd2e4d Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 29 May 2026 19:32:24 +0300 Subject: [PATCH 13/34] refactor(api): parse Autumn webhook payloads with Zod at the boundary Replace unchecked `as` casts of untrusted webhook data with Zod schemas, deriving the payload types via z.infer. Malformed payloads now return a logged 400 instead of crashing deep inside a handler. --- apps/api/src/routes/webhooks/autumn.ts | 103 +++++++++++++++---------- 1 file changed, 62 insertions(+), 41 deletions(-) diff --git a/apps/api/src/routes/webhooks/autumn.ts b/apps/api/src/routes/webhooks/autumn.ts index 96fff5c7d..690405601 100644 --- a/apps/api/src/routes/webhooks/autumn.ts +++ b/apps/api/src/routes/webhooks/autumn.ts @@ -21,6 +21,7 @@ import { Elysia } from "elysia"; import { useLogger } from "evlog/elysia"; import { Resend } from "resend"; import { Webhook } from "svix"; +import { z } from "zod"; import { mergeWideEvent } from "../../lib/tracing"; const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; @@ -32,43 +33,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 +396,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 +456,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" } ); From d14b453de2f71eae639405f71bc9b2216bade1b2 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Sat, 30 May 2026 20:25:39 +0300 Subject: [PATCH 14/34] feat(insights): bill agent usage and gate generation on credits Add a resolveInsightsBilling DI seam that resolves the billing customer and checks agent credits before running the website agent, skipping generation when credits are exhausted. Track usage against Autumn after each agent run. Align getComparisonPeriod with the detection wowWindow so the agent and signal detection share the same week-over-week window. --- apps/insights/src/billing.test.ts | 91 +++++++++++++++++++++++++++++++ apps/insights/src/billing.ts | 36 ++++++++++++ apps/insights/src/generation.ts | 89 +++++++++++++++++++++--------- 3 files changed, 189 insertions(+), 27 deletions(-) create mode 100644 apps/insights/src/billing.test.ts create mode 100644 apps/insights/src/billing.ts diff --git a/apps/insights/src/billing.test.ts b/apps/insights/src/billing.test.ts new file mode 100644 index 000000000..f688c1396 --- /dev/null +++ b/apps/insights/src/billing.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; +import { + type InsightsBillingDeps, + resolveInsightsBilling, +} from "./billing"; + +function makeDeps( + overrides: Partial & { + customerId?: string | null; + allowed?: boolean; + } = {} +): { + deps: InsightsBillingDeps; + calls: { + resolveArgs: Parameters[0][]; + ensureArgs: (string | null)[]; + }; +} { + const calls = { + resolveArgs: [] as Parameters< + InsightsBillingDeps["resolveBillingCustomerId"] + >[0][], + ensureArgs: [] as (string | null)[], + }; + const deps: InsightsBillingDeps = { + resolveBillingCustomerId: + overrides.resolveBillingCustomerId ?? + ((principal) => { + calls.resolveArgs.push(principal); + return Promise.resolve(overrides.customerId ?? null); + }), + ensureCreditsAvailable: + overrides.ensureCreditsAvailable ?? + ((id) => { + calls.ensureArgs.push(id); + return Promise.resolve(overrides.allowed ?? true); + }), + }; + return { deps, calls }; +} + +describe("resolveInsightsBilling", () => { + it("allows and checks against null when there is no billing customer", async () => { + const { deps, calls } = makeDeps({ customerId: null, allowed: true }); + + const decision = await resolveInsightsBilling( + { organizationId: "org_1", userId: "user_1" }, + deps + ); + + expect(decision).toEqual({ allowed: true, billingCustomerId: null }); + expect(calls.ensureArgs).toEqual([null]); + }); + + it("denies when the resolved customer is out of credits", async () => { + const { deps, calls } = makeDeps({ customerId: "cust_9", allowed: false }); + + const decision = await resolveInsightsBilling( + { organizationId: "org_1", userId: "user_1" }, + deps + ); + + expect(decision).toEqual({ allowed: false, billingCustomerId: "cust_9" }); + expect(calls.ensureArgs).toEqual(["cust_9"]); + }); + + it("checks credits against the resolved customer id", async () => { + const { deps, calls } = makeDeps({ customerId: "cust_42", allowed: true }); + + const decision = await resolveInsightsBilling( + { organizationId: "org_1", userId: "user_1" }, + deps + ); + + expect(decision.billingCustomerId).toBe("cust_42"); + expect(calls.ensureArgs).toEqual(["cust_42"]); + }); + + it("passes the principal through to customer resolution", async () => { + const { deps, calls } = makeDeps({ customerId: "cust_1" }); + + await resolveInsightsBilling( + { organizationId: "org_7", userId: null }, + deps + ); + + expect(calls.resolveArgs).toEqual([ + { organizationId: "org_7", userId: null }, + ]); + }); +}); diff --git a/apps/insights/src/billing.ts b/apps/insights/src/billing.ts new file mode 100644 index 000000000..d1a56e365 --- /dev/null +++ b/apps/insights/src/billing.ts @@ -0,0 +1,36 @@ +import { + ensureAgentCreditsAvailable, + resolveAgentBillingCustomerId, +} from "@databuddy/ai/agents/execution"; + +export interface InsightsBillingDeps { + ensureCreditsAvailable: ( + billingCustomerId: string | null + ) => Promise; + resolveBillingCustomerId: (principal: { + organizationId?: string | null; + userId?: string | null; + }) => Promise; +} + +export interface InsightsBillingDecision { + allowed: boolean; + billingCustomerId: string | null; +} + +const defaultBillingDeps: InsightsBillingDeps = { + ensureCreditsAvailable: ensureAgentCreditsAvailable, + resolveBillingCustomerId: resolveAgentBillingCustomerId, +}; + +export async function resolveInsightsBilling( + principal: { organizationId: string; userId: string | null }, + deps: InsightsBillingDeps = defaultBillingDeps +): Promise { + const billingCustomerId = await deps.resolveBillingCustomerId({ + organizationId: principal.organizationId, + userId: principal.userId, + }); + const allowed = await deps.ensureCreditsAvailable(billingCustomerId); + return { allowed, billingCustomerId }; +} diff --git a/apps/insights/src/generation.ts b/apps/insights/src/generation.ts index 89a2dd828..5a0d13d30 100644 --- a/apps/insights/src/generation.ts +++ b/apps/insights/src/generation.ts @@ -3,6 +3,7 @@ import { ANTHROPIC_CACHE_1H, createModelFromId, } from "@databuddy/ai/config/models"; +import { trackAgentUsageAndBill } from "@databuddy/ai/agents/execution"; import { hasWebInsightData } from "@databuddy/ai/insights/fetch-context"; import type { WeekOverWeekPeriod } from "@databuddy/ai/insights/types"; import { validateInsights } from "@databuddy/ai/insights/validate"; @@ -23,7 +24,8 @@ import { getOAuthToken } from "@databuddy/ai/tools/utils/oauth-token"; import { stepCountIs, tool, ToolLoopAgent } from "ai"; import { randomUUIDv7 } from "bun"; import dayjs from "dayjs"; -import { detectSignals } from "./detection"; +import { resolveInsightsBilling } from "./billing"; +import { detectSignals, wowWindow } from "./detection"; import { detectFunnelGoalSignals } from "./funnel-detection"; import { enrichSignals, type EnrichedSignal } from "./enrichment"; import { @@ -81,40 +83,41 @@ export interface GenerateWebsiteInsightsResult { } function getComparisonPeriod(lookbackDays: number): WeekOverWeekPeriod { - const days = Math.max(1, Math.min(90, lookbackDays)); - const now = dayjs(); + const window = wowWindow(dayjs(), lookbackDays); return { - current: { - from: now.subtract(days, "day").format("YYYY-MM-DD"), - to: now.format("YYYY-MM-DD"), - }, - previous: { - from: now.subtract(days * 2, "day").format("YYYY-MM-DD"), - to: now.subtract(days, "day").format("YYYY-MM-DD"), - }, + current: { from: window.currentFrom, to: window.currentTo }, + previous: { from: window.previousFrom, to: window.previousTo }, }; } +const INSIGHTS_MODEL_IDS = { + fast: "openai/gpt-5.4-mini", + balanced: "anthropic/claude-sonnet-4.6", + deep: "anthropic/claude-opus-4.7", +} as const; + const INSIGHTS_MODELS = { - quick: createModelFromId("openai/gpt-5.4-mini"), - balanced: createModelFromId("anthropic/claude-sonnet-4.6"), - deep: createModelFromId("anthropic/claude-opus-4.7"), + fast: createModelFromId(INSIGHTS_MODEL_IDS.fast), + balanced: createModelFromId(INSIGHTS_MODEL_IDS.balanced), + deep: createModelFromId(INSIGHTS_MODEL_IDS.deep), }; -function modelForTier( +type InsightsModelKey = keyof typeof INSIGHTS_MODELS; + +function modelKeyForTier( tier: InsightGenerationConfigSnapshot["modelTier"], hasCriticalSignals?: boolean -) { +): InsightsModelKey { if (tier === "fast") { - return INSIGHTS_MODELS.quick; + return "fast"; } if (tier === "deep") { - return INSIGHTS_MODELS.deep; + return "deep"; } if (tier === "balanced" && hasCriticalSignals) { - return INSIGHTS_MODELS.deep; + return "deep"; } - return INSIGHTS_MODELS.balanced; + return "balanced"; } function normalizeAllowedTools( @@ -152,6 +155,7 @@ function validateCollectedInsights( } async function analyzeWebsite(params: { + billingCustomerId: string | null; config: InsightGenerationConfigSnapshot; domain: string; githubRepo?: { owner: string; repo: string }; @@ -320,13 +324,12 @@ ${orgContext}${annotationContext}${historyBlock}${dismissedBlock}`; ...availableTools, emit_insight: emitInsightTool, }; + const modelKey = modelKeyForTier( + params.config.modelTier, + enrichedSignals.some((s) => s.severity === "critical") + ); const agent = new ToolLoopAgent({ - model: ai.wrap( - modelForTier( - params.config.modelTier, - enrichedSignals.some((s) => s.severity === "critical") - ) - ), + model: ai.wrap(INSIGHTS_MODELS[modelKey]), instructions: { role: "system", content: buildSystemPrompt(params.config, { investigationMode }), @@ -379,10 +382,21 @@ ${orgContext}${annotationContext}${historyBlock}${dismissedBlock}`; }, }); - await agent.generate({ + const result = await agent.generate({ messages: [{ role: "user", content: userPrompt }], }); + await trackAgentUsageAndBill({ + usage: result.totalUsage, + modelId: INSIGHTS_MODEL_IDS[modelKey], + source: "insights", + organizationId: params.organizationId, + userId: params.userId ?? null, + chatId: appContext.chatId, + billingCustomerId: params.billingCustomerId, + websiteId: params.websiteId, + }); + if (collected.length > 0) { const validated = validateCollectedInsights(collected, { config: params.config, @@ -506,6 +520,26 @@ export async function generateWebsiteInsights( organization_site_count: orgSites.length, }); + const { allowed, billingCustomerId } = await resolveInsightsBilling({ + organizationId: input.organizationId, + userId: input.requestedByUserId, + }); + if (!allowed) { + emitInsightsEvent("info", "generation.website.skipped_no_credits", { + organization_id: input.organizationId, + website_id: input.websiteId, + run_id: input.runId, + billing_customer_id: billingCustomerId, + duration_ms: Math.round(performance.now() - startedAt), + }); + return { + status: "skipped", + resultCount: 0, + insightIds: [], + message: "Insufficient agent credits", + }; + } + const period = getComparisonPeriod(input.config.lookbackDays); const userId = input.requestedByUserId ?? undefined; const ghIntegration = site.integrations?.github as @@ -513,6 +547,7 @@ export async function generateWebsiteInsights( | undefined; const insights = await analyzeWebsite({ + billingCustomerId, config: input.config, domain: site.domain, githubRepo: ghIntegration, From b73337a37d59c7b57728ca28fe3286784842d9a3 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Sat, 30 May 2026 20:38:21 +0300 Subject: [PATCH 15/34] fix(ai): allow "insights" as an agent usage source The insights generation pipeline bills agent usage via trackAgentUsageAndBill; widen the source union so it can identify itself as the insights source. --- packages/ai/src/ai/agents/execution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai/src/ai/agents/execution.ts b/packages/ai/src/ai/agents/execution.ts index 924460e17..79aa07f53 100644 --- a/packages/ai/src/ai/agents/execution.ts +++ b/packages/ai/src/ai/agents/execution.ts @@ -19,7 +19,7 @@ interface AgentUsageTrackingInput { chatId?: string; modelId: string; organizationId?: string | null; - source: "dashboard" | "mcp" | "slack"; + source: "dashboard" | "mcp" | "slack" | "insights"; usage: LanguageModelUsage; userId?: string | null; websiteId?: string; From 7a216427742bbf7df218b701758939a9babc7127 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Sat, 30 May 2026 20:38:31 +0300 Subject: [PATCH 16/34] style(docs): sort pricing table interface members and attributes Satisfy the lint assist rules (sorted interface members and JSX attributes) that were failing CI on the pricing table components. --- .../pricing/_pricing/gated-feature-rows.tsx | 40 ++++++++++++++----- .../app/(home)/pricing/_pricing/table.tsx | 6 +-- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/apps/docs/app/(home)/pricing/_pricing/gated-feature-rows.tsx b/apps/docs/app/(home)/pricing/_pricing/gated-feature-rows.tsx index dc362bbf9..61e31b156 100644 --- a/apps/docs/app/(home)/pricing/_pricing/gated-feature-rows.tsx +++ b/apps/docs/app/(home)/pricing/_pricing/gated-feature-rows.tsx @@ -108,16 +108,30 @@ const GATED_FEATURE_LINKS: Partial> = { }; interface PlatformFeature { - name: string; description: string; href?: string; + name: string; } const PLATFORM_FEATURES: PlatformFeature[] = [ - { name: "Uptime Monitoring", description: "Endpoint checks, alerts, and status pages", href: "/uptime" }, - { name: "Short Links", description: "Branded links with click analytics and deep linking", href: "/links" }, - { name: "Revenue Tracking", description: "Stripe and Paddle revenue attribution" }, - { name: "Alerts & Notifications", description: "Traffic, error, and anomaly alerts" }, + { + name: "Uptime Monitoring", + description: "Endpoint checks, alerts, and status pages", + href: "/uptime", + }, + { + name: "Short Links", + description: "Branded links with click analytics and deep linking", + href: "/links", + }, + { + name: "Revenue Tracking", + description: "Stripe and Paddle revenue attribution", + }, + { + name: "Alerts & Notifications", + description: "Traffic, error, and anomaly alerts", + }, { name: "Team Members", description: "Unlimited seats on all plans" }, { name: "Websites", description: "Unlimited websites on all plans" }, { name: "API Access", description: "REST API with scoped API keys" }, @@ -167,7 +181,7 @@ function AllPlansCheckRow({ className="px-4 py-3 text-left font-normal text-muted-foreground text-sm sm:px-5 lg:px-6" scope="row" > - + {plans.map((p) => ( @@ -210,7 +224,11 @@ export function GatedFeaturePricingRows({ className="px-4 py-3 text-left font-normal text-muted-foreground text-sm sm:px-5 lg:px-6" scope="row" > - + {plans.map((p) => ( @@ -249,10 +267,10 @@ export function GatedFeaturePricingRows({ {PLATFORM_FEATURES.map((feat) => ( diff --git a/apps/docs/app/(home)/pricing/_pricing/table.tsx b/apps/docs/app/(home)/pricing/_pricing/table.tsx index 295e82617..b32919c4e 100644 --- a/apps/docs/app/(home)/pricing/_pricing/table.tsx +++ b/apps/docs/app/(home)/pricing/_pricing/table.tsx @@ -300,9 +300,9 @@ export function PlansComparisonTable({ plans }: Props) { questions, and surfaces insights automatically.

- Unlimited seats & sites.{" "} - Team members, websites, and API access are unlimited on every plan. - Overage is tiered with lower rates as volume increases. + Unlimited seats & sites. Team + members, websites, and API access are unlimited on every plan. Overage + is tiered with lower rates as volume increases.

From f236aad956348fa732b6db899f7fbffbf33f7240 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Sat, 30 May 2026 21:05:39 +0300 Subject: [PATCH 17/34] fix(api): use namespace import for zod in Autumn webhook --- apps/api/src/routes/webhooks/autumn.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/webhooks/autumn.ts b/apps/api/src/routes/webhooks/autumn.ts index 690405601..c71865177 100644 --- a/apps/api/src/routes/webhooks/autumn.ts +++ b/apps/api/src/routes/webhooks/autumn.ts @@ -21,7 +21,8 @@ import { Elysia } from "elysia"; import { useLogger } from "evlog/elysia"; import { Resend } from "resend"; import { Webhook } from "svix"; -import { z } from "zod"; +// 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; From cfbe7c3c33a737bbd94599bb15b9d8c7abd2bd37 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Sat, 30 May 2026 21:05:39 +0300 Subject: [PATCH 18/34] feat(db): scope agent chats to organization Make agent_chats.website_id nullable, index by organization instead of website, and switch the website FK to ON DELETE SET NULL so chats survive website deletion. Agent chats are now an organization-level workspace. --- packages/db/src/drizzle/schema/agent.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/db/src/drizzle/schema/agent.ts b/packages/db/src/drizzle/schema/agent.ts index 284c2fb02..413182c85 100644 --- a/packages/db/src/drizzle/schema/agent.ts +++ b/packages/db/src/drizzle/schema/agent.ts @@ -58,7 +58,7 @@ export const agentChats = pgTable( "agent_chats", { id: text().primaryKey(), - websiteId: text("website_id").notNull(), + websiteId: text("website_id"), userId: text("user_id").notNull(), organizationId: text("organization_id"), title: text().notNull().default(""), @@ -72,8 +72,8 @@ export const agentChats = pgTable( .$onUpdate(() => new Date()), }, (table) => [ - index("agent_chats_website_user_updated_idx").on( - table.websiteId, + index("agent_chats_org_user_updated_idx").on( + table.organizationId, table.userId, table.updatedAt.desc() ), @@ -85,7 +85,7 @@ export const agentChats = pgTable( columns: [table.websiteId], foreignColumns: [websites.id], name: "agent_chats_website_id_fkey", - }).onDelete("cascade"), + }).onDelete("set null"), foreignKey({ columns: [table.userId], foreignColumns: [user.id], From db05d7a6becf91038e22308eda1f36b0996a6e29 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Sat, 30 May 2026 21:05:40 +0300 Subject: [PATCH 19/34] feat(rpc): list agent chats by organization List and authorize agent chats against the organization workspace rather than a single website, and tolerate chats with a null website. --- packages/rpc/src/routers/agent-chats.ts | 53 +++++++++++++++---------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/rpc/src/routers/agent-chats.ts b/packages/rpc/src/routers/agent-chats.ts index bc60fabe4..4c7790dd1 100644 --- a/packages/rpc/src/routers/agent-chats.ts +++ b/packages/rpc/src/routers/agent-chats.ts @@ -4,11 +4,14 @@ import { getActiveStream } from "@databuddy/redis/stream-buffer"; import { z } from "zod"; import { rpcError } from "../errors"; import { sessionProcedure, trackedSessionProcedure } from "../orpc"; -import { withWorkspace } from "../procedures/with-workspace"; +import { + withWorkspace, + workspaceInputSchema, +} from "../procedures/with-workspace"; const chatListItemSchema = z.object({ id: z.string(), - websiteId: z.string(), + websiteId: z.string().nullable(), title: z.string(), createdAt: z.coerce.date(), updatedAt: z.coerce.date(), @@ -58,14 +61,14 @@ export const agentChatsRouter = { .route({ method: "POST", path: "/agent-chats/list", - summary: "List agent chats for the current user and website", + summary: "List agent chats for the current user and organization", tags: ["AgentChats"], }) - .input(z.object({ websiteId: z.string() })) + .input(workspaceInputSchema) .output(z.array(chatListItemSchema)) .handler(async ({ context, input }) => { - await withWorkspace(context, { - websiteId: input.websiteId, + const workspace = await withWorkspace(context, { + organizationId: input.organizationId, permissions: ["read"], }); @@ -81,7 +84,7 @@ export const agentChatsRouter = { .where( and( eq(agentChats.userId, context.user.id), - eq(agentChats.websiteId, input.websiteId) + eq(agentChats.organizationId, workspace.organizationId) ) ) .orderBy(desc(agentChats.updatedAt)) @@ -108,12 +111,14 @@ export const agentChatsRouter = { return null; } - await withWorkspace(context, { - websiteId: row.websiteId, - permissions: ["read"], - }); + if (row.organizationId) { + await withWorkspace(context, { + organizationId: row.organizationId, + permissions: ["read"], + }); + } - const activeStreamId = await getActiveStream(row.websiteId, row.id); + const activeStreamId = await getActiveStream(row.userId, row.id); return { id: row.id, @@ -143,17 +148,19 @@ export const agentChatsRouter = { .handler(async ({ context, input }) => { const row = await context.db.query.agentChats.findFirst({ where: { id: input.id, userId: context.user.id }, - columns: { id: true, websiteId: true }, + columns: { id: true, organizationId: true }, }); if (!row) { throw rpcError.notFound("agent chat", input.id); } - await withWorkspace(context, { - websiteId: row.websiteId, - permissions: ["update"], - }); + if (row.organizationId) { + await withWorkspace(context, { + organizationId: row.organizationId, + permissions: ["read"], + }); + } await context.db .update(agentChats) @@ -175,17 +182,19 @@ export const agentChatsRouter = { .handler(async ({ context, input }) => { const row = await context.db.query.agentChats.findFirst({ where: { id: input.id, userId: context.user.id }, - columns: { id: true, websiteId: true }, + columns: { id: true, organizationId: true }, }); if (!row) { throw rpcError.notFound("agent chat", input.id); } - await withWorkspace(context, { - websiteId: row.websiteId, - permissions: ["delete"], - }); + if (row.organizationId) { + await withWorkspace(context, { + organizationId: row.organizationId, + permissions: ["read"], + }); + } await context.db.delete(agentChats).where(eq(agentChats.id, input.id)); From 350c91e3a3e5098221f4dac7a284c928ad2f6001 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Sat, 30 May 2026 21:05:51 +0300 Subject: [PATCH 20/34] feat(ai): give the agent multi-website workspace context Add a list-websites tool and thread accessible-website context (accessible websites + default website id) through the toolkit so the agent can query across an organization's sites instead of a single hard-coded website. --- packages/ai/src/ai/agents/analytics.ts | 3 ++ packages/ai/src/ai/agents/types.ts | 7 ++- packages/ai/src/ai/config/context.ts | 54 ++++++++++++++++--- .../ai/src/ai/insights/business-context.ts | 4 +- packages/ai/src/ai/insights/flag-context.ts | 7 +-- packages/ai/src/ai/insights/ops-context.ts | 4 +- .../ai/src/ai/insights/product-context.ts | 4 +- packages/ai/src/ai/prompts/analytics.ts | 31 ++++++++++- packages/ai/src/ai/tools/dashboard-actions.ts | 3 +- packages/ai/src/ai/tools/execute-sql-query.ts | 20 +++++-- packages/ai/src/ai/tools/get-data.ts | 37 +++++++++++-- .../ai/src/ai/tools/investigation-tools.ts | 4 +- packages/ai/src/ai/tools/list-websites.ts | 22 ++++++++ packages/ai/src/ai/tools/profiles.ts | 48 ++++++++++++----- packages/ai/src/ai/tools/scrape-page.ts | 26 +++++++-- packages/ai/src/ai/tools/search-console.ts | 32 +++++++++-- packages/ai/src/ai/tools/toolkit.ts | 4 +- packages/ai/src/ai/tools/utils/context.ts | 41 ++++++++++++++ packages/ai/src/ai/tools/utils/index.ts | 6 ++- 19 files changed, 302 insertions(+), 55 deletions(-) create mode 100644 packages/ai/src/ai/tools/list-websites.ts diff --git a/packages/ai/src/ai/agents/analytics.ts b/packages/ai/src/ai/agents/analytics.ts index b2f8010d8..a9ecb44cf 100644 --- a/packages/ai/src/ai/agents/analytics.ts +++ b/packages/ai/src/ai/agents/analytics.ts @@ -41,6 +41,9 @@ export function createConfig( userId: context.userId, websiteId: context.websiteId, websiteDomain: context.websiteDomain, + defaultWebsiteId: context.defaultWebsiteId ?? context.websiteId, + accessibleWebsites: context.accessibleWebsites, + organizationId: context.organizationId, timezone: context.timezone, currentDateTime: new Date().toISOString(), chatId: context.chatId, diff --git a/packages/ai/src/ai/agents/types.ts b/packages/ai/src/ai/agents/types.ts index 93f6f930c..1d0ca4ed8 100644 --- a/packages/ai/src/ai/agents/types.ts +++ b/packages/ai/src/ai/agents/types.ts @@ -5,6 +5,7 @@ import type { ToolLoopAgent, ToolSet, } from "ai"; +import type { WebsiteSummary } from "../../lib/accessible-websites"; type ProviderOptions = NonNullable< ConstructorParameters[0]["providerOptions"] @@ -28,15 +29,17 @@ export const AGENT_TIERS: readonly AgentTier[] = [ ] as const; export interface AgentContext { + accessibleWebsites?: WebsiteSummary[]; billingCustomerId?: string | null; chatId: string; + defaultWebsiteId?: string | null; organizationId?: string; requestHeaders?: Headers; thinking?: AgentThinking; timezone: string; userId: string; - websiteDomain: string; - websiteId: string; + websiteDomain?: string; + websiteId?: string; } export interface AgentConfig { diff --git a/packages/ai/src/ai/config/context.ts b/packages/ai/src/ai/config/context.ts index 8a956d41e..3a5adfd5c 100644 --- a/packages/ai/src/ai/config/context.ts +++ b/packages/ai/src/ai/config/context.ts @@ -1,3 +1,5 @@ +import type { WebsiteSummary } from "../../lib/accessible-websites"; + export type AppMutationMode = "allow" | "dry-run"; export interface ServiceAuth { @@ -6,25 +8,61 @@ export interface ServiceAuth { } export interface AppContext { + accessibleWebsites?: WebsiteSummary[]; billingCustomerId?: string | null; chatId: string; currentDateTime: string; + defaultWebsiteId?: string | null; mutationMode?: AppMutationMode; organizationId?: string | null; requestHeaders?: Headers; serviceAuth?: ServiceAuth; timezone: string; userId: string; - websiteDomain: string; - websiteId: string; + websiteDomain?: string; + websiteId?: string; [key: string]: unknown; } +export function requireWebsiteId(context: AppContext): string { + const websiteId = context.defaultWebsiteId ?? context.websiteId; + if (!websiteId) { + throw new Error("This operation requires a website in context."); + } + return websiteId; +} + +function escapeAttr(value: string): string { + return value.replace(/"/g, """); +} + export function formatContextForLLM(context: AppContext): string { - return ` -${context.currentDateTime} -${context.timezone} -${context.websiteId} -${context.websiteDomain} -`; + const lines = [ + `${context.currentDateTime}`, + `${context.timezone}`, + ]; + + const websites = context.accessibleWebsites ?? []; + if (websites.length > 0) { + const rows = websites + .map((w) => { + const domain = w.domain ? ` domain="${escapeAttr(w.domain)}"` : ""; + const name = w.name ? ` name="${escapeAttr(w.name)}"` : ""; + return ` `; + }) + .join("\n"); + lines.push(`\n${rows}\n`); + } + + const defaultId = context.defaultWebsiteId ?? context.websiteId; + if (defaultId) { + lines.push(`${defaultId}`); + if (context.websiteDomain) { + lines.push( + `${context.websiteDomain}` + ); + } + } + + return `\n${lines.join("\n")}\n`; } diff --git a/packages/ai/src/ai/insights/business-context.ts b/packages/ai/src/ai/insights/business-context.ts index fe6eb6ae7..f1007c09d 100644 --- a/packages/ai/src/ai/insights/business-context.ts +++ b/packages/ai/src/ai/insights/business-context.ts @@ -1,4 +1,4 @@ -import type { AppContext } from "../config/context"; +import { type AppContext, requireWebsiteId } from "../config/context"; import { callRPCProcedure } from "../tools/utils"; import { executeQuery } from "../../query"; import type { QueryRequest } from "../../query/types"; @@ -28,7 +28,7 @@ function runQuery( ) { return executeQuery( { - projectId: appContext.websiteId, + projectId: requireWebsiteId(appContext), type, from: range.from, to: range.to, diff --git a/packages/ai/src/ai/insights/flag-context.ts b/packages/ai/src/ai/insights/flag-context.ts index 0767a8ecb..2e30efd93 100644 --- a/packages/ai/src/ai/insights/flag-context.ts +++ b/packages/ai/src/ai/insights/flag-context.ts @@ -1,6 +1,6 @@ import { and, db, desc, eq, gte, isNull, lte, or } from "@databuddy/db"; import { flagChangeEvents } from "@databuddy/db/schema"; -import type { AppContext } from "../config/context"; +import { type AppContext, requireWebsiteId } from "../config/context"; import dayjs from "dayjs"; import timezonePlugin from "dayjs/plugin/timezone"; import utcPlugin from "dayjs/plugin/utc"; @@ -24,16 +24,17 @@ export async function fetchFlagChangeContext( limit: number ) { const { start, end } = getRangeBounds(range, appContext.timezone); + const websiteId = requireWebsiteId(appContext); const scopeCondition = appContext.organizationId ? or( - eq(flagChangeEvents.websiteId, appContext.websiteId), + eq(flagChangeEvents.websiteId, websiteId), and( eq(flagChangeEvents.organizationId, appContext.organizationId), isNull(flagChangeEvents.websiteId) ) ) - : eq(flagChangeEvents.websiteId, appContext.websiteId); + : eq(flagChangeEvents.websiteId, websiteId); const rows = await db .select({ diff --git a/packages/ai/src/ai/insights/ops-context.ts b/packages/ai/src/ai/insights/ops-context.ts index fa7cbe1d2..c1a01ee95 100644 --- a/packages/ai/src/ai/insights/ops-context.ts +++ b/packages/ai/src/ai/insights/ops-context.ts @@ -1,4 +1,4 @@ -import type { AppContext } from "../config/context"; +import { type AppContext, requireWebsiteId } from "../config/context"; import { callRPCProcedure } from "../tools/utils"; import { executeQuery } from "../../query"; import type { QueryRequest } from "../../query/types"; @@ -30,7 +30,7 @@ function runQuery( ) { return executeQuery( { - projectId: appContext.websiteId, + projectId: requireWebsiteId(appContext), type, from: range.from, to: range.to, diff --git a/packages/ai/src/ai/insights/product-context.ts b/packages/ai/src/ai/insights/product-context.ts index 8a2f5bbd2..67c60f3d1 100644 --- a/packages/ai/src/ai/insights/product-context.ts +++ b/packages/ai/src/ai/insights/product-context.ts @@ -1,4 +1,4 @@ -import type { AppContext } from "../config/context"; +import { type AppContext, requireWebsiteId } from "../config/context"; import { callRPCProcedure } from "../tools/utils"; import { executeQuery } from "../../query"; import type { QueryRequest } from "../../query/types"; @@ -146,7 +146,7 @@ function runQuery( ) { return executeQuery( { - projectId: appContext.websiteId, + projectId: requireWebsiteId(appContext), type, from: range.from, to: range.to, diff --git a/packages/ai/src/ai/prompts/analytics.ts b/packages/ai/src/ai/prompts/analytics.ts index a44ae6779..a4702dc2c 100644 --- a/packages/ai/src/ai/prompts/analytics.ts +++ b/packages/ai/src/ai/prompts/analytics.ts @@ -181,13 +181,42 @@ Slack rules: Examples: "which first?" with thread metrics => read thread and pick one. "nah that's wrong" => ask for correction. `; +function buildWebsiteScopeGuidance(ctx: AppContext): string { + const websites = ctx.accessibleWebsites ?? []; + const defaultId = ctx.defaultWebsiteId ?? ctx.websiteId; + + if (defaultId) { + const defaultDomain = ctx.websiteDomain ? ` (${ctx.websiteDomain})` : ""; + return `A default website is selected for this chat: websiteId "${defaultId}"${defaultDomain}. Omit websiteId on tools to use it. The user can mention other websites; when they name or @-mention a different site, pass that website's id explicitly. Use list_websites if you need to look up an id.`; + } + + const only = websites[0]; + if (websites.length === 1 && only) { + return `This workspace has one website: websiteId "${only.id}"${only.domain ? ` (${only.domain})` : ""}. Use it for analytics tools; you do not need to call list_websites.`; + } + + if (websites.length > 1) { + return "No single website is selected. The accessible websites are listed in . For analytics tools, pass the websiteId that matches the user's request; if the request is ambiguous about which site, ask which one. Use list_websites if you need the full list. To compare sites, query each with its own websiteId."; + } + + return "No website is selected yet. Call list_websites first to discover available websites, then pass the chosen websiteId to analytics tools."; +} + export function buildAnalyticsInstructions(ctx: AppContext): string { - return `You are Databunny, an analytics assistant for ${ctx.websiteDomain}. + const intro = ctx.websiteDomain + ? `You are Databunny, an analytics assistant for ${ctx.websiteDomain}.` + : "You are Databunny, an analytics assistant for this workspace."; + + return `${intro} ${formatContextForLLM(ctx)} + +${buildWebsiteScopeGuidance(ctx)} + + ${COMMON_AGENT_RULES} ${ANALYTICS_BODY} diff --git a/packages/ai/src/ai/tools/dashboard-actions.ts b/packages/ai/src/ai/tools/dashboard-actions.ts index 85b78a9d5..5c099fa14 100644 --- a/packages/ai/src/ai/tools/dashboard-actions.ts +++ b/packages/ai/src/ai/tools/dashboard-actions.ts @@ -84,7 +84,8 @@ export const dashboardActionsTool = tool({ }), execute: ({ actions, title, websiteId }, options) => { const context = getAppContext(options); - const resolvedWebsiteId = websiteId ?? context.websiteId; + const resolvedWebsiteId = + websiteId ?? context.defaultWebsiteId ?? context.websiteId; return { type: "dashboard-actions", diff --git a/packages/ai/src/ai/tools/execute-sql-query.ts b/packages/ai/src/ai/tools/execute-sql-query.ts index 6e54ac9b7..aa514f154 100644 --- a/packages/ai/src/ai/tools/execute-sql-query.ts +++ b/packages/ai/src/ai/tools/execute-sql-query.ts @@ -6,7 +6,12 @@ import { } from "@databuddy/db/clickhouse"; import { tool } from "ai"; import { z } from "zod"; -import { executeTimedQuery, getAppContext, type QueryResult } from "./utils"; +import { + executeTimedQuery, + getAppContext, + type QueryResult, + resolveToolWebsite, +} from "./utils"; const MAX_MODEL_ROWS = 50; @@ -90,6 +95,12 @@ Gotchas: timestamp column is "time" in events, "timestamp" elsewhere. Pageviews .describe( "Read-only ClickHouse SELECT/WITH query for an explicit analytics request. Must include client_id = {websiteId:String} AND-ed at the top level of every SELECT's WHERE." ), + websiteId: z + .string() + .optional() + .describe( + "Target website id. Omit to use the workspace default. Get ids from list_websites. The {websiteId:String} placeholder is bound to this site server-side." + ), params: z .record(z.string(), z.unknown()) .optional() @@ -97,11 +108,12 @@ Gotchas: timestamp column is "time" in events, "timestamp" elsewhere. Pageviews "Optional typed placeholder values. websiteId and websiteDomain are bound by the server and cannot be overridden." ), }), - execute: ({ sql, params }, options): Promise => { + execute: ({ sql, websiteId, params }, options): Promise => { const ctx = getAppContext(options); + const resolved = resolveToolWebsite(ctx, websiteId); return executeAgentSqlForWebsite({ - websiteId: ctx.websiteId, - websiteDomain: ctx.websiteDomain, + websiteId: resolved.websiteId, + websiteDomain: resolved.domain, sql, params, }); diff --git a/packages/ai/src/ai/tools/get-data.ts b/packages/ai/src/ai/tools/get-data.ts index b63c7179b..59bdd1edd 100644 --- a/packages/ai/src/ai/tools/get-data.ts +++ b/packages/ai/src/ai/tools/get-data.ts @@ -3,10 +3,16 @@ import { z } from "zod"; import { getWebsiteDomain } from "../../lib/website-utils"; import { executeQuery, QueryBuilders } from "../../query"; import type { QueryRequest } from "../../query/types"; -import { getAppContext } from "./utils"; +import { getAppContext, resolveToolWebsite } from "./utils"; const queryItemSchema = z.object({ type: z.string(), + websiteId: z + .string() + .optional() + .describe( + "Target website id. Omit to use the workspace default. Required when comparing or querying a specific site in a multi-website workspace; get ids from list_websites." + ), from: z.string().optional(), to: z.string().optional(), preset: z @@ -50,6 +56,7 @@ interface QueryItemResult { executionTime: number; rowCount: number; type: string; + websiteId?: string; } const MAX_MODEL_ROWS = 50; @@ -108,7 +115,7 @@ const BUILDER_CATEGORIES = `Builder types by category: - Revenue: revenue_overview, revenue_time_series, revenue_by_provider, revenue_by_product, revenue_attribution_overview, revenue_by_country, revenue_by_region, revenue_by_city, revenue_by_browser, revenue_by_device, revenue_by_os, revenue_by_referrer, revenue_by_utm_source, revenue_by_utm_medium, revenue_by_utm_campaign, revenue_by_entry_page, recent_transactions`; export const getDataTool = tool({ - description: `Run analytics query builders only when the latest user message explicitly asks for website analytics data, metrics, reports, comparisons, breakdowns, trends, revenue, sessions, pages, events, errors, vitals, uptime, LLM usage, links, profiles, or similar quantitative analysis. Do not use for greetings, thanks, acknowledgments, short reactions, clarification-only replies, frustration, or meta-conversation about the assistant/chat. Batch 1-10 queries in parallel. Use preset (last_7d/last_30d/...) or from+to dates. The current website is bound server-side from the authorized chat session.\n\n${BUILDER_CATEGORIES}`, + description: `Run analytics query builders only when the latest user message explicitly asks for website analytics data, metrics, reports, comparisons, breakdowns, trends, revenue, sessions, pages, events, errors, vitals, uptime, LLM usage, links, profiles, or similar quantitative analysis. Do not use for greetings, thanks, acknowledgments, short reactions, clarification-only replies, frustration, or meta-conversation about the assistant/chat. Batch 1-10 queries in parallel. Use preset (last_7d/last_30d/...) or from+to dates. Each query may target a specific website via its websiteId; omit it to use the workspace default. To compare websites, send one query per website with different websiteId values.\n\n${BUILDER_CATEGORIES}`, inputSchema: z.object({ queries: z .array(queryItemSchema) @@ -120,9 +127,7 @@ export const getDataTool = tool({ }), execute: async ({ queries }, options) => { const ctx = getAppContext(options); - const websiteId = ctx.websiteId; const batchStart = Date.now(); - const domain = ctx.websiteDomain || (await getWebsiteDomain(websiteId)); const results = await Promise.all( queries.map(async (item): Promise => { @@ -138,6 +143,25 @@ export const getDataTool = tool({ }; } + let websiteId: string; + let resolvedDomain: string | undefined; + try { + const resolved = resolveToolWebsite(ctx, item.websiteId); + websiteId = resolved.websiteId; + resolvedDomain = resolved.domain; + } catch (error) { + return { + type: item.type, + websiteId: item.websiteId, + data: [], + rowCount: 0, + executionTime: 0, + error: + error instanceof Error ? error.message : "Website not resolved", + }; + } + + const domain = resolvedDomain || (await getWebsiteDomain(websiteId)); const { from, to } = resolveDates(item); const req: QueryRequest = { projectId: websiteId, @@ -155,6 +179,7 @@ export const getDataTool = tool({ const data = await executeQuery(req, domain, req.timezone); return { type: item.type, + websiteId, data: data.slice(0, MAX_MODEL_ROWS), rowCount: data.length, executionTime: Date.now() - queryStart, @@ -164,7 +189,9 @@ export const getDataTool = tool({ const resultMap: Record = {}; for (const r of results) { - resultMap[r.type] = r; + const key = + r.websiteId && resultMap[r.type] ? `${r.type}@${r.websiteId}` : r.type; + resultMap[key] = r; } return { diff --git a/packages/ai/src/ai/tools/investigation-tools.ts b/packages/ai/src/ai/tools/investigation-tools.ts index dbe74b727..e116729aa 100644 --- a/packages/ai/src/ai/tools/investigation-tools.ts +++ b/packages/ai/src/ai/tools/investigation-tools.ts @@ -4,7 +4,7 @@ import { createScrapeTools } from "./scrape-page"; import { createSearchConsoleTools } from "./search-console"; export interface InvestigationToolsParams { - domain: string; + domain?: string; organizationId: string; userId?: string; } @@ -13,7 +13,7 @@ export function createInvestigationTools( params: InvestigationToolsParams ): ToolSet { return { - ...createScrapeTools(params.domain), + ...createScrapeTools(), ...createSearchConsoleTools({ domain: params.domain, organizationId: params.organizationId, diff --git a/packages/ai/src/ai/tools/list-websites.ts b/packages/ai/src/ai/tools/list-websites.ts new file mode 100644 index 000000000..6bd43388f --- /dev/null +++ b/packages/ai/src/ai/tools/list-websites.ts @@ -0,0 +1,22 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { getAppContext } from "./utils"; + +export const listWebsitesTool = tool({ + description: + "List the websites in this workspace that you can query. Returns each website's id, name, and domain. Use a returned id as the websiteId for analytics tools when the workspace has more than one website or when the user names a specific site.", + inputSchema: z.object({}), + execute: (_args, options) => { + const ctx = getAppContext(options); + const websites = ctx.accessibleWebsites ?? []; + return { + websites: websites.map((w) => ({ + id: w.id, + name: w.name, + domain: w.domain, + })), + defaultWebsiteId: ctx.defaultWebsiteId ?? ctx.websiteId ?? null, + count: websites.length, + }; + }, +}); diff --git a/packages/ai/src/ai/tools/profiles.ts b/packages/ai/src/ai/tools/profiles.ts index 5faa578a2..2d0447fc4 100644 --- a/packages/ai/src/ai/tools/profiles.ts +++ b/packages/ai/src/ai/tools/profiles.ts @@ -3,9 +3,16 @@ import { z } from "zod"; import { getWebsiteDomain } from "../../lib/website-utils"; import { executeQuery } from "../../query"; import type { QueryRequest } from "../../query/types"; -import { getAppContext } from "./utils"; +import { getAppContext, resolveToolWebsite } from "./utils"; import { createToolLogger } from "./utils/logger"; +const websiteIdInput = z + .string() + .optional() + .describe( + "Target website id. Omit to use the workspace default. Get ids from list_websites." + ); + const logger = createToolLogger("Profiles"); function daysAgo(d: number): string { @@ -21,8 +28,9 @@ function today(): string { export function createProfileTools() { const listProfilesTool = tool({ description: - "List recent visitor profiles (sessions, pageviews, device, geo, browser, referrer). Use for visitors/users/audience questions. The current website is bound server-side from the authorized chat session.", + "List recent visitor profiles (sessions, pageviews, device, geo, browser, referrer). Use for visitors/users/audience questions. Pass websiteId to target a specific site; omit to use the workspace default.", inputSchema: z.object({ + websiteId: websiteIdInput, days: z.number().min(1).max(90).default(7), limit: z.number().min(1).max(50).default(10), filters: z @@ -43,11 +51,15 @@ export function createProfileTools() { ) .optional(), }), - execute: async ({ days, limit, filters }, options) => { + execute: async ( + { websiteId: inputWebsiteId, days, limit, filters }, + options + ) => { const ctx = getAppContext(options); - const websiteId = ctx.websiteId; + const resolved = resolveToolWebsite(ctx, inputWebsiteId); + const websiteId = resolved.websiteId; try { - const domain = ctx.websiteDomain || (await getWebsiteDomain(websiteId)); + const domain = resolved.domain || (await getWebsiteDomain(websiteId)); const from = daysAgo(days); const to = today(); @@ -88,16 +100,21 @@ export function createProfileTools() { const getProfileTool = tool({ description: - "Visitor detail by anonymous_id: first/last activity, sessions across analytics/custom/error/vital/link events, pageviews, duration, device, browser, OS, location. The current website is bound server-side.", + "Visitor detail by anonymous_id: first/last activity, sessions across analytics/custom/error/vital/link events, pageviews, duration, device, browser, OS, location. Pass websiteId to target a specific site; omit to use the workspace default.", inputSchema: z.object({ + websiteId: websiteIdInput, visitorId: z.string(), days: z.number().min(1).max(365).default(30), }), - execute: async ({ visitorId, days }, options) => { + execute: async ( + { websiteId: inputWebsiteId, visitorId, days }, + options + ) => { const ctx = getAppContext(options); - const websiteId = ctx.websiteId; + const resolved = resolveToolWebsite(ctx, inputWebsiteId); + const websiteId = resolved.websiteId; try { - const domain = ctx.websiteDomain || (await getWebsiteDomain(websiteId)); + const domain = resolved.domain || (await getWebsiteDomain(websiteId)); const from = daysAgo(days); const to = today(); @@ -140,17 +157,22 @@ export function createProfileTools() { const getProfileSessionsTool = tool({ description: - "Session history for a visitor, including analytics events, custom events, errors, outgoing links, and separate web vitals context. Use after list_profiles/get_profile. The current website is bound server-side.", + "Session history for a visitor, including analytics events, custom events, errors, outgoing links, and separate web vitals context. Use after list_profiles/get_profile. Pass websiteId to target a specific site; omit to use the workspace default.", inputSchema: z.object({ + websiteId: websiteIdInput, visitorId: z.string(), days: z.number().min(1).max(365).default(30), limit: z.number().min(1).max(100).default(20), }), - execute: async ({ visitorId, days, limit }, options) => { + execute: async ( + { websiteId: inputWebsiteId, visitorId, days, limit }, + options + ) => { const ctx = getAppContext(options); - const websiteId = ctx.websiteId; + const resolved = resolveToolWebsite(ctx, inputWebsiteId); + const websiteId = resolved.websiteId; try { - const domain = ctx.websiteDomain || (await getWebsiteDomain(websiteId)); + const domain = resolved.domain || (await getWebsiteDomain(websiteId)); const from = daysAgo(days); const to = today(); diff --git a/packages/ai/src/ai/tools/scrape-page.ts b/packages/ai/src/ai/tools/scrape-page.ts index 29f916241..0fb4d6902 100644 --- a/packages/ai/src/ai/tools/scrape-page.ts +++ b/packages/ai/src/ai/tools/scrape-page.ts @@ -1,5 +1,7 @@ import { tool } from "ai"; import { z } from "zod"; +import { getWebsiteDomain } from "../../lib/website-utils"; +import { getAppContext, resolveToolWebsite } from "./utils"; const FIRECRAWL_API = "https://api.firecrawl.dev/v1"; const MAX_CONTENT_CHARS = 12_000; @@ -202,17 +204,33 @@ export async function getCachedSiteContext( } } -export function createScrapeTools(domain: string) { +export function createScrapeTools() { const scrapeTool = tool({ - description: `Scrape a page from ${domain} and return its content as markdown plus internal links. Use to understand the product: what the site does, key pages, pricing, CTAs. Also use when investigating page-level anomalies. Scrape "/" first for product context, then specific pages as needed. Results are cached for 24h.`, + description: + 'Scrape a page from one of the workspace websites and return its content as markdown plus internal links. Use to understand the product: what the site does, key pages, pricing, CTAs. Also use when investigating page-level anomalies. Scrape "/" first for product context, then specific pages as needed. Pass websiteId to target a specific site; omit to use the workspace default. Results are cached for 24h.', inputSchema: z.object({ + websiteId: z + .string() + .optional() + .describe( + "Target website id. Omit to use the workspace default. Get ids from list_websites." + ), path: z .string() .describe( - "Page path to scrape (e.g. '/', '/pricing', '/docs'). Must be a path on the current website." + "Page path to scrape (e.g. '/', '/pricing', '/docs'). Must be a path on the target website." ), }), - execute: ({ path }) => scrapePage(domain, path), + execute: async ({ websiteId, path }, options) => { + const ctx = getAppContext(options); + const resolved = resolveToolWebsite(ctx, websiteId); + const domain = + resolved.domain || (await getWebsiteDomain(resolved.websiteId)); + if (!domain) { + return { error: "Could not resolve a domain for the target website" }; + } + return scrapePage(domain, path); + }, }); return { scrape_page: scrapeTool }; diff --git a/packages/ai/src/ai/tools/search-console.ts b/packages/ai/src/ai/tools/search-console.ts index 9398e9c0e..2a4de6b71 100644 --- a/packages/ai/src/ai/tools/search-console.ts +++ b/packages/ai/src/ai/tools/search-console.ts @@ -1,5 +1,7 @@ import { tool } from "ai"; import { z } from "zod"; +import { getWebsiteDomain } from "../../lib/website-utils"; +import { getAppContext, resolveToolWebsite } from "./utils"; import { createCachedTokenFn } from "./utils/oauth-token"; const GSC_API = "https://www.googleapis.com/webmasters/v3"; @@ -8,6 +10,12 @@ const MAX_ROWS = 25; const dimensionEnum = z.enum(["query", "page", "country", "device", "date"]); const searchAnalyticsInput = z.object({ + websiteId: z + .string() + .optional() + .describe( + "Target website id. Omit to use the workspace default. Get ids from list_websites." + ), startDate: z.string().describe("Start date YYYY-MM-DD"), endDate: z.string().describe("End date YYYY-MM-DD"), dimensions: dimensionEnum @@ -86,7 +94,7 @@ export async function querySearchAnalytics( } export function createSearchConsoleTools(params: { - domain: string; + domain?: string; organizationId: string; userId?: string; }) { @@ -95,13 +103,29 @@ export function createSearchConsoleTools(params: { params.organizationId, params.userId ); - const siteUrl = `sc-domain:${params.domain}`; return { search_console: tool({ - description: `Query Google Search Console for ${params.domain}. Returns search queries, pages, countries, or devices with clicks, impressions, CTR, and average position. Use to find which keywords lost rankings, which pages dropped in impressions, or where traffic is coming from in Google search.`, + description: + "Query Google Search Console for a workspace website. Returns search queries, pages, countries, or devices with clicks, impressions, CTR, and average position. Use to find which keywords lost rankings, which pages dropped in impressions, or where traffic is coming from in Google search. Pass websiteId to target a specific site; omit to use the workspace default.", inputSchema: searchAnalyticsInput, - execute: async (input) => { + execute: async (input, options) => { + const ctx = getAppContext(options); + let domain = params.domain; + if (!domain) { + const resolved = resolveToolWebsite(ctx, input.websiteId); + domain = + resolved.domain || + (await getWebsiteDomain(resolved.websiteId)) || + undefined; + } + if (!domain) { + return { + error: "Could not resolve a domain for the target website", + }; + } + const siteUrl = `sc-domain:${domain}`; + const token = await getToken(); if (!token) { return { diff --git a/packages/ai/src/ai/tools/toolkit.ts b/packages/ai/src/ai/tools/toolkit.ts index bf9854a42..5c3bfa1c4 100644 --- a/packages/ai/src/ai/tools/toolkit.ts +++ b/packages/ai/src/ai/tools/toolkit.ts @@ -7,6 +7,7 @@ import { getDataTool } from "./get-data"; import { createGoalTools } from "./goals"; import { createInvestigationTools } from "./investigation-tools"; import { createLinksTools } from "./links"; +import { listWebsitesTool } from "./list-websites"; import { createMemoryTools } from "./memory"; import { createProfileTools } from "./profiles"; import { dashboardActionsTool } from "./dashboard-actions"; @@ -26,6 +27,7 @@ export interface ToolkitParams { } const ANALYTICS_TOOLS: ToolSet = { + list_websites: listWebsitesTool, get_data: getDataTool, execute_sql_query: executeSqlQueryTool, }; @@ -55,7 +57,7 @@ export function createToolkit(params: ToolkitParams): ToolSet { Object.assign(tools, ANALYTICS_TOOLS); } - if (caps.has("investigation") && params.domain && params.organizationId) { + if (caps.has("investigation") && params.organizationId) { Object.assign( tools, createInvestigationTools({ diff --git a/packages/ai/src/ai/tools/utils/context.ts b/packages/ai/src/ai/tools/utils/context.ts index 37e629cb7..065ed3ccd 100644 --- a/packages/ai/src/ai/tools/utils/context.ts +++ b/packages/ai/src/ai/tools/utils/context.ts @@ -11,3 +11,44 @@ export function getAppContext(options: { } return ctx as AppContext; } + +export interface ResolvedWebsite { + domain?: string; + websiteId: string; +} + +export function resolveToolWebsite( + ctx: AppContext, + inputWebsiteId?: string | null +): ResolvedWebsite { + const accessible = ctx.accessibleWebsites ?? []; + const domainFor = (id: string): string | undefined => + accessible.find((w) => w.id === id)?.domain ?? + (id === ctx.websiteId ? ctx.websiteDomain : undefined); + + if (inputWebsiteId) { + if ( + accessible.length > 0 && + !accessible.some((w) => w.id === inputWebsiteId) + ) { + throw new Error( + `Website "${inputWebsiteId}" is not in this workspace. Call list_websites to see available websites.` + ); + } + return { websiteId: inputWebsiteId, domain: domainFor(inputWebsiteId) }; + } + + const fallbackId = ctx.defaultWebsiteId ?? ctx.websiteId; + if (fallbackId) { + return { websiteId: fallbackId, domain: domainFor(fallbackId) }; + } + + const [only] = accessible; + if (accessible.length === 1 && only) { + return { websiteId: only.id, domain: only.domain ?? undefined }; + } + + throw new Error( + "No website specified. This workspace has multiple websites — pass a websiteId for this query. Call list_websites to see the options." + ); +} diff --git a/packages/ai/src/ai/tools/utils/index.ts b/packages/ai/src/ai/tools/utils/index.ts index d278ca9d4..990b70a17 100644 --- a/packages/ai/src/ai/tools/utils/index.ts +++ b/packages/ai/src/ai/tools/utils/index.ts @@ -1,5 +1,9 @@ /** biome-ignore-all lint/performance/noBarrelFile: no barrel file */ -export { getAppContext } from "./context"; +export { + getAppContext, + resolveToolWebsite, + type ResolvedWebsite, +} from "./context"; export { createToolLogger } from "./logger"; export { getOAuthToken, createCachedTokenFn } from "./oauth-token"; export { executeTimedQuery, type QueryResult } from "./query"; From 4477437080d5fe2208a376846b0392847e832490 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Sat, 30 May 2026 21:06:03 +0300 Subject: [PATCH 21/34] feat(api): serve the agent over an organization workspace Resolve the agent's accessible websites and default website from the organization context so a single chat can operate across the workspace's sites instead of one fixed website. --- apps/api/src/routes/agent.ts | 259 +++++++++++++++++++++-------------- 1 file changed, 159 insertions(+), 100 deletions(-) diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index 5e6082305..eccde96c0 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,34 @@ 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 +602,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 +619,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 +651,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 +670,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 +731,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 +739,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 +753,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 +779,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({ @@ -805,8 +838,10 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) { userId, organizationId: organizationId ?? undefined, - websiteId: body.websiteId, - websiteDomain: domain, + websiteId: defaultWebsiteId ?? undefined, + websiteDomain: defaultDomain, + defaultWebsiteId, + accessibleWebsites, timezone, chatId, requestHeaders: request.headers, @@ -817,9 +852,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"); @@ -863,14 +909,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; } @@ -881,7 +931,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) metadata: dashboardTelemetryMetadata, }); - if (isMemoryEnabled() && lastMessage) { + if (isMemoryEnabled() && lastMessage && defaultWebsiteId) { storeConversation( [{ role: "user", content: lastMessage }], userId, @@ -890,9 +940,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) metadata: { source: "dashboard", }, - websiteId: body.websiteId, + websiteId: defaultWebsiteId, conversationId: chatId, - domain, + domain: defaultDomain ?? "unknown", } ); } @@ -941,7 +991,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, @@ -952,14 +1002,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) { @@ -969,7 +1022,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, @@ -988,7 +1041,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 } + : {}), }); } } @@ -998,7 +1053,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; @@ -1008,7 +1063,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, @@ -1036,7 +1091,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 } + : {}), }); } }, @@ -1069,7 +1126,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, { @@ -1088,7 +1147,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 } @@ -1104,7 +1163,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 } : {}), @@ -1121,13 +1180,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), }); @@ -1143,16 +1202,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", () => { From bfdf951d3bc133fc310079fc22a7e800ba212401 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Sat, 30 May 2026 21:06:16 +0300 Subject: [PATCH 22/34] feat(dashboard): rebuild the agent as an organization workspace Move the agent out from under a single website into a top-level workspace: add a global /agent route with chat history, mentions for referencing specific websites, and a no-websites empty state. Chats are now scoped to the organization, so switching websites no longer loses the conversation. --- .../app/(main)/agent/[chatId]/page.tsx | 11 + .../agent/_components/agent-no-websites.tsx | 28 ++ .../agent/_components/global-agent-page.tsx | 40 +++ .../_components/global-agent-redirect.tsx | 29 ++ apps/dashboard/app/(main)/agent/layout.tsx | 6 + apps/dashboard/app/(main)/agent/page.tsx | 5 + apps/dashboard/app/(main)/layout.tsx | 27 +- .../websites/[id]/agent/[chatId]/page.tsx | 7 +- .../agent/_components/agent-page-client.tsx | 20 +- .../agent/_components/agent-page-content.tsx | 96 ------ .../dashboard/components/agent/agent-atoms.ts | 8 + .../components/agent/agent-chat-surface.tsx | 59 +++- .../components/agent/agent-input.tsx | 294 +++++++++++++----- .../components/agent/agent-mention-menu.tsx | 75 +++++ .../components/agent/agent-mentions.ts | 33 ++ .../components/agent/agent-workspace.tsx | 120 +++++++ .../components/agent/chat-history.tsx | 56 ++-- .../agent/global-agent-provider.tsx | 136 ++------ .../components/agent/hooks/use-agent-chat.ts | 21 +- .../components/agent/hooks/use-chat-db.ts | 29 +- .../components/agent/new-chat-button.tsx | 6 +- .../layout/navigation/navigation-config.tsx | 4 + apps/dashboard/contexts/chat-context.tsx | 16 +- 23 files changed, 743 insertions(+), 383 deletions(-) create mode 100644 apps/dashboard/app/(main)/agent/[chatId]/page.tsx create mode 100644 apps/dashboard/app/(main)/agent/_components/agent-no-websites.tsx create mode 100644 apps/dashboard/app/(main)/agent/_components/global-agent-page.tsx create mode 100644 apps/dashboard/app/(main)/agent/_components/global-agent-redirect.tsx create mode 100644 apps/dashboard/app/(main)/agent/layout.tsx create mode 100644 apps/dashboard/app/(main)/agent/page.tsx delete mode 100644 apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-content.tsx create mode 100644 apps/dashboard/components/agent/agent-mention-menu.tsx create mode 100644 apps/dashboard/components/agent/agent-mentions.ts create mode 100644 apps/dashboard/components/agent/agent-workspace.tsx 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-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..ea35e0575 --- /dev/null +++ b/apps/dashboard/app/(main)/agent/_components/global-agent-page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { AgentWorkspace } from "@/components/agent/agent-workspace"; +import { useGlobalAgent } from "@/components/agent/global-agent-provider"; +import { ChatProvider } from "@/contexts/chat-context"; +import { Skeleton } from "@databuddy/ui"; +import { AgentNoWebsites } from "./agent-no-websites"; + +export function GlobalAgentPage({ chatId }: { chatId: string }) { + const router = useRouter(); + const { organizationId, hasWebsites, isLoading } = useGlobalAgent(); + + if (organizationId && !(isLoading || hasWebsites)) { + return ; + } + + if (isLoading || !organizationId) { + return ( +
+ + +
+ ); + } + + return ( + + + router.push(nextChatId ? `/agent/${nextChatId}` : "/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..f5be8b4ec --- /dev/null +++ b/apps/dashboard/app/(main)/agent/_components/global-agent-redirect.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useGlobalAgent } from "@/components/agent/global-agent-provider"; +import { Skeleton } from "@databuddy/ui"; +import { AgentNoWebsites } from "./agent-no-websites"; + +export function GlobalAgentRedirect() { + const router = useRouter(); + const { chatId, organizationId, hasWebsites, isLoading } = useGlobalAgent(); + + useEffect(() => { + if (chatId) { + router.replace(`/agent/${chatId}`); + } + }, [chatId, router]); + + if (organizationId && !(isLoading || hasWebsites)) { + return ; + } + + 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)/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)/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..dd416756a 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,8 @@ "use client"; -import { AgentPageContent } from "./agent-page-content"; +import { AgentWorkspace } from "@/components/agent/agent-workspace"; +import { useOrganizationsContext } from "@/components/providers/organizations-provider"; +import { ChatProvider } from "@/contexts/chat-context"; interface AgentPageClientProps { chatId: string; @@ -8,9 +10,19 @@ interface AgentPageClientProps { } export function AgentPageClient({ chatId, websiteId }: AgentPageClientProps) { + const { activeOrganizationId } = useOrganizationsContext(); + return ( -
- -
+ + + ); } 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..e9e4f1a26 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,63 @@ export function AgentInput() { setCommandsDismissed(true); }; + const selectMention = (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()); + }; + + 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,6 +279,8 @@ export function AgentInput() { setCommandsDismissed(false); } setSelectedCommandIndex(0); + setMentionsDismissed(false); + setSelectedMentionIndex(0); }; return ( @@ -203,88 +293,101 @@ export function AgentInput() { /> ) : null} + {mentions.length > 0 ? ( + + ) : null} + -
-
- -
-